Skip to content

Commit 166e17f

Browse files
committed
[Collections] Support multiple cert pinning configs per domain
1 parent d96d30c commit 166e17f

File tree

6 files changed

+135
-39
lines changed

6 files changed

+135
-39
lines changed

Sources/PackageCollections/PackageCollections+CertificatePolicy.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,33 +17,36 @@ 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-
private static let defaultSourceCertPolicies: [String: CertificatePolicyConfig] = [
21-
"developer.apple.com": CertificatePolicyConfig(
20+
private static let defaultSourceCertPolicies: [String: [CertificatePolicyConfig]] = [
21+
"developer.apple.com": [CertificatePolicyConfig(
2222
certPolicyKey: .appleDistribution(subjectUserID: "XLVRDL8TZV"),
2323
base64EncodedRootCerts: ["MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh"]
24-
),
24+
)],
2525
]
2626

27-
private let sourceCertPolicies: [String: CertificatePolicyConfig]
27+
private let sourceCertPolicies: [String: [CertificatePolicyConfig]]
2828

29-
var allRootCerts: [String]? {
29+
var allRootCerts: Set<String>? {
3030
let allRootCerts = self.sourceCertPolicies.values
31-
.compactMap { $0.base64EncodedRootCerts }
31+
.flatMap { configs in configs.compactMap { $0.base64EncodedRootCerts } }
3232
.flatMap { $0 }
33-
return allRootCerts.isEmpty ? nil : allRootCerts
33+
return allRootCerts.isEmpty ? nil : Set(allRootCerts)
3434
}
3535

36-
init(sourceCertPolicies: [String: CertificatePolicyConfig]? = nil) {
36+
init(sourceCertPolicies: [String: [CertificatePolicyConfig]]? = nil) {
37+
guard sourceCertPolicies?.values.first(where: { $0.isEmpty }) == nil else {
38+
preconditionFailure("CertificatePolicyConfig array must not be empty")
39+
}
3740
self.sourceCertPolicies = sourceCertPolicies ?? Self.defaultSourceCertPolicies
3841
}
3942

4043
func mustBeSigned(source: Model.CollectionSource) -> Bool {
4144
source.certPolicyConfigKey.map { self.sourceCertPolicies[$0] != nil } ?? false
4245
}
4346

44-
func certificatePolicyKey(for source: Model.CollectionSource) -> CertificatePolicyKey? {
47+
func certificatePolicyKeys(for source: Model.CollectionSource) -> [CertificatePolicyKey]? {
4548
// Certificate policy is associated to a collection host
46-
source.certPolicyConfigKey.flatMap { self.sourceCertPolicies[$0]?.certPolicyKey }
49+
source.certPolicyConfigKey.flatMap { self.sourceCertPolicies[$0]?.map { $0.certPolicyKey } }
4750
}
4851

4952
struct CertificatePolicyConfig {

Sources/PackageCollections/Providers/JSONPackageCollectionProvider.swift

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
3333
static let isSignatureCheckSupported = false
3434
#endif
3535

36+
static let defaultCertPolicyKeys: [CertificatePolicyKey] = [.default]
37+
3638
private let configuration: Configuration
3739
private let diagnosticsEngine: DiagnosticsEngine
3840
private let httpClient: HTTPClient
@@ -54,7 +56,7 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
5456
self.validator = JSONModel.Validator(configuration: configuration.validator)
5557
self.signatureValidator = signatureValidator ?? PackageCollectionSigning(
5658
trustedRootCertsDir: configuration.trustedRootCertsDir ?? fileSystem.swiftPMConfigDirectory.appending(component: "trust-root-certs").asURL,
57-
additionalTrustedRootCerts: sourceCertPolicy.allRootCerts,
59+
additionalTrustedRootCerts: sourceCertPolicy.allRootCerts.map { Array($0) },
5860
callbackQueue: .sharedConcurrent,
5961
diagnosticsEngine: diagnosticsEngine
6062
)
@@ -75,7 +77,7 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
7577
do {
7678
let fileContents = try localFileSystem.readFileContents(absolutePath)
7779
return fileContents.withData { data in
78-
self.decodeAndRunSignatureCheck(source: source, data: data, certPolicyKey: .default, callback: callback)
80+
self.decodeAndRunSignatureCheck(source: source, data: data, certPolicyKeys: Self.defaultCertPolicyKeys, callback: callback)
7981
}
8082
} catch {
8183
return callback(.failure(error))
@@ -129,8 +131,8 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
129131
return callback(.failure(JSONPackageCollectionProviderError.invalidResponse(source.url, "Body is empty")))
130132
}
131133

132-
let certPolicyKey = self.sourceCertPolicy.certificatePolicyKey(for: source) ?? .default
133-
self.decodeAndRunSignatureCheck(source: source, data: body, certPolicyKey: certPolicyKey, callback: callback)
134+
let certPolicyKeys = self.sourceCertPolicy.certificatePolicyKeys(for: source) ?? Self.defaultCertPolicyKeys
135+
self.decodeAndRunSignatureCheck(source: source, data: body, certPolicyKeys: certPolicyKeys, callback: callback)
134136
}
135137
}
136138
}
@@ -139,7 +141,7 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
139141

140142
private func decodeAndRunSignatureCheck(source: Model.CollectionSource,
141143
data: Data,
142-
certPolicyKey: CertificatePolicyKey,
144+
certPolicyKeys: [CertificatePolicyKey],
143145
callback: @escaping (Result<Model.Collection, Error>) -> Void) {
144146
do {
145147
// This fails if collection is not signed (i.e., no "signature")
@@ -154,17 +156,26 @@ struct JSONPackageCollectionProvider: PackageCollectionProvider {
154156
}
155157

156158
// Check the signature
157-
self.signatureValidator.validate(signedCollection: signedCollection, certPolicyKey: certPolicyKey) { result in
158-
switch result {
159-
case .failure(let error):
160-
self.diagnosticsEngine.emit(warning: "The signature of package collection [\(source)] is invalid: \(error)")
161-
if PackageCollectionSigningError.noTrustedRootCertsConfigured == error as? PackageCollectionSigningError {
162-
callback(.failure(PackageCollectionError.cannotVerifySignature))
163-
} else {
164-
callback(.failure(PackageCollectionError.invalidSignature))
159+
let signatureResults = ThreadSafeArrayStore<Result<Void, Error>>()
160+
certPolicyKeys.forEach { certPolicyKey in
161+
self.signatureValidator.validate(signedCollection: signedCollection, certPolicyKey: certPolicyKey) { result in
162+
let count = signatureResults.append(result)
163+
if count == certPolicyKeys.count {
164+
if signatureResults.compactMap({ $0.success }).first != nil {
165+
callback(self.makeCollection(from: signedCollection.collection, source: source, signature: Model.SignatureData(from: signedCollection.signature, isVerified: true)))
166+
} else {
167+
guard let error = signatureResults.compactMap({ $0.failure }).first else {
168+
fatalError("Expected at least one package collection signature validation failure but got none")
169+
}
170+
171+
self.diagnosticsEngine.emit(warning: "The signature of package collection [\(source)] is invalid: \(error)")
172+
if PackageCollectionSigningError.noTrustedRootCertsConfigured == error as? PackageCollectionSigningError {
173+
callback(.failure(PackageCollectionError.cannotVerifySignature))
174+
} else {
175+
callback(.failure(PackageCollectionError.invalidSignature))
176+
}
177+
}
165178
}
166-
case .success:
167-
callback(self.makeCollection(from: signedCollection.collection, source: source, signature: Model.SignatureData(from: signedCollection.signature, isVerified: true)))
168179
}
169180
}
170181
}

Sources/PackageCollectionsSigning/Certificate/CertificatePolicy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ struct AppleDeveloperCertificatePolicy: CertificatePolicy {
762762
}
763763
}
764764

765-
public enum CertificatePolicyKey: Equatable, Hashable {
765+
public enum CertificatePolicyKey: Hashable {
766766
case `default`(subjectUserID: String?)
767767
case appleDistribution(subjectUserID: String?)
768768

Tests/PackageCollectionsTests/JSONPackageCollectionProviderTests.swift

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,81 @@ class JSONPackageCollectionProviderTests: XCTestCase {
652652
let signatureValidator = MockCollectionSignatureValidator(["Sample Package Collection"])
653653
// Collections from www.test.com must be signed
654654
let sourceCertPolicy = PackageCollectionSourceCertificatePolicy(
655-
sourceCertPolicies: ["www.test.com": .init(certPolicyKey: CertificatePolicyKey.default, base64EncodedRootCerts: nil)]
655+
sourceCertPolicies: ["www.test.com": [.init(certPolicyKey: CertificatePolicyKey.default, base64EncodedRootCerts: nil)]]
656+
)
657+
let provider = JSONPackageCollectionProvider(httpClient: httpClient, signatureValidator: signatureValidator,
658+
sourceCertPolicy: sourceCertPolicy, diagnosticsEngine: DiagnosticsEngine())
659+
let source = PackageCollectionsModel.CollectionSource(type: .json, url: url)
660+
let collection = try tsc_await { callback in provider.get(source, callback: callback) }
661+
662+
XCTAssertEqual(collection.name, "Sample Package Collection")
663+
XCTAssertEqual(collection.overview, "This is a sample package collection listing made-up packages.")
664+
XCTAssertEqual(collection.keywords, ["sample package collection"])
665+
XCTAssertEqual(collection.createdBy?.name, "Jane Doe")
666+
XCTAssertEqual(collection.packages.count, 2)
667+
let package = collection.packages.first!
668+
XCTAssertEqual(package.repository, .init(url: "https://www.example.com/repos/RepoOne.git"))
669+
XCTAssertEqual(package.summary, "Package One")
670+
XCTAssertEqual(package.keywords, ["sample package"])
671+
XCTAssertEqual(package.readmeURL, URL(string: "https://www.example.com/repos/RepoOne/README")!)
672+
XCTAssertEqual(package.license, .init(type: .Apache2_0, url: URL(string: "https://www.example.com/repos/RepoOne/LICENSE")!))
673+
XCTAssertEqual(package.versions.count, 1)
674+
let version = package.versions.first!
675+
XCTAssertEqual(version.summary, "Fixed a few bugs")
676+
let manifest = version.manifests.values.first!
677+
XCTAssertEqual(manifest.packageName, "PackageOne")
678+
XCTAssertEqual(manifest.targets, [.init(name: "Foo", moduleName: "Foo")])
679+
XCTAssertEqual(manifest.products, [.init(name: "Foo", type: .library(.automatic), targets: [.init(name: "Foo", moduleName: "Foo")])])
680+
XCTAssertEqual(manifest.toolsVersion, ToolsVersion(string: "5.1")!)
681+
XCTAssertEqual(manifest.minimumPlatformVersions, [SupportedPlatform(platform: .macOS, version: .init("10.15"))])
682+
XCTAssertEqual(version.verifiedCompatibility?.count, 3)
683+
XCTAssertEqual(version.verifiedCompatibility!.first!.platform, .macOS)
684+
XCTAssertEqual(version.verifiedCompatibility!.first!.swiftVersion, SwiftLanguageVersion(string: "5.1")!)
685+
XCTAssertEqual(version.license, .init(type: .Apache2_0, url: URL(string: "https://www.example.com/repos/RepoOne/LICENSE")!))
686+
XCTAssertNotNil(version.createdAt)
687+
XCTAssertTrue(collection.isSigned)
688+
let signature = collection.signature!
689+
XCTAssertTrue(signature.isVerified)
690+
XCTAssertEqual("Sample Subject", signature.certificate.subject.commonName)
691+
XCTAssertEqual("Sample Issuer", signature.certificate.issuer.commonName)
692+
}
693+
}
694+
695+
func testRequiredSigningMultiplePoliciesGood() throws {
696+
fixture(name: "Collections") { directoryPath in
697+
let path = directoryPath.appending(components: "JSON", "good_signed.json")
698+
let url = URL(string: "https://www.test.com/collection.json")!
699+
let data = Data(try localFileSystem.readFileContents(path).contents)
700+
701+
let handler: HTTPClient.Handler = { request, _, completion in
702+
XCTAssertEqual(request.url, url, "url should match")
703+
switch request.method {
704+
case .head:
705+
completion(.success(.init(statusCode: 200,
706+
headers: .init([.init(name: "Content-Length", value: "\(data.count)")]))))
707+
case .get:
708+
completion(.success(.init(statusCode: 200,
709+
headers: .init([.init(name: "Content-Length", value: "\(data.count)")]),
710+
body: data)))
711+
default:
712+
XCTFail("method should match")
713+
}
714+
}
715+
716+
var httpClient = HTTPClient(handler: handler)
717+
httpClient.configuration.circuitBreakerStrategy = .none
718+
httpClient.configuration.retryStrategy = .none
719+
720+
// Mark collection as having valid signature
721+
let signatureValidator = MockCollectionSignatureValidator(certPolicyKeys: [CertificatePolicyKey.default(subjectUserID: "test")])
722+
// Collections from www.test.com must be signed
723+
let sourceCertPolicy = PackageCollectionSourceCertificatePolicy(
724+
sourceCertPolicies: [
725+
"www.test.com": [
726+
.init(certPolicyKey: CertificatePolicyKey.default, base64EncodedRootCerts: nil),
727+
.init(certPolicyKey: CertificatePolicyKey.default(subjectUserID: "test"), base64EncodedRootCerts: nil),
728+
],
729+
]
656730
)
657731
let provider = JSONPackageCollectionProvider(httpClient: httpClient, signatureValidator: signatureValidator,
658732
sourceCertPolicy: sourceCertPolicy, diagnosticsEngine: DiagnosticsEngine())
@@ -721,7 +795,7 @@ class JSONPackageCollectionProviderTests: XCTestCase {
721795
let signatureValidator = MockCollectionSignatureValidator()
722796
// Collections from www.test.com must be signed
723797
let sourceCertPolicy = PackageCollectionSourceCertificatePolicy(
724-
sourceCertPolicies: ["www.test.com": .init(certPolicyKey: CertificatePolicyKey.default, base64EncodedRootCerts: nil)]
798+
sourceCertPolicies: ["www.test.com": [.init(certPolicyKey: CertificatePolicyKey.default, base64EncodedRootCerts: nil)]]
725799
)
726800
let provider = JSONPackageCollectionProvider(httpClient: httpClient, signatureValidator: signatureValidator,
727801
sourceCertPolicy: sourceCertPolicy, diagnosticsEngine: DiagnosticsEngine())

Tests/PackageCollectionsTests/PackageCollectionSourceCertificatePolicyTests.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,30 @@ import PackageCollectionsSigning
1717
final class PackageCollectionSourceCertificatePolicyTests: XCTestCase {
1818
func testCustomData() {
1919
let sourceCertPolicy = PackageCollectionSourceCertificatePolicy(sourceCertPolicies: [
20-
"package-collection-1": PackageCollectionSourceCertificatePolicy.CertificatePolicyConfig(
21-
certPolicyKey: CertificatePolicyKey.default,
22-
base64EncodedRootCerts: ["root-cert-1a", "root-cert-1b"]
23-
),
24-
"package-collection-2": PackageCollectionSourceCertificatePolicy.CertificatePolicyConfig(
20+
"package-collection-1": [
21+
PackageCollectionSourceCertificatePolicy.CertificatePolicyConfig(
22+
certPolicyKey: CertificatePolicyKey.default,
23+
base64EncodedRootCerts: ["root-cert-1a", "root-cert-1b"]
24+
),
25+
PackageCollectionSourceCertificatePolicy.CertificatePolicyConfig(
26+
certPolicyKey: .default(subjectUserID: "test"),
27+
base64EncodedRootCerts: ["root-cert-1c"]
28+
),
29+
],
30+
"package-collection-2": [PackageCollectionSourceCertificatePolicy.CertificatePolicyConfig(
2531
certPolicyKey: CertificatePolicyKey.default,
2632
base64EncodedRootCerts: ["root-cert-2"]
27-
),
33+
)],
2834
])
2935
let source1 = Model.CollectionSource(type: .json, url: URL(string: "https://package-collection-1")!)
3036
let unsignedSource = Model.CollectionSource(type: .json, url: URL(string: "https://package-collection-unsigned")!)
3137

32-
XCTAssertEqual(["root-cert-1a", "root-cert-1b", "root-cert-2"], sourceCertPolicy.allRootCerts?.sorted())
38+
XCTAssertEqual(["root-cert-1a", "root-cert-1b", "root-cert-1c", "root-cert-2"], sourceCertPolicy.allRootCerts?.sorted())
3339

3440
XCTAssertTrue(sourceCertPolicy.mustBeSigned(source: source1))
3541
XCTAssertFalse(sourceCertPolicy.mustBeSigned(source: unsignedSource))
3642

37-
XCTAssertEqual(CertificatePolicyKey.default, sourceCertPolicy.certificatePolicyKey(for: source1))
38-
XCTAssertNil(sourceCertPolicy.certificatePolicyKey(for: unsignedSource))
43+
XCTAssertEqual([.default, .default(subjectUserID: "test")], sourceCertPolicy.certificatePolicyKeys(for: source1))
44+
XCTAssertNil(sourceCertPolicy.certificatePolicyKeys(for: unsignedSource))
3945
}
4046
}

Tests/PackageCollectionsTests/Utility.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,12 @@ struct MockMetadataProvider: PackageMetadataProvider {
174174

175175
struct MockCollectionSignatureValidator: PackageCollectionSignatureValidator {
176176
let collections: Set<String>
177+
let certPolicyKeys: Set<CertificatePolicyKey>
177178
let hasTrustedRootCerts: Bool
178179

179-
init(_ collections: Set<String> = [], hasTrustedRootCerts: Bool = true) {
180+
init(_ collections: Set<String> = [], certPolicyKeys: Set<CertificatePolicyKey> = [], hasTrustedRootCerts: Bool = true) {
180181
self.collections = collections
182+
self.certPolicyKeys = certPolicyKeys
181183
self.hasTrustedRootCerts = hasTrustedRootCerts
182184
}
183185

@@ -188,7 +190,7 @@ struct MockCollectionSignatureValidator: PackageCollectionSignatureValidator {
188190
return callback(.failure(PackageCollectionSigningError.noTrustedRootCertsConfigured))
189191
}
190192

191-
if self.collections.contains(signedCollection.collection.name) {
193+
if self.collections.contains(signedCollection.collection.name) || self.certPolicyKeys.contains(certPolicyKey) {
192194
callback(.success(()))
193195
} else {
194196
callback(.failure(PackageCollectionSigningError.invalidSignature))

0 commit comments

Comments
 (0)