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 }