import CryptoKit import Foundation import Security import XCTest @testable import BuzzPushKit #if canImport(FoundationNetworking) import FoundationNetworking #endif final class BuzzDevPushEnrollmentDriverTests: XCTestCase { private static let gatewayURL = URL(string: "http://push.example/")! private static let gatewayOrigin = "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 setUp() { super.setUp() URLProtocolStub.reset() } 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: [ "self": Self.relayPubkey, "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-dogfood") 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.gatewayOrigin, Self.gatewayOrigin) XCTAssertEqual(record.relayOrigin, "wss://relay.example") try assertMatchesVector( "enroll", actual: appAttest.clientData[0], expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", fixture: makeFixtureTranscript( name: "enroll", replacements: [] ) ) try assertMatchesVector( "delegate", actual: appAttest.clientData[1], expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", fixture: makeFixtureTranscript( name: "delegate", replacements: [ (Self.firstChallengeId, Self.secondChallengeId) ] ) ) XCTAssertEqual( record, BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt ) ) XCTAssertEqual(store.saved, [record]) } func testCommittedInstallationRecoversAfterFinalGrantSaveFailure() async throws { let store = MemoryGrantStore(grantSaveFailuresRemaining: 1) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeCount = 0 var installationCount = 0 var delegationCount = 0 URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): return Self.response( request, status: 200, json: [ "self": Self.relayPubkey, "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"): installationCount += 1 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"): delegationCount += 1 let body = try Self.body(request) XCTAssertEqual(body["generation"] as? Int, delegationCount) return Self.response( request, status: 201, json: ["endpoint_grant": "opaque-grant-\(delegationCount)"] ) default: XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") return Self.response(request, status: 500, json: [:]) } } do { _ = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), relayURL: Self.relayURL ) XCTFail("Expected the injected local save failure") } catch { XCTAssertEqual((error as NSError).domain, "MemoryGrantStore") } XCTAssertEqual(store.pending.first?.delegationGeneration, 1) let recovered = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), relayURL: Self.relayURL ) XCTAssertEqual(installationCount, 1) XCTAssertEqual(delegationCount, 2) XCTAssertEqual(recovered.generation, 2) XCTAssertEqual(recovered.endpointGrant, "opaque-grant-2") XCTAssertTrue(store.pending.isEmpty) } func testCommittedInstallationRecoversAfterResponseLoss() async throws { let store = MemoryGrantStore() let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) var challengeCount = 0 var installationCount = 0 var firstInstallationBody: [String: Any]? URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): return Self.response( request, status: 200, json: [ "self": Self.relayPubkey, "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"): installationCount += 1 let body = try Self.body(request) if installationCount == 1 { firstInstallationBody = body throw URLError(.networkConnectionLost) } XCTAssertTrue( NSDictionary(dictionary: body).isEqual(to: try XCTUnwrap(firstInstallationBody)) ) 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: [:]) } } do { _ = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), relayURL: Self.relayURL ) XCTFail("Expected the simulated lost installation response") } catch { XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) } XCTAssertNil(store.pending.first?.gatewayInstallationHandle) let recovered = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), relayURL: Self.relayURL ) XCTAssertEqual(challengeCount, 2) XCTAssertEqual(installationCount, 2) XCTAssertEqual(recovered.endpointGrant, "opaque-grant") XCTAssertTrue(store.pending.isEmpty) } func testResponseLostEnrollmentSurvivesTokenRotationAndAnotherReplayFailure() async throws { let store = MemoryGrantStore() let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) let rotatedToken = Data(repeating: 42, count: 32) let rotatedHandle = "44444444-4444-4444-8444-444444444444" var oldRequests = 0 var newRequests = 0 var firstBody: [String: Any]? var delegatedHandles: [String] = [] 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"): return Self.response(request, status: 200, json: [ "challenge_id": Self.firstChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ]) case ("POST", "http://push.example/v1/installations"): let body = try Self.body(request) let oldEndpoint = body["endpoint"] as? String == Self.endpoint if oldEndpoint { oldRequests += 1 if let firstBody { XCTAssertTrue(NSDictionary(dictionary: body).isEqual(to: firstBody)) } else { firstBody = body } if oldRequests <= 2 { throw URLError(.networkConnectionLost) } } else { newRequests += 1 XCTAssertEqual(body["endpoint"] as? String, Self.hex(rotatedToken)) XCTAssertEqual(delegatedHandles, [Self.installationHandle]) } return Self.response(request, status: 201, json: [ "installation_handle": oldEndpoint ? Self.installationHandle : rotatedHandle, "endpoint_epoch": 1, "expires_at": Self.expiresAt, ]) case ("POST", "http://push.example/v1/delegations"): let handle = try XCTUnwrap(Self.body(request)["installation_handle"] as? String) delegatedHandles.append(handle) return Self.response(request, status: 201, json: ["endpoint_grant": "grant-\(handle)"]) default: XCTFail("Unexpected request, including any migration/revocation route: \(request.url!)") return Self.response(request, status: 500, json: [:]) } } for token in [Data((1...32).map(UInt8.init)), rotatedToken] { do { _ = try await driver.enroll(deviceToken: token, relayURL: Self.relayURL) XCTFail("Expected response loss") } catch { XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) } XCTAssertEqual(store.pending.first?.endpoint, Self.endpoint) XCTAssertTrue(store.saved.isEmpty) } let grant = try await driver.enroll(deviceToken: rotatedToken, relayURL: Self.relayURL) XCTAssertEqual(oldRequests, 3) XCTAssertEqual(newRequests, 1) XCTAssertEqual(delegatedHandles, [Self.installationHandle, rotatedHandle]) XCTAssertEqual(grant.endpointHash, Self.hex(SHA256.hash(data: rotatedToken))) XCTAssertTrue(store.pending.isEmpty) } 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: [ "self": Self.relayPubkey, "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") } func testLegacyGrantWithoutGatewayOriginIsRejected() throws { let data = Data( #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# .utf8 ) XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } func testGrantWithoutAppAttestKeyIsRejected() throws { let data = Data( #"{"gatewayOrigin":"https://push.example","relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# .utf8 ) XCTAssertThrowsError(try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data)) } func testOtherGatewayGrantIsNeitherExportedNorMigrated() throws { let other = BuzzPushEndpointGrantRecord( gatewayOrigin: "https://other-push.example", relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "other-gateway-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [other]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) XCTAssertEqual(try driver.endpointGrants(), []) XCTAssertEqual(store.saved, [other]) XCTAssertTrue(URLProtocolStub.requests.isEmpty) } 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 testRealAppAttestAssertionUsesRequestedKeyAndMapsObject() 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(keyId: Self.keyId, 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 testRealAppAttestAssertionUsesInstallationKeyInsteadOfLatestStoredKey() async throws { let installationKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() let service = RecordingDCAppAttestService(assertionObject: Data([0x07])) let provider = BuzzDCAppAttestProvider( service: service, keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) ) _ = try await provider.assertion( keyId: installationKeyId, clientData: Data("retired installation transcript".utf8) ) XCTAssertEqual(service.assertedKeyIds, [installationKeyId]) } 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( keyId: Self.keyId, 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 testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 4, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.absoluteString, "https://second.example/") return Self.response( request, status: 200, json: [ "self": Self.relayPubkey, "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], ] ) } let record = try await driver.enroll( deviceToken: Data((1...32).map(UInt8.init)), relayURL: URL(string: "wss://second.example/")! ) XCTAssertEqual(record.relayOrigin, "wss://second.example") XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) XCTAssertEqual(record.endpointGrant, existing.endpointGrant) XCTAssertEqual(record.generation, existing.generation) XCTAssertEqual(record.installationId, Self.installationId) XCTAssertNotEqual(record.installationId, existing.installationId) XCTAssertEqual(store.saved.count, 2) XCTAssertEqual(URLProtocolStub.requests.count, 1) } func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { let secondRelayPubkey = String(repeating: "b", count: 64) let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://first.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, appAttestKeyId: Self.keyId, installationId: String(repeating: "f", count: 32), endpointGrant: "first-relay-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 7, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) let appAttest = RecordingAppAttest() let driver = try makeDriver(store: store, appAttest: appAttest) URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://second.example/"): return Self.response( request, status: 200, json: [ "self": secondRelayPubkey, "push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]], ] ) case ("POST", "http://push.example/v1/installations/challenges"): return Self.response( request, status: 200, json: [ "challenge_id": Self.firstChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ] ) case ("POST", "http://push.example/v1/delegations"): let body = try Self.body(request) XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey) XCTAssertEqual(body["generation"] as? Int, 1) return Self.response( request, status: 201, json: ["endpoint_grant": "second-relay-grant"] ) case ("POST", "http://push.example/v1/installations"): XCTFail("A second relay must not create a duplicate APNs installation") return Self.response(request, status: 500, json: [:]) 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: URL(string: "wss://second.example/")! ) XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) XCTAssertEqual(record.relayPubkey, secondRelayPubkey) XCTAssertEqual(record.endpointGrant, "second-relay-grant") XCTAssertEqual(record.generation, 1) XCTAssertEqual(appAttest.clientData.count, 1) XCTAssertEqual(store.saved.count, 2) } func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, gatewayInstallationHandle: Self.installationHandle, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 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) } ) URLProtocolStub.handler = { request in switch (request.httpMethod, request.url?.absoluteString) { case ("GET", "https://relay.example/"): return Self.response( request, status: 200, json: [ "self": Self.relayPubkey, "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], ] ) case ("POST", "http://push.example/v1/installations/challenges"): return Self.response( request, status: 200, json: [ "challenge_id": Self.firstChallengeId, "challenge": Self.challenge, "expires_at": Self.now + 300, ] ) case ("POST", "http://push.example/v1/installations"): XCTFail("An expiring installation must renew through authenticated delegation") return Self.response(request, status: 500, json: [:]) case ("POST", "http://push.example/v1/delegations"): let body = try Self.body(request) XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) XCTAssertEqual(body["generation"] as? Int, 8) XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) 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.gatewayInstallationHandle, Self.installationHandle) XCTAssertEqual(record.generation, 8) XCTAssertEqual(record.expiresAt, Self.expiresAt) 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: [ "self": Self.relayPubkey, "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 testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { let pushPubkey = String(repeating: "b", count: 64) let oldMetadataPubkey = String(repeating: "c", count: 64) let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: pushPubkey, relayMetadataPubkey: oldMetadataPubkey, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in Self.response( request, status: 200, json: [ "self": Self.relayPubkey, "push": [ "keys": [ ["pubkey": pushPubkey, "current": true] ] ], ] ) } let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) XCTAssertEqual(record.relayPubkey, pushPubkey) XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey) XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey) XCTAssertEqual(record.endpointGrant, existing.endpointGrant) XCTAssertEqual(store.saved, [record]) XCTAssertEqual(URLProtocolStub.requests.count, 1) } func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in Self.response( request, status: 200, json: [ "push": [ "keys": [["pubkey": Self.relayPubkey, "current": true]] ] ] ) } let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) XCTAssertEqual(record.relayPubkey, Self.relayPubkey) XCTAssertNil(record.relayMetadataPubkey) XCTAssertEqual(record.endpointGrant, existing.endpointGrant) XCTAssertEqual(store.saved, [record]) XCTAssertEqual(URLProtocolStub.requests.count, 1) } func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { let deviceToken = Data((1...32).map(UInt8.init)) let existing = BuzzPushEndpointGrantRecord( gatewayOrigin: Self.gatewayOrigin, relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, relayMetadataPubkey: Self.relayPubkey, appAttestKeyId: Self.keyId, installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: deviceToken)), appProfile: "buzz-ios-dogfood", endpointEpoch: 1, generation: 1, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in Self.response( request, status: 200, json: [ "self": 42, "push": [ "keys": [["pubkey": Self.relayPubkey, "current": true]] ], ] ) } let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) XCTAssertEqual(record.relayPubkey, Self.relayPubkey) XCTAssertNil(record.relayMetadataPubkey) XCTAssertEqual(record.endpointGrant, existing.endpointGrant) XCTAssertEqual(store.saved, [record]) 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: [ "self": Self.relayPubkey, "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( gatewayBaseURL: URL = BuzzDevPushEnrollmentDriverTests.gatewayURL, 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: gatewayBaseURL, 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 { 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] var pending: [BuzzPushPendingEnrollmentRecord] = [] var grantSaveFailuresRemaining: Int var pendingRemoveFailuresRemaining: Int init( records: [BuzzPushEndpointGrantRecord] = [], pending: [BuzzPushPendingEnrollmentRecord] = [], grantSaveFailuresRemaining: Int = 0, pendingRemoveFailuresRemaining: Int = 0 ) { saved = records self.pending = pending self.grantSaveFailuresRemaining = grantSaveFailuresRemaining self.pendingRemoveFailuresRemaining = pendingRemoveFailuresRemaining } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { if grantSaveFailuresRemaining > 0 { grantSaveFailuresRemaining -= 1 throw NSError(domain: "MemoryGrantStore", code: 1) } saved.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile } saved.append(record) } func pendingEnrollment( gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws -> BuzzPushPendingEnrollmentRecord? { pending.first { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin && $0.appProfile == appProfile } } func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { pending.removeAll { $0.gatewayOrigin == record.gatewayOrigin && $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile } pending.append(record) } func removePendingEnrollment( gatewayOrigin: String, relayOrigin: String, appProfile: String ) throws { if pendingRemoveFailuresRemaining > 0 { pendingRemoveFailuresRemaining -= 1 throw NSError(domain: "MemoryGrantStore", code: 3) } pending.removeAll { $0.gatewayOrigin == gatewayOrigin && $0.relayOrigin == relayOrigin && $0.appProfile == appProfile } } } private final class RecordingAppAttest: BuzzDevAppAttesting { var clientData: [Data] = [] var preparedAttestations: [BuzzDevAttestation] = [] var assertionKeyIds: [String] = [] func prepareAttestation() async throws -> BuzzDevAttestation { let prepared = BuzzDevAttestation( keyId: BuzzDevPushEnrollmentDriverTests.keyId, attestation: BuzzDevPushEnrollmentDriverTests.attestation ) preparedAttestations.append(prepared) return prepared } func attestation( _ prepared: BuzzDevAttestation, clientData: Data ) async throws -> BuzzDevAttestation { self.clientData.append(clientData) return prepared } func assertion(keyId: String, clientData: Data) async throws -> String { self.clientData.append(clientData) assertionKeyIds.append(keyId) 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) } } override func stopLoading() {} static func reset() { lock.lock() handler = nil requests = [] lock.unlock() } }