From 99cb60bec06eb6fa4affe0d91b59eba12298f9f0 Mon Sep 17 00:00:00 2001 From: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Date: Sun, 2 Aug 2026 08:27:10 -0700 Subject: [PATCH] feat(ios): add real App Attest client Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .github/workflows/ci.yml | 2 + .../BuzzDevPushEnrollmentDriver.swift | 1123 +++++++------ .../BuzzDevPushEnrollmentDriverTests.swift | 1427 ++++++++++------- 3 files changed, 1531 insertions(+), 1021 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f7fbec2..2d2b3c43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -872,6 +872,8 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Build run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit - name: Validate iOS project semantics diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index c69617d34..407124502 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -1,4 +1,5 @@ import CryptoKit +import DeviceCheck import Foundation #if canImport(Security) @@ -61,47 +62,65 @@ public protocol BuzzPushEndpointGrantStore { func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws } -#if DEBUG - public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { - case invalidGatewayURL - case invalidRelayURL - case invalidRelayDescriptor - case invalidResponse(route: String) - case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) - case randomGenerationFailed(Int32) - case generationExhausted +public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { + case invalidGatewayURL + case invalidRelayURL + case invalidRelayDescriptor + case invalidResponse(route: String) + case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) + case randomGenerationFailed(Int32) + case appAttestUnsupported + case invalidAppAttestKeyId + case generationExhausted - public var errorDescription: String? { - switch self { - case .invalidGatewayURL: - return "The development push gateway URL must be an HTTP or HTTPS origin." - case .invalidRelayURL: - return "The relay URL must be a ws or wss origin." - case .invalidRelayDescriptor: - return "NIP-11 must contain exactly one valid current push key." - case .invalidResponse(let route): - return "The response from \(route) did not match the closed push protocol." - case .unexpectedStatus(let route, let expected, let actual, let body): - return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" - case .randomGenerationFailed(let status): - return "Secure random generation failed with status \(status)." - case .generationExhausted: - return "The development push grant generation cannot advance further." - } + public var errorDescription: String? { + switch self { + case .invalidGatewayURL: + return "The development push gateway URL must be an HTTP or HTTPS origin." + case .invalidRelayURL: + return "The relay URL must be a ws or wss origin." + case .invalidRelayDescriptor: + return "NIP-11 must contain exactly one valid current push key." + case .invalidResponse(let route): + return "The response from \(route) did not match the closed push protocol." + case .unexpectedStatus(let route, let expected, let actual, let body): + return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" + case .randomGenerationFailed(let status): + return "Secure random generation failed with status \(status)." + case .appAttestUnsupported: + return "App Attest is unavailable on this device." + case .invalidAppAttestKeyId: + return "The App Attest key identifier is missing or invalid." + case .generationExhausted: + return "The development push grant generation cannot advance further." } } +} - protocol BuzzDevAppAttesting { - func prepareAttestation() throws -> BuzzDevAttestation - func attestation(_ prepared: BuzzDevAttestation, clientData: Data) throws -> BuzzDevAttestation - func assertion(clientData: Data) throws -> String - } +protocol BuzzDevAppAttesting { + func prepareAttestation() async throws -> BuzzDevAttestation + func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws + -> BuzzDevAttestation + func assertion(clientData: Data) async throws -> String +} - struct BuzzDevAttestation: Equatable { - let keyId: String - let attestation: String +struct BuzzDevAttestation: Equatable { + let keyId: String + let attestation: String +} + +private enum BuzzSecureRandom { + static func bytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) + } + return Data(bytes) } +} +#if DEBUG struct BuzzDevAppAttestProvider: BuzzDevAppAttesting { private static let attestationPrefix = Data("buzz-dev-app-attest-v1:".utf8) private static let assertionBytes = Data("buzz-dev-app-assertion-v1".utf8) @@ -110,13 +129,13 @@ public protocol BuzzPushEndpointGrantStore { init( randomBytes: @escaping () throws -> Data = { - try BuzzDevAppAttestProvider.secureRandomBytes() + try BuzzSecureRandom.bytes(count: 32) } ) { self.randomBytes = randomBytes } - func prepareAttestation() throws -> BuzzDevAttestation { + func prepareAttestation() async throws -> BuzzDevAttestation { let entropy = try randomBytes() precondition(entropy.count == 32, "Development attestation entropy must be exactly 32 bytes") let bytes = Self.attestationPrefix + entropy @@ -126,41 +145,235 @@ public protocol BuzzPushEndpointGrantStore { ) } - func attestation(_ prepared: BuzzDevAttestation, clientData: Data) throws -> BuzzDevAttestation - { + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { precondition(!clientData.isEmpty, "Enrollment client data must not be empty") return prepared } - func assertion(clientData: Data) throws -> String { + func assertion(clientData: Data) async throws -> String { precondition(!clientData.isEmpty, "Delegation client data must not be empty") return Self.assertionBytes.base64EncodedString() } + } +#endif - fileprivate static func secureRandomBytes(count: Int = 32) throws -> Data { - var bytes = [UInt8](repeating: 0, count: count) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) - } - return Data(bytes) +private enum BuzzAppAttestKeyId { + static func isValid(_ keyId: String) -> Bool { + guard !keyId.isEmpty, + keyId.unicodeScalars.allSatisfy(\.isASCII), + let bytes = Data(base64Encoded: keyId) + else { return false } + return bytes.count == 32 && bytes.base64EncodedString() == keyId + } +} + +protocol BuzzAppAttestKeyIdStoring { + func keyId() throws -> String? + func saveKeyId(_ keyId: String) throws +} + +struct BuzzAppAttestKeyIdKeychainStore: BuzzAppAttestKeyIdStoring { + private static let service = "buzz.push.app-attest" + private static let account = "key-id-v1" + + private let accessGroup: String? + private let copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + private let update: (CFDictionary, CFDictionary) -> OSStatus + private let add: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + + init( + accessGroup: String?, + copyMatching: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = + SecItemCopyMatching, + update: @escaping (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate, + add: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemAdd + ) { + self.accessGroup = accessGroup + self.copyMatching = copyMatching + self.update = update + self.add = add + } + + func keyId() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw keychainError(status, operation: "read") + } + guard let data = result as? Data, + let keyId = String(data: data, encoding: .utf8), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + return keyId + } + + func saveKeyId(_ keyId: String) throws { + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let data = Data(keyId.utf8) + let updateStatus = update( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var item = baseQuery() + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = add(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") } } - /// DEBUG-only enrollment and delegation driver for the gated gateway bypass. - /// This type and both sentinel byte strings are absent from non-DEBUG builds. - public final class BuzzDevPushEnrollmentDriver { - public static let appProfile = "buzz-ios-sandbox" - public static let endpointEpoch: Int64 = 1 + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } - private let gatewayBaseURL: URL - private let store: BuzzPushEndpointGrantStore - private let session: URLSession - private let appAttest: BuzzDevAppAttesting - private let now: () -> Date - private let lifetimeSeconds: Int64 - private let installationIdBytes: () throws -> Data + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "App Attest key identifier Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } +} +protocol BuzzDCAppAttestServicing { + var isSupported: Bool { get } + func generateKey() async throws -> String + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data +} + +extension DCAppAttestService: BuzzDCAppAttestServicing {} + +struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { + private let service: BuzzDCAppAttestServicing + private let keyIdStore: BuzzAppAttestKeyIdStoring + + init( + service: BuzzDCAppAttestServicing = DCAppAttestService.shared, + keyIdStore: BuzzAppAttestKeyIdStoring + ) { + self.service = service + self.keyIdStore = keyIdStore + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + try requireSupportedService() + let keyId = try await service.generateKey() + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + try keyIdStore.saveKeyId(keyId) + return BuzzDevAttestation(keyId: keyId, attestation: "") + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + try requireSupportedService() + guard BuzzAppAttestKeyId.isValid(prepared.keyId), + try keyIdStore.keyId() == prepared.keyId + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.attestKey( + prepared.keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return BuzzDevAttestation( + keyId: prepared.keyId, + attestation: object.base64EncodedString() + ) + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + try requireSupportedService() + guard let keyId = try keyIdStore.keyId(), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.generateAssertion( + keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return object.base64EncodedString() + } + + private func requireSupportedService() throws { + guard service.isSupported else { + throw BuzzDevPushEnrollmentError.appAttestUnsupported + } + } +} + +/// Enrollment and delegation driver for real App Attest and the gated debug bypass. +public final class BuzzDevPushEnrollmentDriver { + public static let appProfile = "buzz-ios-sandbox" + public static let endpointEpoch: Int64 = 1 + + private let gatewayBaseURL: URL + private let store: BuzzPushEndpointGrantStore + private let session: URLSession + private let appAttest: BuzzDevAppAttesting + private let now: () -> Date + private let lifetimeSeconds: Int64 + private let installationIdBytes: () throws -> Data + + /// Creates a driver backed by Apple's App Attest service and persists the + /// generated App Attest key identifier in the requested Keychain access group. + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + appAttestKeychainAccessGroup: String?, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDCAppAttestProvider( + keyIdStore: BuzzAppAttestKeyIdKeychainStore( + accessGroup: appAttestKeychainAccessGroup + ) + ), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + + #if DEBUG public convenience init( gatewayBaseURL: URL, store: BuzzPushEndpointGrantStore, @@ -173,435 +386,435 @@ public protocol BuzzPushEndpointGrantStore { appAttest: BuzzDevAppAttestProvider(), now: Date.init, lifetimeSeconds: 2_592_000, - installationIdBytes: { try BuzzDevAppAttestProvider.secureRandomBytes(count: 16) } + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } ) } + #endif - init( - gatewayBaseURL: URL, - store: BuzzPushEndpointGrantStore, - session: URLSession, - appAttest: BuzzDevAppAttesting, - now: @escaping () -> Date, - lifetimeSeconds: Int64, - installationIdBytes: @escaping () throws -> Data = { - try BuzzDevAppAttestProvider.secureRandomBytes(count: 16) + init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession, + appAttest: BuzzDevAppAttesting, + now: @escaping () -> Date, + lifetimeSeconds: Int64, + installationIdBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 16) + } + ) throws { + guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = gatewayBaseURL + self.store = store + self.session = session + self.appAttest = appAttest + self.now = now + self.lifetimeSeconds = lifetimeSeconds + self.installationIdBytes = installationIdBytes + } + + public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { + try store.records() + } + + /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, + /// delegates to that key, and durably saves the resulting opaque grant. + public func enroll( + deviceToken: Data, + relayURL: URL + ) async throws -> BuzzPushEndpointGrantRecord { + precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + let relayOrigin = try Self.relayOrigin(relayURL) + let relayPubkey = try await fetchCurrentRelayPushPubkey(from: relayOrigin.url) + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let nowSeconds = Int64(now().timeIntervalSince1970) + + let storedRecords = try store.records() + let storedForOrigin = storedRecords.first { + $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + } + if let current = storedForOrigin, + current.relayPubkey == relayPubkey, + current.endpointHash == endpointHash, + current.endpointEpoch == Self.endpointEpoch, + current.expiresAt > nowSeconds + 300 + { + return current + } + let generation: Int64 + if let storedForOrigin { + let (next, overflow) = storedForOrigin.generation.addingReportingOverflow(1) + guard !overflow, next > 0 else { + throw BuzzDevPushEnrollmentError.generationExhausted } - ) throws { - guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { - throw BuzzDevPushEnrollmentError.invalidGatewayURL - } - self.gatewayBaseURL = gatewayBaseURL - self.store = store - self.session = session - self.appAttest = appAttest - self.now = now - self.lifetimeSeconds = lifetimeSeconds - self.installationIdBytes = installationIdBytes + generation = next + } else { + generation = 1 } - public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { - try store.records() + let (expiresAt, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL } - /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, - /// delegates to that key, and durably saves the resulting opaque grant. - public func enroll( - deviceToken: Data, - relayURL: URL - ) async throws -> BuzzPushEndpointGrantRecord { - precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") - let relayOrigin = try Self.relayOrigin(relayURL) - let relayPubkey = try await fetchCurrentRelayPushPubkey(from: relayOrigin.url) - let endpoint = Self.lowercaseHex(deviceToken) - let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) - let nowSeconds = Int64(now().timeIntervalSince1970) + let enrollmentChallenge = try await challenge() + let preparedAttestation = try await appAttest.prepareAttestation() + let enrollmentClientData = try BuzzPushTranscript.enroll( + challengeId: enrollmentChallenge.id, + challenge: enrollmentChallenge.value, + keyId: preparedAttestation.keyId, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + let attestation = try await appAttest.attestation( + preparedAttestation, + clientData: enrollmentClientData + ) + guard attestation.keyId == preparedAttestation.keyId else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") + } + let installation = try await enrollInstallation( + challenge: enrollmentChallenge, + endpoint: endpoint, + expiresAt: expiresAt, + attestation: attestation + ) - let storedRecords = try store.records() - let storedForOrigin = storedRecords.first { - $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile - } - if let current = storedForOrigin, - current.relayPubkey == relayPubkey, - current.endpointHash == endpointHash, - current.endpointEpoch == Self.endpointEpoch, - current.expiresAt > nowSeconds + 300 - { - return current - } - let generation: Int64 - if let storedForOrigin { - let (next, overflow) = storedForOrigin.generation.addingReportingOverflow(1) - guard !overflow, next > 0 else { - throw BuzzDevPushEnrollmentError.generationExhausted - } - generation = next - } else { - generation = 1 - } + let delegationChallenge = try await challenge() + let delegationClientData = try BuzzPushTranscript.delegate( + challengeId: delegationChallenge.id, + challenge: delegationChallenge.value, + installationHandle: installation, + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: nowSeconds, + expiresAt: expiresAt + ) + let assertion = try await appAttest.assertion(clientData: delegationClientData) + let endpointGrant = try await delegate( + challenge: delegationChallenge, + installationHandle: installation, + relayPubkey: relayPubkey, + generation: generation, + notBefore: nowSeconds, + expiresAt: expiresAt, + assertion: assertion + ) - let (expiresAt, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) - guard !expiresOverflow else { - throw BuzzDevPushEnrollmentError.invalidGatewayURL - } + let installationId: String + if let storedForOrigin { + installationId = storedForOrigin.installationId + } else { + let installationBytes = try installationIdBytes() + precondition( + installationBytes.count == 16, + "NIP-PL installation identity entropy must be exactly 16 bytes" + ) + // NIP-PL:76 also requires a fresh value on reinstall. Keychain survival can + // retain this value across reinstall. Reinstall detection is intentionally deferred. + installationId = Self.lowercaseHex(installationBytes) + } + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + installationId: installationId, + endpointGrant: endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: Self.endpointEpoch, + generation: generation, + publishedGeneration: nil, + expiresAt: expiresAt + ) + try store.save(record) + return record + } - let enrollmentChallenge = try await challenge() - let preparedAttestation = try appAttest.prepareAttestation() - let enrollmentClientData = try BuzzPushTranscript.enroll( - challengeId: enrollmentChallenge.id, - challenge: enrollmentChallenge.value, - keyId: preparedAttestation.keyId, + private func challenge() async throws -> Challenge { + let response: ChallengeResponse = try await post( + route: "v1/installations/challenges", + expectedStatus: 200, + body: VersionRequest(v: 1) + ) + guard let id = UUID(uuidString: response.challengeId), + response.challengeId == id.uuidString.lowercased(), + Self.isBase64URLChallenge(response.challenge), + response.expiresAt > Int64(now().timeIntervalSince1970) + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges") + } + return Challenge(id: id, value: response.challenge) + } + + private func enrollInstallation( + challenge: Challenge, + endpoint: String, + expiresAt: Int64, + attestation: BuzzDevAttestation + ) async throws -> UUID { + let response: InstallationResponse = try await post( + route: "v1/installations", + expectedStatus: 201, + body: InstallationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation, appProfile: Self.appProfile, endpoint: endpoint, endpointEpoch: Self.endpointEpoch, expiresAt: expiresAt ) - let attestation = try appAttest.attestation( - preparedAttestation, - clientData: enrollmentClientData - ) - guard attestation.keyId == preparedAttestation.keyId else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") - } - let installation = try await enrollInstallation( - challenge: enrollmentChallenge, - endpoint: endpoint, - expiresAt: expiresAt, - attestation: attestation - ) + ) + guard let installation = UUID(uuidString: response.installationHandle), + response.installationHandle == installation.uuidString.lowercased(), + response.endpointEpoch == Self.endpointEpoch, + response.expiresAt == expiresAt + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations") + } + return installation + } - let delegationChallenge = try await challenge() - let delegationClientData = try BuzzPushTranscript.delegate( - challengeId: delegationChallenge.id, - challenge: delegationChallenge.value, - installationHandle: installation, + private func delegate( + challenge: Challenge, + installationHandle: UUID, + relayPubkey: String, + generation: Int64, + notBefore: Int64, + expiresAt: Int64, + assertion: String + ) async throws -> String { + let response: DelegationResponse = try await post( + route: "v1/delegations", + expectedStatus: 201, + body: DelegationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + installationHandle: installationHandle.uuidString.lowercased(), endpointEpoch: Self.endpointEpoch, generation: generation, relayPubkey: relayPubkey, - notBefore: nowSeconds, - expiresAt: expiresAt - ) - let assertion = try appAttest.assertion(clientData: delegationClientData) - let endpointGrant = try await delegate( - challenge: delegationChallenge, - installationHandle: installation, - relayPubkey: relayPubkey, - generation: generation, - notBefore: nowSeconds, + notBefore: notBefore, expiresAt: expiresAt, assertion: assertion ) + ) + guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations") + } + return response.endpointGrant + } - let installationId: String - if let storedForOrigin { - installationId = storedForOrigin.installationId - } else { - let installationBytes = try installationIdBytes() - precondition( - installationBytes.count == 16, - "NIP-PL installation identity entropy must be exactly 16 bytes" - ) - // NIP-PL:76 also requires a fresh value on reinstall. Keychain survival can - // retain this value across reinstall. Reinstall detection is intentionally deferred. - installationId = Self.lowercaseHex(installationBytes) - } - let record = BuzzPushEndpointGrantRecord( - relayOrigin: relayOrigin.text, - relayPubkey: relayPubkey, - installationId: installationId, - endpointGrant: endpointGrant, - endpointHash: endpointHash, - appProfile: Self.appProfile, - endpointEpoch: Self.endpointEpoch, - generation: generation, - publishedGeneration: nil, - expiresAt: expiresAt + private func fetchCurrentRelayPushPubkey(from relayOrigin: URL) async throws -> String { + var request = URLRequest(url: relayOrigin) + request.httpMethod = "GET" + request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200) + let document: RelayInformation + do { + document = try JSONDecoder().decode(RelayInformation.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let current = document.push.keys.filter(\.current) + guard current.count == 1, Self.isLowercaseHexPubkey(current[0].pubkey) else { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + return current[0].pubkey + } + + private func post( + route: String, + expectedStatus: Int, + body: Request + ) async throws -> Response { + let url = route.split(separator: "/").reduce(gatewayBaseURL) { + $0.appendingPathComponent(String($1)) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: route, expected: expectedStatus) + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + } + + private static func expectStatus( + _ response: URLResponse, + data: Data, + route: String, + expected: Int + ) throws { + guard let http = response as? HTTPURLResponse else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + guard http.statusCode == expected else { + let body = String(decoding: data.prefix(512), as: UTF8.self) + throw BuzzDevPushEnrollmentError.unexpectedStatus( + route: route, expected: expected, actual: http.statusCode, body: body ) - try store.save(record) - return record - } - - private func challenge() async throws -> Challenge { - let response: ChallengeResponse = try await post( - route: "v1/installations/challenges", - expectedStatus: 200, - body: VersionRequest(v: 1) - ) - guard let id = UUID(uuidString: response.challengeId), - response.challengeId == id.uuidString.lowercased(), - Self.isBase64URLChallenge(response.challenge), - response.expiresAt > Int64(now().timeIntervalSince1970) - else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges") - } - return Challenge(id: id, value: response.challenge) - } - - private func enrollInstallation( - challenge: Challenge, - endpoint: String, - expiresAt: Int64, - attestation: BuzzDevAttestation - ) async throws -> UUID { - let response: InstallationResponse = try await post( - route: "v1/installations", - expectedStatus: 201, - body: InstallationRequest( - v: 1, - challengeId: challenge.id.uuidString.lowercased(), - challenge: challenge.value, - keyId: attestation.keyId, - attestation: attestation.attestation, - appProfile: Self.appProfile, - endpoint: endpoint, - endpointEpoch: Self.endpointEpoch, - expiresAt: expiresAt - ) - ) - guard let installation = UUID(uuidString: response.installationHandle), - response.installationHandle == installation.uuidString.lowercased(), - response.endpointEpoch == Self.endpointEpoch, - response.expiresAt == expiresAt - else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations") - } - return installation - } - - private func delegate( - challenge: Challenge, - installationHandle: UUID, - relayPubkey: String, - generation: Int64, - notBefore: Int64, - expiresAt: Int64, - assertion: String - ) async throws -> String { - let response: DelegationResponse = try await post( - route: "v1/delegations", - expectedStatus: 201, - body: DelegationRequest( - v: 1, - challengeId: challenge.id.uuidString.lowercased(), - challenge: challenge.value, - installationHandle: installationHandle.uuidString.lowercased(), - endpointEpoch: Self.endpointEpoch, - generation: generation, - relayPubkey: relayPubkey, - notBefore: notBefore, - expiresAt: expiresAt, - assertion: assertion - ) - ) - guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations") - } - return response.endpointGrant - } - - private func fetchCurrentRelayPushPubkey(from relayOrigin: URL) async throws -> String { - var request = URLRequest(url: relayOrigin) - request.httpMethod = "GET" - request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") - let (data, response) = try await session.data(for: request) - try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200) - let document: RelayInformation - do { - document = try JSONDecoder().decode(RelayInformation.self, from: data) - } catch { - throw BuzzDevPushEnrollmentError.invalidRelayDescriptor - } - let current = document.push.keys.filter(\.current) - guard current.count == 1, Self.isLowercaseHexPubkey(current[0].pubkey) else { - throw BuzzDevPushEnrollmentError.invalidRelayDescriptor - } - return current[0].pubkey - } - - private func post( - route: String, - expectedStatus: Int, - body: Request - ) async throws -> Response { - let url = route.split(separator: "/").reduce(gatewayBaseURL) { - $0.appendingPathComponent(String($1)) - } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - let (data, response) = try await session.data(for: request) - try Self.expectStatus(response, data: data, route: route, expected: expectedStatus) - do { - return try JSONDecoder().decode(Response.self, from: data) - } catch { - throw BuzzDevPushEnrollmentError.invalidResponse(route: route) - } - } - - private static func expectStatus( - _ response: URLResponse, - data: Data, - route: String, - expected: Int - ) throws { - guard let http = response as? HTTPURLResponse else { - throw BuzzDevPushEnrollmentError.invalidResponse(route: route) - } - guard http.statusCode == expected else { - let body = String(decoding: data.prefix(512), as: UTF8.self) - throw BuzzDevPushEnrollmentError.unexpectedStatus( - route: route, expected: expected, actual: http.statusCode, body: body - ) - } - } - - private static func isHTTPOrigin(_ url: URL) -> Bool { - (url.scheme == "http" || url.scheme == "https") - && url.host != nil - && (url.path.isEmpty || url.path == "/") - && url.user == nil - && url.password == nil - && url.query == nil - && url.fragment == nil - } - - private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { - guard url.scheme == "ws" || url.scheme == "wss", - url.host != nil, - url.path.isEmpty || url.path == "/", - url.user == nil, - url.password == nil, - url.query == nil, - url.fragment == nil - else { - throw BuzzDevPushEnrollmentError.invalidRelayURL - } - var components = URLComponents() - components.scheme = url.scheme == "wss" ? "https" : "http" - components.host = url.host - components.port = url.port - components.path = "/" - guard let httpURL = components.url else { - throw BuzzDevPushEnrollmentError.invalidRelayURL - } - var relayComponents = components - relayComponents.scheme = url.scheme - relayComponents.path = "" - guard let relayText = relayComponents.string else { - throw BuzzDevPushEnrollmentError.invalidRelayURL - } - return (httpURL, relayText) - } - - private static func isLowercaseHexPubkey(_ value: String) -> Bool { - value.utf8.count == 64 - && value.utf8.allSatisfy { - (48...57).contains($0) || (97...102).contains($0) - } - } - - private static func isBase64URLChallenge(_ value: String) -> Bool { - guard value.utf8.count == 43, - value.utf8.allSatisfy({ - (48...57).contains($0) || (65...90).contains($0) - || (97...122).contains($0) || $0 == 45 || $0 == 95 - }) - else { return false } - var padded = value.replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - padded += String(repeating: "=", count: (4 - padded.count % 4) % 4) - return Data(base64Encoded: padded)?.count == 32 - } - - private static func lowercaseHex(_ data: Data) -> String { - data.map { String(format: "%02x", $0) }.joined() } } - private struct VersionRequest: Encodable { let v: Int } - private struct Challenge { - let id: UUID - let value: String + private static func isHTTPOrigin(_ url: URL) -> Bool { + (url.scheme == "http" || url.scheme == "https") + && url.host != nil + && (url.path.isEmpty || url.path == "/") + && url.user == nil + && url.password == nil + && url.query == nil + && url.fragment == nil } - private struct ChallengeResponse: Decodable { - let challengeId: String - let challenge: String - let expiresAt: Int64 - enum CodingKeys: String, CodingKey { - case challengeId = "challenge_id" - case challenge - case expiresAt = "expires_at" + + private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { + guard url.scheme == "ws" || url.scheme == "wss", + url.host != nil, + url.path.isEmpty || url.path == "/", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil + else { + throw BuzzDevPushEnrollmentError.invalidRelayURL } - } - private struct InstallationRequest: Encodable { - let v: Int - let challengeId: String - let challenge: String - let keyId: String - let attestation: String - let appProfile: String - let endpoint: String - let endpointEpoch: Int64 - let expiresAt: Int64 - enum CodingKeys: String, CodingKey { - case v - case challengeId = "challenge_id" - case challenge - case keyId = "key_id" - case attestation - case appProfile = "app_profile" - case endpoint - case endpointEpoch = "endpoint_epoch" - case expiresAt = "expires_at" + var components = URLComponents() + components.scheme = url.scheme == "wss" ? "https" : "http" + components.host = url.host + components.port = url.port + components.path = "/" + guard let httpURL = components.url else { + throw BuzzDevPushEnrollmentError.invalidRelayURL } - } - private struct InstallationResponse: Decodable { - let installationHandle: String - let endpointEpoch: Int64 - let expiresAt: Int64 - enum CodingKeys: String, CodingKey { - case installationHandle = "installation_handle" - case endpointEpoch = "endpoint_epoch" - case expiresAt = "expires_at" + var relayComponents = components + relayComponents.scheme = url.scheme + relayComponents.path = "" + guard let relayText = relayComponents.string else { + throw BuzzDevPushEnrollmentError.invalidRelayURL } + return (httpURL, relayText) } - private struct DelegationRequest: Encodable { - let v: Int - let challengeId: String - let challenge: String - let installationHandle: String - let endpointEpoch: Int64 - let generation: Int64 - let relayPubkey: String - let notBefore: Int64 - let expiresAt: Int64 - let assertion: String - enum CodingKeys: String, CodingKey { - case v - case challengeId = "challenge_id" - case challenge - case installationHandle = "installation_handle" - case endpointEpoch = "endpoint_epoch" - case generation - case relayPubkey = "relay_pubkey" - case notBefore = "not_before" - case expiresAt = "expires_at" - case assertion - } - } - private struct DelegationResponse: Decodable { - let endpointGrant: String - enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } - } - private struct RelayInformation: Decodable { - struct Push: Decodable { - struct Key: Decodable { - let pubkey: String - let current: Bool + + private static func isLowercaseHexPubkey(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) } - let keys: [Key] - } - let push: Push } -#endif + + private static func isBase64URLChallenge(_ value: String) -> Bool { + guard value.utf8.count == 43, + value.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) + || (97...122).contains($0) || $0 == 45 || $0 == 95 + }) + else { return false } + var padded = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + padded += String(repeating: "=", count: (4 - padded.count % 4) % 4) + return Data(base64Encoded: padded)?.count == 32 + } + + private static func lowercaseHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private struct VersionRequest: Encodable { let v: Int } +private struct Challenge { + let id: UUID + let value: String +} +private struct ChallengeResponse: Decodable { + let challengeId: String + let challenge: String + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case challengeId = "challenge_id" + case challenge + case expiresAt = "expires_at" + } +} +private struct InstallationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let keyId: String + let attestation: String + let appProfile: String + let endpoint: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case keyId = "key_id" + case attestation + case appProfile = "app_profile" + case endpoint + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct InstallationResponse: Decodable { + let installationHandle: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct DelegationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let generation: Int64 + let relayPubkey: String + let notBefore: Int64 + let expiresAt: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case generation + case relayPubkey = "relay_pubkey" + case notBefore = "not_before" + case expiresAt = "expires_at" + case assertion + } +} +private struct DelegationResponse: Decodable { + let endpointGrant: String + enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } +} +private struct RelayInformation: Decodable { + struct Push: Decodable { + struct Key: Decodable { + let pubkey: String + let current: Bool + } + let keys: [Key] + } + let push: Push +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index ed6c67612..7db78ceee 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -1,223 +1,225 @@ -#if DEBUG - import CryptoKit - import Foundation - #if canImport(FoundationNetworking) - import FoundationNetworking - #endif - import XCTest +import CryptoKit +import Foundation +import Security +import XCTest - @testable import BuzzPushKit +@testable import BuzzPushKit - final class BuzzDevPushEnrollmentDriverTests: XCTestCase { - private static let gatewayURL = URL(string: "http://push.example/")! - private static let relayURL = URL(string: "wss://relay.example/")! - private static let relayPubkey = String(repeating: "a", count: 64) - private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" - private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" - private static let installationHandle = "22222222-2222-4222-8222-222222222222" - private static let installationId = "000102030405060708090a0b0c0d0e0f" - private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" - private static let now: Int64 = 1_752_620_000 - private static let expiresAt: Int64 = 1_752_624_000 - private static let endpoint = - "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" - fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" - fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString() - fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString() +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif - override func setUp() { - super.setUp() - URLProtocolStub.reset() - } +final class BuzzDevPushEnrollmentDriverTests: XCTestCase { + private static let gatewayURL = URL(string: "http://push.example/")! + private static let relayURL = URL(string: "wss://relay.example/")! + private static let relayPubkey = String(repeating: "a", count: 64) + private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" + private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" + private static let installationHandle = "22222222-2222-4222-8222-222222222222" + private static let installationId = "000102030405060708090a0b0c0d0e0f" + private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + private static let now: Int64 = 1_752_620_000 + private static let expiresAt: Int64 = 1_752_624_000 + private static let endpoint = + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString() + fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString() - override func tearDown() { - URLProtocolStub.reset() - super.tearDown() - } + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } - func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws { - let store = MemoryGrantStore() - let appAttest = RecordingAppAttest() - let driver = try makeDriver(store: store, appAttest: appAttest) - var challengeCount = 0 - URLProtocolStub.handler = { request in - switch (request.httpMethod, request.url?.absoluteString) { - case ("GET", "https://relay.example/"): - XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json") - return Self.response( - request, - status: 200, - json: [ - "push": [ - "keys": [ - ["id": "current", "pubkey": Self.relayPubkey, "current": true] - ] + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws { + let store = MemoryGrantStore() + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json") + return Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [ + ["id": "current", "pubkey": Self.relayPubkey, "current": true] ] ] - ) - case ("POST", "http://push.example/v1/installations/challenges"): - challengeCount += 1 - let body = try Self.body(request) - XCTAssertEqual(body["v"] as? Int, 1) - XCTAssertEqual(body.count, 1) - let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId - return Self.response( - request, - status: 200, - json: [ - "challenge_id": id, - "challenge": Self.challenge, - "expires_at": Self.now + 300, - ] - ) - case ("POST", "http://push.example/v1/installations"): - let body = try Self.body(request) - XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) - XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) - XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) - XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) - XCTAssertEqual(body["challenge"] as? String, Self.challenge) - XCTAssertEqual(body["key_id"] as? String, Self.keyId) - XCTAssertEqual(body["attestation"] as? String, Self.attestation) - XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-sandbox") - return Self.response( - request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] - ) - case ("POST", "http://push.example/v1/delegations"): - let body = try Self.body(request) - XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) - XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) - XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId) - XCTAssertEqual(body["challenge"] as? String, Self.challenge) - XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) - XCTAssertEqual(body["not_before"] as? Int64, Self.now) - XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) - XCTAssertEqual(body["assertion"] as? String, Self.assertion) - XCTAssertEqual(body["generation"] as? Int, 1) - return Self.response( - request, - status: 201, - json: ["endpoint_grant": "opaque-grant"] - ) - default: - XCTFail( - "Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")" - ) - return Self.response(request, status: 500, json: [:]) - } - } - - let record = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - - XCTAssertEqual(appAttest.clientData.count, 2) - XCTAssertEqual(record.relayOrigin, "wss://relay.example") - try assertMatchesVector( - "enroll", - actual: appAttest.clientData[0], - expectedSHA256: "362f5fc4c1fe7418dc879d9950223a273b2e915e4e37b008e66a1ab6b2fb1548", - fixture: makeFixtureTranscript( - name: "enroll", - replacements: [ - ("buzz-ios-production", "buzz-ios-sandbox") ] ) - ) - try assertMatchesVector( - "delegate", - actual: appAttest.clientData[1], - expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", - fixture: makeFixtureTranscript( - name: "delegate", - replacements: [ - (Self.firstChallengeId, Self.secondChallengeId) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["v"] as? Int, 1) + XCTAssertEqual(body.count, 1) + let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId + return Self.response( + request, + status: 200, + json: [ + "challenge_id": id, + "challenge": Self.challenge, + "expires_at": Self.now + 300, ] ) - ) - XCTAssertEqual( - record, - BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - installationId: Self.installationId, - endpointGrant: "opaque-grant", - endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-sandbox", - endpointEpoch: 1, - generation: 1, - publishedGeneration: nil, - expiresAt: Self.expiresAt + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["key_id"] as? String, Self.keyId) + XCTAssertEqual(body["attestation"] as? String, Self.attestation) + XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-sandbox") + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] ) - ) - XCTAssertEqual(store.saved, [record]) - } - - func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { - let relayURL = URL(string: "wss://relay.example:8443/")! - let store = MemoryGrantStore() - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) - var challengeCount = 0 - URLProtocolStub.handler = { request in - switch (request.httpMethod, request.url?.absoluteString) { - case ("GET", "https://relay.example:8443/"): - return Self.response( - request, - status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] - ) - case ("POST", "http://push.example/v1/installations/challenges"): - challengeCount += 1 - return Self.response( - request, - status: 200, - json: [ - "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, - "challenge": Self.challenge, - "expires_at": Self.now + 300, - ] - ) - case ("POST", "http://push.example/v1/installations"): - return Self.response( - request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] - ) - case ("POST", "http://push.example/v1/delegations"): - return Self.response( - request, - status: 201, - json: ["endpoint_grant": "opaque-grant"] - ) - default: - XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") - return Self.response(request, status: 500, json: [:]) - } + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["not_before"] as? Int64, Self.now) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["assertion"] as? String, Self.assertion) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail( + "Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")" + ) + return Self.response(request, status: 500, json: [:]) } - - let record = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: relayURL - ) - - XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") } - func testDevelopmentAttestationMatchesGatewayBypassShape() throws { + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.relayOrigin, "wss://relay.example") + try assertMatchesVector( + "enroll", + actual: appAttest.clientData[0], + expectedSHA256: "362f5fc4c1fe7418dc879d9950223a273b2e915e4e37b008e66a1ab6b2fb1548", + fixture: makeFixtureTranscript( + name: "enroll", + replacements: [ + ("buzz-ios-production", "buzz-ios-sandbox") + ] + ) + ) + try assertMatchesVector( + "delegate", + actual: appAttest.clientData[1], + expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", + fixture: makeFixtureTranscript( + name: "delegate", + replacements: [ + (Self.firstChallengeId, Self.secondChallengeId) + ] + ) + ) + XCTAssertEqual( + record, + BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "opaque-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-sandbox", + endpointEpoch: 1, + generation: 1, + publishedGeneration: nil, + expiresAt: Self.expiresAt + ) + ) + XCTAssertEqual(store.saved, [record]) + } + + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { + let relayURL = URL(string: "wss://relay.example:8443/")! + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example:8443/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: relayURL + ) + + XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") + } + + #if DEBUG + func testDevelopmentAttestationMatchesGatewayBypassShape() async throws { let entropy = Data(repeating: 0xAB, count: 32) let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) - let prepared = try provider.prepareAttestation() + let prepared = try await provider.prepareAttestation() let bytes = try XCTUnwrap(Data(base64Encoded: prepared.attestation)) XCTAssertEqual( bytes, @@ -227,392 +229,685 @@ prepared.keyId, Data(SHA256.hash(data: bytes)).base64EncodedString() ) - XCTAssertEqual( - try provider.assertion(clientData: Data("transcript".utf8)), - Self.assertion + let assertion = try await provider.assertion(clientData: Data("transcript".utf8)) + XCTAssertEqual(assertion, Self.assertion) + } + #endif + + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { + let service = RecordingDCAppAttestService(isSupported: false) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Expected App Attest to be unavailable") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) + } + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { + let service = RecordingDCAppAttestService( + generatedKeyId: Self.keyId, + attestationObject: Data([0x01, 0x02, 0x03]) + ) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("enrollment transcript".utf8) + + let prepared = try await provider.prepareAttestation() + let attestation = try await provider.attestation(prepared, clientData: clientData) + + XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) + XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) + XCTAssertEqual(attestation.keyId, Self.keyId) + XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) + XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.attestationClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + } + + func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("delegation transcript".utf8) + + let assertion = try await provider.assertion(clientData: clientData) + + XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) + XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.assertionClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { + for invalidKeyId in [ + "not-a-key-id", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) + } + } + + func testRealAppAttestRejectsMismatchedPreparedKey() async throws { + let service = RecordingDCAppAttestService() + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + + do { + _ = try await provider.attestation( + BuzzDevAttestation(keyId: otherKeyId, attestation: ""), + clientData: Data("enrollment transcript".utf8) + ) + XCTFail("Expected the prepared key ID to match persistent state") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(service.attestedKeyIds.isEmpty) + } + + func testRealAppAttestForwardsServiceErrors() async throws { + let expected = NSError(domain: "DeviceCheckTest", code: 41) + let service = RecordingDCAppAttestService(error: expected) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + XCTFail("Expected the DeviceCheck error") + } catch { + XCTAssertEqual((error as NSError).domain, expected.domain) + XCTAssertEqual((error as NSError).code, expected.code) + } + } + + func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { + var capturedQuery: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + copyMatching: { query, result in + capturedQuery = query as! [String: Any] + result?.pointee = Data(Self.keyId.utf8) as CFData + return errSecSuccess + } + ) + + XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertEqual( + capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") + XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") + XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) + XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) + } + + func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { + let missing = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertNil(try missing.keyId()) + + for invalidKeyId in [ + "bad", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let invalid = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, result in + result?.pointee = Data(invalidKeyId.utf8) as CFData + return errSecSuccess + } + ) + XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { + XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + } + } + + func testKeychainStoreUpdatesExistingKeyId() throws { + var updatedQuery: [String: Any] = [:] + var updatedValues: [String: Any] = [:] + var addCallCount = 0 + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { query, values in + updatedQuery = query as! [String: Any] + updatedValues = values as! [String: Any] + return errSecSuccess + }, + add: { _, _ in + addCallCount += 1 + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual(addCallCount, 0) + } + + func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws { + var addedItem: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + update: { _, _ in errSecItemNotFound }, + add: { item, _ in + addedItem = item as! [String: Any] + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual( + addedItem[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz") + } + + func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws { + let readFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try readFailure.keyId()) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let updateFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let addFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecItemNotFound }, + add: { _, _ in errSecDuplicateItem } + ) + XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) + } + } + + func testReusesPersistedUnexpiredGrant() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-sandbox", + endpointEpoch: 1, + generation: 1, + publishedGeneration: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + guard request.httpMethod == "GET" else { + XCTFail("Persisted grant reuse must not call the gateway") + return Self.response(request, status: 500, json: [:]) + } + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] ) } - func testReusesPersistedUnexpiredGrant() async throws { - let existing = BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - installationId: Self.installationId, - endpointGrant: "existing-grant", - endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-sandbox", - endpointEpoch: 1, - generation: 1, - publishedGeneration: 1, - expiresAt: Self.expiresAt + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record, existing) + XCTAssertEqual(record.publishedGeneration, 1) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testExpiredGrantRefreshReusesInstallationIdAndIncrementsGeneration() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-sandbox", + endpointEpoch: 1, + generation: 7, + publishedGeneration: 7, + expiresAt: Self.now + 300 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("Grant refresh must reuse the persisted installation id") + return Data(repeating: 0xFF, count: 16) + } + ) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, 8) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "refreshed-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 8) + XCTAssertNil(record.publishedGeneration) + XCTAssertEqual(record.endpointGrant, "refreshed-grant") + } + + func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [ + ["pubkey": Self.relayPubkey, "current": true], + ["pubkey": String(repeating: "b", count: 64), "current": true], + ] + ] + ] ) - let store = MemoryGrantStore(records: [existing]) - let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - guard request.httpMethod == "GET" else { - XCTFail("Persisted grant reuse must not call the gateway") - return Self.response(request, status: 500, json: [:]) - } + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected an invalid relay descriptor") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) + } + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.httpMethod == "GET" { return Self.response( request, status: 200, json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] ) } - - let record = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - - XCTAssertEqual(record, existing) - XCTAssertEqual(record.publishedGeneration, 1) - XCTAssertEqual(store.saved, [existing]) - XCTAssertEqual(URLProtocolStub.requests.count, 1) + return Self.response(request, status: 400, json: ["error": "invalid_request"]) } - func testExpiredGrantRefreshReusesInstallationIdAndIncrementsGeneration() async throws { - let existing = BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example", - relayPubkey: Self.relayPubkey, - installationId: Self.installationId, - endpointGrant: "existing-grant", - endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), - appProfile: "buzz-ios-sandbox", - endpointEpoch: 1, - generation: 7, - publishedGeneration: 7, - expiresAt: Self.now + 300 - ) - let store = MemoryGrantStore(records: [existing]) - let driver = try makeDriver( - store: store, - appAttest: RecordingAppAttest(), - installationIdBytes: { - XCTFail("Grant refresh must reuse the persisted installation id") - return Data(repeating: 0xFF, count: 16) - } - ) - var challengeCount = 0 - URLProtocolStub.handler = { request in - switch (request.httpMethod, request.url?.absoluteString) { - case ("GET", "https://relay.example/"): - return Self.response( - request, - status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] - ) - case ("POST", "http://push.example/v1/installations/challenges"): - challengeCount += 1 - return Self.response( - request, - status: 200, - json: [ - "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, - "challenge": Self.challenge, - "expires_at": Self.now + 300, - ] - ) - case ("POST", "http://push.example/v1/installations"): - return Self.response( - request, - status: 201, - json: [ - "installation_handle": Self.installationHandle, - "endpoint_epoch": 1, - "expires_at": Self.expiresAt, - ] - ) - case ("POST", "http://push.example/v1/delegations"): - let body = try Self.body(request) - XCTAssertEqual(body["generation"] as? Int, 8) - return Self.response( - request, - status: 201, - json: ["endpoint_grant": "refreshed-grant"] - ) - default: - XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") - return Self.response(request, status: 500, json: [:]) - } - } - - let record = try await driver.enroll( - deviceToken: Data((1...32).map(UInt8.init)), - relayURL: Self.relayURL - ) - - XCTAssertEqual(record.installationId, Self.installationId) - XCTAssertEqual(record.generation, 8) - XCTAssertNil(record.publishedGeneration) - XCTAssertEqual(record.endpointGrant, "refreshed-grant") - } - - func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { - let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - Self.response( - request, - status: 200, - json: [ - "push": [ - "keys": [ - ["pubkey": Self.relayPubkey, "current": true], - ["pubkey": String(repeating: "b", count: 64), "current": true], - ] - ] - ] - ) - } - - do { - _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) - XCTFail("Expected an invalid relay descriptor") - } catch { - XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) - } - XCTAssertEqual(URLProtocolStub.requests.count, 1) - } - - func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { - let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) - URLProtocolStub.handler = { request in - if request.httpMethod == "GET" { - return Self.response( - request, - status: 200, - json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] - ) - } - return Self.response(request, status: 400, json: ["error": "invalid_request"]) - } - - do { - _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) - XCTFail("Expected the gateway error") - } catch let error as BuzzDevPushEnrollmentError { - XCTAssertEqual( - error, - .unexpectedStatus( - route: "v1/installations/challenges", - expected: 200, - actual: 400, - body: "{\"error\":\"invalid_request\"}" - ) - ) - } - } - - private func makeDriver( - store: BuzzPushEndpointGrantStore, - appAttest: BuzzDevAppAttesting, - installationIdBytes: @escaping () throws -> Data = { - Data(0..<16) - } - ) throws -> BuzzDevPushEnrollmentDriver { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [URLProtocolStub.self] - return try BuzzDevPushEnrollmentDriver( - gatewayBaseURL: Self.gatewayURL, - store: store, - session: URLSession(configuration: configuration), - appAttest: appAttest, - now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, - lifetimeSeconds: Self.expiresAt - Self.now, - installationIdBytes: installationIdBytes - ) - } - - private func makeFixtureTranscript( - name: String, - replacements: [(String, String)] - ) throws -> (bytes: Data, sha256: String) { - let fixture = try Self.fixture() - let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name }) - let transcript = replacements.reduce(vector.transcript) { - $0.replacingOccurrences(of: $1.0, with: $1.1) - } - return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8)))) - } - - private func assertMatchesVector( - _ name: String, - actual: Data, - expectedSHA256: String, - fixture: (bytes: Data, sha256: String), - file: StaticString = #filePath, - line: UInt = #line - ) throws { + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected the gateway error") + } catch let error as BuzzDevPushEnrollmentError { XCTAssertEqual( - fixture.sha256, - expectedSHA256, - "\(name) substituted gateway vector SHA-256", - file: file, - line: line - ) - XCTAssertEqual( - actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line) - XCTAssertEqual( - Self.hex(SHA256.hash(data: actual)), - fixture.sha256, - "\(name) transcript SHA-256", - file: file, - line: line - ) - } - - private struct Fixture: Decodable { - struct Vector: Decodable { - let name: String - let transcript: String - } - let vectors: [Vector] - } - - private static func fixture() throws -> Fixture { - let path = try XCTUnwrap( - Bundle.module.url( - forResource: "app_attest_transcripts", - withExtension: "json" - ), - "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)" - ) - let data = try Data(contentsOf: path) - return try JSONDecoder().decode(Fixture.self, from: data) - } - - private static func body(_ request: URLRequest) throws -> [String: Any] { - let data: Data - if let httpBody = request.httpBody { - data = httpBody - } else { - let stream = try XCTUnwrap(request.httpBodyStream) - stream.open() - defer { stream.close() } - var bytes = Data() - var buffer = [UInt8](repeating: 0, count: 1_024) - while true { - let count = stream.read(&buffer, maxLength: buffer.count) - if count < 0 { - throw try XCTUnwrap(stream.streamError) - } - if count == 0 { break } - bytes.append(buffer, count: count) - } - data = bytes - } - return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - } - - private static func response( - _ request: URLRequest, - status: Int, - json: [String: Any] - ) -> (HTTPURLResponse, Data) { - let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) - let response = HTTPURLResponse( - url: request.url!, - statusCode: status, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"] - )! - return (response, data) - } - - private static func hex(_ data: D) -> String where D.Element == UInt8 { - data.map { String(format: "%02x", $0) }.joined() - } - } - - private final class MemoryGrantStore: BuzzPushEndpointGrantStore { - var saved: [BuzzPushEndpointGrantRecord] - init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } - func records() throws -> [BuzzPushEndpointGrantRecord] { saved } - func save(_ record: BuzzPushEndpointGrantRecord) throws { saved = [record] } - func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws { - precondition(generation > 0, "Published push lease generation must be positive") - guard - let current = saved.first(where: { - $0.relayOrigin == relayOrigin && $0.appProfile == appProfile - }), current.generation == generation - else { - throw NSError(domain: "MemoryGrantStore", code: 1) - } - saved = [ - BuzzPushEndpointGrantRecord( - relayOrigin: current.relayOrigin, - relayPubkey: current.relayPubkey, - installationId: current.installationId, - endpointGrant: current.endpointGrant, - endpointHash: current.endpointHash, - appProfile: current.appProfile, - endpointEpoch: current.endpointEpoch, - generation: current.generation, - publishedGeneration: generation, - expiresAt: current.expiresAt + error, + .unexpectedStatus( + route: "v1/installations/challenges", + expected: 200, + actual: 400, + body: "{\"error\":\"invalid_request\"}" ) - ] - } - } - - private final class RecordingAppAttest: BuzzDevAppAttesting { - var clientData: [Data] = [] - - func prepareAttestation() throws -> BuzzDevAttestation { - BuzzDevAttestation( - keyId: BuzzDevPushEnrollmentDriverTests.keyId, - attestation: BuzzDevPushEnrollmentDriverTests.attestation ) } - - func attestation( - _ prepared: BuzzDevAttestation, - clientData: Data - ) throws -> BuzzDevAttestation { - self.clientData.append(clientData) - return prepared - } - - func assertion(clientData: Data) throws -> String { - self.clientData.append(clientData) - return BuzzDevPushEnrollmentDriverTests.assertion - } } - private final class URLProtocolStub: URLProtocol, @unchecked Sendable { - static let lock = NSLock() - static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? - static var requests: [URLRequest] = [] + private func makeDriver( + store: BuzzPushEndpointGrantStore, + appAttest: BuzzDevAppAttesting, + installationIdBytes: @escaping () throws -> Data = { + Data(0..<16) + } + ) throws -> BuzzDevPushEnrollmentDriver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: Self.gatewayURL, + store: store, + session: URLSession(configuration: configuration), + appAttest: appAttest, + now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, + lifetimeSeconds: Self.expiresAt - Self.now, + installationIdBytes: installationIdBytes + ) + } - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + private func makeFixtureTranscript( + name: String, + replacements: [(String, String)] + ) throws -> (bytes: Data, sha256: String) { + let fixture = try Self.fixture() + let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name }) + let transcript = replacements.reduce(vector.transcript) { + $0.replacingOccurrences(of: $1.0, with: $1.1) + } + return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8)))) + } - override func startLoading() { - Self.lock.lock() - Self.requests.append(request) - let handler = Self.handler - Self.lock.unlock() - do { - let (response, data) = - try handler?(request) - ?? { - throw URLError(.unsupportedURL) - }() - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) + private func assertMatchesVector( + _ name: String, + actual: Data, + expectedSHA256: String, + fixture: (bytes: Data, sha256: String), + file: StaticString = #filePath, + line: UInt = #line + ) throws { + XCTAssertEqual( + fixture.sha256, + expectedSHA256, + "\(name) substituted gateway vector SHA-256", + file: file, + line: line + ) + XCTAssertEqual( + actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line) + XCTAssertEqual( + Self.hex(SHA256.hash(data: actual)), + fixture.sha256, + "\(name) transcript SHA-256", + file: file, + line: line + ) + } + + private struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let transcript: String + } + let vectors: [Vector] + } + + private static func fixture() throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)" + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + private static func body(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let httpBody = request.httpBody { + data = httpBody + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { break } + bytes.append(buffer, count: count) } + data = bytes } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } - override func stopLoading() {} + private static func response( + _ request: URLRequest, + status: Int, + json: [String: Any] + ) -> (HTTPURLResponse, Data) { + let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } - static func reset() { - lock.lock() - handler = nil - requests = [] - lock.unlock() + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private final class MemoryGrantStore: BuzzPushEndpointGrantStore { + var saved: [BuzzPushEndpointGrantRecord] + init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } + func records() throws -> [BuzzPushEndpointGrantRecord] { saved } + func save(_ record: BuzzPushEndpointGrantRecord) throws { saved = [record] } + func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws { + precondition(generation > 0, "Published push lease generation must be positive") + guard + let current = saved.first(where: { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + }), current.generation == generation + else { + throw NSError(domain: "MemoryGrantStore", code: 1) + } + saved = [ + BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + publishedGeneration: generation, + expiresAt: current.expiresAt + ) + ] + } +} + +private final class RecordingAppAttest: BuzzDevAppAttesting { + var clientData: [Data] = [] + + func prepareAttestation() async throws -> BuzzDevAttestation { + BuzzDevAttestation( + keyId: BuzzDevPushEnrollmentDriverTests.keyId, + attestation: BuzzDevPushEnrollmentDriverTests.attestation + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + self.clientData.append(clientData) + return prepared + } + + func assertion(clientData: Data) async throws -> String { + self.clientData.append(clientData) + return BuzzDevPushEnrollmentDriverTests.assertion + } +} + +private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring { + var keyIdValue: String? + var savedKeyIds: [String] = [] + + init(keyId: String? = nil) { + keyIdValue = keyId + } + + func keyId() throws -> String? { keyIdValue } + + func saveKeyId(_ keyId: String) throws { + savedKeyIds.append(keyId) + keyIdValue = keyId + } +} + +private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing { + let isSupported: Bool + let generatedKeyId: String + let attestationObject: Data + let assertionObject: Data + let error: Error? + + var generateKeyCallCount = 0 + var attestedKeyIds: [String] = [] + var attestationClientDataHashes: [Data] = [] + var assertedKeyIds: [String] = [] + var assertionClientDataHashes: [Data] = [] + + init( + isSupported: Bool = true, + generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId, + attestationObject: Data = Data("attestation-object".utf8), + assertionObject: Data = Data("assertion-object".utf8), + error: Error? = nil + ) { + self.isSupported = isSupported + self.generatedKeyId = generatedKeyId + self.attestationObject = attestationObject + self.assertionObject = assertionObject + self.error = error + } + + func generateKey() async throws -> String { + generateKeyCallCount += 1 + if let error { throw error } + return generatedKeyId + } + + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data { + attestedKeyIds.append(keyId) + attestationClientDataHashes.append(clientDataHash) + if let error { throw error } + return attestationObject + } + + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data { + assertedKeyIds.append(keyId) + assertionClientDataHashes.append(clientDataHash) + if let error { throw error } + return assertionObject + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = + try handler?(request) + ?? { + throw URLError(.unsupportedURL) + }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) } } -#endif + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +}