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:
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);