Skip to content

Commit 8e919a0

Browse files
authored
[Collections] Check for collections that are required to be signed (#3297)
Motivation: If a collection source has an entry in the certificate pinning configuration that it must be signed. Currently we are not checking for this. Modifications: Update `JSONPackageCollectionProvider` to check that if a collection is unsigned, then it shouldn't have an entry in the certificate pinning configuration. Otherwise, throw `missingSignature` error which cannot be bypassed.
1 parent 32c49a3 commit 8e919a0

File tree

7 files changed

+276
-22
lines changed

7 files changed

+276
-22
lines changed

Sources/Commands/SwiftPackageCollectionsTool.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ private enum CollectionsError: Swift.Error {
2222
case unsigned
2323
case cannotVerifySignature
2424
case invalidSignature
25+
case missingSignature
2526
}
2627

2728
// FIXME: add links to docs in error messages
@@ -38,6 +39,8 @@ extension CollectionsError: CustomStringConvertible {
3839
return "The collection's signature cannot be verified due to missing configuration. Please refer to documentations on how to set up trusted root certificates or rerun 'add' with '--skip-signature-check'."
3940
case .invalidSignature:
4041
return "The collection's signature is invalid. If you would still like to add it please rerun 'add' with '--skip-signature-check'."
42+
case .missingSignature:
43+
return "The collection is missing required signature, which means it might have been compromised. Please contact the collection's authors and alert them of the issue."
4144
}
4245
}
4346
}
@@ -139,6 +142,8 @@ public struct SwiftPackageCollectionsTool: ParsableCommand {
139142
throw CollectionsError.cannotVerifySignature
140143
} catch PackageCollectionError.invalidSignature {
141144
throw CollectionsError.invalidSignature
145+
} catch PackageCollectionError.missingSignature {
146+
throw CollectionsError.missingSignature
142147
}
143148
}
144149

Sources/PackageCollections/API.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,6 @@ public enum PackageCollectionError: Equatable, Error {
180180
case cannotVerifySignature
181181

182182
case invalidSignature
183+
184+
case missingSignature
183185
}

Sources/PackageCollections/PackageCollections+CertificatePolicy.swift

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,30 @@ import TSCBasic
1717
/// that are more restrictive. For example, a source may want to require that all their package
1818
/// collections be signed using certificate that belongs to certain subject user ID.
1919
internal struct PackageCollectionSourceCertificatePolicy {
20-
static let sourceCertPolicies: [String: CertificatePolicyConfig] = [:]
20+
private static let defaultSourceCertPolicies: [String: CertificatePolicyConfig] = [:]
2121

22-
static func certificatePolicyKey(for source: Model.CollectionSource) -> CertificatePolicyKey? {
23-
// Certificate policy is associated to a collection host
24-
source.url.host.flatMap { self.sourceCertPolicies[$0]?.certPolicyKey }
25-
}
22+
private let sourceCertPolicies: [String: CertificatePolicyConfig]
2623

27-
static var allRootCerts: [String]? {
28-
let allRootCerts = Self.sourceCertPolicies.values
24+
var allRootCerts: [String]? {
25+
let allRootCerts = self.sourceCertPolicies.values
2926
.compactMap { $0.base64EncodedRootCerts }
3027
.flatMap { $0 }
3128
return allRootCerts.isEmpty ? nil : allRootCerts
3229
}
3330

31+
init(sourceCertPolicies: [String: CertificatePolicyConfig]? = nil) {
32+
self.sourceCertPolicies = sourceCertPolicies ?? Self.defaultSourceCertPolicies
33+
}
34+
35+
func mustBeSigned(source: Model.CollectionSource) -> Bool {
36+
source.certPolicyConfigKey.map { self.sourceCertPolicies[$0] != nil } ?? false
37+
}
38+
39+
func certificatePolicyKey(for source: Model.CollectionSource) -> CertificatePolicyKey? {
40+
// Certificate policy is associated to a collection host
41+
source.certPolicyConfigKey.flatMap { self.sourceCertPolicies[$0]?.certPolicyKey }
42+
}
43+
3444
struct CertificatePolicyConfig {
3545
let certPolicyKey: CertificatePolicyKey
3646

@@ -39,3 +49,9 @@ internal struct PackageCollectionSourceCertificatePolicy {
3949
let base64EncodedRootCerts: [String]?
4050
}
4151
}
52+
53+
private extension Model.CollectionSource {
54+
var certPolicyConfigKey: String? {
55+
self.url.host
56+
}
57+
}

Sources/PackageCollections/Providers/JSONPackageCollectionProvider.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
3434
private let decoder: JSONDecoder
3535
private let validator: JSONModel.Validator
3636
private let signatureValidator: PackageCollectionSignatureValidator
37+
private let sourceCertPolicy: PackageCollectionSourceCertificatePolicy
3738

3839
init(configuration: Configuration = .init(),
3940
httpClient: HTTPClient? = nil,
4041
signatureValidator: PackageCollectionSignatureValidator? = nil,
42+
sourceCertPolicy: PackageCollectionSourceCertificatePolicy = PackageCollectionSourceCertificatePolicy(),
4143
fileSystem: FileSystem = localFileSystem,
4244
diagnosticsEngine: DiagnosticsEngine) {
4345
self.configuration = configuration
@@ -47,10 +49,11 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
4749
self.validator = JSONModel.Validator(configuration: configuration.validator)
4850
self.signatureValidator = signatureValidator ?? PackageCollectionSigning(
4951
trustedRootCertsDir: configuration.trustedRootCertsDir ?? fileSystem.dotSwiftPM.appending(components: "config", "trust-root-certs").asURL,
50-
additionalTrustedRootCerts: PackageCollectionSourceCertificatePolicy.allRootCerts,
52+
additionalTrustedRootCerts: sourceCertPolicy.allRootCerts,
5153
callbackQueue: DispatchQueue.global(),
5254
diagnosticsEngine: diagnosticsEngine
5355
)
56+
self.sourceCertPolicy = sourceCertPolicy
5457
}
5558

5659
func get(_ source: Model.CollectionSource, callback: @escaping (Result<Model.Collection, Error>) -> Void) {
@@ -109,7 +112,7 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
109112
return callback(.failure(Errors.invalidResponse("Body is empty")))
110113
}
111114

112-
let certPolicyKey = PackageCollectionSourceCertificatePolicy.certificatePolicyKey(for: source) ?? .default
115+
let certPolicyKey = self.sourceCertPolicy.certificatePolicyKey(for: source) ?? .default
113116
self.decodeAndRunSignatureCheck(source: source, data: body, certPolicyKey: certPolicyKey, callback: callback)
114117
}
115118
}
@@ -149,7 +152,11 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
149152
}
150153
}
151154
} catch {
152-
// Collection is not signed
155+
// Bad: collection is supposed to be signed but it isn't
156+
guard !self.sourceCertPolicy.mustBeSigned(source: source) else {
157+
return callback(.failure(PackageCollectionError.missingSignature))
158+
}
159+
// Collection is unsigned
153160
guard let collection = try? self.decoder.decode(JSONModel.Collection.self, from: data) else {
154161
return callback(.failure(Errors.invalidJSON(error)))
155162
}

Tests/PackageCollectionsSigningTests/CertificatePolicyTests.swift

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -319,16 +319,46 @@ class CertificatePolicyTests: XCTestCase {
319319

320320
#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
321321
// The Apple root certs come preinstalled on Apple platforms and they are automatically trusted
322-
let policy = DefaultCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
323-
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
324-
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
322+
323+
// Subject user ID matches
324+
do {
325+
let policy = DefaultCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
326+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
327+
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
328+
}
329+
// Subject user ID does not match
330+
do {
331+
let mismatchSubjectUserID = "\(expectedSubjectUserID)-2"
332+
let policy = DefaultCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: mismatchSubjectUserID,
333+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
334+
XCTAssertThrowsError(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) }) { error in
335+
guard CertificatePolicyError.subjectUserIDMismatch == error as? CertificatePolicyError else {
336+
return XCTFail("Expected CertificatePolicyError.subjectUserIDMismatch")
337+
}
338+
}
339+
}
325340
#else
326341
// On other platforms we have to specify `trustedRootCertsDir` so the Apple root cert is trusted
327342
try withTemporaryDirectory { tmp in
328343
try localFileSystem.copy(from: rootCAPath, to: tmp.appending(components: "AppleIncRoot.cer"))
329-
let policy = DefaultCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
330-
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
331-
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
344+
345+
// Subject user ID matches
346+
do {
347+
let policy = DefaultCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
348+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
349+
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
350+
}
351+
// Subject user ID does not match
352+
do {
353+
let mismatchSubjectUserID = "\(expectedSubjectUserID)-2"
354+
let policy = DefaultCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: mismatchSubjectUserID,
355+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
356+
XCTAssertThrowsError(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) }) { error in
357+
guard CertificatePolicyError.subjectUserIDMismatch == error as? CertificatePolicyError else {
358+
return XCTFail("Expected CertificatePolicyError.subjectUserIDMismatch")
359+
}
360+
}
361+
}
332362
}
333363
#endif
334364
}
@@ -359,16 +389,46 @@ class CertificatePolicyTests: XCTestCase {
359389

360390
#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS)
361391
// The Apple root certs come preinstalled on Apple platforms and they are automatically trusted
362-
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
363-
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
364-
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
392+
393+
// Subject user ID matches
394+
do {
395+
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
396+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
397+
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
398+
}
399+
// Subject user ID does not match
400+
do {
401+
let mismatchSubjectUserID = "\(expectedSubjectUserID)-2"
402+
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: nil, additionalTrustedRootCerts: nil, expectedSubjectUserID: mismatchSubjectUserID,
403+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
404+
XCTAssertThrowsError(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) }) { error in
405+
guard CertificatePolicyError.subjectUserIDMismatch == error as? CertificatePolicyError else {
406+
return XCTFail("Expected CertificatePolicyError.subjectUserIDMismatch")
407+
}
408+
}
409+
}
365410
#else
366411
// On other platforms we have to specify `trustedRootCertsDir` so the Apple root cert is trusted
367412
try withTemporaryDirectory { tmp in
368413
try localFileSystem.copy(from: rootCAPath, to: tmp.appending(components: "AppleIncRoot.cer"))
369-
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
370-
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
371-
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
414+
415+
// Subject user ID matches
416+
do {
417+
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: expectedSubjectUserID,
418+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
419+
XCTAssertNoThrow(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) })
420+
}
421+
// Subject user ID does not match
422+
do {
423+
let mismatchSubjectUserID = "\(expectedSubjectUserID)-2"
424+
let policy = AppleDeveloperCertificatePolicy(trustedRootCertsDir: tmp.asURL, additionalTrustedRootCerts: nil, expectedSubjectUserID: mismatchSubjectUserID,
425+
callbackQueue: DispatchQueue.global(), diagnosticsEngine: DiagnosticsEngine())
426+
XCTAssertThrowsError(try tsc_await { callback in policy.validate(certChain: certChain, callback: callback) }) { error in
427+
guard CertificatePolicyError.subjectUserIDMismatch == error as? CertificatePolicyError else {
428+
return XCTFail("Expected CertificatePolicyError.subjectUserIDMismatch")
429+
}
430+
}
431+
}
372432
}
373433
#endif
374434
}

0 commit comments

Comments
 (0)