crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PushRequestAuthenticator.swift (7751B)


      1 import CryptoKit
      2 import DeviceCheck
      3 import Foundation
      4 
      5 enum PushRequestAuthError: LocalizedError {
      6     case appAttestUnsupported
      7     case missingChallenge
      8     case attestationRejected
      9     case invalidResponse
     10 
     11     var errorDescription: String? {
     12         switch self {
     13         case .appAttestUnsupported:
     14             "App Attest is not available on this device."
     15         case .missingChallenge:
     16             "The push worker did not return an App Attest challenge."
     17         case .attestationRejected:
     18             "The push worker rejected this app installation's attestation."
     19         case .invalidResponse:
     20             "The push worker returned an invalid authentication response."
     21         }
     22     }
     23 }
     24 
     25 /// Enrolls this app installation with the push worker using App Attest, then
     26 /// signs each worker request with the attested key. The worker stores only the
     27 /// public key and a monotonically increasing assertion counter; Crossmate still
     28 /// has no server-side user accounts.
     29 actor PushRequestAuthenticator {
     30     private static let keyIDKey = "push.appAttest.keyID.v1"
     31 
     32     private let baseURL: URL
     33     private let deviceID: String
     34     private let session: URLSession
     35     private let service = DCAppAttestService.shared
     36 
     37     private var cachedKeyID: String?
     38     private var registeringTask: Task<String, Error>?
     39 
     40     init(
     41         baseURL: URL,
     42         deviceID: String,
     43         session: URLSession
     44     ) {
     45         self.baseURL = baseURL
     46         self.deviceID = deviceID
     47         self.session = session
     48     }
     49 
     50     func signedHeaders(
     51         method: String,
     52         path: String,
     53         body: Data
     54     ) async throws -> [String: String] {
     55         let keyID = try await registeredKeyID()
     56         return try await assertionHeaders(
     57             keyID: keyID,
     58             method: method,
     59             path: path,
     60             body: body
     61         )
     62     }
     63 
     64     func resetRegistration() {
     65         cachedKeyID = nil
     66         registeringTask?.cancel()
     67         registeringTask = nil
     68         KeychainHelper.delete(key: Self.keyIDKey)
     69     }
     70 
     71     private func registeredKeyID() async throws -> String {
     72         if let cachedKeyID { return cachedKeyID }
     73         if let data = KeychainHelper.load(key: Self.keyIDKey),
     74            let keyID = String(data: data, encoding: .utf8),
     75            !keyID.isEmpty {
     76             cachedKeyID = keyID
     77             return keyID
     78         }
     79         if let registeringTask {
     80             return try await registeringTask.value
     81         }
     82         let task = Task { try await registerFreshKey() }
     83         registeringTask = task
     84         do {
     85             let keyID = try await task.value
     86             registeringTask = nil
     87             cachedKeyID = keyID
     88             return keyID
     89         } catch {
     90             registeringTask = nil
     91             throw error
     92         }
     93     }
     94 
     95     private func registerFreshKey() async throws -> String {
     96         guard service.isSupported else {
     97             throw PushRequestAuthError.appAttestUnsupported
     98         }
     99         let keyID = try await service.generateKey()
    100         let challenge = try await fetchChallenge(for: keyID)
    101         let clientDataHash = Self.clientDataHashForAttestation(
    102             challenge: challenge,
    103             deviceID: deviceID,
    104             keyID: keyID
    105         )
    106         let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
    107         try await submitAttestation(
    108             keyID: keyID,
    109             challenge: challenge,
    110             attestation: attestation
    111         )
    112         try KeychainHelper.save(key: Self.keyIDKey, data: Data(keyID.utf8))
    113         return keyID
    114     }
    115 
    116     private func fetchChallenge(for keyID: String) async throws -> String {
    117         var request = URLRequest(
    118             url: baseURL.appendingPathComponent("attest").appendingPathComponent("challenge")
    119         )
    120         request.httpMethod = "POST"
    121         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    122         request.httpBody = try JSONEncoder().encode(
    123             ChallengeRequest(deviceID: deviceID, keyID: keyID)
    124         )
    125         let (data, response) = try await session.data(for: request)
    126         guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
    127             throw PushRequestAuthError.missingChallenge
    128         }
    129         let decoded = try JSONDecoder().decode(ChallengeResponse.self, from: data)
    130         guard !decoded.challenge.isEmpty else {
    131             throw PushRequestAuthError.missingChallenge
    132         }
    133         return decoded.challenge
    134     }
    135 
    136     private func submitAttestation(
    137         keyID: String,
    138         challenge: String,
    139         attestation: Data
    140     ) async throws {
    141         var request = URLRequest(
    142             url: baseURL.appendingPathComponent("attest").appendingPathComponent("register")
    143         )
    144         request.httpMethod = "POST"
    145         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    146         let body = AttestationRequest(
    147             deviceID: deviceID,
    148             keyID: keyID,
    149             challenge: challenge,
    150             attestationObject: attestation.base64URLEncodedString()
    151         )
    152         request.httpBody = try JSONEncoder().encode(body)
    153         let (_, response) = try await session.data(for: request)
    154         guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
    155             throw PushRequestAuthError.attestationRejected
    156         }
    157     }
    158 
    159     private func assertionHeaders(
    160         keyID: String,
    161         method: String,
    162         path: String,
    163         body: Data
    164     ) async throws -> [String: String] {
    165         let timestamp = String(Int(Date().timeIntervalSince1970))
    166         let nonce = UUID().uuidString
    167         let bodyHash = Data(SHA256.hash(data: body)).base64URLEncodedString()
    168         let canonical = Self.canonicalRequest(
    169             method: method,
    170             path: path,
    171             bodyHash: bodyHash,
    172             timestamp: timestamp,
    173             nonce: nonce,
    174             deviceID: deviceID,
    175             keyID: keyID
    176         )
    177         let clientDataHash = Data(SHA256.hash(data: Data(canonical.utf8)))
    178         let assertion = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
    179         return [
    180             "X-Crossmate-Auth-Version": "appattest-v1",
    181             "X-Crossmate-Device-ID": deviceID,
    182             "X-Crossmate-Key-ID": keyID,
    183             "X-Crossmate-Timestamp": timestamp,
    184             "X-Crossmate-Nonce": nonce,
    185             "X-Crossmate-Body-SHA256": bodyHash,
    186             "X-Crossmate-Assertion": assertion.base64URLEncodedString()
    187         ]
    188     }
    189 
    190     private static func clientDataHashForAttestation(
    191         challenge: String,
    192         deviceID: String,
    193         keyID: String
    194     ) -> Data {
    195         let canonical = [
    196             "crossmate-appattest-v1",
    197             challenge,
    198             deviceID,
    199             keyID
    200         ].joined(separator: "\n")
    201         return Data(SHA256.hash(data: Data(canonical.utf8)))
    202     }
    203 
    204     static func canonicalRequest(
    205         method: String,
    206         path: String,
    207         bodyHash: String,
    208         timestamp: String,
    209         nonce: String,
    210         deviceID: String,
    211         keyID: String
    212     ) -> String {
    213         [
    214             "crossmate-push-request-v1",
    215             method.uppercased(),
    216             path,
    217             bodyHash,
    218             timestamp,
    219             nonce,
    220             deviceID,
    221             keyID
    222         ].joined(separator: "\n")
    223     }
    224 
    225     private struct ChallengeRequest: Encodable {
    226         var deviceID: String
    227         var keyID: String
    228     }
    229 
    230     private struct ChallengeResponse: Decodable {
    231         var challenge: String
    232     }
    233 
    234     private struct AttestationRequest: Encodable {
    235         var deviceID: String
    236         var keyID: String
    237         var challenge: String
    238         var attestationObject: String
    239     }
    240 }