PushRequestAuthenticatorTests.swift (10507B)
1 import Foundation 2 import Testing 3 4 @testable import Crossmate 5 6 @Suite("Push request authentication", .serialized) 7 struct PushRequestAuthenticatorTests { 8 @Test("App Attest key IDs canonicalize standard and URL-safe Base64") 9 func keyIDCanonicalization() throws { 10 let bytes = Data([0xfb, 0xff] + Array(0..<30)) 11 let standard = bytes.base64EncodedString() 12 let urlSafe = bytes.base64URLEncodedString() 13 14 #expect(standard.contains("+")) 15 #expect(standard.contains("/")) 16 #expect(standard.hasSuffix("=")) 17 #expect(try PushRequestAuthenticator.canonicalKeyID(standard) == urlSafe) 18 #expect(try PushRequestAuthenticator.canonicalKeyID(urlSafe) == urlSafe) 19 } 20 21 @Test("App Attest key IDs reject malformed and storage-smuggling values") 22 func keyIDRejection() { 23 for keyID in ["", "A", "AA=A", "+_8=", "not:base64", String(repeating: "A", count: 257)] { 24 #expect(throws: PushRequestAuthError.self) { 25 try PushRequestAuthenticator.canonicalKeyID(keyID) 26 } 27 } 28 } 29 30 @Test("challenge failures retain privacy-safe status and retry information") 31 func challengeDiagnostics() throws { 32 let url = try #require(URL(string: "https://push.test/attest/challenge")) 33 let badRequest = try #require(HTTPURLResponse( 34 url: url, 35 statusCode: 400, 36 httpVersion: nil, 37 headerFields: nil 38 )) 39 let badRequestError = PushRequestAuthenticator.challengeError( 40 response: badRequest, 41 data: Data("Malformed deviceID or keyID".utf8), 42 now: Date(timeIntervalSince1970: 0) 43 ) 44 #expect(badRequestError.localizedDescription.contains("HTTP 400")) 45 #expect(badRequestError.localizedDescription.contains("Malformed deviceID or keyID")) 46 47 let secretEcho = PushRequestAuthenticator.challengeError( 48 response: badRequest, 49 data: Data("keyID abcdef0123456789".utf8), 50 now: Date(timeIntervalSince1970: 0) 51 ) 52 #expect(secretEcho.localizedDescription.contains("unrecognized response")) 53 #expect(!secretEcho.localizedDescription.contains("abcdef0123456789")) 54 55 let limited = try #require(HTTPURLResponse( 56 url: url, 57 statusCode: 429, 58 httpVersion: nil, 59 headerFields: ["Retry-After": "90"] 60 )) 61 let limitedError = PushRequestAuthenticator.challengeError( 62 response: limited, 63 data: Data("Rate limit exceeded".utf8), 64 now: Date(timeIntervalSince1970: 0) 65 ) 66 #expect(limitedError.localizedDescription.contains("HTTP 429")) 67 #expect(limitedError.localizedDescription.contains("90 seconds")) 68 } 69 70 @Test("failed challenge reuses the persisted key after backoff") 71 func failedChallengeReusesKey() async throws { 72 AuthURLProtocol.reset() 73 defer { AuthURLProtocol.reset() } 74 75 let bytes = Data([0xfb, 0xff] + Array(0..<30)) 76 let originalKeyID = bytes.base64EncodedString() 77 let canonicalKeyID = bytes.base64URLEncodedString() 78 let service = FakeAppAttestService(keyID: originalKeyID) 79 let storage = MemoryPushAuthKeyStore() 80 let sleeps = SleepRecorder() 81 AuthURLProtocol.setHandler { request, challengeAttempt in 82 switch request.url?.path { 83 case "/attest/challenge" where challengeAttempt == 1: 84 return (500, [:], Data("internal details".utf8)) 85 case "/attest/challenge": 86 return (200, [:], Data(#"{"challenge":"challenge-1"}"#.utf8)) 87 case "/attest/register": 88 return (204, [:], Data()) 89 default: 90 return (404, [:], Data("Not found".utf8)) 91 } 92 } 93 94 let configuration = URLSessionConfiguration.ephemeral 95 configuration.protocolClasses = [AuthURLProtocol.self] 96 let authenticator = PushRequestAuthenticator( 97 baseURL: URL(string: "https://push.test")!, 98 deviceID: "device-1", 99 session: URLSession(configuration: configuration), 100 service: service, 101 keyStore: storage.store, 102 now: { Date(timeIntervalSince1970: 1_000) }, 103 sleep: { await sleeps.record($0) }, 104 jitter: { 1 } 105 ) 106 107 await #expect(throws: PushRequestAuthError.self) { 108 try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data()) 109 } 110 let headers = try await authenticator.signedHeaders( 111 method: "POST", 112 path: "/register", 113 body: Data() 114 ) 115 116 #expect(headers["X-Crossmate-Key-ID"] == canonicalKeyID) 117 #expect(service.generateKeyCount == 1) 118 #expect(service.attestKeyIDs == [originalKeyID]) 119 #expect(await sleeps.values == [2]) 120 } 121 122 @Test("lost registration response resends the persisted attestation") 123 func lostRegistrationResponseReusesAttestation() async throws { 124 AuthURLProtocol.reset() 125 defer { AuthURLProtocol.reset() } 126 127 let bytes = Data([0xfb, 0xff] + Array(0..<30)) 128 let originalKeyID = bytes.base64EncodedString() 129 let service = FakeAppAttestService(keyID: originalKeyID) 130 let storage = MemoryPushAuthKeyStore() 131 let sleeps = SleepRecorder() 132 let registerAttempts = LockedCounter() 133 AuthURLProtocol.setHandler { request, _ in 134 switch request.url?.path { 135 case "/attest/challenge": 136 return (200, [:], Data(#"{"challenge":"challenge-1"}"#.utf8)) 137 case "/attest/register": 138 let attempt = registerAttempts.increment() 139 return attempt == 1 ? (500, [:], Data()) : (204, [:], Data()) 140 default: 141 return (404, [:], Data("Not found".utf8)) 142 } 143 } 144 145 let configuration = URLSessionConfiguration.ephemeral 146 configuration.protocolClasses = [AuthURLProtocol.self] 147 let authenticator = PushRequestAuthenticator( 148 baseURL: URL(string: "https://push.test")!, 149 deviceID: "device-1", 150 session: URLSession(configuration: configuration), 151 service: service, 152 keyStore: storage.store, 153 now: { Date(timeIntervalSince1970: 1_000) }, 154 sleep: { await sleeps.record($0) }, 155 jitter: { 1 } 156 ) 157 158 await #expect(throws: PushRequestAuthError.self) { 159 try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data()) 160 } 161 _ = try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data()) 162 163 #expect(service.generateKeyCount == 1) 164 #expect(service.attestKeyIDs == [originalKeyID]) 165 #expect(registerAttempts.value == 2) 166 #expect(await sleeps.values == [2]) 167 } 168 } 169 170 private final class FakeAppAttestService: AppAttestServicing, @unchecked Sendable { 171 private let lock = NSLock() 172 private let keyID: String 173 private var generated = 0 174 private var attested: [String] = [] 175 176 init(keyID: String) { 177 self.keyID = keyID 178 } 179 180 var isSupported: Bool { true } 181 182 var generateKeyCount: Int { 183 lock.withLock { generated } 184 } 185 186 var attestKeyIDs: [String] { 187 lock.withLock { attested } 188 } 189 190 func generateKey() async throws -> String { 191 lock.withLock { generated += 1 } 192 return keyID 193 } 194 195 func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data { 196 lock.withLock { attested.append(keyID) } 197 return Data([1, 2, 3]) 198 } 199 200 func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data { 201 Data([4, 5, 6]) 202 } 203 } 204 205 private final class MemoryPushAuthKeyStore: @unchecked Sendable { 206 private let lock = NSLock() 207 private var values: [String: Data] = [:] 208 209 var store: PushAuthKeyStore { 210 PushAuthKeyStore( 211 load: { [self] key in lock.withLock { values[key] } }, 212 save: { [self] key, data in lock.withLock { values[key] = data } }, 213 delete: { [self] key in lock.withLock { _ = values.removeValue(forKey: key) } } 214 ) 215 } 216 } 217 218 private actor SleepRecorder { 219 private(set) var values: [TimeInterval] = [] 220 221 func record(_ value: TimeInterval) { 222 values.append(value) 223 } 224 } 225 226 private final class LockedCounter: @unchecked Sendable { 227 private let lock = NSLock() 228 private var count = 0 229 230 var value: Int { lock.withLock { count } } 231 232 func increment() -> Int { 233 lock.withLock { 234 count += 1 235 return count 236 } 237 } 238 } 239 240 private final class AuthURLProtocol: URLProtocol { 241 typealias Result = (status: Int, headers: [String: String], data: Data) 242 private static let state = AuthURLProtocolState() 243 244 static func reset() { state.reset() } 245 static func setHandler(_ handler: @escaping @Sendable (URLRequest, Int) -> Result) { 246 state.setHandler(handler) 247 } 248 249 override class func canInit(with request: URLRequest) -> Bool { true } 250 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } 251 252 override func startLoading() { 253 let result = Self.state.result(for: request) 254 let response = HTTPURLResponse( 255 url: request.url!, 256 statusCode: result.status, 257 httpVersion: nil, 258 headerFields: result.headers 259 )! 260 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 261 client?.urlProtocol(self, didLoad: result.data) 262 client?.urlProtocolDidFinishLoading(self) 263 } 264 265 override func stopLoading() {} 266 } 267 268 private final class AuthURLProtocolState: @unchecked Sendable { 269 private let lock = NSLock() 270 private var challengeAttempts = 0 271 private var handler: (@Sendable (URLRequest, Int) -> AuthURLProtocol.Result)? 272 273 func reset() { 274 lock.withLock { 275 challengeAttempts = 0 276 handler = nil 277 } 278 } 279 280 func setHandler(_ next: @escaping @Sendable (URLRequest, Int) -> AuthURLProtocol.Result) { 281 lock.withLock { handler = next } 282 } 283 284 func result(for request: URLRequest) -> AuthURLProtocol.Result { 285 lock.withLock { 286 if request.url?.path == "/attest/challenge" { challengeAttempts += 1 } 287 return handler?(request, challengeAttempts) ?? (500, [:], Data()) 288 } 289 } 290 }