Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
136 changes: 56 additions & 80 deletions Sources/AppStoreServerLibrary/ChainVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: DecodedSignedData>(signedData: String, type: T.Type, onlineVerification: Bool, environment: AppStoreEnvironment) async -> VerificationResult<T> where T: Decodable {
let header: JWTHeader
let decodedBody: T
func verify<T: DecodedSignedData>(signedData: String, type: T.Type, onlineVerification: Bool, environment: AppStoreEnvironment) async -> VerificationResult<T> 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<P256>(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)] {
Expand All @@ -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() {
Expand All @@ -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()
}
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions Sources/AppStoreServerLibrary/SignedDataVerifier.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -148,7 +149,23 @@ public struct SignedDataVerifier {
return appTransactionResult
}

private func decodeSignedData<T: DecodedSignedData>(signedData: String, type: T.Type) async -> VerificationResult<T> where T : Decodable {
return await chainVerifier.verify(signedData: signedData, type: type, onlineVerification: self.enableOnlineChecks, environment: self.environment)
private func decodeSignedData<T: DecodedSignedData & JWTPayload>(signedData: String, type: T.Type) async -> VerificationResult<T> 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 {}
}
16 changes: 8 additions & 8 deletions Tests/AppStoreServerLibraryTests/SignedModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
20 changes: 7 additions & 13 deletions Tests/AppStoreServerLibraryTests/TestingUtility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,22 @@ public class TestingUtility {

public static func getSignedDataVerifier() -> SignedDataVerifier {
return getSignedDataVerifier(.localTesting, "com.example")
}
}

public static func confirmCodableInternallyConsistent<T>(_ codable: T) where T : Codable, T : Equatable {
let type = type(of: codable)
let parsedValue = try! getJsonDecoder().decode(type, from: getJsonEncoder().encode(codable))
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<Payload: JWTPayload>(_ 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 {
Expand Down