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 }