// DeviceIntegrityTests.swift // OSGKeyboardTests // // Hermetic DeviceCheck and App Attest registration/assertion tests. import CryptoKit @testable import OSGKeyboardHostSupport import XCTest final class DeviceIntegrityTests: XCTestCase { func testAppleSignInEvidenceRegistersKeyAndSignsCanonicalPayload() async throws { let attestationChallenge = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")! let assertionChallenge = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")! let transport = QueueAccountTransport([ .init( statusCode: 201, body: challengeData( id: attestationChallenge, challenge: "AQID" ) ), .init(statusCode: 204, body: Data()), .init( statusCode: 201, body: challengeData( id: assertionChallenge, challenge: "BAUG" ) ) ]) let store = InMemoryAccountSecurityStore() let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let appAttestState = FakeAppAttestState() let coordinator = DeviceIntegrityCoordinator( apiClient: client, deviceCheck: FakeDeviceCheckProvider( isSupported: true, token: Data([0x10, 0x20]) ), appAttest: FakeAppAttestProvider( isSupported: true, state: appAttestState, keyId: "key-id", attestationObject: Data([0xAA, 0xBB]), assertion: Data([0xCC]) ), keyStateStore: store ) let credential = AppleSignInCredential( identityToken: "identity-token", authorizationCode: "authorization-code" ) let evidence = try await coordinator.evidenceForAppleSignIn( credential: credential, rawNonce: "raw-nonce" ) XCTAssertEqual(evidence.deviceCheckToken, "ECA=") XCTAssertEqual( evidence.appAttest, AppAttestAssertion( keyId: "key-id", challengeId: assertionChallenge, challenge: "BAUG", assertion: "zA==" ) ) let keyState = await store.keyState XCTAssertEqual(keyState, AppAttestKeyState(keyId: "key-id", isRegistered: true)) let generatedKeyCount = await appAttestState.generatedKeyCount XCTAssertEqual(generatedKeyCount, 1) let attestationHashes = await appAttestState.attestationHashes XCTAssertEqual(attestationHashes, [Data(SHA256.hash(data: Data([1, 2, 3])))]) let canonicalPayload = """ osg-app-attest-v1 purpose=apple-sign-in challenge=BAUG identity_token_sha256=OcwzHhEgHO3_IBV8hI8o_WTuAx0hgRrERJAbfcPbjvA authorization_code_sha256=WVYUJ4163Fe7kuKPogOooY15egdoT9_3XLvyqW6_hXc nonce_sha256=LF0QeTgFOiJ18CLBU8mnH2XuB3VLi8pUPul6DDzGaZA """ let assertionHashes = await appAttestState.assertionHashes XCTAssertEqual( assertionHashes, [Data(SHA256.hash(data: Data(canonicalPayload.utf8)))] ) let requests = await transport.requests XCTAssertEqual(requests.map(\.url?.path), [ "/v1/integrity/challenges", "/v1/integrity/attest", "/v1/integrity/challenges" ]) let attestationBody = try XCTUnwrap(requests[1].httpBody) let attestationJSON = try XCTUnwrap( JSONSerialization.jsonObject(with: attestationBody) as? [String: Any] ) XCTAssertEqual(attestationJSON["attestationObject"] as? String, "qrs=") } func testDeviceCheckRemainsUsableWhenAppAttestRecoveryFails() async throws { let transport = QueueAccountTransport([ .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")!, challenge: "AQID" ) ) ]) let store = InMemoryAccountSecurityStore( keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: true) ) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let coordinator = DeviceIntegrityCoordinator( apiClient: client, deviceCheck: FakeDeviceCheckProvider( isSupported: true, token: Data([0x10, 0x20]) ), appAttest: AlwaysFailingAppAttestProvider(), keyStateStore: store ) let evidence = try await coordinator.evidenceForAppleSignIn( credential: AppleSignInCredential( identityToken: "identity-token", authorizationCode: "authorization-code" ), rawNonce: "raw-nonce" ) XCTAssertEqual(evidence.deviceCheckToken, "ECA=") XCTAssertNil(evidence.appAttest) } func testInvalidAppAttestKeyIsReplacedAndRegisteredOnce() async throws { let transport = QueueAccountTransport([ .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")!, challenge: "AQID" ) ), .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "ffffffff-ffff-ffff-ffff-ffffffffffff")!, challenge: "BAUG" ) ), .init(statusCode: 204, body: Data()), .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!, challenge: "BwgJ" ) ) ]) let store = InMemoryAccountSecurityStore( keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: true) ) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let appAttestState = RecoveringAppAttestState() let coordinator = DeviceIntegrityCoordinator( apiClient: client, deviceCheck: FakeDeviceCheckProvider(isSupported: false, token: Data()), appAttest: RecoveringAppAttestProvider(state: appAttestState), keyStateStore: store ) let evidence = try await coordinator.evidenceForAppleSignIn( credential: AppleSignInCredential( identityToken: "identity-token", authorizationCode: "authorization-code" ), rawNonce: "raw-nonce" ) XCTAssertEqual(evidence.appAttest?.keyId, "fresh-key") let keyState = await store.keyState XCTAssertEqual(keyState, AppAttestKeyState(keyId: "fresh-key", isRegistered: true)) let clearCount = await appAttestState.failedAssertionCount XCTAssertEqual(clearCount, 1) } func testUnregisteredKeyFromAnotherEnvironmentIsReplaced() async throws { let transport = QueueAccountTransport([ .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "13131313-1313-1313-1313-131313131313")!, challenge: "AQID" ) ), .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "14141414-1414-1414-1414-141414141414")!, challenge: "BAUG" ) ), .init(statusCode: 204, body: Data()), .init( statusCode: 201, body: challengeData( id: UUID(uuidString: "15151515-1515-1515-1515-151515151515")!, challenge: "BwgJ" ) ) ]) let store = InMemoryAccountSecurityStore( keyState: AppAttestKeyState(keyId: "stale-key", isRegistered: false) ) let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let appAttestState = RecoveringAppAttestState() let coordinator = DeviceIntegrityCoordinator( apiClient: client, deviceCheck: FakeDeviceCheckProvider(isSupported: false, token: Data()), appAttest: RecoveringAppAttestProvider(state: appAttestState), keyStateStore: store ) let evidence = try await coordinator.evidenceForAppleSignIn( credential: AppleSignInCredential( identityToken: "identity-token", authorizationCode: "authorization-code" ), rawNonce: "raw-nonce" ) XCTAssertEqual(evidence.appAttest?.keyId, "fresh-key") let keyState = await store.keyState let failedAttestationCount = await appAttestState.failedAttestationCount XCTAssertEqual(keyState, AppAttestKeyState(keyId: "fresh-key", isRegistered: true)) XCTAssertEqual(failedAttestationCount, 1) } func testChallengeAssertionSendsBase64URLClientDataHash() async throws { let challenge = AppAttestChallenge( challengeId: UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!, challenge: "AQID", expiresAtEpochSeconds: 4_000_000_000 ) let transport = QueueAccountTransport([ .init(statusCode: 200, body: Data(#"{"counter":7}"#.utf8)) ]) let store = InMemoryAccountSecurityStore() let client = AccountAPIClient( baseURL: URL(string: "https://account.test")!, transport: transport, sessionVault: store ) let hash = Data(SHA256.hash(data: Data([1, 2, 3]))) let counter = try await client.submitAssertion( challenge: challenge, keyId: "key-id", assertion: Data([0x01, 0x02]), clientDataHash: hash ) XCTAssertEqual(counter, 7) let requests = await transport.requests let request = try XCTUnwrap(requests.single) let body = try XCTUnwrap(request.httpBody) let json = try XCTUnwrap( JSONSerialization.jsonObject(with: body) as? [String: Any] ) XCTAssertEqual( json["clientDataHash"] as? String, hash.base64URLEncodedString() ) XCTAssertEqual(json["assertion"] as? String, "AQI=") } } private enum TestIntegrityFailure: Error { case unavailable } private struct AlwaysFailingAppAttestProvider: AppAttestProviding { let isSupported = true func generateKey() async throws -> String { throw TestIntegrityFailure.unavailable } func attestKey( _ keyId: String, clientDataHash: Data ) async throws -> Data { throw TestIntegrityFailure.unavailable } func generateAssertion( _ keyId: String, clientDataHash: Data ) async throws -> Data { throw TestIntegrityFailure.unavailable } } private actor RecoveringAppAttestState { private(set) var failedAssertionCount = 0 private(set) var failedAttestationCount = 0 func recordFailedAssertion() { failedAssertionCount += 1 } func recordFailedAttestation() { failedAttestationCount += 1 } } private struct RecoveringAppAttestProvider: AppAttestProviding { let isSupported = true let state: RecoveringAppAttestState func generateKey() async throws -> String { "fresh-key" } func attestKey( _ keyId: String, clientDataHash: Data ) async throws -> Data { if keyId == "stale-key" { await state.recordFailedAttestation() throw TestIntegrityFailure.unavailable } return Data([0xaa, 0xbb]) } func generateAssertion( _ keyId: String, clientDataHash: Data ) async throws -> Data { if keyId == "stale-key" { await state.recordFailedAssertion() throw TestIntegrityFailure.unavailable } return Data([0x01, 0x02]) } } private func challengeData(id: UUID, challenge: String) -> Data { Data( """ {"challengeId":"\(id.uuidString.lowercased())","challenge":"\(challenge)","expiresAtEpochSeconds":4000000000} """.utf8 ) } private extension Array { var single: Element? { count == 1 ? first : nil } }