crossmate

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

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 }