crossmate

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

commit 8fdf9e81d5ff5a172cab3b093f8d143a606d2da5
parent b21a2574eb93ebb04ce74c053049eb3640afaa58
Author: Michael Camilleri <[email protected]>
Date:   Thu, 11 Jun 2026 16:17:26 +0900

Authenticate push worker requests with App Attest

The push worker was gated by a bearer copied into every app build, so a
single extracted binary secret could publish or register pushes for any
Crossmate install. This commit replaces that shared credential with a
per-install App Attest key: updated clients enroll with the worker,
store the attested key ID locally, and sign each register, unregister,
and publish request with an assertion over the request body and routing
headers.

The worker now issues one-time attestation challenges, verifies the
Apple App Attest certificate chain and nonce extension, stores the
attested public key with its assertion counter, and rejects protected
push routes unless the request is signed by the registered key. App
identity and attestation environment are configured through Wrangler,
with the Apple App Attestation root certificate supplied as a worker
secret. A legacy bearer path remains behind 'ALLOW_LEGACY_PUSH_BEARER'
only as a temporary rollback valve.

Existing collaborative games keep their CloudKit state and push address
derivation unchanged. Updated clients therefore re-register the same
addresses under App Attest auth instead of migrating game records, so
in-progress games continue once all active clients have the new build.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++--
MCrossmate/Crossmate.entitlements | 10++++++----
MCrossmate/Info.plist | 2--
MCrossmate/Services/PushClient.swift | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
ACrossmate/Services/PushRequestAuthenticator.swift | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MWorkers/push-worker.js | 620+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MWorkers/wrangler.push.toml | 5+++++
Mproject.yml | 3+--
8 files changed, 944 insertions(+), 43 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -159,6 +159,7 @@ EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; + F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */; }; @@ -361,6 +362,7 @@ F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; + FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRequestAuthenticator.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; }; FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; }; @@ -634,6 +636,7 @@ BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */, 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */, + FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */, B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, 710BCB6A647A820B106CE666 /* PuzzleSession.swift */, 3FFD574AC2D0A910053E2A73 /* ReplayLoader.swift */, @@ -909,6 +912,7 @@ E354A588DBA74627A9CD5591 /* Presence.swift in Sources */, A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */, 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */, + F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, @@ -1071,13 +1075,13 @@ 8BC97916898B0BF1E6951C48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APP_ATTEST_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; CROSSMATE_PUSH_BASE_URL = "$(inherited)"; - CROSSMATE_PUSH_BEARER = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1093,13 +1097,13 @@ AF49D30A1B81631106E05429 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APP_ATTEST_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; CROSSMATE_PUSH_BASE_URL = "$(inherited)"; - CROSSMATE_PUSH_BEARER = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -1,10 +1,12 @@ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> -<dict> - <key>aps-environment</key> - <string>production</string> - <key>com.apple.developer.icloud-container-identifiers</key> + <dict> + <key>aps-environment</key> + <string>production</string> + <key>com.apple.developer.devicecheck.appattest-environment</key> + <string>$(APP_ATTEST_ENVIRONMENT)</string> + <key>com.apple.developer.icloud-container-identifiers</key> <array> <string>iCloud.net.inqk.crossmate.v3</string> <string>iCloud.net.inqk.crossmate</string> diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -42,8 +42,6 @@ <string>$(CROSSMATE_ENGAGEMENT_SOCKET_URL)</string> <key>CrossmatePushBaseURL</key> <string>$(CROSSMATE_PUSH_BASE_URL)</string> - <key>CrossmatePushBearer</key> - <string>$(CROSSMATE_PUSH_BEARER)</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSRequiresIPhoneOS</key> diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -13,11 +13,11 @@ final class PushClient { } private let baseURL: URL - private let bearer: String private let deviceID: String private let environment: Environment private let session: URLSession private let log: (String) -> Void + private let authenticator: PushRequestAuthenticator private var apnsToken: String? private var authorID: String? @@ -48,15 +48,17 @@ final class PushClient { let rawBase = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBaseURL") as? String, case let trimmedBase = rawBase.trimmingCharacters(in: .whitespacesAndNewlines), !trimmedBase.isEmpty, - let base = URL(string: trimmedBase), - let bearer = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBearer") as? String, - !bearer.isEmpty + let base = URL(string: trimmedBase) else { return nil } self.baseURL = base - self.bearer = bearer self.deviceID = deviceID self.session = session self.log = log + self.authenticator = PushRequestAuthenticator( + baseURL: base, + deviceID: deviceID, + session: session + ) #if DEBUG self.environment = .sandbox #else @@ -170,11 +172,16 @@ final class PushClient { } var request = URLRequest(url: baseURL.appendingPathComponent("publish")) request.httpMethod = "POST" - applyAuth(&request) do { - request.httpBody = try JSONSerialization.data(withJSONObject: payload) - let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let body = try JSONSerialization.data(withJSONObject: payload) + request.httpBody = body + let response = try await sendAuthorized( + request, + method: "POST", + path: "/publish", + body: body + ) + guard response.statusCode == 200 else { throw URLError(.badServerResponse) } log("push(\(kind)): worker accepted") @@ -214,41 +221,95 @@ final class PushClient { private func register(token: String, addresses: [String]) async throws { var request = URLRequest(url: baseURL.appendingPathComponent("register")) request.httpMethod = "POST" - applyAuth(&request) let body: [String: Any] = [ "deviceID": deviceID, "token": token, "environment": environment.rawValue, "addresses": addresses ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (_, response) = try await session.data(for: request) + let data = try JSONSerialization.data(withJSONObject: body) + request.httpBody = data + let response = try await sendAuthorized( + request, + method: "POST", + path: "/register", + body: data + ) try assert204(response) } private func unregister(addresses: [String]) async { var request = URLRequest(url: baseURL.appendingPathComponent("register")) request.httpMethod = "DELETE" - applyAuth(&request) - request.httpBody = try? JSONSerialization.data(withJSONObject: [ + let data = (try? JSONSerialization.data(withJSONObject: [ "deviceID": deviceID, "addresses": addresses - ]) + ])) ?? Data() + request.httpBody = data do { - let (_, response) = try await session.data(for: request) + let response = try await sendAuthorized( + request, + method: "DELETE", + path: "/register", + body: data + ) try assert204(response) } catch { log("Push unregister failed: \(error.localizedDescription)") } } - private func applyAuth(_ request: inout URLRequest) { - request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization") + private func sendAuthorized( + _ request: URLRequest, + method: String, + path: String, + body: Data + ) async throws -> HTTPURLResponse { + try await sendAuthorized( + request, + method: method, + path: path, + body: body, + retryAfterRegistrationReset: true + ) + } + + private func sendAuthorized( + _ request: URLRequest, + method: String, + path: String, + body: Data, + retryAfterRegistrationReset: Bool + ) async throws -> HTTPURLResponse { + var request = request request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let headers = try await authenticator.signedHeaders( + method: method, + path: path, + body: body + ) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if http.statusCode == 401, retryAfterRegistrationReset { + await authenticator.resetRegistration() + return try await sendAuthorized( + request, + method: method, + path: path, + body: body, + retryAfterRegistrationReset: false + ) + } + return http } - private func assert204(_ response: URLResponse) throws { - guard let http = response as? HTTPURLResponse, http.statusCode == 204 else { + private func assert204(_ response: HTTPURLResponse) throws { + guard response.statusCode == 204 else { throw URLError(.badServerResponse) } } diff --git a/Crossmate/Services/PushRequestAuthenticator.swift b/Crossmate/Services/PushRequestAuthenticator.swift @@ -0,0 +1,238 @@ +import CryptoKit +import DeviceCheck +import Foundation + +enum PushRequestAuthError: LocalizedError { + case appAttestUnsupported + case missingChallenge + case attestationRejected + case invalidResponse + + var errorDescription: String? { + switch self { + case .appAttestUnsupported: + "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 .invalidResponse: + "The push worker returned an invalid authentication response." + } + } +} + +/// Enrolls this app installation with the push worker using App Attest, then +/// signs each worker request with the attested key. The worker stores only the +/// public key and a monotonically increasing assertion counter; Crossmate still +/// has no server-side user accounts. +actor PushRequestAuthenticator { + private static let keyIDKey = "push.appAttest.keyID.v1" + + private let baseURL: URL + private let deviceID: String + private let session: URLSession + private let service = DCAppAttestService.shared + + private var cachedKeyID: String? + private var registeringTask: Task<String, Error>? + + init( + baseURL: URL, + deviceID: String, + session: URLSession + ) { + self.baseURL = baseURL + self.deviceID = deviceID + self.session = session + } + + func signedHeaders( + method: String, + path: String, + body: Data + ) async throws -> [String: String] { + let keyID = try await registeredKeyID() + return try await assertionHeaders( + keyID: keyID, + method: method, + path: path, + body: body + ) + } + + func resetRegistration() { + cachedKeyID = nil + registeringTask?.cancel() + registeringTask = nil + KeychainHelper.delete(key: Self.keyIDKey) + } + + private func registeredKeyID() async throws -> String { + if let cachedKeyID { return cachedKeyID } + if let data = KeychainHelper.load(key: Self.keyIDKey), + let keyID = String(data: data, encoding: .utf8), + !keyID.isEmpty { + cachedKeyID = keyID + return keyID + } + if let registeringTask { + return try await registeringTask.value + } + let task = Task { try await registerFreshKey() } + registeringTask = task + do { + let keyID = try await task.value + registeringTask = nil + cachedKeyID = keyID + return keyID + } catch { + registeringTask = nil + throw error + } + } + + private func registerFreshKey() async throws -> String { + 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)) + return keyID + } + + private func fetchChallenge(for keyID: String) async throws -> String { + var components = URLComponents( + url: baseURL.appendingPathComponent("attest").appendingPathComponent("challenge"), + resolvingAgainstBaseURL: false + ) + components?.queryItems = [ + URLQueryItem(name: "deviceID", value: deviceID), + URLQueryItem(name: "keyID", value: keyID) + ] + guard let url = components?.url else { + throw PushRequestAuthError.invalidResponse + } + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw PushRequestAuthError.missingChallenge + } + let decoded = try JSONDecoder().decode(ChallengeResponse.self, from: data) + guard !decoded.challenge.isEmpty else { + throw PushRequestAuthError.missingChallenge + } + return decoded.challenge + } + + private func submitAttestation( + keyID: String, + challenge: String, + attestation: Data + ) async throws { + var request = URLRequest( + url: baseURL.appendingPathComponent("attest").appendingPathComponent("register") + ) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body = AttestationRequest( + deviceID: deviceID, + keyID: keyID, + challenge: challenge, + attestationObject: attestation.base64URLEncodedString() + ) + 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 + } + } + + private func assertionHeaders( + keyID: String, + method: String, + path: String, + body: Data + ) async throws -> [String: String] { + let timestamp = String(Int(Date().timeIntervalSince1970)) + let nonce = UUID().uuidString + let bodyHash = Data(SHA256.hash(data: body)).base64URLEncodedString() + let canonical = Self.canonicalRequest( + method: method, + path: path, + bodyHash: bodyHash, + timestamp: timestamp, + nonce: nonce, + deviceID: deviceID, + keyID: keyID + ) + 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-Timestamp": timestamp, + "X-Crossmate-Nonce": nonce, + "X-Crossmate-Body-SHA256": bodyHash, + "X-Crossmate-Assertion": assertion.base64URLEncodedString() + ] + } + + private static func clientDataHashForAttestation( + challenge: String, + deviceID: String, + keyID: String + ) -> Data { + let canonical = [ + "crossmate-appattest-v1", + challenge, + deviceID, + keyID + ].joined(separator: "\n") + return Data(SHA256.hash(data: Data(canonical.utf8))) + } + + static func canonicalRequest( + method: String, + path: String, + bodyHash: String, + timestamp: String, + nonce: String, + deviceID: String, + keyID: String + ) -> String { + [ + "crossmate-push-request-v1", + method.uppercased(), + path, + bodyHash, + timestamp, + nonce, + deviceID, + keyID + ].joined(separator: "\n") + } + + private struct ChallengeResponse: Decodable { + var challenge: String + } + + private struct AttestationRequest: Encodable { + var deviceID: String + var keyID: String + var challenge: String + var attestationObject: String + } +} diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -8,24 +8,44 @@ export class PushRegistry { async fetch(request) { const url = new URL(request.url); - const auth = this.authenticate(request); + + if (url.pathname === "/attest/challenge" && request.method === "GET") { + return this.handleAttestationChallenge(url); + } + if (url.pathname === "/attest/register" && request.method === "POST") { + return this.handleAttestationRegister(request); + } + + const bodyText = await request.text(); + const auth = await this.authenticate(request, bodyText); if (!auth.ok) { return new Response(auth.message, { status: auth.status }); } if (url.pathname === "/register" && request.method === "POST") { - return this.handleRegister(request); + return this.handleRegister(bodyText, auth); } if (url.pathname === "/register" && request.method === "DELETE") { - return this.handleUnregister(request); + return this.handleUnregister(bodyText, auth); } if (url.pathname === "/publish" && request.method === "POST") { - return this.handlePublish(request); + return this.handlePublish(bodyText, auth); } return new Response("Not found", { status: 404 }); } - authenticate(request) { + async authenticate(request, bodyText) { + let appAttest; + try { + appAttest = await this.authenticateAppAttest(request, bodyText); + } catch (error) { + appAttest = { ok: false, status: 401, message: `Bad App Attest auth: ${error.message}` }; + } + if (appAttest.ok) return appAttest; + if (appAttest.status !== 401 || this.env.ALLOW_LEGACY_PUSH_BEARER !== "1") { + return appAttest; + } + const header = request.headers.get("Authorization") || ""; const expected = `Bearer ${this.env.PUSH_BEARER || ""}`; if (!this.env.PUSH_BEARER) { @@ -34,16 +54,209 @@ export class PushRegistry { if (!timingSafeEqual(header, expected)) { return { ok: false, status: 401, message: "Bad bearer" }; } - return { ok: true }; + return { ok: true, deviceID: "legacy-bearer" }; + } + + async authenticateAppAttest(request, bodyText) { + if ((request.headers.get("X-Crossmate-Auth-Version") || "") !== "appattest-v1") { + return { ok: false, status: 401, message: "Missing App Attest auth" }; + } + const deviceID = request.headers.get("X-Crossmate-Device-ID") || ""; + const keyID = request.headers.get("X-Crossmate-Key-ID") || ""; + const timestamp = request.headers.get("X-Crossmate-Timestamp") || ""; + const nonce = request.headers.get("X-Crossmate-Nonce") || ""; + const bodyHash = request.headers.get("X-Crossmate-Body-SHA256") || ""; + const assertionBase64 = request.headers.get("X-Crossmate-Assertion") || ""; + if (!deviceID || !keyID || !timestamp || !nonce || !bodyHash || !assertionBase64) { + return { ok: false, status: 401, message: "Incomplete App Attest auth" }; + } + + const nowSeconds = Math.floor(Date.now() / 1000); + const timestampSeconds = Number(timestamp); + const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120"); + if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) { + return { ok: false, status: 401, message: "Stale auth timestamp" }; + } + + const computedBodyHash = base64URLEncode(await sha256Bytes(new TextEncoder().encode(bodyText))); + if (!timingSafeEqual(bodyHash, computedBodyHash)) { + return { ok: false, status: 401, message: "Bad body hash" }; + } + + const registrationKey = this.appAttestRegistrationKey(deviceID, keyID); + const registration = await this.state.storage.get(registrationKey); + if (!registration) { + return { ok: false, status: 401, message: "Unknown App Attest key" }; + } + + const nonceKey = `request-nonce:${deviceID}:${nonce}`; + if (await this.state.storage.get(nonceKey)) { + return { ok: false, status: 401, message: "Nonce already used" }; + } + + const path = new URL(request.url).pathname; + const canonical = canonicalPushRequest({ + method: request.method, + path, + bodyHash, + timestamp, + nonce, + deviceID, + keyID + }); + const clientDataHash = await sha256Bytes(new TextEncoder().encode(canonical)); + const assertion = decodeAssertion(base64URLDecode(assertionBase64)); + const authData = parseAuthenticatorData(assertion.authenticatorData); + const expectedAppIDHash = await this.expectedAppIDHash(); + if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { + return { ok: false, status: 401, message: "Bad App Attest app id" }; + } + if (authData.signCount <= (registration.signCount || 0)) { + return { ok: false, status: 401, message: "Stale App Attest counter" }; + } + + const signedBytes = concatBytes(assertion.authenticatorData, clientDataHash); + const publicKey = await crypto.subtle.importKey( + "jwk", + registration.publicKeyJWK, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"] + ); + const verified = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + derECDSASignatureToRaw(assertion.signature), + signedBytes + ); + if (!verified) { + return { ok: false, status: 401, message: "Bad App Attest assertion" }; + } + + registration.signCount = authData.signCount; + registration.updatedAt = Date.now(); + await this.state.storage.put(registrationKey, registration); + await this.state.storage.put(nonceKey, Date.now(), { + expirationTtl: Number(this.env.REQUEST_NONCE_TTL_SECONDS || "300") + }); + return { ok: true, deviceID }; + } + + async handleAttestationChallenge(url) { + const deviceID = url.searchParams.get("deviceID") || ""; + const keyID = url.searchParams.get("keyID") || ""; + if (!deviceID || !keyID) { + return badRequest("deviceID and keyID required"); + } + const challenge = base64URLEncode(crypto.getRandomValues(new Uint8Array(32))); + await this.state.storage.put( + this.appAttestChallengeKey(deviceID, keyID, challenge), + Date.now(), + { expirationTtl: Number(this.env.APP_ATTEST_CHALLENGE_TTL_SECONDS || "300") } + ); + return Response.json({ challenge }); + } + + async handleAttestationRegister(request) { + const body = await readJSONText(await request.text()); + if (!body) return badRequest("Body must be JSON"); + const { deviceID, keyID, challenge, attestationObject } = body; + if (!deviceID || !keyID || !challenge || !attestationObject) { + return badRequest("deviceID, keyID, challenge, attestationObject required"); + } + const challengeKey = this.appAttestChallengeKey(deviceID, keyID, challenge); + if (!(await this.state.storage.get(challengeKey))) { + return new Response("Unknown App Attest challenge", { status: 401 }); + } + + try { + const registration = await this.verifyAttestation({ + deviceID, + keyID, + challenge, + attestationObject: base64URLDecode(attestationObject) + }); + await this.state.storage.put(this.appAttestRegistrationKey(deviceID, keyID), registration); + await this.state.storage.delete(challengeKey); + return new Response(null, { status: 204 }); + } catch (error) { + return new Response(`Bad App Attest attestation: ${error.message}`, { status: 401 }); + } + } + + async verifyAttestation({ deviceID, keyID, challenge, attestationObject }) { + const attestation = decodeAttestationObject(attestationObject); + const authData = parseAuthenticatorData(attestation.authData); + const expectedAppIDHash = await this.expectedAppIDHash(); + if (!bytesEqual(authData.rpIDHash, expectedAppIDHash)) { + throw new Error("app id hash mismatch"); + } + if (!authData.credentialID || !bytesEqual(authData.credentialID, base64URLDecodeFlexible(keyID))) { + throw new Error("credential id mismatch"); + } + if (!isExpectedAppAttestAAGUID(authData.aaguid, this.env.APP_ATTEST_ENVIRONMENT || "production")) { + throw new Error("unexpected aaguid"); + } + if (!attestation.attStmt || !Array.isArray(attestation.attStmt.x5c) || attestation.attStmt.x5c.length < 2) { + throw new Error("missing certificate chain"); + } + + const leaf = parseCertificate(attestation.attStmt.x5c[0]); + const intermediate = parseCertificate(attestation.attStmt.x5c[1]); + await verifyCertificateSignature(leaf, intermediate.subjectPublicKeyInfo); + const rootPEM = this.env.APP_ATTEST_ROOT_CERT_PEM || ""; + if (!rootPEM) { + throw new Error("worker missing APP_ATTEST_ROOT_CERT_PEM"); + } + const root = parseCertificate(pemToDer(rootPEM)); + await verifyCertificateSignature(intermediate, root.subjectPublicKeyInfo); + + const clientDataHash = await sha256Bytes(new TextEncoder().encode([ + "crossmate-appattest-v1", + challenge, + deviceID, + keyID + ].join("\n"))); + const expectedNonce = await sha256Bytes(concatBytes(attestation.authData, clientDataHash)); + const certNonce = certificateAppAttestNonce(leaf); + if (!bytesEqual(certNonce, expectedNonce)) { + throw new Error("certificate nonce mismatch"); + } + + return { + publicKeyJWK: coseEC2PublicKeyToJWK(authData.cosePublicKey), + signCount: authData.signCount, + createdAt: Date.now() + }; } - async handleRegister(request) { - const body = await readJSON(request); + async expectedAppIDHash() { + const teamID = this.env.APP_TEAM_ID || ""; + const bundleID = this.env.APP_BUNDLE_ID || this.env.APNS_TOPIC || ""; + if (!teamID || !bundleID) { + throw new Error("APP_TEAM_ID and APP_BUNDLE_ID/APNS_TOPIC are required"); + } + return sha256Bytes(new TextEncoder().encode(`${teamID}.${bundleID}`)); + } + + appAttestChallengeKey(deviceID, keyID, challenge) { + return `appattest-challenge:${deviceID}:${keyID}:${challenge}`; + } + + appAttestRegistrationKey(deviceID, keyID) { + return `appattest-key:${deviceID}:${keyID}`; + } + + async handleRegister(bodyText, auth) { + const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); const { deviceID, token, environment, addresses } = body; if (!deviceID || !token || !Array.isArray(addresses)) { return badRequest("deviceID, token, addresses required"); } + if (auth.deviceID !== "legacy-bearer" && auth.deviceID !== deviceID) { + return new Response("Authenticated device mismatch", { status: 403 }); + } if (environment !== "sandbox" && environment !== "production") { return badRequest("environment must be 'sandbox' or 'production'"); } @@ -61,13 +274,16 @@ export class PushRegistry { return new Response(null, { status: 204 }); } - async handleUnregister(request) { - const body = await readJSON(request); + async handleUnregister(bodyText, auth) { + const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); const { deviceID, addresses } = body; if (!deviceID || !Array.isArray(addresses)) { return badRequest("deviceID and addresses required"); } + if (auth.deviceID !== "legacy-bearer" && auth.deviceID !== deviceID) { + return new Response("Authenticated device mismatch", { status: 403 }); + } for (const address of addresses) { if (typeof address !== "string" || address.length === 0) continue; await this.state.storage.delete(`addr:${address}:${deviceID}`); @@ -75,8 +291,8 @@ export class PushRegistry { return new Response(null, { status: 204 }); } - async handlePublish(request) { - const body = await readJSON(request); + async handlePublish(bodyText, auth) { + const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); const { kind, @@ -228,8 +444,12 @@ export default { }; async function readJSON(request) { + return readJSONText(await request.text()); +} + +async function readJSONText(text) { try { - return await request.json(); + return JSON.parse(text || "{}"); } catch { return null; } @@ -239,6 +459,380 @@ function badRequest(message) { return new Response(message, { status: 400 }); } +function canonicalPushRequest({ + method, + path, + bodyHash, + timestamp, + nonce, + deviceID, + keyID +}) { + return [ + "crossmate-push-request-v1", + method.toUpperCase(), + path, + bodyHash, + timestamp, + nonce, + deviceID, + keyID + ].join("\n"); +} + +async function sha256Bytes(bytes) { + return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); +} + +function concatBytes(...arrays) { + let length = 0; + for (const array of arrays) length += array.length; + const result = new Uint8Array(length); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} + +function bytesEqual(left, right) { + if (!left || !right || left.length !== right.length) return false; + let diff = 0; + for (let index = 0; index < left.length; index += 1) { + diff |= left[index] ^ right[index]; + } + return diff === 0; +} + +function base64URLDecode(string) { + let base64 = string.replace(/-/g, "+").replace(/_/g, "/"); + base64 += "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function base64URLDecodeFlexible(string) { + try { + return base64URLDecode(string); + } catch { + const binary = atob(string); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } +} + +function pemToDer(pem) { + const stripped = pem + .replace(/-----BEGIN CERTIFICATE-----/g, "") + .replace(/-----END CERTIFICATE-----/g, "") + .replace(/\s+/g, ""); + return base64URLDecodeFlexible(stripped); +} + +function decodeAttestationObject(bytes) { + const decoded = cborDecode(bytes); + if (!decoded || decoded.fmt !== "apple-appattest" || !(decoded.authData instanceof Uint8Array)) { + throw new Error("invalid attestation object"); + } + return decoded; +} + +function decodeAssertion(bytes) { + const decoded = cborDecode(bytes); + const authData = decoded.authenticatorData || decoded.authData; + if (!(authData instanceof Uint8Array) || !(decoded.signature instanceof Uint8Array)) { + throw new Error("invalid assertion object"); + } + return { + authenticatorData: authData, + signature: decoded.signature + }; +} + +function cborDecode(bytes) { + const reader = new CBORReader(bytes); + const value = reader.read(); + if (!reader.done) throw new Error("trailing cbor data"); + return value; +} + +class CBORReader { + constructor(bytes) { + this.bytes = bytes; + this.offset = 0; + } + + get done() { + return this.offset === this.bytes.length; + } + + read() { + const initial = this.readByte(); + const major = initial >> 5; + const additional = initial & 0x1f; + const value = this.readArgument(additional); + switch (major) { + case 0: + return value; + case 1: + return -1 - value; + case 2: + return this.readBytes(value); + case 3: + return new TextDecoder().decode(this.readBytes(value)); + case 4: { + const array = []; + for (let index = 0; index < value; index += 1) { + array.push(this.read()); + } + return array; + } + case 5: { + const object = {}; + for (let index = 0; index < value; index += 1) { + object[this.read()] = this.read(); + } + return object; + } + case 7: + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + break; + default: + break; + } + throw new Error("unsupported cbor value"); + } + + readArgument(additional) { + if (additional < 24) return additional; + if (additional === 24) return this.readByte(); + if (additional === 25) return this.readUInt(2); + if (additional === 26) return this.readUInt(4); + if (additional === 27) return this.readUInt(8); + throw new Error("indefinite cbor values are unsupported"); + } + + readUInt(length) { + let value = 0; + for (let index = 0; index < length; index += 1) { + value = (value * 256) + this.readByte(); + } + return value; + } + + readByte() { + if (this.offset >= this.bytes.length) throw new Error("truncated cbor"); + return this.bytes[this.offset++]; + } + + readBytes(length) { + if (this.offset + length > this.bytes.length) throw new Error("truncated cbor bytes"); + const value = this.bytes.slice(this.offset, this.offset + length); + this.offset += length; + return value; + } +} + +function parseAuthenticatorData(bytes) { + if (bytes.length < 37) throw new Error("authenticator data too short"); + const signCount = ( + (bytes[33] * 0x1000000) + + (bytes[34] << 16) + + (bytes[35] << 8) + + bytes[36] + ) >>> 0; + const result = { + rpIDHash: bytes.slice(0, 32), + flags: bytes[32], + signCount + }; + if ((result.flags & 0x40) !== 0) { + if (bytes.length < 55) throw new Error("attested credential data missing"); + result.aaguid = bytes.slice(37, 53); + const credentialLength = (bytes[53] << 8) | bytes[54]; + const credentialStart = 55; + const credentialEnd = credentialStart + credentialLength; + if (credentialEnd > bytes.length) throw new Error("credential id truncated"); + result.credentialID = bytes.slice(credentialStart, credentialEnd); + result.cosePublicKey = bytes.slice(credentialEnd); + } + return result; +} + +function isExpectedAppAttestAAGUID(aaguid, environment) { + if (!aaguid || aaguid.length !== 16) return false; + const production = new Uint8Array(16); + production.set(new TextEncoder().encode("appattest"), 0); + const development = new TextEncoder().encode("appattestdevelop"); + if (environment === "development") { + return bytesEqual(aaguid, development); + } + return bytesEqual(aaguid, production); +} + +function coseEC2PublicKeyToJWK(bytes) { + const key = cborDecode(bytes); + const x = key[-2]; + const y = key[-3]; + if (key[1] !== 2 || key[-1] !== 1 || !(x instanceof Uint8Array) || !(y instanceof Uint8Array)) { + throw new Error("unsupported cose key"); + } + return { + kty: "EC", + crv: "P-256", + x: base64URLEncode(x), + y: base64URLEncode(y), + ext: true + }; +} + +function parseCertificate(bytes) { + const cert = parseDER(bytes); + if (cert.tag !== 0x30 || cert.children.length < 3) { + throw new Error("invalid certificate"); + } + const tbs = cert.children[0]; + const signatureValue = cert.children[2]; + if (signatureValue.tag !== 0x03) throw new Error("invalid certificate signature"); + + const tbsChildren = tbs.children; + let index = tbsChildren[0].tag === 0xa0 ? 1 : 0; + index += 5; // serial, signature, issuer, validity, subject + const subjectPublicKeyInfo = tbsChildren[index].raw; + const extensions = []; + for (const child of tbsChildren.slice(index + 1)) { + if (child.tag === 0xa3 && child.children[0]?.tag === 0x30) { + for (const ext of child.children[0].children) { + const oid = decodeOID(ext.children[0].value); + const valueNode = ext.children.find((node) => node.tag === 0x04); + if (valueNode) extensions.push({ oid, value: valueNode.value }); + } + } + } + + return { + tbs: tbs.raw, + subjectPublicKeyInfo, + signature: signatureValue.value.slice(1), + extensions + }; +} + +function parseDER(bytes, offset = 0) { + const start = offset; + const tag = bytes[offset++]; + let length = bytes[offset++]; + if ((length & 0x80) !== 0) { + const byteCount = length & 0x7f; + length = 0; + for (let index = 0; index < byteCount; index += 1) { + length = (length * 256) + bytes[offset++]; + } + } + const valueStart = offset; + const end = valueStart + length; + if (end > bytes.length) throw new Error("truncated der"); + const constructed = (tag & 0x20) !== 0; + const children = []; + if (constructed) { + let childOffset = valueStart; + while (childOffset < end) { + const child = parseDER(bytes, childOffset); + children.push(child); + childOffset = child.end; + } + } + return { + tag, + start, + valueStart, + end, + raw: bytes.slice(start, end), + value: bytes.slice(valueStart, end), + children + }; +} + +function decodeOID(bytes) { + const parts = [Math.floor(bytes[0] / 40), bytes[0] % 40]; + let value = 0; + for (const byte of bytes.slice(1)) { + value = (value << 7) | (byte & 0x7f); + if ((byte & 0x80) === 0) { + parts.push(value); + value = 0; + } + } + return parts.join("."); +} + +async function verifyCertificateSignature(cert, issuerSPKI) { + const publicKey = await crypto.subtle.importKey( + "spki", + issuerSPKI, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"] + ); + const ok = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + derECDSASignatureToRaw(cert.signature), + cert.tbs + ); + if (!ok) throw new Error("certificate signature verification failed"); +} + +function certificateAppAttestNonce(cert) { + const extension = cert.extensions.find((entry) => entry.oid === "1.2.840.113635.100.8.2"); + if (!extension) throw new Error("missing app attest nonce extension"); + const nested = parseDER(extension.value); + const octets = findOctetString(nested, 32); + if (!octets) throw new Error("missing app attest nonce"); + return octets; +} + +function findOctetString(node, length) { + if (node.tag === 0x04 && node.value.length === length) return node.value; + for (const child of node.children) { + const found = findOctetString(child, length); + if (found) return found; + } + return null; +} + +function derECDSASignatureToRaw(bytes) { + const sequence = parseDER(bytes); + if (sequence.tag !== 0x30 || sequence.children.length !== 2) { + throw new Error("invalid ecdsa signature"); + } + return concatBytes( + derIntegerToFixed(sequence.children[0].value, 32), + derIntegerToFixed(sequence.children[1].value, 32) + ); +} + +function derIntegerToFixed(bytes, length) { + let value = bytes; + while (value.length > 0 && value[0] === 0) { + value = value.slice(1); + } + if (value.length > length) throw new Error("ecdsa integer too long"); + const result = new Uint8Array(length); + result.set(value, length - value.length); + return result; +} + async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) { if (!keyPEM || !keyID || !teamID) { throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set"); diff --git a/Workers/wrangler.push.toml b/Workers/wrangler.push.toml @@ -6,6 +6,11 @@ preview_urls = false [vars] APNS_TOPIC = "net.inqk.crossmate" +APP_TEAM_ID = "7TD7PZBNXP" +APP_BUNDLE_ID = "net.inqk.crossmate" +APP_ATTEST_ENVIRONMENT = "production" +# Set APP_ATTEST_ROOT_CERT_PEM as a Worker secret or dashboard variable. +# Set ALLOW_LEGACY_PUSH_BEARER = "1" only for a temporary rollback window. [[durable_objects.bindings]] name = "PUSH_REGISTRY" diff --git a/project.yml b/project.yml @@ -73,7 +73,6 @@ targets: CKSharingSupported: true CrossmateEngagementSocketURL: $(CROSSMATE_ENGAGEMENT_SOCKET_URL) CrossmatePushBaseURL: $(CROSSMATE_PUSH_BASE_URL) - CrossmatePushBearer: $(CROSSMATE_PUSH_BEARER) LSSupportsOpeningDocumentsInPlace: false UILaunchScreen: {} UISupportedInterfaceOrientations: @@ -88,7 +87,7 @@ targets: CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements CROSSMATE_ENGAGEMENT_SOCKET_URL: $(inherited) CROSSMATE_PUSH_BASE_URL: $(inherited) - CROSSMATE_PUSH_BEARER: $(inherited) + APP_ATTEST_ENVIRONMENT: production TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_STYLE: Automatic configs: