crossmate

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

PushRequestAuthenticator.swift (19708B)


      1 import CryptoKit
      2 import DeviceCheck
      3 import Foundation
      4 
      5 enum PushRequestAuthError: LocalizedError {
      6     case appAttestUnsupported
      7     case missingChallenge
      8     case malformedKeyID
      9     case challengeBadRequest(reason: String)
     10     case challengeRouteMissing
     11     case challengeRateLimited(retryAfter: TimeInterval?)
     12     case challengeServerError(status: Int)
     13     case challengeRejected(status: Int)
     14     case attestationRejected(status: Int)
     15     case invalidResponse
     16 
     17     var errorDescription: String? {
     18         switch self {
     19         case .appAttestUnsupported:
     20             "App Attest is not available on this device."
     21         case .missingChallenge:
     22             "The push worker did not return an App Attest challenge."
     23         case .malformedKeyID:
     24             "App Attest returned an invalid key identifier."
     25         case let .challengeBadRequest(reason):
     26             "The push worker rejected the App Attest challenge request (HTTP 400: \(reason))."
     27         case .challengeRouteMissing:
     28             "The push worker has no App Attest challenge route (HTTP 404)."
     29         case let .challengeRateLimited(retryAfter):
     30             if let retryAfter {
     31                 "The push worker rate-limited App Attest enrollment (HTTP 429; retry after \(Int(retryAfter.rounded(.up))) seconds)."
     32             } else {
     33                 "The push worker rate-limited App Attest enrollment (HTTP 429)."
     34             }
     35         case let .challengeServerError(status):
     36             "The push worker failed while issuing an App Attest challenge (HTTP \(status))."
     37         case let .challengeRejected(status):
     38             "The push worker rejected the App Attest challenge request (HTTP \(status))."
     39         case let .attestationRejected(status):
     40             "The push worker rejected this app installation's attestation (HTTP \(status))."
     41         case .invalidResponse:
     42             "The push worker returned an invalid authentication response."
     43         }
     44     }
     45 
     46     var retryAfter: TimeInterval? {
     47         if case let .challengeRateLimited(retryAfter) = self { retryAfter } else { nil }
     48     }
     49 }
     50 
     51 protocol AppAttestServicing: Sendable {
     52     var isSupported: Bool { get }
     53     func generateKey() async throws -> String
     54     func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data
     55     func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data
     56 }
     57 
     58 private struct SystemAppAttestService: AppAttestServicing {
     59     var isSupported: Bool { DCAppAttestService.shared.isSupported }
     60 
     61     func generateKey() async throws -> String {
     62         try await DCAppAttestService.shared.generateKey()
     63     }
     64 
     65     func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data {
     66         try await DCAppAttestService.shared.attestKey(keyID, clientDataHash: clientDataHash)
     67     }
     68 
     69     func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data {
     70         try await DCAppAttestService.shared.generateAssertion(keyID, clientDataHash: clientDataHash)
     71     }
     72 }
     73 
     74 struct PushAuthKeyStore: Sendable {
     75     var load: @Sendable (String) -> Data?
     76     var save: @Sendable (String, Data) throws -> Void
     77     var delete: @Sendable (String) -> Void
     78 
     79     static let keychain = PushAuthKeyStore(
     80         load: { KeychainHelper.load(key: $0) },
     81         save: { try KeychainHelper.save(key: $0, data: $1) },
     82         delete: { KeychainHelper.delete(key: $0) }
     83     )
     84 }
     85 
     86 /// Enrolls this app installation with the push worker using App Attest, then
     87 /// signs each worker request with the attested key. The worker stores only the
     88 /// public key and a monotonically increasing assertion counter; Crossmate still
     89 /// has no server-side user accounts.
     90 actor PushRequestAuthenticator {
     91     private static let keyIDKey = "push.appAttest.keyID.v1"
     92     private static let pendingKeyIDKey = "push.appAttest.pendingKeyID.v1"
     93     private static let pendingEnrollmentKey = "push.appAttest.pendingEnrollment.v1"
     94     private static let maximumKeyIDCharacters = 256
     95     private static let maximumKeyIDBytes = 128
     96     private static let maximumBackoff: TimeInterval = 5 * 60
     97     private static let maximumRetryAfter: TimeInterval = 24 * 60 * 60
     98 
     99     private let baseURL: URL
    100     private let deviceID: String
    101     private let session: URLSession
    102     private let service: any AppAttestServicing
    103     private let keyStore: PushAuthKeyStore
    104     private let now: @Sendable () -> Date
    105     private let sleep: @Sendable (TimeInterval) async throws -> Void
    106     private let jitter: @Sendable () -> Double
    107 
    108     private var cachedKeyID: String?
    109     private var registeringTask: Task<String, Error>?
    110     private var consecutiveFailures = 0
    111     private var retryNotBefore: Date?
    112 
    113     init(
    114         baseURL: URL,
    115         deviceID: String,
    116         session: URLSession,
    117         service: any AppAttestServicing = SystemAppAttestService(),
    118         keyStore: PushAuthKeyStore = .keychain,
    119         now: @escaping @Sendable () -> Date = Date.init,
    120         sleep: @escaping @Sendable (TimeInterval) async throws -> Void = {
    121             try await Task.sleep(for: .seconds($0))
    122         },
    123         jitter: @escaping @Sendable () -> Double = { Double.random(in: 0.8...1.2) }
    124     ) {
    125         self.baseURL = baseURL
    126         self.deviceID = deviceID
    127         self.session = session
    128         self.service = service
    129         self.keyStore = keyStore
    130         self.now = now
    131         self.sleep = sleep
    132         self.jitter = jitter
    133     }
    134 
    135     func signedHeaders(
    136         method: String,
    137         path: String,
    138         body: Data
    139     ) async throws -> [String: String] {
    140         let keyID = try await registeredKeyID()
    141         return try await assertionHeaders(
    142             keyID: keyID,
    143             method: method,
    144             path: path,
    145             body: body
    146         )
    147     }
    148 
    149     func resetRegistration() {
    150         cachedKeyID = nil
    151         registeringTask?.cancel()
    152         registeringTask = nil
    153         consecutiveFailures = 0
    154         retryNotBefore = nil
    155         keyStore.delete(Self.keyIDKey)
    156         keyStore.delete(Self.pendingKeyIDKey)
    157         keyStore.delete(Self.pendingEnrollmentKey)
    158     }
    159 
    160     private func registeredKeyID() async throws -> String {
    161         if let cachedKeyID { return cachedKeyID }
    162         if let data = keyStore.load(Self.keyIDKey),
    163            let keyID = String(data: data, encoding: .utf8),
    164            !keyID.isEmpty {
    165             cachedKeyID = keyID
    166             return keyID
    167         }
    168         if let registeringTask {
    169             return try await registeringTask.value
    170         }
    171         let delay = max(0, retryNotBefore?.timeIntervalSince(now()) ?? 0)
    172         let task = Task {
    173             if delay > 0 { try await sleep(delay) }
    174             return try await registerFreshKey()
    175         }
    176         registeringTask = task
    177         do {
    178             let keyID = try await task.value
    179             registeringTask = nil
    180             cachedKeyID = keyID
    181             consecutiveFailures = 0
    182             retryNotBefore = nil
    183             return keyID
    184         } catch {
    185             registeringTask = nil
    186             recordRegistrationFailure(error)
    187             throw error
    188         }
    189     }
    190 
    191     private func registerFreshKey() async throws -> String {
    192         guard service.isSupported else {
    193             throw PushRequestAuthError.appAttestUnsupported
    194         }
    195         let keyID: String
    196         if let data = keyStore.load(Self.pendingKeyIDKey),
    197            let pending = String(data: data, encoding: .utf8),
    198            !pending.isEmpty {
    199             keyID = pending
    200         } else {
    201             keyID = try await service.generateKey()
    202             // Apple doesn't provide a way to recover this identifier later.
    203             // Persist it before the first network operation so a challenge or
    204             // attestation outage doesn't strand another Secure Enclave key.
    205             try keyStore.save(Self.pendingKeyIDKey, Data(keyID.utf8))
    206         }
    207         let networkKeyID = try Self.canonicalKeyID(keyID)
    208         var pending = loadPendingEnrollment(keyID: keyID, networkKeyID: networkKeyID)
    209         if pending == nil {
    210             let challenge = try await fetchChallenge(for: networkKeyID)
    211             pending = PendingEnrollment(
    212                 keyID: keyID,
    213                 networkKeyID: networkKeyID,
    214                 challenge: challenge,
    215                 attestation: nil
    216             )
    217             try savePendingEnrollment(pending!)
    218         }
    219         var enrollment = pending!
    220         if enrollment.attestation == nil {
    221             let clientDataHash = Self.clientDataHashForAttestation(
    222                 challenge: enrollment.challenge,
    223                 deviceID: deviceID,
    224                 keyID: networkKeyID
    225             )
    226             enrollment.attestation = try await service.attestKey(
    227                 keyID,
    228                 clientDataHash: clientDataHash
    229             )
    230             // If the Worker accepts the registration but its response is lost,
    231             // resend this exact attestation instead of asking Apple to attest
    232             // the key again with a different challenge.
    233             try savePendingEnrollment(enrollment)
    234         }
    235         do {
    236             try await submitAttestation(
    237                 keyID: networkKeyID,
    238                 challenge: enrollment.challenge,
    239                 attestation: enrollment.attestation!
    240             )
    241         } catch let error as PushRequestAuthError {
    242             // A definitive client/auth rejection means the server did not
    243             // accept this attestation. Apple's guidance is to discard that
    244             // identifier; transient server failures retain it for retry.
    245             if case let .attestationRejected(status) = error, (400..<500).contains(status) {
    246                 keyStore.delete(Self.pendingKeyIDKey)
    247                 keyStore.delete(Self.pendingEnrollmentKey)
    248             }
    249             throw error
    250         }
    251         try keyStore.save(Self.keyIDKey, Data(keyID.utf8))
    252         keyStore.delete(Self.pendingKeyIDKey)
    253         keyStore.delete(Self.pendingEnrollmentKey)
    254         return keyID
    255     }
    256 
    257     private func fetchChallenge(for keyID: String) async throws -> String {
    258         var request = URLRequest(
    259             url: baseURL.appendingPathComponent("attest").appendingPathComponent("challenge")
    260         )
    261         request.httpMethod = "POST"
    262         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    263         request.httpBody = try JSONEncoder().encode(
    264             ChallengeRequest(deviceID: deviceID, keyID: keyID)
    265         )
    266         let (data, response) = try await session.data(for: request)
    267         guard let http = response as? HTTPURLResponse else {
    268             throw PushRequestAuthError.invalidResponse
    269         }
    270         guard http.statusCode == 200 else {
    271             throw Self.challengeError(response: http, data: data, now: now())
    272         }
    273         let decoded = try JSONDecoder().decode(ChallengeResponse.self, from: data)
    274         guard !decoded.challenge.isEmpty else {
    275             throw PushRequestAuthError.missingChallenge
    276         }
    277         return decoded.challenge
    278     }
    279 
    280     private func submitAttestation(
    281         keyID: String,
    282         challenge: String,
    283         attestation: Data
    284     ) async throws {
    285         var request = URLRequest(
    286             url: baseURL.appendingPathComponent("attest").appendingPathComponent("register")
    287         )
    288         request.httpMethod = "POST"
    289         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    290         let body = AttestationRequest(
    291             deviceID: deviceID,
    292             keyID: keyID,
    293             challenge: challenge,
    294             attestationObject: attestation.base64URLEncodedString()
    295         )
    296         request.httpBody = try JSONEncoder().encode(body)
    297         let (_, response) = try await session.data(for: request)
    298         guard let http = response as? HTTPURLResponse else {
    299             throw PushRequestAuthError.invalidResponse
    300         }
    301         guard http.statusCode == 204 else {
    302             throw PushRequestAuthError.attestationRejected(status: http.statusCode)
    303         }
    304     }
    305 
    306     private func assertionHeaders(
    307         keyID: String,
    308         method: String,
    309         path: String,
    310         body: Data
    311     ) async throws -> [String: String] {
    312         let networkKeyID = try Self.canonicalKeyID(keyID)
    313         let timestamp = String(Int(Date().timeIntervalSince1970))
    314         let nonce = UUID().uuidString
    315         let bodyHash = Data(SHA256.hash(data: body)).base64URLEncodedString()
    316         let canonical = Self.canonicalRequest(
    317             method: method,
    318             path: path,
    319             bodyHash: bodyHash,
    320             timestamp: timestamp,
    321             nonce: nonce,
    322             deviceID: deviceID,
    323             keyID: networkKeyID
    324         )
    325         let clientDataHash = Data(SHA256.hash(data: Data(canonical.utf8)))
    326         let assertion = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
    327         return [
    328             "X-Crossmate-Auth-Version": "appattest-v1",
    329             "X-Crossmate-Device-ID": deviceID,
    330             "X-Crossmate-Key-ID": networkKeyID,
    331             "X-Crossmate-Timestamp": timestamp,
    332             "X-Crossmate-Nonce": nonce,
    333             "X-Crossmate-Body-SHA256": bodyHash,
    334             "X-Crossmate-Assertion": assertion.base64URLEncodedString()
    335         ]
    336     }
    337 
    338     private func recordRegistrationFailure(_ error: Error) {
    339         consecutiveFailures = min(consecutiveFailures + 1, 16)
    340         let exponential = min(
    341             Self.maximumBackoff,
    342             2 * pow(2, Double(consecutiveFailures - 1))
    343         )
    344         let retryAfter = min(
    345             (error as? PushRequestAuthError)?.retryAfter ?? 0,
    346             Self.maximumRetryAfter
    347         )
    348         let delay = max(retryAfter, exponential * min(max(jitter(), 0.5), 1.5))
    349         retryNotBefore = now().addingTimeInterval(delay)
    350     }
    351 
    352     private func loadPendingEnrollment(
    353         keyID: String,
    354         networkKeyID: String
    355     ) -> PendingEnrollment? {
    356         guard let data = keyStore.load(Self.pendingEnrollmentKey),
    357               let pending = try? JSONDecoder().decode(PendingEnrollment.self, from: data),
    358               pending.keyID == keyID,
    359               pending.networkKeyID == networkKeyID
    360         else {
    361             keyStore.delete(Self.pendingEnrollmentKey)
    362             return nil
    363         }
    364         return pending
    365     }
    366 
    367     private func savePendingEnrollment(_ pending: PendingEnrollment) throws {
    368         try keyStore.save(Self.pendingEnrollmentKey, JSONEncoder().encode(pending))
    369     }
    370 
    371     static func canonicalKeyID(_ keyID: String) throws -> String {
    372         guard !keyID.isEmpty, keyID.count <= maximumKeyIDCharacters else {
    373             throw PushRequestAuthError.malformedKeyID
    374         }
    375         let alphabet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/_-=")
    376         guard keyID.unicodeScalars.allSatisfy(alphabet.contains) else {
    377             throw PushRequestAuthError.malformedKeyID
    378         }
    379         let usesStandardAlphabet = keyID.contains("+") || keyID.contains("/")
    380         let usesURLSafeAlphabet = keyID.contains("-") || keyID.contains("_")
    381         guard !(usesStandardAlphabet && usesURLSafeAlphabet) else {
    382             throw PushRequestAuthError.malformedKeyID
    383         }
    384         if let firstPaddingIndex = keyID.firstIndex(of: "=") {
    385             let firstPadding = keyID.distance(from: keyID.startIndex, to: firstPaddingIndex)
    386             guard keyID.dropFirst(firstPadding).allSatisfy({ $0 == "=" }) else {
    387                 throw PushRequestAuthError.malformedKeyID
    388             }
    389         }
    390         let unpadded = keyID.trimmingCharacters(in: CharacterSet(charactersIn: "="))
    391         guard keyID.count - unpadded.count <= 2 else {
    392             throw PushRequestAuthError.malformedKeyID
    393         }
    394         var standard = unpadded.replacingOccurrences(of: "-", with: "+")
    395             .replacingOccurrences(of: "_", with: "/")
    396         guard standard.count % 4 != 1 else { throw PushRequestAuthError.malformedKeyID }
    397         standard += String(repeating: "=", count: (4 - standard.count % 4) % 4)
    398         guard let bytes = Data(base64Encoded: standard),
    399               !bytes.isEmpty,
    400               bytes.count <= maximumKeyIDBytes
    401         else {
    402             throw PushRequestAuthError.malformedKeyID
    403         }
    404         let canonical = bytes.base64URLEncodedString()
    405         let suppliedCanonical = unpadded.replacingOccurrences(of: "+", with: "-")
    406             .replacingOccurrences(of: "/", with: "_")
    407         guard canonical == suppliedCanonical else {
    408             throw PushRequestAuthError.malformedKeyID
    409         }
    410         return canonical
    411     }
    412 
    413     static func challengeError(
    414         response: HTTPURLResponse,
    415         data: Data,
    416         now: Date
    417     ) -> PushRequestAuthError {
    418         switch response.statusCode {
    419         case 400:
    420             return .challengeBadRequest(reason: safeChallengeReason(data))
    421         case 404:
    422             return .challengeRouteMissing
    423         case 429:
    424             return .challengeRateLimited(retryAfter: retryAfter(response, now: now))
    425         case 500..<600:
    426             return .challengeServerError(status: response.statusCode)
    427         default:
    428             return .challengeRejected(status: response.statusCode)
    429         }
    430     }
    431 
    432     private static func safeChallengeReason(_ data: Data) -> String {
    433         let reason = String(data: data.prefix(160), encoding: .utf8)?
    434             .trimmingCharacters(in: .whitespacesAndNewlines)
    435         let knownReasons = [
    436             "Body too large",
    437             "Body must be JSON",
    438             "deviceID and keyID required",
    439             "Malformed deviceID or keyID",
    440             "Rate limit exceeded",
    441             "Not found"
    442         ]
    443         return knownReasons.contains(reason ?? "") ? reason! : "unrecognized response"
    444     }
    445 
    446     private static func retryAfter(_ response: HTTPURLResponse, now: Date) -> TimeInterval? {
    447         guard let value = response.value(forHTTPHeaderField: "Retry-After") else { return nil }
    448         if let seconds = TimeInterval(value), seconds >= 0 { return seconds }
    449         let formatter = DateFormatter()
    450         formatter.locale = Locale(identifier: "en_US_POSIX")
    451         formatter.timeZone = TimeZone(secondsFromGMT: 0)
    452         formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
    453         guard let date = formatter.date(from: value) else { return nil }
    454         return max(0, date.timeIntervalSince(now))
    455     }
    456 
    457     private static func clientDataHashForAttestation(
    458         challenge: String,
    459         deviceID: String,
    460         keyID: String
    461     ) -> Data {
    462         let canonical = [
    463             "crossmate-appattest-v1",
    464             challenge,
    465             deviceID,
    466             keyID
    467         ].joined(separator: "\n")
    468         return Data(SHA256.hash(data: Data(canonical.utf8)))
    469     }
    470 
    471     static func canonicalRequest(
    472         method: String,
    473         path: String,
    474         bodyHash: String,
    475         timestamp: String,
    476         nonce: String,
    477         deviceID: String,
    478         keyID: String
    479     ) -> String {
    480         [
    481             "crossmate-push-request-v1",
    482             method.uppercased(),
    483             path,
    484             bodyHash,
    485             timestamp,
    486             nonce,
    487             deviceID,
    488             keyID
    489         ].joined(separator: "\n")
    490     }
    491 
    492     private struct ChallengeRequest: Encodable {
    493         var deviceID: String
    494         var keyID: String
    495     }
    496 
    497     private struct ChallengeResponse: Decodable {
    498         var challenge: String
    499     }
    500 
    501     private struct AttestationRequest: Encodable {
    502         var deviceID: String
    503         var keyID: String
    504         var challenge: String
    505         var attestationObject: String
    506     }
    507 
    508     private struct PendingEnrollment: Codable {
    509         var keyID: String
    510         var networkKeyID: String
    511         var challenge: String
    512         var attestation: Data?
    513     }
    514 }