crossmate

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

room-worker.js (17253B)


      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 EngagementRegisterLimiter {
      8   constructor(state, env) {
      9     this.state = state;
     10     this.env = env;
     11   }
     12 
     13   async fetch(request) {
     14     const url = new URL(request.url);
     15     const roomID = url.searchParams.get("roomID") || "";
     16     if (!roomID) {
     17       return new Response("Missing room ID", { status: 400 });
     18     }
     19 
     20     const result = await this.checkRoomRegisterRateLimit(request, roomID);
     21     return result.ok ? new Response(null, { status: 204 }) : rateLimitedResponse(result);
     22   }
     23 
     24   async checkRoomRegisterRateLimit(request, roomID) {
     25     const ip = request.headers.get("CF-Connecting-IP") || "unknown";
     26     const config = rateLimitConfig(this.env, "ROOM_REGISTER_IP", 60, 60 * 60);
     27     const now = Date.now();
     28     const windowMillis = config.windowSeconds * 1000;
     29     const cutoff = now - windowMillis;
     30     const keySecret = rateLimitKeySecret(this.env);
     31     const key = await rateLimitStorageKey("room-register:ip", ip, keySecret);
     32     const roomToken = await rateLimitToken(roomID, keySecret);
     33     const stored = await this.state.storage.get(key);
     34     const recent = Array.isArray(stored)
     35       ? stored.filter((entry) => entry && typeof entry.at === "number" && entry.at > cutoff && typeof entry.room === "string")
     36       : [];
     37     recent.sort((left, right) => left.at - right.at);
     38 
     39     const existing = recent.find((entry) => entry.room === roomToken);
     40     if (existing) {
     41       existing.at = now;
     42       await this.state.storage.put(key, recent);
     43       await this.ensureSweepScheduled(windowMillis);
     44       return { ok: true };
     45     }
     46 
     47     if (recent.length >= config.limit) {
     48       const retryAfterSeconds = Math.max(1, Math.ceil((recent[0].at + windowMillis - now) / 1000));
     49       await this.state.storage.put(key, recent);
     50       await this.ensureSweepScheduled(windowMillis);
     51       return { ok: false, retryAfterSeconds };
     52     }
     53 
     54     recent.push({ room: roomToken, at: now });
     55     await this.state.storage.put(key, recent);
     56     await this.ensureSweepScheduled(windowMillis);
     57     return { ok: true };
     58   }
     59 
     60   // Rate keys are written per client IP and would otherwise persist forever
     61   // once an IP stops registering. Arm-if-unarmed keeps the sweep at most one
     62   // per window even under constant traffic.
     63   async ensureSweepScheduled(windowMillis) {
     64     const scheduled = await this.state.storage.getAlarm();
     65     if (scheduled === null) {
     66       await this.state.storage.setAlarm(Date.now() + windowMillis);
     67     }
     68   }
     69 
     70   async alarm() {
     71     const config = rateLimitConfig(this.env, "ROOM_REGISTER_IP", 60, 60 * 60);
     72     const windowMillis = config.windowSeconds * 1000;
     73     const cutoff = Date.now() - windowMillis;
     74     const entries = await this.state.storage.list({ prefix: "rate:" });
     75     let liveKeys = 0;
     76     for (const [key, stored] of entries) {
     77       const newest = Array.isArray(stored)
     78         ? stored.reduce((max, entry) => (entry && typeof entry.at === "number" && entry.at > max ? entry.at : max), 0)
     79         : 0;
     80       if (newest <= cutoff) {
     81         await this.state.storage.delete(key);
     82       } else {
     83         liveKeys += 1;
     84       }
     85     }
     86     if (liveKeys > 0) {
     87       await this.state.storage.setAlarm(Date.now() + windowMillis);
     88     }
     89   }
     90 }
     91 
     92 export class EngagementRoom {
     93   constructor(state, env) {
     94     this.state = state;
     95     this.env = env;
     96     // Answer keepalive pings without waking the Durable Object, so an otherwise
     97     // idle foreground socket stays warm through NAT / edge idle timeouts. Pairs
     98     // with the client's periodic "ping" (EngagementHost.startPing). Auto-answered
     99     // frames never reach `webSocketMessage`, so they cost nothing here.
    100     this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
    101   }
    102 
    103   async fetch(request) {
    104     const url = new URL(request.url);
    105     const route = roomRouteFromPath(url.pathname);
    106     if (!route) {
    107       return new Response("Missing room ID", { status: 400 });
    108     }
    109 
    110     if (route.endpoint === "register") {
    111       if (request.method !== "POST") {
    112         return new Response("Method not allowed", { status: 405 });
    113       }
    114       return this.register(request);
    115     }
    116 
    117     const upgrade = request.headers.get("Upgrade");
    118     if (upgrade !== "websocket") {
    119       return new Response("Expected WebSocket upgrade", { status: 426 });
    120     }
    121 
    122     const auth = await this.authenticate(route.roomID, url.searchParams);
    123     if (!auth.ok) {
    124       return new Response(auth.message, { status: auth.status });
    125     }
    126 
    127     const pair = new WebSocketPair();
    128     const [client, server] = Object.values(pair);
    129     server.serializeAttachment({
    130       authorID: auth.authorID,
    131       deviceID: auth.deviceID,
    132       connectedAt: Date.now()
    133     });
    134     // Supersede any earlier socket from the same device. A reconnect would
    135     // otherwise leave the stale one lingering until its TCP dies, so the room
    136     // fans every broadcast at a zombie and can echo the device's own prior
    137     // frames back to it. `server` is not accepted yet, so it is not in the set.
    138     for (const existing of this.state.getWebSockets()) {
    139       const attachment = existing.deserializeAttachment();
    140       if (attachment && attachment.authorID === auth.authorID && attachment.deviceID === auth.deviceID) {
    141         try {
    142           existing.close(1000, "Superseded by a newer connection");
    143         } catch {
    144           // Already closing; the runtime will reap it.
    145         }
    146       }
    147     }
    148     this.state.acceptWebSocket(server);
    149 
    150     return new Response(null, {
    151       status: 101,
    152       webSocket: client
    153     });
    154   }
    155 
    156   // Registers the room secret ahead of a socket connect. First write wins:
    157   // the secret is created if absent, confirmed if it matches, and refused if
    158   // it differs. The secret arrives in the request body, never in a URL.
    159   //
    160   // Every legitimate holder of the room creds (distributed via the shared
    161   // CloudKit Game record) registers idempotently before each connect, so an
    162   // idle-expired room is resurrected by whichever peer returns first and
    163   // there is no ordering race between the minter and its peers. An attacker
    164   // who learns a room ID (it appears in URL paths) but not the secret can
    165   // neither register over a live room nor sign a connect.
    166   async register(request) {
    167     let body;
    168     try {
    169       body = await request.json();
    170     } catch {
    171       return new Response("Invalid JSON body", { status: 400 });
    172     }
    173     const secret = typeof body.secret === "string" ? body.secret : "";
    174     if (!isAcceptableSecret(secret)) {
    175       return new Response("Invalid secret", { status: 400 });
    176     }
    177 
    178     const stored = await this.state.storage.get("secret");
    179     if (stored) {
    180       if (!timingSafeEqual(stored, secret)) {
    181         return new Response("Room secret mismatch", { status: 409 });
    182       }
    183     } else {
    184       await this.state.storage.put("secret", secret);
    185       await this.state.storage.put("createdAt", Date.now());
    186       // TOFU-era rooms stored only a connect-time hash; the registered
    187       // secret supersedes it.
    188       await this.state.storage.delete("secretHash");
    189     }
    190 
    191     await this.state.storage.put("lastSeenAt", Date.now());
    192     await this.scheduleExpiry();
    193     return new Response(null, { status: stored ? 204 : 201 });
    194   }
    195 
    196   async authenticate(roomID, params) {
    197     const authorID = params.get("authorID") || "";
    198     const deviceID = params.get("deviceID") || "";
    199     const timestamp = params.get("timestamp") || "";
    200     const nonce = params.get("nonce") || "";
    201     const signature = params.get("signature") || "";
    202 
    203     if (!authorID || !deviceID || !timestamp || !nonce || !signature) {
    204       return { ok: false, status: 401, message: "Missing auth parameters" };
    205     }
    206 
    207     // The secret never travels on a connect; it must have been registered
    208     // via `register` (clients re-register idempotently before each connect,
    209     // which also resurrects a room whose idle expiry wiped this storage).
    210     const secret = await this.state.storage.get("secret");
    211     if (!secret) {
    212       return { ok: false, status: 403, message: "Room not registered" };
    213     }
    214 
    215     const nowSeconds = Math.floor(Date.now() / 1000);
    216     const timestampSeconds = Number(timestamp);
    217     const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120");
    218     if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) {
    219       return { ok: false, status: 401, message: "Stale auth timestamp" };
    220     }
    221 
    222     const nonceKey = `nonce:${nonce}`;
    223     if (await this.state.storage.get(nonceKey)) {
    224       return { ok: false, status: 401, message: "Nonce already used" };
    225     }
    226 
    227     const payload = [roomID, authorID, deviceID, timestamp, nonce].join("|");
    228     const expectedSignature = await hmacSHA256(secret, payload);
    229     if (!timingSafeEqual(signature, expectedSignature)) {
    230       return { ok: false, status: 401, message: "Invalid signature" };
    231     }
    232 
    233     await this.state.storage.put("lastSeenAt", Date.now());
    234     await this.state.storage.put(nonceKey, Date.now());
    235     await this.pruneNonces();
    236     await this.scheduleExpiry();
    237     this.lastTouchAt = Date.now();
    238 
    239     return { ok: true, authorID, deviceID };
    240   }
    241 
    242   async webSocketMessage(ws, message) {
    243     // Bound the relay before any storage or fanout work. The client enforces
    244     // the same limit (EngagementMessage.maxEncodedFrameBytes) on send and
    245     // receive, so only a hostile or badly broken peer trips this; closing the
    246     // socket (1009: message too big) is cheaper for the room than repeatedly
    247     // dropping its frames. String frames are measured in UTF-16 code units,
    248     // a lower bound on their UTF-8 size — real payloads travel as binary.
    249     const frameBytes = typeof message === "string" ? message.length : message.byteLength;
    250     if (frameBytes > maxFrameBytes(this.env)) {
    251       try {
    252         ws.close(1009, "Frame exceeds size limit");
    253       } catch {
    254         // Already closing; the runtime will reap it.
    255       }
    256       return;
    257     }
    258     const data = typeof message === "string" ? message : message.slice(0);
    259     await this.touch();
    260     for (const peer of this.state.getWebSockets()) {
    261       if (peer === ws) continue;
    262       try {
    263         peer.send(data);
    264       } catch {
    265         // A peer caught mid-close throws here; skip it so the rest of the room
    266         // still receives this frame. The dead socket is reaped via
    267         // `webSocketClose` / runtime cleanup.
    268       }
    269     }
    270   }
    271 
    272   async webSocketClose(ws, code, reason) {
    273     await this.state.storage.put("lastSeenAt", Date.now());
    274     await this.scheduleExpiry();
    275     ws.close(code, reason);
    276   }
    277 
    278   async alarm() {
    279     await this.pruneNonces();
    280     const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
    281     const sockets = this.state.getWebSockets();
    282     if (sockets.length > 0) {
    283       // Still connected — the room is alive regardless of how long since the
    284       // last broadcast (keepalive pings are auto-answered and don't advance
    285       // `lastSeenAt`). Re-arm from now, not the stale deadline, or a long idle
    286       // session would refire the alarm in a tight loop.
    287       await this.state.storage.setAlarm(Date.now() + ttlMs);
    288       return;
    289     }
    290 
    291     const lastSeenAt = await this.state.storage.get("lastSeenAt");
    292     const idleMs = Date.now() - (lastSeenAt || Date.now());
    293     if (idleMs > ttlMs) {
    294       await this.state.storage.deleteAll();
    295       return;
    296     }
    297     await this.scheduleExpiry();
    298   }
    299 
    300   async touch() {
    301     const now = Date.now();
    302     // Debounce the durable write + alarm reschedule off the message hot path.
    303     if (this.lastTouchAt && now - this.lastTouchAt < TOUCH_DEBOUNCE_MS) return;
    304     this.lastTouchAt = now;
    305     await this.state.storage.put("lastSeenAt", now);
    306     await this.scheduleExpiry();
    307   }
    308 
    309   async pruneNonces() {
    310     const maxAgeMs = Number(this.env.NONCE_TTL_SECONDS || "300") * 1000;
    311     const cutoff = Date.now() - maxAgeMs;
    312     const nonces = await this.state.storage.list({ prefix: "nonce:" });
    313     for (const [key, createdAt] of nonces) {
    314       if (createdAt < cutoff) {
    315         await this.state.storage.delete(key);
    316       }
    317     }
    318   }
    319 
    320   async scheduleExpiry() {
    321     const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000;
    322     const lastSeenAt = (await this.state.storage.get("lastSeenAt")) || Date.now();
    323     await this.state.storage.setAlarm(lastSeenAt + ttlMs);
    324   }
    325 }
    326 
    327 export default {
    328   async fetch(request, env) {
    329     const url = new URL(request.url);
    330     if (url.pathname === "/health") {
    331       return new Response("ok");
    332     }
    333     const route = roomRouteFromPath(url.pathname);
    334     if (!route) {
    335       return new Response("Not found", { status: 404 });
    336     }
    337     if (route.endpoint === "register" && request.method === "POST") {
    338       const limited = await checkRegisterRateLimit(request, env, route.roomID);
    339       if (limited) return limited;
    340     }
    341     const id = env.ENGAGEMENT_ROOMS.idFromName(route.roomID);
    342     return env.ENGAGEMENT_ROOMS.get(id).fetch(request);
    343   }
    344 };
    345 
    346 function roomRouteFromPath(pathname) {
    347   const match = pathname.match(/^\/rooms\/([^/]+)\/(socket|register)$/);
    348   return match ? { roomID: match[1], endpoint: match[2] } : null;
    349 }
    350 
    351 async function checkRegisterRateLimit(request, env, roomID) {
    352   // Fail closed: without the limiter binding, register would be unmetered
    353   // room creation. A misconfigured deploy should refuse registration (clients
    354   // re-register idempotently and durable Moves sync backstops realtime), not
    355   // silently drop the gate.
    356   if (!env.ENGAGEMENT_REGISTER_LIMITER) {
    357     return new Response("Rate limiter unavailable", { status: 503 });
    358   }
    359   const id = env.ENGAGEMENT_REGISTER_LIMITER.idFromName("register");
    360   const url = new URL(request.url);
    361   url.searchParams.set("roomID", roomID);
    362   const response = await env.ENGAGEMENT_REGISTER_LIMITER.get(id).fetch(
    363     new Request(url.toString(), {
    364       method: "POST",
    365       headers: request.headers
    366     })
    367   );
    368   return response.status === 204 ? null : response;
    369 }
    370 
    371 // Mirrors the client's EngagementMessage.maxEncodedFrameBytes (512 KiB);
    372 // Cloudflare's own per-message cap (1 MiB) is the outer backstop.
    373 function maxFrameBytes(env) {
    374   const parsed = Number(env.ROOM_MAX_FRAME_BYTES || "524288");
    375   return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 524288;
    376 }
    377 
    378 function rateLimitConfig(env, prefix, defaultLimit, defaultWindowSeconds) {
    379   const limit = Number(env[`${prefix}_LIMIT`] || String(defaultLimit));
    380   const windowSeconds = Number(env[`${prefix}_WINDOW_SECONDS`] || String(defaultWindowSeconds));
    381   return {
    382     limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaultLimit,
    383     windowSeconds: Number.isFinite(windowSeconds) && windowSeconds > 0
    384       ? Math.floor(windowSeconds)
    385       : defaultWindowSeconds
    386   };
    387 }
    388 
    389 function rateLimitedResponse(result) {
    390   return new Response("Rate limit exceeded", {
    391     status: 429,
    392     headers: {
    393       "Retry-After": String(result.retryAfterSeconds)
    394     }
    395   });
    396 }
    397 
    398 async function rateLimitStorageKey(bucket, identity, secret) {
    399   return `rate:${bucket}:${await rateLimitToken(identity, secret)}`;
    400 }
    401 
    402 async function rateLimitToken(identity, secret) {
    403   const key = await crypto.subtle.importKey(
    404     "raw",
    405     new TextEncoder().encode(secret),
    406     { name: "HMAC", hash: "SHA-256" },
    407     false,
    408     ["sign"]
    409   );
    410   const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(String(identity || "")));
    411   return base64URLEncode(new Uint8Array(signature));
    412 }
    413 
    414 function rateLimitKeySecret(env) {
    415   return env.RATE_LIMIT_HASH_KEY || "crossmate-room-rate-limit-v1";
    416 }
    417 
    418 // The secret doubles as the HMAC key for connect signatures, so a registered
    419 // value must decode to at least 32 key bytes (clients mint exactly 32).
    420 function isAcceptableSecret(secret) {
    421   if (!secret) return false;
    422   let bytes;
    423   try {
    424     bytes = base64URLDecode(secret);
    425   } catch {
    426     return false;
    427   }
    428   return bytes.length >= 32;
    429 }
    430 
    431 async function hmacSHA256(secret, payload) {
    432   const key = await crypto.subtle.importKey(
    433     "raw",
    434     base64URLDecode(secret),
    435     { name: "HMAC", hash: "SHA-256" },
    436     false,
    437     ["sign"]
    438   );
    439   const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
    440   return base64URLEncode(new Uint8Array(signature));
    441 }
    442 
    443 function base64URLDecode(value) {
    444   const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
    445   const binary = atob(base64);
    446   return Uint8Array.from(binary, (char) => char.charCodeAt(0));
    447 }
    448 
    449 function base64URLEncode(bytes) {
    450   let binary = "";
    451   for (const byte of bytes) {
    452     binary += String.fromCharCode(byte);
    453   }
    454   return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    455 }
    456 
    457 function timingSafeEqual(a, b) {
    458   const left = new TextEncoder().encode(a);
    459   const right = new TextEncoder().encode(b);
    460   if (left.length !== right.length) return false;
    461   let diff = 0;
    462   for (let index = 0; index < left.length; index += 1) {
    463     diff |= left[index] ^ right[index];
    464   }
    465   return diff === 0;
    466 }