diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1df94f4..aa60137 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,6 +36,7 @@ interface Session { socket: WebSocket; playerId: PlayerId | null; roomId: string | null; + claimFails: number; } const sessions = new Set(); @@ -70,7 +71,7 @@ function broadcastRoomState(room: Room): void { } wss.on("connection", (socket) => { - const session: Session = { socket, playerId: null, roomId: null }; + const session: Session = { socket, playerId: null, roomId: null, claimFails: 0 }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); @@ -142,8 +143,15 @@ wss.on("connection", (socket) => { break; } case "claimTransfer": { + if (session.claimFails >= 5) { + return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" }); + } const result = claimTransferCode(String(msg.code ?? "")); - if ("error" in result) return send(socket, { type: "error", message: result.error }); + if ("error" in result) { + session.claimFails++; + return send(socket, { type: "error", message: result.error }); + } + session.claimFails = 0; send(socket, { type: "transferClaimed", seat: result }); break; } diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index a5f536b..7a8cd79 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -190,7 +190,11 @@ const TRANSFER_WORDS = [ "moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable", "onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror", "portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol", -] as const; + "anvil", "bramble", "crypt", "dusk", "fable", "grotto", "hollow", "ivory", + "jinx", "keep", "lantern", "marsh", "nettle", "oath", "plume", "quartz", + "relic", "sconce", "talon", "umber", "vellum", "warden", "yarrow", "zeal", + "bastion", "chalice", "drake", "eaves", "fen", "gargoyle", "harrow", "imp", +] as const; // 64 words; 4-word phrases = 64^4 = ~16.7M combinations (~24 bits) interface PendingTransfer { roomId: string; @@ -212,17 +216,36 @@ export function makeTransferCode(room: Room, playerId: PlayerId): { code: string } let code: string; do { - code = Array.from({ length: 3 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-"); + code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-"); } while (transfers.has(code)); const expiresAt = now + TRANSFER_TTL_MS; transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt }); return { code, expiresAt }; } +// Brute-force backstop: too many failed claims globally voids every pending +// code (they cost nothing to re-mint) and cools the endpoint off. +let failedClaims = 0; +let failWindowStart = 0; +const FAIL_WINDOW_MS = 10 * 60 * 1000; +const FAIL_LIMIT = 30; + export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } { + const now = Date.now(); + if (now - failWindowStart > FAIL_WINDOW_MS) { + failWindowStart = now; + failedClaims = 0; + } + if (failedClaims >= FAIL_LIMIT) { + return { error: "too many failed claims — transfers are cooling off, mint a fresh phrase" }; + } const normalized = code.trim().toLowerCase().replace(/\s+/g, "-"); const t = transfers.get(normalized); - if (!t || t.expiresAt < Date.now()) return { error: "that transfer phrase is unknown or has expired" }; + if (!t || t.expiresAt < now) { + failedClaims++; + if (failedClaims >= FAIL_LIMIT) transfers.clear(); + return { error: "that transfer phrase is unknown or has expired" }; + } transfers.delete(normalized); // one-time const room = rooms.get(t.roomId); if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" };