crossmate

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

commit 19c73585c2e4ad2db6b5e912e5fcb213e96a4afa
parent 4a95670cb5de9d92a861301a8f8c1b0aabec308a
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 09:44:54 +0900

Register engagement room secrets instead of trusting first connect

This commit replaces the engagement worker's trust-on-first-use room
auth. Rooms are now registered via POST /rooms/{id}/register with the
secret in the request body — first write wins, idempotent for the same
secret, 409 for a different one — and the websocket connect no longer
carries the secret at all: the worker verifies the existing HMAC
signature (timestamp + nonce checks unchanged) against the registered
secret and refuses unregistered rooms.

Clients register idempotently before every connect, preserving the
previous de-facto resurrection of idle-expired rooms and removing any
ordering race between the minting peer and peers that learn the creds
via CloudKit. A 409 means the room ID is unusable with our creds, so the
coordinator surfaces it as .roomRejected and EngagementLifecycle clears
the Game record's engagement field; a later reconcile mints a fresh room
and LWW converges the peers onto it.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/EngagementHost.swift | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCrossmate/Services/EngagementLifecycle.swift | 14+++++++++++++-
MCrossmate/Sync/EngagementCoordinator.swift | 32++++++++++++++++++++++++++------
MTests/Unit/Sync/EngagementCoordinatorTests.swift | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MWorkers/engagement-worker.js | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
5 files changed, 239 insertions(+), 42 deletions(-)

diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift @@ -39,6 +39,11 @@ final class EngagementHost: NSObject { deviceID: String ) async throws { disconnect(engagementID: engagementID) + // Register (idempotently) before every connect. The worker only + // accepts sockets for rooms whose secret it already holds, and any + // legitimate creds-holder may register first — which also resurrects + // a room whose idle expiry wiped the worker-side secret. + try await registerRoom(room) guard let url = try Self.socketURL(room: room, authorID: authorID, deviceID: deviceID) else { throw EngagementHostError.missingEndpoint } @@ -151,17 +156,59 @@ final class EngagementHost: NSObject { .appendingPathComponent(room.roomID.uuidString) .appendingPathComponent("socket") var components = URLComponents(url: socketURL, resolvingAgainstBaseURL: false) + // The room secret never rides in the URL: the worker verifies the + // signature against the secret registered via `registrationRequest`. components?.queryItems = [ URLQueryItem(name: "authorID", value: authorID), URLQueryItem(name: "deviceID", value: deviceID), URLQueryItem(name: "timestamp", value: timestamp), URLQueryItem(name: "nonce", value: nonce), - URLQueryItem(name: "secret", value: room.secret), URLQueryItem(name: "signature", value: signature) ] return components?.url } + static func registrationRequest( + room: EngagementRoomCredentials, + baseURL: URL? = endpointURL + ) throws -> URLRequest? { + guard let baseURL else { return nil } + guard baseURL.scheme == "ws" || baseURL.scheme == "wss" else { + throw EngagementHostError.invalidEndpoint + } + let registerURL = baseURL + .appendingPathComponent("rooms") + .appendingPathComponent(room.roomID.uuidString) + .appendingPathComponent("register") + var components = URLComponents(url: registerURL, resolvingAgainstBaseURL: false) + components?.scheme = baseURL.scheme == "ws" ? "http" : "https" + guard let url = components?.url else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(["secret": room.secret]) + return request + } + + private func registerRoom(_ room: EngagementRoomCredentials) async throws { + guard let request = try Self.registrationRequest(room: room) else { + throw EngagementHostError.missingEndpoint + } + let (_, response) = try await session.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + switch statusCode { + case 200..<300: + return + case 409: + // The worker holds a different secret for this room ID. Retrying + // with the same creds can never succeed; the lifecycle clears the + // advertised creds so a fresh room is minted. + throw EngagementHostError.roomSecretMismatch + default: + throw EngagementHostError.registrationFailed(statusCode: statusCode) + } + } + private static var endpointURL: URL? { guard let raw = Bundle.main.object(forInfoDictionaryKey: "CrossmateEngagementSocketURL") as? String, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, @@ -202,11 +249,13 @@ extension EngagementHost: URLSessionWebSocketDelegate { } } -enum EngagementHostError: LocalizedError { +enum EngagementHostError: LocalizedError, Equatable { case missingEndpoint case missingSocket case invalidEndpoint case invalidSecret + case roomSecretMismatch + case registrationFailed(statusCode: Int) var errorDescription: String? { switch self { @@ -218,6 +267,10 @@ enum EngagementHostError: LocalizedError { "CrossmateEngagementSocketURL must use ws or wss." case .invalidSecret: "The engagement room secret is invalid." + case .roomSecretMismatch: + "The engagement room is registered with a different secret." + case .registrationFailed(let statusCode): + "Engagement room registration failed (HTTP \(statusCode))." } } } diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift @@ -130,7 +130,19 @@ final class EngagementLifecycle { ) } } - await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) + let outcome = await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) + if outcome == .roomRejected, let rejected = creds { + // The worker holds a different secret for this room ID (e.g. the + // room was squatted after idle expiry). Reconnecting with these + // creds can never succeed, so drop them from the Game record; a + // later reconcile mints a fresh room and LWW converges the peers + // onto it. + if store.setEngagement(nil, for: gameID) { + syncMonitor.note( + "engagement: cleared rejected room \(rejected.roomID.uuidString) for \(gameID.uuidString)" + ) + } + } // When the soonest present peer drops out of presence, re-reconcile: // tear down (dropping the bolt) if no renewal arrived, or reschedule // onto the new horizon if one did. That instant is the lease plus the diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift @@ -146,6 +146,16 @@ struct EngagementMessage: Codable, Equatable, Sendable { } } +enum EngagementReconcileOutcome: Equatable, Sendable { + /// The reconcile ran; any connect failure is transient and covered by the + /// normal retry backstops. + case reconciled + /// The worker refused the advertised room because it is registered under a + /// different secret. Retrying with the same creds can never succeed; the + /// caller should clear the advertised creds so a fresh room is minted. + case roomRejected +} + @MainActor protocol EngagementTransporting: AnyObject, Sendable { func connect( @@ -221,7 +231,8 @@ actor EngagementCoordinator { /// teardown (peer left) — and the create race needs no arbiter: if two /// participants mint, the Game record's LWW picks one set of creds, and /// every device reconciles onto it, the loser migrating off its own room. - func reconcile(gameID: UUID, creds: EngagementRoomCredentials?, hasPeer: Bool) async { + @discardableResult + func reconcile(gameID: UUID, creds: EngagementRoomCredentials?, hasPeer: Bool) async -> EngagementReconcileOutcome { await sweepStaleConnections() let current = state(for: gameID) guard hasPeer, let creds else { @@ -231,17 +242,18 @@ actor EngagementCoordinator { await log("engagement: no present peer for \(gameID.uuidString), tearing down \(engagementID.uuidString)") await host.disconnect(engagementID: engagementID) } - return + return .reconciled } // Already connecting/live to the advertised room — nothing to do. - if current.roomID == creds.roomID { return } + if current.roomID == creds.roomID { return .reconciled } // Connect to the desired room first (so the old socket's `.channelClose` // sees a state that has already moved on and no-ops), then drop the old. let staleEngagementID = current.engagementID - await connect(gameID: gameID, room: creds) + let outcome = await connect(gameID: gameID, room: creds) if let staleEngagementID { await host.disconnect(engagementID: staleEngagementID) } + return outcome } private func sweepStaleConnections() async { @@ -358,10 +370,10 @@ actor EngagementCoordinator { /// migrating off a previous room disconnect the stale engagement *after* /// this returns, so the old socket's close races against a state that has /// already moved on. - private func connect(gameID: UUID, room: EngagementRoomCredentials) async { + private func connect(gameID: UUID, room: EngagementRoomCredentials) async -> EngagementReconcileOutcome { guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else { await log("engagement: connect skipped for \(gameID.uuidString), missing local author") - return + return .reconciled } let engagementID = UUID() states[gameID] = .connecting(engagementID: engagementID, room: room, at: now()) @@ -373,10 +385,18 @@ actor EngagementCoordinator { deviceID: localDeviceID ) await log("engagement: connecting \(gameID.uuidString) to room \(room.roomID.uuidString)") + } catch EngagementHostError.roomSecretMismatch { + states[gameID] = .idle + await log( + "engagement: room \(room.roomID.uuidString) rejected for \(gameID.uuidString): " + + "registered with a different secret" + ) + return .roomRejected } catch { states[gameID] = .idle await log("engagement: connect failed for \(gameID.uuidString): \(error.localizedDescription)") } + return .reconciled } private func state(for gameID: UUID) -> State { diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift @@ -71,9 +71,37 @@ struct EngagementCoordinatorTests { #expect(url.scheme == "wss") #expect(url.host() == "example.org") #expect(url.path == "/rooms/23232323-2323-2323-2323-232323232323/socket") - #expect(URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .contains(URLQueryItem(name: "authorID", value: "alice")) == true) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + #expect(queryItems?.contains(URLQueryItem(name: "authorID", value: "alice")) == true) + // The room secret must never travel in the URL; the worker verifies + // the signature against the secret registered at room registration. + #expect(queryItems?.contains { $0.name == "secret" } == false) + #expect(queryItems?.contains { $0.name == "signature" } == true) + } + + @Test("registration request posts the secret in the body over HTTPS") + @MainActor + func registrationRequest() throws { + let room = EngagementRoomCredentials( + roomID: UUID(uuidString: "24242424-2424-2424-2424-242424242424")!, + secret: Data(repeating: 4, count: 32).base64URLEncodedString(), + createdAt: Date(timeIntervalSince1970: 100), + expiresAt: Date(timeIntervalSince1970: 700) + ) + + let maybeRequest = try EngagementHost.registrationRequest( + room: room, + baseURL: URL(string: "wss://example.org") + ) + let request = try #require(maybeRequest) + + #expect(request.url?.scheme == "https") + #expect(request.url?.host() == "example.org") + #expect(request.url?.path == "/rooms/24242424-2424-2424-2424-242424242424/register") + #expect(request.url?.query() == nil) + #expect(request.httpMethod == "POST") + let body = try JSONDecoder().decode([String: String].self, from: try #require(request.httpBody)) + #expect(body == ["secret": room.secret]) } @Test("debug message envelope round trips") @@ -216,6 +244,34 @@ struct EngagementCoordinatorTests { #expect(host.disconnected == [engagementID]) } + @Test("reconcile surfaces a room rejected by the worker") + @MainActor + func reconcileSurfacesRoomRejection() async throws { + let gameID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! + let host = MockEngagementHost() + host.connectError = EngagementHostError.roomSecretMismatch + let coordinator = makeCoordinator(host: host) + + let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) + + #expect(outcome == .roomRejected) + #expect(host.connections.isEmpty) + } + + @Test("transient connect failures do not report a rejected room") + @MainActor + func transientConnectFailureIsNotRejection() async throws { + let gameID = UUID(uuidString: "45454545-4545-4545-4545-454545454545")! + let host = MockEngagementHost() + host.connectError = EngagementHostError.registrationFailed(statusCode: 503) + let coordinator = makeCoordinator(host: host) + + let outcome = await coordinator.reconcile(gameID: gameID, creds: roomCredentials(), hasPeer: true) + + #expect(outcome == .reconciled) + #expect(host.connections.isEmpty) + } + @Test("a stale connecting attempt is swept and retried on the next reconcile") @MainActor func staleConnectingDemotesOnReconcile() async throws { @@ -296,6 +352,7 @@ private final class MockEngagementHost: EngagementTransporting, @unchecked Senda var connections: [Connection] = [] var disconnected: [UUID] = [] var sentMessages: [(engagementID: UUID, message: Data)] = [] + var connectError: Error? func connect( engagementID: UUID, @@ -303,6 +360,9 @@ private final class MockEngagementHost: EngagementTransporting, @unchecked Senda authorID: String, deviceID: String ) async throws { + if let connectError { + throw connectError + } connections.append(Connection( engagementID: engagementID, room: room, diff --git a/Workers/engagement-worker.js b/Workers/engagement-worker.js @@ -16,18 +16,25 @@ export class EngagementRoom { } async fetch(request) { + const url = new URL(request.url); + const route = roomRouteFromPath(url.pathname); + if (!route) { + return new Response("Missing room ID", { status: 400 }); + } + + if (route.endpoint === "register") { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + return this.register(request); + } + const upgrade = request.headers.get("Upgrade"); if (upgrade !== "websocket") { return new Response("Expected WebSocket upgrade", { status: 426 }); } - const url = new URL(request.url); - const roomID = roomIDFromPath(url.pathname); - if (!roomID) { - return new Response("Missing room ID", { status: 400 }); - } - - const auth = await this.authenticate(roomID, url.searchParams); + const auth = await this.authenticate(route.roomID, url.searchParams); if (!auth.ok) { return new Response(auth.message, { status: auth.status }); } @@ -61,18 +68,65 @@ export class EngagementRoom { }); } + // Registers the room secret ahead of a socket connect. First write wins: + // the secret is created if absent, confirmed if it matches, and refused if + // it differs. The secret arrives in the request body, never in a URL. + // + // Every legitimate holder of the room creds (distributed via the shared + // CloudKit Game record) registers idempotently before each connect, so an + // idle-expired room is resurrected by whichever peer returns first and + // there is no ordering race between the minter and its peers. An attacker + // who learns a room ID (it appears in URL paths) but not the secret can + // neither register over a live room nor sign a connect. + async register(request) { + let body; + try { + body = await request.json(); + } catch { + return new Response("Invalid JSON body", { status: 400 }); + } + const secret = typeof body.secret === "string" ? body.secret : ""; + if (!isAcceptableSecret(secret)) { + return new Response("Invalid secret", { status: 400 }); + } + + const stored = await this.state.storage.get("secret"); + if (stored) { + if (!timingSafeEqual(stored, secret)) { + return new Response("Room secret mismatch", { status: 409 }); + } + } else { + await this.state.storage.put("secret", secret); + await this.state.storage.put("createdAt", Date.now()); + // TOFU-era rooms stored only a connect-time hash; the registered + // secret supersedes it. + await this.state.storage.delete("secretHash"); + } + + await this.state.storage.put("lastSeenAt", Date.now()); + await this.scheduleExpiry(); + return new Response(null, { status: stored ? 204 : 201 }); + } + async authenticate(roomID, params) { const authorID = params.get("authorID") || ""; const deviceID = params.get("deviceID") || ""; const timestamp = params.get("timestamp") || ""; const nonce = params.get("nonce") || ""; - const secret = params.get("secret") || ""; const signature = params.get("signature") || ""; - if (!authorID || !deviceID || !timestamp || !nonce || !secret || !signature) { + if (!authorID || !deviceID || !timestamp || !nonce || !signature) { return { ok: false, status: 401, message: "Missing auth parameters" }; } + // The secret never travels on a connect; it must have been registered + // via `register` (clients re-register idempotently before each connect, + // which also resurrects a room whose idle expiry wiped this storage). + const secret = await this.state.storage.get("secret"); + if (!secret) { + return { ok: false, status: 403, message: "Room not registered" }; + } + const nowSeconds = Math.floor(Date.now() / 1000); const timestampSeconds = Number(timestamp); const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120"); @@ -91,16 +145,6 @@ export class EngagementRoom { return { ok: false, status: 401, message: "Invalid signature" }; } - const secretHash = await sha256(secret); - const storedSecretHash = await this.state.storage.get("secretHash"); - if (storedSecretHash && storedSecretHash !== secretHash) { - return { ok: false, status: 403, message: "Wrong room secret" }; - } - if (!storedSecretHash) { - await this.state.storage.put("secretHash", secretHash); - await this.state.storage.put("createdAt", Date.now()); - } - await this.state.storage.put("lastSeenAt", Date.now()); await this.state.storage.put(nonceKey, Date.now()); await this.pruneNonces(); @@ -186,18 +230,31 @@ export default { if (url.pathname === "/health") { return new Response("ok"); } - const roomID = roomIDFromPath(url.pathname); - if (!roomID) { + const route = roomRouteFromPath(url.pathname); + if (!route) { return new Response("Not found", { status: 404 }); } - const id = env.ENGAGEMENT_ROOMS.idFromName(roomID); + const id = env.ENGAGEMENT_ROOMS.idFromName(route.roomID); return env.ENGAGEMENT_ROOMS.get(id).fetch(request); } }; -function roomIDFromPath(pathname) { - const match = pathname.match(/^\/rooms\/([^/]+)\/socket$/); - return match ? match[1] : null; +function roomRouteFromPath(pathname) { + const match = pathname.match(/^\/rooms\/([^/]+)\/(socket|register)$/); + return match ? { roomID: match[1], endpoint: match[2] } : null; +} + +// The secret doubles as the HMAC key for connect signatures, so a registered +// value must decode to at least 32 key bytes (clients mint exactly 32). +function isAcceptableSecret(secret) { + if (!secret) return false; + let bytes; + try { + bytes = base64URLDecode(secret); + } catch { + return false; + } + return bytes.length >= 32; } async function hmacSHA256(secret, payload) { @@ -212,11 +269,6 @@ async function hmacSHA256(secret, payload) { return base64URLEncode(new Uint8Array(signature)); } -async function sha256(value) { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); - return base64URLEncode(new Uint8Array(digest)); -} - function base64URLDecode(value) { const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(base64);