commit 8db7b6f12971d01cb72cb2c87efc784acf27d098
parent 3a7415885dc2df40e6e8de425de775eca5ca7b5b
Author: Michael Camilleri <[email protected]>
Date: Tue, 21 Jul 2026 11:22:52 +0900
Restore App Attest push enrolment
Push enrolment failed before attestation when Apple's opaque key ID
contained padded standard Base64. The Worker treated it as a
Crossmate-generated URL-safe identifier, while the client discarded the
response details and generated another Secure Enclave key on later
attempts.
This commit validates App Attest key IDs as bounded Base64 and uses one
canonical base64url representation across client requests and Worker
storage. Challenge failures now report privacy-safe HTTP reasons and
respect Retry-After through coalesced exponential backoff. In-progress
keys, challenges and attestation objects survive interruption, allowing
the exact enrolment to resume without stranding keys or repeating
Apple's attestation work. Worker registration is idempotent when a
successful response is lost.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 787 insertions(+), 38 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -59,6 +59,7 @@
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; };
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; };
328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; };
+ 33CEEDAFBAA46128A9713AF5 /* PushRequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 630A65B1CCFEFEA825D3DD41 /* PushRequestAuthenticatorTests.swift */; };
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
351CB23C537BAB61863D95F6 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; };
35D97436772257DAD3936ECB /* TipStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */; };
@@ -370,6 +371,7 @@
603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; };
605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChanges.swift; sourceTree = "<group>"; };
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; };
+ 630A65B1CCFEFEA825D3DD41 /* PushRequestAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRequestAuthenticatorTests.swift; sourceTree = "<group>"; };
63AE7146492AD286EF13C3E7 /* PushClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClientTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
663934F0B7CA8BDD462DFAA4 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; };
@@ -647,6 +649,7 @@
63AE7146492AD286EF13C3E7 /* PushClientTests.swift */,
6C793B5B9684D49328E42129 /* PushPayloadCipherTests.swift */,
E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */,
+ 630A65B1CCFEFEA825D3DD41 /* PushRequestAuthenticatorTests.swift */,
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */,
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */,
D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */,
@@ -1120,6 +1123,7 @@
5E80545A8ECEB1E2ED371C53 /* PushCredentialRotationTests.swift in Sources */,
2A273C98FE3AC5E4C9BE1D88 /* PushPayloadCipherTests.swift in Sources */,
A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */,
+ 33CEEDAFBAA46128A9713AF5 /* PushRequestAuthenticatorTests.swift in Sources */,
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
82D1714DC441E4943E96C882 /* PuzzleFileReaderTests.swift in Sources */,
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
diff --git a/Crossmate/Services/PushRequestAuthenticator.swift b/Crossmate/Services/PushRequestAuthenticator.swift
@@ -5,7 +5,13 @@ import Foundation
enum PushRequestAuthError: LocalizedError {
case appAttestUnsupported
case missingChallenge
- case attestationRejected
+ case malformedKeyID
+ case challengeBadRequest(reason: String)
+ case challengeRouteMissing
+ case challengeRateLimited(retryAfter: TimeInterval?)
+ case challengeServerError(status: Int)
+ case challengeRejected(status: Int)
+ case attestationRejected(status: Int)
case invalidResponse
var errorDescription: String? {
@@ -14,12 +20,67 @@ enum PushRequestAuthError: LocalizedError {
"App Attest is not available on this device."
case .missingChallenge:
"The push worker did not return an App Attest challenge."
- case .attestationRejected:
- "The push worker rejected this app installation's attestation."
+ case .malformedKeyID:
+ "App Attest returned an invalid key identifier."
+ case let .challengeBadRequest(reason):
+ "The push worker rejected the App Attest challenge request (HTTP 400: \(reason))."
+ case .challengeRouteMissing:
+ "The push worker has no App Attest challenge route (HTTP 404)."
+ case let .challengeRateLimited(retryAfter):
+ if let retryAfter {
+ "The push worker rate-limited App Attest enrollment (HTTP 429; retry after \(Int(retryAfter.rounded(.up))) seconds)."
+ } else {
+ "The push worker rate-limited App Attest enrollment (HTTP 429)."
+ }
+ case let .challengeServerError(status):
+ "The push worker failed while issuing an App Attest challenge (HTTP \(status))."
+ case let .challengeRejected(status):
+ "The push worker rejected the App Attest challenge request (HTTP \(status))."
+ case let .attestationRejected(status):
+ "The push worker rejected this app installation's attestation (HTTP \(status))."
case .invalidResponse:
"The push worker returned an invalid authentication response."
}
}
+
+ var retryAfter: TimeInterval? {
+ if case let .challengeRateLimited(retryAfter) = self { retryAfter } else { nil }
+ }
+}
+
+protocol AppAttestServicing: Sendable {
+ var isSupported: Bool { get }
+ func generateKey() async throws -> String
+ func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data
+ func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data
+}
+
+private struct SystemAppAttestService: AppAttestServicing {
+ var isSupported: Bool { DCAppAttestService.shared.isSupported }
+
+ func generateKey() async throws -> String {
+ try await DCAppAttestService.shared.generateKey()
+ }
+
+ func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data {
+ try await DCAppAttestService.shared.attestKey(keyID, clientDataHash: clientDataHash)
+ }
+
+ func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data {
+ try await DCAppAttestService.shared.generateAssertion(keyID, clientDataHash: clientDataHash)
+ }
+}
+
+struct PushAuthKeyStore: Sendable {
+ var load: @Sendable (String) -> Data?
+ var save: @Sendable (String, Data) throws -> Void
+ var delete: @Sendable (String) -> Void
+
+ static let keychain = PushAuthKeyStore(
+ load: { KeychainHelper.load(key: $0) },
+ save: { try KeychainHelper.save(key: $0, data: $1) },
+ delete: { KeychainHelper.delete(key: $0) }
+ )
}
/// Enrolls this app installation with the push worker using App Attest, then
@@ -28,23 +89,47 @@ enum PushRequestAuthError: LocalizedError {
/// has no server-side user accounts.
actor PushRequestAuthenticator {
private static let keyIDKey = "push.appAttest.keyID.v1"
+ private static let pendingKeyIDKey = "push.appAttest.pendingKeyID.v1"
+ private static let pendingEnrollmentKey = "push.appAttest.pendingEnrollment.v1"
+ private static let maximumKeyIDCharacters = 256
+ private static let maximumKeyIDBytes = 128
+ private static let maximumBackoff: TimeInterval = 5 * 60
+ private static let maximumRetryAfter: TimeInterval = 24 * 60 * 60
private let baseURL: URL
private let deviceID: String
private let session: URLSession
- private let service = DCAppAttestService.shared
+ private let service: any AppAttestServicing
+ private let keyStore: PushAuthKeyStore
+ private let now: @Sendable () -> Date
+ private let sleep: @Sendable (TimeInterval) async throws -> Void
+ private let jitter: @Sendable () -> Double
private var cachedKeyID: String?
private var registeringTask: Task<String, Error>?
+ private var consecutiveFailures = 0
+ private var retryNotBefore: Date?
init(
baseURL: URL,
deviceID: String,
- session: URLSession
+ session: URLSession,
+ service: any AppAttestServicing = SystemAppAttestService(),
+ keyStore: PushAuthKeyStore = .keychain,
+ now: @escaping @Sendable () -> Date = Date.init,
+ sleep: @escaping @Sendable (TimeInterval) async throws -> Void = {
+ try await Task.sleep(for: .seconds($0))
+ },
+ jitter: @escaping @Sendable () -> Double = { Double.random(in: 0.8...1.2) }
) {
self.baseURL = baseURL
self.deviceID = deviceID
self.session = session
+ self.service = service
+ self.keyStore = keyStore
+ self.now = now
+ self.sleep = sleep
+ self.jitter = jitter
}
func signedHeaders(
@@ -65,12 +150,16 @@ actor PushRequestAuthenticator {
cachedKeyID = nil
registeringTask?.cancel()
registeringTask = nil
- KeychainHelper.delete(key: Self.keyIDKey)
+ consecutiveFailures = 0
+ retryNotBefore = nil
+ keyStore.delete(Self.keyIDKey)
+ keyStore.delete(Self.pendingKeyIDKey)
+ keyStore.delete(Self.pendingEnrollmentKey)
}
private func registeredKeyID() async throws -> String {
if let cachedKeyID { return cachedKeyID }
- if let data = KeychainHelper.load(key: Self.keyIDKey),
+ if let data = keyStore.load(Self.keyIDKey),
let keyID = String(data: data, encoding: .utf8),
!keyID.isEmpty {
cachedKeyID = keyID
@@ -79,15 +168,22 @@ actor PushRequestAuthenticator {
if let registeringTask {
return try await registeringTask.value
}
- let task = Task { try await registerFreshKey() }
+ let delay = max(0, retryNotBefore?.timeIntervalSince(now()) ?? 0)
+ let task = Task {
+ if delay > 0 { try await sleep(delay) }
+ return try await registerFreshKey()
+ }
registeringTask = task
do {
let keyID = try await task.value
registeringTask = nil
cachedKeyID = keyID
+ consecutiveFailures = 0
+ retryNotBefore = nil
return keyID
} catch {
registeringTask = nil
+ recordRegistrationFailure(error)
throw error
}
}
@@ -96,20 +192,65 @@ actor PushRequestAuthenticator {
guard service.isSupported else {
throw PushRequestAuthError.appAttestUnsupported
}
- let keyID = try await service.generateKey()
- let challenge = try await fetchChallenge(for: keyID)
- let clientDataHash = Self.clientDataHashForAttestation(
- challenge: challenge,
- deviceID: deviceID,
- keyID: keyID
- )
- let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
- try await submitAttestation(
- keyID: keyID,
- challenge: challenge,
- attestation: attestation
- )
- try KeychainHelper.save(key: Self.keyIDKey, data: Data(keyID.utf8))
+ let keyID: String
+ if let data = keyStore.load(Self.pendingKeyIDKey),
+ let pending = String(data: data, encoding: .utf8),
+ !pending.isEmpty {
+ keyID = pending
+ } else {
+ keyID = try await service.generateKey()
+ // Apple doesn't provide a way to recover this identifier later.
+ // Persist it before the first network operation so a challenge or
+ // attestation outage doesn't strand another Secure Enclave key.
+ try keyStore.save(Self.pendingKeyIDKey, Data(keyID.utf8))
+ }
+ let networkKeyID = try Self.canonicalKeyID(keyID)
+ var pending = loadPendingEnrollment(keyID: keyID, networkKeyID: networkKeyID)
+ if pending == nil {
+ let challenge = try await fetchChallenge(for: networkKeyID)
+ pending = PendingEnrollment(
+ keyID: keyID,
+ networkKeyID: networkKeyID,
+ challenge: challenge,
+ attestation: nil
+ )
+ try savePendingEnrollment(pending!)
+ }
+ var enrollment = pending!
+ if enrollment.attestation == nil {
+ let clientDataHash = Self.clientDataHashForAttestation(
+ challenge: enrollment.challenge,
+ deviceID: deviceID,
+ keyID: networkKeyID
+ )
+ enrollment.attestation = try await service.attestKey(
+ keyID,
+ clientDataHash: clientDataHash
+ )
+ // If the Worker accepts the registration but its response is lost,
+ // resend this exact attestation instead of asking Apple to attest
+ // the key again with a different challenge.
+ try savePendingEnrollment(enrollment)
+ }
+ do {
+ try await submitAttestation(
+ keyID: networkKeyID,
+ challenge: enrollment.challenge,
+ attestation: enrollment.attestation!
+ )
+ } catch let error as PushRequestAuthError {
+ // A definitive client/auth rejection means the server did not
+ // accept this attestation. Apple's guidance is to discard that
+ // identifier; transient server failures retain it for retry.
+ if case let .attestationRejected(status) = error, (400..<500).contains(status) {
+ keyStore.delete(Self.pendingKeyIDKey)
+ keyStore.delete(Self.pendingEnrollmentKey)
+ }
+ throw error
+ }
+ try keyStore.save(Self.keyIDKey, Data(keyID.utf8))
+ keyStore.delete(Self.pendingKeyIDKey)
+ keyStore.delete(Self.pendingEnrollmentKey)
return keyID
}
@@ -123,8 +264,11 @@ actor PushRequestAuthenticator {
ChallengeRequest(deviceID: deviceID, keyID: keyID)
)
let (data, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
- throw PushRequestAuthError.missingChallenge
+ guard let http = response as? HTTPURLResponse else {
+ throw PushRequestAuthError.invalidResponse
+ }
+ guard http.statusCode == 200 else {
+ throw Self.challengeError(response: http, data: data, now: now())
}
let decoded = try JSONDecoder().decode(ChallengeResponse.self, from: data)
guard !decoded.challenge.isEmpty else {
@@ -151,8 +295,11 @@ actor PushRequestAuthenticator {
)
request.httpBody = try JSONEncoder().encode(body)
let (_, response) = try await session.data(for: request)
- guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
- throw PushRequestAuthError.attestationRejected
+ guard let http = response as? HTTPURLResponse else {
+ throw PushRequestAuthError.invalidResponse
+ }
+ guard http.statusCode == 204 else {
+ throw PushRequestAuthError.attestationRejected(status: http.statusCode)
}
}
@@ -162,6 +309,7 @@ actor PushRequestAuthenticator {
path: String,
body: Data
) async throws -> [String: String] {
+ let networkKeyID = try Self.canonicalKeyID(keyID)
let timestamp = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
let bodyHash = Data(SHA256.hash(data: body)).base64URLEncodedString()
@@ -172,14 +320,14 @@ actor PushRequestAuthenticator {
timestamp: timestamp,
nonce: nonce,
deviceID: deviceID,
- keyID: keyID
+ keyID: networkKeyID
)
let clientDataHash = Data(SHA256.hash(data: Data(canonical.utf8)))
let assertion = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
return [
"X-Crossmate-Auth-Version": "appattest-v1",
"X-Crossmate-Device-ID": deviceID,
- "X-Crossmate-Key-ID": keyID,
+ "X-Crossmate-Key-ID": networkKeyID,
"X-Crossmate-Timestamp": timestamp,
"X-Crossmate-Nonce": nonce,
"X-Crossmate-Body-SHA256": bodyHash,
@@ -187,6 +335,125 @@ actor PushRequestAuthenticator {
]
}
+ private func recordRegistrationFailure(_ error: Error) {
+ consecutiveFailures = min(consecutiveFailures + 1, 16)
+ let exponential = min(
+ Self.maximumBackoff,
+ 2 * pow(2, Double(consecutiveFailures - 1))
+ )
+ let retryAfter = min(
+ (error as? PushRequestAuthError)?.retryAfter ?? 0,
+ Self.maximumRetryAfter
+ )
+ let delay = max(retryAfter, exponential * min(max(jitter(), 0.5), 1.5))
+ retryNotBefore = now().addingTimeInterval(delay)
+ }
+
+ private func loadPendingEnrollment(
+ keyID: String,
+ networkKeyID: String
+ ) -> PendingEnrollment? {
+ guard let data = keyStore.load(Self.pendingEnrollmentKey),
+ let pending = try? JSONDecoder().decode(PendingEnrollment.self, from: data),
+ pending.keyID == keyID,
+ pending.networkKeyID == networkKeyID
+ else {
+ keyStore.delete(Self.pendingEnrollmentKey)
+ return nil
+ }
+ return pending
+ }
+
+ private func savePendingEnrollment(_ pending: PendingEnrollment) throws {
+ try keyStore.save(Self.pendingEnrollmentKey, JSONEncoder().encode(pending))
+ }
+
+ static func canonicalKeyID(_ keyID: String) throws -> String {
+ guard !keyID.isEmpty, keyID.count <= maximumKeyIDCharacters else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ let alphabet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/_-=")
+ guard keyID.unicodeScalars.allSatisfy(alphabet.contains) else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ let usesStandardAlphabet = keyID.contains("+") || keyID.contains("/")
+ let usesURLSafeAlphabet = keyID.contains("-") || keyID.contains("_")
+ guard !(usesStandardAlphabet && usesURLSafeAlphabet) else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ if let firstPaddingIndex = keyID.firstIndex(of: "=") {
+ let firstPadding = keyID.distance(from: keyID.startIndex, to: firstPaddingIndex)
+ guard keyID.dropFirst(firstPadding).allSatisfy({ $0 == "=" }) else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ }
+ let unpadded = keyID.trimmingCharacters(in: CharacterSet(charactersIn: "="))
+ guard keyID.count - unpadded.count <= 2 else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ var standard = unpadded.replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ guard standard.count % 4 != 1 else { throw PushRequestAuthError.malformedKeyID }
+ standard += String(repeating: "=", count: (4 - standard.count % 4) % 4)
+ guard let bytes = Data(base64Encoded: standard),
+ !bytes.isEmpty,
+ bytes.count <= maximumKeyIDBytes
+ else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ let canonical = bytes.base64URLEncodedString()
+ let suppliedCanonical = unpadded.replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ guard canonical == suppliedCanonical else {
+ throw PushRequestAuthError.malformedKeyID
+ }
+ return canonical
+ }
+
+ static func challengeError(
+ response: HTTPURLResponse,
+ data: Data,
+ now: Date
+ ) -> PushRequestAuthError {
+ switch response.statusCode {
+ case 400:
+ return .challengeBadRequest(reason: safeChallengeReason(data))
+ case 404:
+ return .challengeRouteMissing
+ case 429:
+ return .challengeRateLimited(retryAfter: retryAfter(response, now: now))
+ case 500..<600:
+ return .challengeServerError(status: response.statusCode)
+ default:
+ return .challengeRejected(status: response.statusCode)
+ }
+ }
+
+ private static func safeChallengeReason(_ data: Data) -> String {
+ let reason = String(data: data.prefix(160), encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let knownReasons = [
+ "Body too large",
+ "Body must be JSON",
+ "deviceID and keyID required",
+ "Malformed deviceID or keyID",
+ "Rate limit exceeded",
+ "Not found"
+ ]
+ return knownReasons.contains(reason ?? "") ? reason! : "unrecognized response"
+ }
+
+ private static func retryAfter(_ response: HTTPURLResponse, now: Date) -> TimeInterval? {
+ guard let value = response.value(forHTTPHeaderField: "Retry-After") else { return nil }
+ if let seconds = TimeInterval(value), seconds >= 0 { return seconds }
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
+ guard let date = formatter.date(from: value) else { return nil }
+ return max(0, date.timeIntervalSince(now))
+ }
+
private static func clientDataHashForAttestation(
challenge: String,
deviceID: String,
@@ -237,4 +504,11 @@ actor PushRequestAuthenticator {
var challenge: String
var attestationObject: String
}
+
+ private struct PendingEnrollment: Codable {
+ var keyID: String
+ var networkKeyID: String
+ var challenge: String
+ var attestation: Data?
+ }
}
diff --git a/Tests/Unit/PushRequestAuthenticatorTests.swift b/Tests/Unit/PushRequestAuthenticatorTests.swift
@@ -0,0 +1,290 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Push request authentication", .serialized)
+struct PushRequestAuthenticatorTests {
+ @Test("App Attest key IDs canonicalize standard and URL-safe Base64")
+ func keyIDCanonicalization() throws {
+ let bytes = Data([0xfb, 0xff] + Array(0..<30))
+ let standard = bytes.base64EncodedString()
+ let urlSafe = bytes.base64URLEncodedString()
+
+ #expect(standard.contains("+"))
+ #expect(standard.contains("/"))
+ #expect(standard.hasSuffix("="))
+ #expect(try PushRequestAuthenticator.canonicalKeyID(standard) == urlSafe)
+ #expect(try PushRequestAuthenticator.canonicalKeyID(urlSafe) == urlSafe)
+ }
+
+ @Test("App Attest key IDs reject malformed and storage-smuggling values")
+ func keyIDRejection() {
+ for keyID in ["", "A", "AA=A", "+_8=", "not:base64", String(repeating: "A", count: 257)] {
+ #expect(throws: PushRequestAuthError.self) {
+ try PushRequestAuthenticator.canonicalKeyID(keyID)
+ }
+ }
+ }
+
+ @Test("challenge failures retain privacy-safe status and retry information")
+ func challengeDiagnostics() throws {
+ let url = try #require(URL(string: "https://push.test/attest/challenge"))
+ let badRequest = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 400,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+ let badRequestError = PushRequestAuthenticator.challengeError(
+ response: badRequest,
+ data: Data("Malformed deviceID or keyID".utf8),
+ now: Date(timeIntervalSince1970: 0)
+ )
+ #expect(badRequestError.localizedDescription.contains("HTTP 400"))
+ #expect(badRequestError.localizedDescription.contains("Malformed deviceID or keyID"))
+
+ let secretEcho = PushRequestAuthenticator.challengeError(
+ response: badRequest,
+ data: Data("keyID abcdef0123456789".utf8),
+ now: Date(timeIntervalSince1970: 0)
+ )
+ #expect(secretEcho.localizedDescription.contains("unrecognized response"))
+ #expect(!secretEcho.localizedDescription.contains("abcdef0123456789"))
+
+ let limited = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 429,
+ httpVersion: nil,
+ headerFields: ["Retry-After": "90"]
+ ))
+ let limitedError = PushRequestAuthenticator.challengeError(
+ response: limited,
+ data: Data("Rate limit exceeded".utf8),
+ now: Date(timeIntervalSince1970: 0)
+ )
+ #expect(limitedError.localizedDescription.contains("HTTP 429"))
+ #expect(limitedError.localizedDescription.contains("90 seconds"))
+ }
+
+ @Test("failed challenge reuses the persisted key after backoff")
+ func failedChallengeReusesKey() async throws {
+ AuthURLProtocol.reset()
+ defer { AuthURLProtocol.reset() }
+
+ let bytes = Data([0xfb, 0xff] + Array(0..<30))
+ let originalKeyID = bytes.base64EncodedString()
+ let canonicalKeyID = bytes.base64URLEncodedString()
+ let service = FakeAppAttestService(keyID: originalKeyID)
+ let storage = MemoryPushAuthKeyStore()
+ let sleeps = SleepRecorder()
+ AuthURLProtocol.setHandler { request, challengeAttempt in
+ switch request.url?.path {
+ case "/attest/challenge" where challengeAttempt == 1:
+ return (500, [:], Data("internal details".utf8))
+ case "/attest/challenge":
+ return (200, [:], Data(#"{"challenge":"challenge-1"}"#.utf8))
+ case "/attest/register":
+ return (204, [:], Data())
+ default:
+ return (404, [:], Data("Not found".utf8))
+ }
+ }
+
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [AuthURLProtocol.self]
+ let authenticator = PushRequestAuthenticator(
+ baseURL: URL(string: "https://push.test")!,
+ deviceID: "device-1",
+ session: URLSession(configuration: configuration),
+ service: service,
+ keyStore: storage.store,
+ now: { Date(timeIntervalSince1970: 1_000) },
+ sleep: { await sleeps.record($0) },
+ jitter: { 1 }
+ )
+
+ await #expect(throws: PushRequestAuthError.self) {
+ try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data())
+ }
+ let headers = try await authenticator.signedHeaders(
+ method: "POST",
+ path: "/register",
+ body: Data()
+ )
+
+ #expect(headers["X-Crossmate-Key-ID"] == canonicalKeyID)
+ #expect(service.generateKeyCount == 1)
+ #expect(service.attestKeyIDs == [originalKeyID])
+ #expect(await sleeps.values == [2])
+ }
+
+ @Test("lost registration response resends the persisted attestation")
+ func lostRegistrationResponseReusesAttestation() async throws {
+ AuthURLProtocol.reset()
+ defer { AuthURLProtocol.reset() }
+
+ let bytes = Data([0xfb, 0xff] + Array(0..<30))
+ let originalKeyID = bytes.base64EncodedString()
+ let service = FakeAppAttestService(keyID: originalKeyID)
+ let storage = MemoryPushAuthKeyStore()
+ let sleeps = SleepRecorder()
+ let registerAttempts = LockedCounter()
+ AuthURLProtocol.setHandler { request, _ in
+ switch request.url?.path {
+ case "/attest/challenge":
+ return (200, [:], Data(#"{"challenge":"challenge-1"}"#.utf8))
+ case "/attest/register":
+ let attempt = registerAttempts.increment()
+ return attempt == 1 ? (500, [:], Data()) : (204, [:], Data())
+ default:
+ return (404, [:], Data("Not found".utf8))
+ }
+ }
+
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.protocolClasses = [AuthURLProtocol.self]
+ let authenticator = PushRequestAuthenticator(
+ baseURL: URL(string: "https://push.test")!,
+ deviceID: "device-1",
+ session: URLSession(configuration: configuration),
+ service: service,
+ keyStore: storage.store,
+ now: { Date(timeIntervalSince1970: 1_000) },
+ sleep: { await sleeps.record($0) },
+ jitter: { 1 }
+ )
+
+ await #expect(throws: PushRequestAuthError.self) {
+ try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data())
+ }
+ _ = try await authenticator.signedHeaders(method: "POST", path: "/register", body: Data())
+
+ #expect(service.generateKeyCount == 1)
+ #expect(service.attestKeyIDs == [originalKeyID])
+ #expect(registerAttempts.value == 2)
+ #expect(await sleeps.values == [2])
+ }
+}
+
+private final class FakeAppAttestService: AppAttestServicing, @unchecked Sendable {
+ private let lock = NSLock()
+ private let keyID: String
+ private var generated = 0
+ private var attested: [String] = []
+
+ init(keyID: String) {
+ self.keyID = keyID
+ }
+
+ var isSupported: Bool { true }
+
+ var generateKeyCount: Int {
+ lock.withLock { generated }
+ }
+
+ var attestKeyIDs: [String] {
+ lock.withLock { attested }
+ }
+
+ func generateKey() async throws -> String {
+ lock.withLock { generated += 1 }
+ return keyID
+ }
+
+ func attestKey(_ keyID: String, clientDataHash: Data) async throws -> Data {
+ lock.withLock { attested.append(keyID) }
+ return Data([1, 2, 3])
+ }
+
+ func generateAssertion(_ keyID: String, clientDataHash: Data) async throws -> Data {
+ Data([4, 5, 6])
+ }
+}
+
+private final class MemoryPushAuthKeyStore: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [String: Data] = [:]
+
+ var store: PushAuthKeyStore {
+ PushAuthKeyStore(
+ load: { [self] key in lock.withLock { values[key] } },
+ save: { [self] key, data in lock.withLock { values[key] = data } },
+ delete: { [self] key in lock.withLock { _ = values.removeValue(forKey: key) } }
+ )
+ }
+}
+
+private actor SleepRecorder {
+ private(set) var values: [TimeInterval] = []
+
+ func record(_ value: TimeInterval) {
+ values.append(value)
+ }
+}
+
+private final class LockedCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ var value: Int { lock.withLock { count } }
+
+ func increment() -> Int {
+ lock.withLock {
+ count += 1
+ return count
+ }
+ }
+}
+
+private final class AuthURLProtocol: URLProtocol {
+ typealias Result = (status: Int, headers: [String: String], data: Data)
+ private static let state = AuthURLProtocolState()
+
+ static func reset() { state.reset() }
+ static func setHandler(_ handler: @escaping @Sendable (URLRequest, Int) -> Result) {
+ state.setHandler(handler)
+ }
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ let result = Self.state.result(for: request)
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: result.status,
+ httpVersion: nil,
+ headerFields: result.headers
+ )!
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: result.data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+
+ override func stopLoading() {}
+}
+
+private final class AuthURLProtocolState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var challengeAttempts = 0
+ private var handler: (@Sendable (URLRequest, Int) -> AuthURLProtocol.Result)?
+
+ func reset() {
+ lock.withLock {
+ challengeAttempts = 0
+ handler = nil
+ }
+ }
+
+ func setHandler(_ next: @escaping @Sendable (URLRequest, Int) -> AuthURLProtocol.Result) {
+ lock.withLock { handler = next }
+ }
+
+ func result(for request: URLRequest) -> AuthURLProtocol.Result {
+ lock.withLock {
+ if request.url?.path == "/attest/challenge" { challengeAttempts += 1 }
+ return handler?(request, challengeAttempts) ?? (500, [:], Data())
+ }
+ }
+}
diff --git a/Tests/Workers/push-worker.test.mjs b/Tests/Workers/push-worker.test.mjs
@@ -16,6 +16,115 @@ function attestRequest(ip) {
});
}
+const appAttestKeyBytes = Buffer.from([
+ 0xfb, 0xff, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
+ 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
+ 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
+ 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d
+]);
+const standardAppAttestKeyID = appAttestKeyBytes.toString("base64");
+const urlSafeAppAttestKeyID = appAttestKeyBytes.toString("base64url");
+
+function challengeRequest(keyID, deviceID = "device-1") {
+ return new Request("https://push.example/attest/challenge", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "CF-Connecting-IP": "203.0.113.9"
+ },
+ body: JSON.stringify({ deviceID, keyID })
+ });
+}
+
+test("challenge accepts standard and URL-safe App Attest key IDs", async () => {
+ assert.match(standardAppAttestKeyID, /[+/]/);
+ assert.match(standardAppAttestKeyID, /=+$/);
+ const standard = makeRegistry();
+ const standardResponse = await standard.registry.fetch(challengeRequest(standardAppAttestKeyID));
+ assert.equal(standardResponse.status, 200);
+
+ const urlSafe = makeRegistry();
+ const urlSafeResponse = await urlSafe.registry.fetch(challengeRequest(urlSafeAppAttestKeyID));
+ assert.equal(urlSafeResponse.status, 200);
+});
+
+test("registration accepts both App Attest key-ID alphabets before attestation verification", async () => {
+ for (const keyID of [standardAppAttestKeyID, urlSafeAppAttestKeyID]) {
+ const { registry } = makeRegistry();
+ const response = await registry.handleAttestationRegister(
+ attestRequest("203.0.113.9"),
+ JSON.stringify({
+ deviceID: "device-1",
+ keyID,
+ challenge: "unknown-challenge",
+ attestationObject: "AA"
+ })
+ );
+ assert.equal(response.status, 401);
+ assert.equal(await response.text(), "Unknown App Attest challenge");
+ }
+});
+
+test("App Attest key-ID storage uses one URL-safe canonical component", () => {
+ const { registry } = makeRegistry();
+ const expected = `appattest-key:device-1:${urlSafeAppAttestKeyID}`;
+ assert.equal(registry.appAttestRegistrationKey("device-1", standardAppAttestKeyID), expected);
+ assert.equal(registry.appAttestRegistrationKey("device-1", urlSafeAppAttestKeyID), expected);
+});
+
+test("registration retry succeeds after its first response was lost", async () => {
+ const { registry, storage } = makeRegistry();
+ const registrationKey = registry.appAttestRegistrationKey("device-1", standardAppAttestKeyID);
+ await storage.put(registrationKey, { publicKeySPKI: "already-verified" });
+
+ const response = await registry.handleAttestationRegister(
+ attestRequest("203.0.113.9"),
+ JSON.stringify({
+ deviceID: "device-1",
+ keyID: standardAppAttestKeyID,
+ challenge: "already-consumed",
+ attestationObject: "AA"
+ })
+ );
+
+ assert.equal(response.status, 204);
+});
+
+test("pre-885 registration storage migrates to the canonical key on lookup", async () => {
+ const { registry, storage } = makeRegistry();
+ const legacyKey = `appattest-key:device-1:${standardAppAttestKeyID}`;
+ const canonicalKey = `appattest-key:device-1:${urlSafeAppAttestKeyID}`;
+ const registration = { publicKeySPKI: "legacy-record" };
+ await storage.put(legacyKey, registration);
+
+ const loaded = await registry.loadAppAttestRegistration(
+ "device-1",
+ standardAppAttestKeyID,
+ urlSafeAppAttestKeyID
+ );
+
+ assert.deepEqual(loaded, registration);
+ assert.equal(storage.map.has(legacyKey), false);
+ assert.deepEqual(await storage.get(canonicalKey), registration);
+});
+
+test("challenge rejects empty, oversized, malformed, and storage-smuggling key IDs", async () => {
+ const invalidKeyIDs = [
+ "",
+ "A".repeat(257),
+ "not:base64",
+ "+_8=",
+ "AA=A",
+ "A",
+ `${standardAppAttestKeyID}:forged`
+ ];
+ for (const keyID of invalidKeyIDs) {
+ const { registry } = makeRegistry();
+ const response = await registry.fetch(challengeRequest(keyID));
+ assert.equal(response.status, 400, `expected rejection for ${JSON.stringify(keyID.slice(0, 20))}`);
+ }
+});
+
test("rate limit config uses table defaults", () => {
const { registry } = makeRegistry();
assert.deepEqual(registry.rateLimitConfig("PUBLISH_CRED"), { limit: 60, windowSeconds: 60 });
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -27,6 +27,10 @@ const INGRESS_LIMIT_DEFAULTS = {
// alphabet cannot be genuine and would otherwise flow into storage keys and
// the APNs request URL.
const MAX_ID_CHARS = 128;
+// App Attest key IDs are opaque Base64 supplied by Apple, not identifiers
+// minted by Crossmate. Accept both standard and URL-safe spellings, then use
+// one canonical base64url spelling anywhere the value becomes a storage key.
+const MAX_APP_ATTEST_KEY_ID_CHARS = 256;
const MAX_KIND_CHARS = 64;
const MAX_MUTED_KIND_COUNT = 32;
const MAX_TEXT_CHARS = 512; // title / alert body
@@ -116,7 +120,8 @@ export class PushRegistry {
return { ok: false, status: 401, message: "Incomplete App Attest auth" };
}
// These three form storage keys below; bound them before any storage touch.
- if (!isValidID(deviceID) || !isValidID(keyID) || !isValidID(nonce)) {
+ const canonicalKeyID = canonicalAppAttestKeyID(keyID);
+ if (!isValidID(deviceID) || !canonicalKeyID || !isValidID(nonce)) {
return { ok: false, status: 401, message: "Malformed App Attest auth" };
}
@@ -132,8 +137,8 @@ export class PushRegistry {
return { ok: false, status: 401, message: "Bad body hash" };
}
- const registrationKey = this.appAttestRegistrationKey(deviceID, keyID);
- const registration = await this.state.storage.get(registrationKey);
+ const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID);
+ const registration = await this.loadAppAttestRegistration(deviceID, keyID, canonicalKeyID);
if (!registration) {
return { ok: false, status: 401, message: "Unknown App Attest key" };
}
@@ -190,6 +195,24 @@ export class PushRegistry {
return { ok: true, deviceID };
}
+ async loadAppAttestRegistration(deviceID, keyID, canonicalKeyID) {
+ const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID);
+ let registration = await this.state.storage.get(registrationKey);
+ // TEMPORARY BUILD-885 COMPATIBILITY: keys enrolled before 07aae65 were
+ // stored using Apple's original spelling. Migrate that record on first
+ // successful lookup. Remove this fallback with the other build-885 wire
+ // compatibility after its supported upgrade window closes.
+ if (!registration && keyID !== canonicalKeyID) {
+ const legacyRegistrationKey = `appattest-key:${deviceID}:${keyID}`;
+ registration = await this.state.storage.get(legacyRegistrationKey);
+ if (registration) {
+ await this.state.storage.put(registrationKey, registration);
+ await this.state.storage.delete(legacyRegistrationKey);
+ }
+ }
+ return registration;
+ }
+
async handleAttestationChallenge(request, bodyText) {
const body = await readJSONText(bodyText);
if (!body) return badRequest("Body must be JSON");
@@ -198,8 +221,9 @@ export class PushRegistry {
if (!deviceID || !keyID) {
return badRequest("deviceID and keyID required");
}
- // Both form storage keys; bound them before rate-limit or challenge writes.
- if (!isValidID(deviceID) || !isValidID(keyID)) {
+ // Apple owns the key-ID format. Validate it as bounded Base64 rather than
+ // applying Crossmate's narrower storage-identifier alphabet.
+ if (!isValidID(deviceID) || !canonicalAppAttestKeyID(keyID)) {
return badRequest("Malformed deviceID or keyID");
}
const limited = await this.checkAttestationRateLimit(request, "challenge", deviceID);
@@ -224,7 +248,8 @@ export class PushRegistry {
if (!deviceID || !keyID || !challenge || !attestationObject) {
return badRequest("deviceID, keyID, challenge, attestationObject required");
}
- if (!isValidID(deviceID) || !isValidID(keyID) || !isValidID(challenge)) {
+ const canonicalKeyID = canonicalAppAttestKeyID(keyID);
+ if (!isValidID(deviceID) || !canonicalKeyID || !isValidID(challenge)) {
return badRequest("Malformed deviceID, keyID, or challenge");
}
// Attestation runs pre-auth, so keep the base64/CBOR/certificate work it
@@ -232,6 +257,14 @@ export class PushRegistry {
if (typeof attestationObject !== "string" || attestationObject.length > MAX_ATTESTATION_OBJECT_CHARS) {
return badRequest("attestationObject too large");
}
+ // Registration is idempotent. If the first 204 was lost, the client
+ // resends the same persisted attestation after its challenge has already
+ // been consumed. Confirm the existing canonical binding so that recovery
+ // does not force it to discard a successfully enrolled Secure Enclave key.
+ const registrationKey = this.appAttestRegistrationKey(deviceID, canonicalKeyID);
+ if (await this.state.storage.get(registrationKey)) {
+ return new Response(null, { status: 204 });
+ }
const limited = await this.checkAttestationRateLimit(request, "register", deviceID);
if (limited) return limited;
const challengeKey = this.appAttestChallengeKey(deviceID, challenge);
@@ -255,10 +288,11 @@ export class PushRegistry {
const registration = await this.verifyAttestation({
deviceID,
keyID,
+ canonicalKeyID,
challenge,
attestationObject: base64URLDecode(attestationObject)
});
- await this.state.storage.put(this.appAttestRegistrationKey(deviceID, keyID), registration);
+ await this.state.storage.put(registrationKey, registration);
await this.state.storage.delete(challengeKey);
console.log("App Attest registration accepted", {
expectedEnvironment: this.env.APP_ATTEST_ENVIRONMENT || "production",
@@ -287,7 +321,7 @@ export class PushRegistry {
}
}
- async verifyAttestation({ deviceID, keyID, challenge, attestationObject }) {
+ async verifyAttestation({ deviceID, keyID, canonicalKeyID, challenge, attestationObject }) {
const attestation = decodeAttestationObject(attestationObject);
const authData = parseAuthenticatorData(attestation.authData, {
requireAttestedCredential: true
@@ -296,7 +330,7 @@ export class PushRegistry {
if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) {
throw new Error("app id hash mismatch");
}
- if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecodeFlexible(keyID))) {
+ if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecode(canonicalKeyID))) {
throw new Error("credential id mismatch");
}
const appAttestEnvironment = this.env.APP_ATTEST_ENVIRONMENT || "production";
@@ -317,6 +351,12 @@ export class PushRegistry {
const root = parseCertificate(pemToDer(rootPEM));
await verifyCertificateSignature(intermediate, root.subjectPublicKeyInfo);
+ // Build 2026.885 signs Apple's original key-ID spelling, which may be
+ // padded standard Base64. Corrected clients send canonicalKeyID instead.
+ // TEMPORARY COMPATIBILITY: after build 2026.885 is outside the supported
+ // upgrade window, require `keyID === canonicalKeyID` at ingress and hash
+ // canonicalKeyID here; standard Base64 parsing can then remain only as a
+ // bounded normalization helper where Apple-originated values are read.
const clientDataHash = await sha256Bytes(new TextEncoder().encode([
"crossmate-appattest-v1",
challenge,
@@ -389,7 +429,9 @@ export class PushRegistry {
}
appAttestRegistrationKey(deviceID, keyID) {
- return `appattest-key:${deviceID}:${keyID}`;
+ const canonicalKeyID = canonicalAppAttestKeyID(keyID);
+ if (!canonicalKeyID) throw new Error("invalid App Attest key ID");
+ return `appattest-key:${deviceID}:${canonicalKeyID}`;
}
async handleRegister(request, bodyText, auth) {
@@ -1051,6 +1093,36 @@ function isValidID(value) {
&& /^[A-Za-z0-9._-]+$/.test(value);
}
+// Returns the canonical, unpadded base64url spelling for an Apple App Attest
+// key ID. Comparing the round-trip spelling rejects malformed Base64 and
+// non-canonical trailing bits while deliberately treating standard Base64,
+// URL-safe Base64, and optional padding as the same opaque byte string.
+function canonicalAppAttestKeyID(value) {
+ const mixesAlphabets = typeof value === "string"
+ && (value.includes("+") || value.includes("/"))
+ && (value.includes("-") || value.includes("_"));
+ if (typeof value !== "string"
+ || value.length === 0
+ || value.length > MAX_APP_ATTEST_KEY_ID_CHARS
+ || mixesAlphabets
+ || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) {
+ return null;
+ }
+ let bytes;
+ try {
+ bytes = base64URLDecodeFlexible(value);
+ } catch {
+ return null;
+ }
+ if (bytes.length === 0 || bytes.length > MAX_ID_CHARS) return null;
+ const canonical = base64URLEncode(bytes);
+ const suppliedCanonical = value
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/g, "");
+ return canonical === suppliedCanonical ? canonical : null;
+}
+
// Forwarded metadata: absent (or null) is fine, anything present must be a
// string within the cap.
function isAbsentOrBounded(value, maxChars) {