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
+26 -3
View File
@@ -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" };