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 }