Harden seat transfer phrases (security review finding)

Three words from 32 was ~15 bits — enumerable within a code's
lifetime against an unthrottled endpoint. Now: 64-word list, 4-word
phrases (~24 bits / 16.7M combinations), a per-connection cap of 5
failed claims, and a global backstop that voids all pending codes and
cools the endpoint after 30 failures in 10 minutes. Codes stay
speakable; a brute force now gets ~35 guesses against 16.7M before
the door closes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 00:00:13 -04:00
co-authored by Claude Fable 5
parent 2184721ab4
commit ce8b6a315b
2 changed files with 36 additions and 5 deletions
+10 -2
View File
@@ -36,6 +36,7 @@ interface Session {
socket: WebSocket; socket: WebSocket;
playerId: PlayerId | null; playerId: PlayerId | null;
roomId: string | null; roomId: string | null;
claimFails: number;
} }
const sessions = new Set<Session>(); const sessions = new Set<Session>();
@@ -70,7 +71,7 @@ function broadcastRoomState(room: Room): void {
} }
wss.on("connection", (socket) => { 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); sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" }); send(socket, { type: "welcome", game: "wizwar" });
@@ -142,8 +143,15 @@ wss.on("connection", (socket) => {
break; break;
} }
case "claimTransfer": { 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 ?? "")); 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 }); send(socket, { type: "transferClaimed", seat: result });
break; break;
} }
+26 -3
View File
@@ -190,7 +190,11 @@ const TRANSFER_WORDS = [
"moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable", "moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable",
"onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror", "onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror",
"portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol", "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 { interface PendingTransfer {
roomId: string; roomId: string;
@@ -212,17 +216,36 @@ export function makeTransferCode(room: Room, playerId: PlayerId): { code: string
} }
let code: string; let code: string;
do { 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)); } while (transfers.has(code));
const expiresAt = now + TRANSFER_TTL_MS; const expiresAt = now + TRANSFER_TTL_MS;
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt }); transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
return { code, 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 } { 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 normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
const t = transfers.get(normalized); 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 transfers.delete(normalized); // one-time
const room = rooms.get(t.roomId); const room = rooms.get(t.roomId);
if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" }; if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" };