diff --git a/Package.swift b/Package.swift index 72fa703..e526f0c 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-asn1.git", from: "1.1.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"), - .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), + .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.2.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio", from: "2.0.0"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.9.0"), diff --git a/Sources/AppStoreServerLibrary/ChainVerifier.swift b/Sources/AppStoreServerLibrary/ChainVerifier.swift index 3d94c9b..41f273b 100644 --- a/Sources/AppStoreServerLibrary/ChainVerifier.swift +++ b/Sources/AppStoreServerLibrary/ChainVerifier.swift @@ -9,87 +9,67 @@ import AsyncHTTPClient import NIOFoundationCompat class ChainVerifier { - private static let EXPECTED_CHAIN_LENGTH = 3 - private static let EXPECTED_JWT_SEGMENTS = 3 - private static let EXPECTED_ALGORITHM = "ES256" - + private static let MAXIMUM_CACHE_SIZE = 32 // There are unlikely to be more than a couple keys at once private static let CACHE_TIME_LIMIT: Int64 = 15 * 60 // 15 minutes in seconds - - private let store: CertificateStore + + private let x5cVerifier: X5CVerifier private let requester: Requester private var verifiedPublicKeyCache: [CacheKey: CacheValue] init(rootCertificates: [Data]) throws { - let parsedCertificates = try rootCertificates.map { try Certificate(derEncoded: [UInt8]($0)) } - self.store = CertificateStore(parsedCertificates) + self.x5cVerifier = try X5CVerifier(rootCertificates: rootCertificates) self.requester = Requester() self.verifiedPublicKeyCache = [:] } - func verify(signedData: String, type: T.Type, onlineVerification: Bool, environment: AppStoreEnvironment) async -> VerificationResult where T: Decodable { - let header: JWTHeader - let decodedBody: T + func verify(signedData: String, type: T.Type, onlineVerification: Bool, environment: AppStoreEnvironment) async -> VerificationResult where T: JWTPayload { + let jsonDecoder = getJsonDecoder() + let parser = DefaultJWTParser(jsonDecoder: jsonDecoder) + let payload: T + let header: JWTKit.JWTHeader + let dataToken = Data(signedData.utf8) + do { - let bodySegments = signedData.components(separatedBy: ".") - if (bodySegments.count != ChainVerifier.EXPECTED_JWT_SEGMENTS) { - return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) - } - let jsonDecoder = getJsonDecoder() - guard let headerData = Data(base64Encoded: base64URLToBase64(bodySegments[0])), let bodyData = Data(base64Encoded: base64URLToBase64(bodySegments[1])) else { - return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) - } - header = try jsonDecoder.decode(JWTHeader.self, from: headerData) - decodedBody = try jsonDecoder.decode(type, from: bodyData) + (header, payload, _) = try parser.parse(dataToken, as: type) } catch { return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) } - + if (environment == AppStoreEnvironment.xcode || environment == AppStoreEnvironment.localTesting) { - // Data is not signed by the App Store, and verification should be skipped + // Data is not signed by the App Store, and verification should be skipped. // The environment MUST be checked in the public method calling this - return VerificationResult.valid(decodedBody) + return VerificationResult.valid(payload) } - guard let x5c_header = header.x5c else { - return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) - } - if ChainVerifier.EXPECTED_ALGORITHM != header.alg || x5c_header.count != ChainVerifier.EXPECTED_CHAIN_LENGTH { - return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) + guard let x5c = header.x5c, x5c.count == ChainVerifier.EXPECTED_CHAIN_LENGTH else { + return .invalid(VerificationError.INVALID_JWT_FORMAT) } - - guard let leaf_der_enocded = Data(base64Encoded: x5c_header[0]), - let intermeidate_der_encoded = Data(base64Encoded: x5c_header[1]) else { - return VerificationResult.invalid(VerificationError.INVALID_CERTIFICATE) - } + let validationTime = !onlineVerification && payload.signedDate != nil ? payload.signedDate! : getDate() + do { - let leafCertificate = try Certificate(derEncoded: Array(leaf_der_enocded)) - let intermediateCertificate = try Certificate(derEncoded: Array(intermeidate_der_encoded)) - let validationTime = !onlineVerification && decodedBody.signedDate != nil ? decodedBody.signedDate! : getDate() - - let verificationResult = await verifyChain(leaf: leafCertificate, intermediate: intermediateCertificate, online: onlineVerification, validationTime: validationTime) - switch verificationResult { - case .validCertificate(let chain): - let leafCertificate = chain.first! - guard let publicKey = P256.Signing.PublicKey(leafCertificate.publicKey) else { - return VerificationResult.invalid(VerificationError.VERIFICATION_FAILURE) + let body = try await x5cVerifier.verifyJWS(dataToken, as: type, jsonDecoder: jsonDecoder, policy: { + RFC5280Policy(validationTime: validationTime) + AppStoreOIDPolicy() + if onlineVerification { + OCSPVerifierPolicy(failureMode: .hard, requester: requester, validationTime: getDate()) } - // Verify using Vapor - let keys = JWTKeyCollection() - await keys.add(ecdsa: try ECDSA.PublicKey(backing: publicKey)) - let _ = try await keys.verify(signedData) as VaporBody - - return VerificationResult.valid(decodedBody) - case .couldNotValidate: - return VerificationResult.invalid(VerificationError.VERIFICATION_FAILURE) - } + }) + return VerificationResult.valid(body) } catch { - return VerificationResult.invalid(VerificationError.INVALID_JWT_FORMAT) + if + let jwtError = error as? JWTError, + jwtError.errorType == .missingX5CHeader || jwtError.errorType == .malformedToken + { + return .invalid(VerificationError.INVALID_JWT_FORMAT) + } else { + return .invalid(VerificationError.VERIFICATION_FAILURE) + } } } - + func verifyChain(leaf: Certificate, intermediate: Certificate, online: Bool, validationTime: Date) async -> X509.VerificationResult { if online { if let cachedResult = verifiedPublicKeyCache[CacheKey(leaf: leaf, intermediate: intermediate)] { @@ -98,11 +78,16 @@ class ChainVerifier { } } } + let verificationResult = await verifyChainWithoutCaching(leaf: leaf, intermediate: intermediate, online: online, validationTime: validationTime) - + if online { - if case .validCertificate = verificationResult { - verifiedPublicKeyCache[CacheKey(leaf: leaf, intermediate: intermediate)] = CacheValue(expirationTime: getDate().addingTimeInterval(TimeInterval(integerLiteral: ChainVerifier.CACHE_TIME_LIMIT)), publicKey: verificationResult) + if case .validCertificate(_) = verificationResult { + verifiedPublicKeyCache[CacheKey(leaf: leaf, intermediate: intermediate)] = CacheValue( + expirationTime: getDate().addingTimeInterval(TimeInterval(integerLiteral: ChainVerifier.CACHE_TIME_LIMIT)), + publicKey: verificationResult + ) + if verifiedPublicKeyCache.count > ChainVerifier.MAXIMUM_CACHE_SIZE { for kv in verifiedPublicKeyCache { if kv.value.expirationTime < getDate() { @@ -112,22 +97,24 @@ class ChainVerifier { } } } - + return verificationResult } - + func verifyChainWithoutCaching(leaf: Certificate, intermediate: Certificate, online: Bool, validationTime: Date) async -> X509.VerificationResult { - var verifier = Verifier(rootCertificates: self.store) { - RFC5280Policy(validationTime: validationTime) - AppStoreOIDPolicy() - if online { - OCSPVerifierPolicy(failureMode: .hard, requester: requester, validationTime: getDate()) - } + do { + return try await x5cVerifier.verifyChain(certificates: [leaf, intermediate], policy: { + RFC5280Policy(validationTime: validationTime) + AppStoreOIDPolicy() + if online { + OCSPVerifierPolicy(failureMode: .hard, requester: requester, validationTime: getDate()) + } + }) + } catch { + return .couldNotValidate([]) } - let intermediateStore = CertificateStore([intermediate]) - return await verifier.validate(leafCertificate: leaf, intermediates: intermediateStore) } - + func getDate() -> Date { return Date() } @@ -143,17 +130,6 @@ struct CacheValue { let publicKey: X509.VerificationResult } -struct VaporBody : JWTPayload { - func verify(using algorithm: some JWTAlgorithm) async throws { - // No-op - } -} - -struct JWTHeader: Decodable, Encodable { - public var alg: String? - public var x5c: [String]? -} - final class AppStoreOIDPolicy: VerifierPolicy { private static let NUMBER_OF_CERTS = 3 diff --git a/Sources/AppStoreServerLibrary/SignedDataVerifier.swift b/Sources/AppStoreServerLibrary/SignedDataVerifier.swift index 2df7b3a..318b8fd 100644 --- a/Sources/AppStoreServerLibrary/SignedDataVerifier.swift +++ b/Sources/AppStoreServerLibrary/SignedDataVerifier.swift @@ -1,6 +1,7 @@ // Copyright (c) 2023 Apple Inc. Licensed under MIT License. import Foundation +import JWTKit ///A verifier and decoder class designed to decode signed data from the App Store. public struct SignedDataVerifier { @@ -148,7 +149,23 @@ public struct SignedDataVerifier { return appTransactionResult } - private func decodeSignedData(signedData: String, type: T.Type) async -> VerificationResult where T : Decodable { - return await chainVerifier.verify(signedData: signedData, type: type, onlineVerification: self.enableOnlineChecks, environment: self.environment) + private func decodeSignedData(signedData: String, type: T.Type) async -> VerificationResult where T : Decodable { + await chainVerifier.verify(signedData: signedData, type: type, onlineVerification: self.enableOnlineChecks, environment: self.environment) } } + +extension AppTransaction: JWTPayload { + public func verify(using algorithm: some JWTAlgorithm) async throws {} +} + +extension ResponseBodyV2DecodedPayload: JWTPayload { + public func verify(using algorithm: some JWTAlgorithm) async throws {} +} + +extension JWSTransactionDecodedPayload: JWTPayload { + public func verify(using algorithm: some JWTAlgorithm) async throws {} +} + +extension JWSRenewalInfoDecodedPayload: JWTPayload { + public func verify(using algorithm: some JWTAlgorithm) async throws {} +} diff --git a/Tests/AppStoreServerLibraryTests/SignedModelTests.swift b/Tests/AppStoreServerLibraryTests/SignedModelTests.swift index 2e4d555..156ab90 100644 --- a/Tests/AppStoreServerLibraryTests/SignedModelTests.swift +++ b/Tests/AppStoreServerLibraryTests/SignedModelTests.swift @@ -6,7 +6,7 @@ import XCTest final class SignedModelTests: XCTestCase { public func testNotificationDecoding() async throws { - let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedNotification.json") + let signedNotification = try await TestingUtility.createSignedDataFromJson("resources/models/signedNotification.json", as: ResponseBodyV2DecodedPayload.self) let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) @@ -40,7 +40,7 @@ final class SignedModelTests: XCTestCase { } public func testConsumptionRequestNotificationDecoding() async throws { - let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedConsumptionRequestNotification.json") + let signedNotification = try await TestingUtility.createSignedDataFromJson("resources/models/signedConsumptionRequestNotification.json", as: ResponseBodyV2DecodedPayload.self) let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) @@ -74,7 +74,7 @@ final class SignedModelTests: XCTestCase { } public func testSummaryNotificationDecoding() async throws { - let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedSummaryNotification.json") + let signedNotification = try await TestingUtility.createSignedDataFromJson("resources/models/signedSummaryNotification.json", as: ResponseBodyV2DecodedPayload.self) let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) @@ -106,7 +106,7 @@ final class SignedModelTests: XCTestCase { } public func testExternalPurchaseTokenNotificationDecoding() async throws { - let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedExternalPurchaseTokenNotification.json") + let signedNotification = try await TestingUtility.createSignedDataFromJson("resources/models/signedExternalPurchaseTokenNotification.json", as: ResponseBodyV2DecodedPayload.self) let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) { bundleId, appAppleId, environment in XCTAssertEqual("com.example", bundleId) @@ -138,7 +138,7 @@ final class SignedModelTests: XCTestCase { } public func testExternalPurchaseTokenSandboxNotificationDecoding() async throws { - let signedNotification = TestingUtility.createSignedDataFromJson("resources/models/signedExternalPurchaseTokenSandboxNotification.json") + let signedNotification = try await TestingUtility.createSignedDataFromJson("resources/models/signedExternalPurchaseTokenSandboxNotification.json", as: ResponseBodyV2DecodedPayload.self) let verifiedNotification = await TestingUtility.getSignedDataVerifier().verifyAndDecodeNotification(signedPayload: signedNotification) { bundleId, appAppleId, environment in XCTAssertEqual("com.example", bundleId) @@ -170,7 +170,7 @@ final class SignedModelTests: XCTestCase { } public func testTransactionDecoding() async throws { - let signedTransaction = TestingUtility.createSignedDataFromJson("resources/models/signedTransaction.json") + let signedTransaction = try await TestingUtility.createSignedDataFromJson("resources/models/signedTransaction.json", as: JWSTransactionDecodedPayload.self) let verifiedTransaction = await TestingUtility.getSignedDataVerifier().verifyAndDecodeTransaction(signedTransaction: signedTransaction) @@ -218,7 +218,7 @@ final class SignedModelTests: XCTestCase { } public func testRenewalInfoDecoding() async throws { - let signedRenewalInfo = TestingUtility.createSignedDataFromJson("resources/models/signedRenewalInfo.json") + let signedRenewalInfo = try await TestingUtility.createSignedDataFromJson("resources/models/signedRenewalInfo.json", as: JWSRenewalInfoDecodedPayload.self) let verifiedRenewalInfo = await TestingUtility.getSignedDataVerifier().verifyAndDecodeRenewalInfo(signedRenewalInfo: signedRenewalInfo) @@ -258,7 +258,7 @@ final class SignedModelTests: XCTestCase { } public func testAppTransactionDecoding() async throws { - let signedAppTransaction = TestingUtility.createSignedDataFromJson("resources/models/appTransaction.json") + let signedAppTransaction = try await TestingUtility.createSignedDataFromJson("resources/models/appTransaction.json", as: AppTransaction.self) let verifiedAppTransaction = await TestingUtility.getSignedDataVerifier().verifyAndDecodeAppTransaction(signedAppTransaction: signedAppTransaction) diff --git a/Tests/AppStoreServerLibraryTests/TestingUtility.swift b/Tests/AppStoreServerLibraryTests/TestingUtility.swift index f59af7d..b18a561 100644 --- a/Tests/AppStoreServerLibraryTests/TestingUtility.swift +++ b/Tests/AppStoreServerLibraryTests/TestingUtility.swift @@ -29,7 +29,7 @@ public class TestingUtility { public static func getSignedDataVerifier() -> SignedDataVerifier { return getSignedDataVerifier(.localTesting, "com.example") -} + } public static func confirmCodableInternallyConsistent(_ codable: T) where T : Codable, T : Equatable { let type = type(of: codable) @@ -37,20 +37,14 @@ public class TestingUtility { XCTAssertEqual(parsedValue, codable) } - public static func createSignedDataFromJson(_ path: String) -> String { - let payload = readFile(path) - let signingKey = Crypto.P256.Signing.PrivateKey() - - let header = JWTHeader(alg: "ES256") + public static func createSignedDataFromJson(_ path: String, as: Payload.Type) async throws -> String { + let payloadString = readFile(path) + let serializer = DefaultJWTSerializer(jsonEncoder: getJsonEncoder()) + let key = await JWTKeyCollection().add(ecdsa: ES256PrivateKey(), serializer: serializer) - let encoder = JSONEncoder() - let headerData = try! encoder.encode(header) - let encodedHeader = headerData.base64EncodedString() - let encodedPayload = base64ToBase64URL(payload.data(using: .utf8)!.base64EncodedString()) + let payload = try getJsonDecoder().decode(Payload.self, from: .init(payloadString.utf8)) - var signingInput = "\(encodedHeader).\(encodedPayload)" - let signature = try! signingInput.withUTF8 { try signingKey.signature(for: $0) } - return "\(signingInput).\(base64ToBase64URL(signature.rawRepresentation.base64EncodedString()))" + return try await key.sign(payload) } private static func base64ToBase64URL(_ encodedString: String) -> String {