crossmate

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

engagement-worker.js (10704B)


      1 // Coarse debounce for the durable `lastSeenAt` write on the message hot path.
      2 // The room TTL only needs second-ish granularity, so a write + alarm reschedule
      3 // on every keystroke broadcast is pure churn. In-memory, so it resets to a
      4 // guaranteed write on each hibernation wake — which is fine.
      5 const TOUCH_DEBOUNCE_MS = 30 * 1000;
      6 
      7 export class EngagementRoom {
      8   constructor(state, env) {
      9     this.state = state;
     10     this.env = env;
     11     // Answer keepalive pings without waking the Durable Object, so an otherwise
     12     // idle foreground socket stays warm through NAT / edge idle timeouts. Pairs
     13     // with the client's periodic "ping" (EngagementHost.startPing). Auto-answered
     14     // frames never reach `webSocketMessage`, so they cost nothing here.
     15     this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
     16   }
     17 
     18   async fetch(request) {
     19     const url = new URL(request.url);
     20     const route = roomRouteFromPath(url.pathname);
     21     if (!route) {
     22       return new Response("Missing room ID", { status: 400 });
     23     }
     24 
     25     if (route.endpoint === "register") {
     26       if (request.method !== "POST") {
     27         return new Response("Method not allowed", { status: 405 });
     28       }
     29       return this.register(request);
     30     }
     31 
     32     const upgrade = request.headers.get("Upgrade");
     33     if (upgrade !== "websocket") {
     34       return new Response("Expected WebSocket upgrade", { status: 426 });
     35     }
     36 
     37     const auth = await this.authenticate(route.roomID, url.searchParams);
     38     if (!auth.ok) {
     39       return new Response(auth.message, { status: auth.status });
     40     }
     41 
     42     const pair = new WebSocketPair();
     43     const [client, server] = Object.values(pair);
     44     server.serializeAttachment({
     45       authorID: auth.authorID,
     46       deviceID: auth.deviceID,
     47       connectedAt: Date.now()
     48     });
     49     // Supersede any earlier socket from the same device. A reconnect would
     50     // otherwise leave the stale one lingering until its TCP dies, so the room
     51     // fans every broadcast at a zombie and can echo the device's own prior
     52     // frames back to it. `server` is not accepted yet, so it is not in the set.
     53     for (const existing of this.state.getWebSockets()) {
     54       const attachment = existing.deserializeAttachment();
     55       if (attachment && attachment.authorID === auth.authorID && attachment.deviceID === auth.deviceID) {
     56         try {
     57           existing.close(1000, "Superseded by a newer connection");
     58         } catch {
     59           // Already closing; the runtime will reap it.
     60         }
     61       }
     62     }
     63     this.state.acceptWebSocket(server);
     64 
     65     return new Response(null, {
     66       status: 101,
     67       webSocket: client
     68     });
     69   }
     70 
     71   // Registers the room secret ahead of a socket connect. First write wins:
     72   // the secret is created if absent, confirmed if it matches, and refused if
     73   // it differs. The secret arrives in the request body, never in a URL.
     74   //
     75   // Every legitimate holder of the room creds (distributed via the shared
     76   // CloudKit Game record) registers idempotently before each connect, so an
     77   // idle-expired room is resurrected by whichever peer returns first and
     78   // there is no ordering race between the minter and its peers. An attacker
     79   // who learns a room ID (it appears in URL paths) but not the secret can
     80   // neither register over a live room nor sign a connect.
     81   async register(request) {
     82     let body;
     83     try {
     84       body = await request.json();
     85     } catch {
     86       return new Response("Invalid JSON body", { status: 400 });
     87     }
     88     const secret = typeof body.secret === "string" ? body.secret : "";
     89     if (!isAcceptableSecret(secret)) {
     90       return new Response("Invalid secret", { status: 400 });
     91     }
     92 
     93     const stored = await this.state.storage.get("secret");
     94     if (stored) {
     95       if (!timingSafeEqual(stored, secret)) {
     96         return new Response("Room secret mismatch", { status: 409 });
     97       }
     98     } else {
     99       await this.state.storage.put("secret", secret);
    100       await this.state.storage.put("createdAt", Date.now());
    101       // TOFU-era rooms stored only a connect-time hash; the registered
    102       // secret supersedes it.
    103       await this.state.storage.delete("secretHash");
    104     }
    105 
    106     await this.state.storage.put("lastSeenAt", Date.now());
    107     await this.scheduleExpiry();
    108     return new Response(null, { status: stored ? 204 : 201 });
    109   }
    110 
    111   async authenticate(roomID, params) {
    112     const authorID = params.get("authorID") || "";
    113     const deviceID = params.get("deviceID") || "";
    114     const timestamp = params.get("timestamp") || "";
    115     const nonce = params.get("nonce") || "";
    116     const signature = params.get("signature") || "";
    117 
    118     if (!authorID || !deviceID || !timestamp || !nonce || !signature) {
    119       return { ok: false, status: 401, message: "Missing auth parameters" };
    120     }
    121 
    122     // The secret never travels on a connect; it must have been registered
    123     // via `register` (clients re-register idempotently before each connect,
    124     // which also resurrects a room whose idle expiry wiped this storage).
    125     const secret = await this.state.storage.get("secret");
    126     if (!secret) {
    127       return { ok: false, status: 403, message: "Room not registered" };
    128     }
    129 
    130     const nowSeconds = Math.floor(Date.now() / 1000);
    131     const timestampSeconds = Number(timestamp);
    132     const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120");
    133     if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) {
    134       return { ok: false, status: 401, message: "Stale auth timestamp" };
    135     }
    136 
    137     const nonceKey = `nonce:${nonce}`;
    138     if (await this.state.storage.get(nonceKey)) {
    139       return { ok: false, status: 401, message: "Nonce already used" };
    140     }
    141 
    142     const payload = [roomID, authorID, deviceID, timestamp, nonce].join("|");
    143     const expectedSignature = await hmacSHA256(secret, payload);
    144     if (!timingSafeEqual(signature, expectedSignature)) {
    145       return { ok: false, status: 401, message: "Invalid signature" };
    146     }
    147 
    148     await this.state.storage.put("lastSeenAt", Date.now());
    149     await this.state.storage.put(nonceKey, Date.now());
    150     await this.pruneNonces();
    151     await this.scheduleExpiry();
    152     this.lastTouchAt = Date.now();
    153 
    154     return { ok: true, authorID, deviceID };
    155   }
    156 
    157   async webSocketMessage(ws, message) {
    158     const data = typeof message === "string" ? message : message.slice(0);
    159     await this.touch();
    160     for (const peer of this.state.getWebSockets()) {
    161       if (peer === ws) continue;
    162       try {
    163         peer.send(data);
    164       } catch {
    165         // A peer caught mid-close throws here; skip it so the rest of the room
    166         // still receives this frame. The dead socket is reaped via
    167         // `webSocketClose` / runtime cleanup.
    168       }
    169     }
    170   }
    171 
    172   async webSocketClose(ws, code, reason) {
    173     await this.state.storage.put("lastSeenAt", Date.now());
    174     await this.scheduleExpiry();
    175     ws.close(code, reason);
    176   }
    177 
    178   async alarm() {
    179     await this.pruneNonces();
    180     const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
    181     const sockets = this.state.getWebSockets();
    182     if (sockets.length > 0) {
    183       // Still connected — the room is alive regardless of how long since the
    184       // last broadcast (keepalive pings are auto-answered and don't advance
    185       // `lastSeenAt`). Re-arm from now, not the stale deadline, or a long idle
    186       // session would refire the alarm in a tight loop.
    187       await this.state.storage.setAlarm(Date.now() + ttlMs);
    188       return;
    189     }
    190 
    191     const lastSeenAt = await this.state.storage.get("lastSeenAt");
    192     const idleMs = Date.now() - (lastSeenAt || Date.now());
    193     if (idleMs > ttlMs) {
    194       await this.state.storage.deleteAll();
    195       return;
    196     }
    197     await this.scheduleExpiry();
    198   }
    199 
    200   async touch() {
    201     const now = Date.now();
    202     // Debounce the durable write + alarm reschedule off the message hot path.
    203     if (this.lastTouchAt && now - this.lastTouchAt < TOUCH_DEBOUNCE_MS) return;
    204     this.lastTouchAt = now;
    205     await this.state.storage.put("lastSeenAt", now);
    206     await this.scheduleExpiry();
    207   }
    208 
    209   async pruneNonces() {
    210     const maxAgeMs = Number(this.env.NONCE_TTL_SECONDS || "300") * 1000;
    211     const cutoff = Date.now() - maxAgeMs;
    212     const nonces = await this.state.storage.list({ prefix: "nonce:" });
    213     for (const [key, createdAt] of nonces) {
    214       if (createdAt < cutoff) {
    215         await this.state.storage.delete(key);
    216       }
    217     }
    218   }
    219 
    220   async scheduleExpiry() {
    221     const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
    222     const lastSeenAt = (await this.state.storage.get("lastSeenAt")) || Date.now();
    223     await this.state.storage.setAlarm(lastSeenAt + ttlMs);
    224   }
    225 }
    226 
    227 export default {
    228   async fetch(request, env) {
    229     const url = new URL(request.url);
    230     if (url.pathname === "/health") {
    231       return new Response("ok");
    232     }
    233     const route = roomRouteFromPath(url.pathname);
    234     if (!route) {
    235       return new Response("Not found", { status: 404 });
    236     }
    237     const id = env.ENGAGEMENT_ROOMS.idFromName(route.roomID);
    238     return env.ENGAGEMENT_ROOMS.get(id).fetch(request);
    239   }
    240 };
    241 
    242 function roomRouteFromPath(pathname) {
    243   const match = pathname.match(/^\/rooms\/([^/]+)\/(socket|register)$/);
    244   return match ? { roomID: match[1], endpoint: match[2] } : null;
    245 }
    246 
    247 // The secret doubles as the HMAC key for connect signatures, so a registered
    248 // value must decode to at least 32 key bytes (clients mint exactly 32).
    249 function isAcceptableSecret(secret) {
    250   if (!secret) return false;
    251   let bytes;
    252   try {
    253     bytes = base64URLDecode(secret);
    254   } catch {
    255     return false;
    256   }
    257   return bytes.length >= 32;
    258 }
    259 
    260 async function hmacSHA256(secret, payload) {
    261   const key = await crypto.subtle.importKey(
    262     "raw",
    263     base64URLDecode(secret),
    264     { name: "HMAC", hash: "SHA-256" },
    265     false,
    266     ["sign"]
    267   );
    268   const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
    269   return base64URLEncode(new Uint8Array(signature));
    270 }
    271 
    272 function base64URLDecode(value) {
    273   const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
    274   const binary = atob(base64);
    275   return Uint8Array.from(binary, (char) => char.charCodeAt(0));
    276 }
    277 
    278 function base64URLEncode(bytes) {
    279   let binary = "";
    280   for (const byte of bytes) {
    281     binary += String.fromCharCode(byte);
    282   }
    283   return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    284 }
    285 
    286 function timingSafeEqual(a, b) {
    287   const left = new TextEncoder().encode(a);
    288   const right = new TextEncoder().encode(b);
    289   if (left.length !== right.length) return false;
    290   let diff = 0;
    291   for (let index = 0; index < left.length; index += 1) {
    292     diff |= left[index] ^ right[index];
    293   }
    294   return diff === 0;
    295 }