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:
co-authored by
Claude Fable 5
parent
2184721ab4
commit
ce8b6a315b
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user