// App Attest: register a hardware-backed key once, then produce a per-request // assertion proving this is a genuine, unmodified instance of the app on a // real device. // // The key is generated and attested once; the attestation goes to the BFF's // registration endpoint, which verifies it and stores the public key under // keyId. Each request then carries an assertion the Worker verifies against // that stored key. import Foundation import CryptoKit import DeviceCheck extension ClaudeBFF { struct Assertion { let keyId: String let assertion: String // base64 } /// Produce an App Attest assertion over the given client data, or nil when /// App Attest is unavailable (Simulator, unsupported device). static func appAttestAssertion(clientData: String) async throws -> Assertion? { let service = DCAppAttestService.shared guard service.isSupported else { return nil } let keyId = try await attestedKeyId(service) let hash = Data(SHA256.hash(data: Data(clientData.utf8))) let assertion = try await service.generateAssertion(keyId, clientDataHash: hash) return Assertion(keyId: keyId, assertion: assertion.base64EncodedString()) } private static let keyIdDefaultsKey = "ClaudeBFF.appAttestKeyId" private static func attestedKeyId(_ service: DCAppAttestService) async throws -> String { if let saved = UserDefaults.standard.string(forKey: keyIdDefaultsKey) { return saved } let keyId = try await service.generateKey() try await register(keyId, service) // Persist only after the BFF accepted the attestation, so a failed // registration retries with a fresh key on the next request. UserDefaults.standard.set(keyId, forKey: keyIdDefaultsKey) return keyId } /// One-time registration: fetch a server-issued challenge, attest the key /// over it, then hand the attestation to the BFF so it can verify the /// chain and store the public key. The BFF consumes each challenge /// exactly once. private static func register(_ keyId: String, _ service: DCAppAttestService) async throws { let challenge = try await fetchChallenge() let hash = Data(SHA256.hash(data: Data(challenge.utf8))) let attestation = try await service.attestKey(keyId, clientDataHash: hash) var request = URLRequest(url: baseURL.appending(path: registerPath)) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode( registrationBody( keyId: keyId, attestation: attestation.base64EncodedString(), challenge: challenge ) ) let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { throw BFFError.registrationFailed } } /// Ask the BFF for a one-time registration challenge. private static func fetchChallenge() async throws -> String { var request = URLRequest(url: baseURL.appending(path: challengePath)) request.httpMethod = "POST" let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200, let body = try? JSONDecoder().decode([String: String].self, from: data), let challenge = body["challenge"] else { throw BFFError.registrationFailed } return challenge } /// Pure body assembly, testable without DeviceCheck or a network. static func registrationBody( keyId: String, attestation: String, challenge: String ) -> [String: String] { ["keyId": keyId, "attestation": attestation, "challenge": challenge] } }