import { createHash, randomInt } from "node:crypto"; import type { RealTime } from "@deadhead/sim"; import { eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { players } from "../db/schema"; /** * Backup codes (decision 0.5.72): 20 characters from 31 that can't be mistaken * for each other (no 0/O, 1/I/L), shown as four groups of five: about 99 bits. * Only a SHA-256 of the normalized code is stored; the code itself is random * enough that a slow hash would add nothing. */ export const BACKUP_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; const GROUPS = 4; const GROUP_LENGTH = 5; export function generateBackupCode(): string { const groups = Array.from({ length: GROUPS }, () => Array.from( { length: GROUP_LENGTH }, () => BACKUP_ALPHABET[randomInt(BACKUP_ALPHABET.length)], ).join(""), ); return groups.join("-"); } /** The code without spaces or dashes, in capitals: how players type it doesn't matter. */ export function normalizeBackupCode(code: string): string { return code.toUpperCase().replace(/[\s-]/g, ""); } export function hashBackupCode(code: string): string { return createHash("sha256").update(normalizeBackupCode(code)).digest("hex"); } /** Makes a new code for the player, replacing any old one; returns it once. */ export async function createBackupCode( tx: Queryable, playerId: string, now: RealTime, ): Promise<{ code: string; createdAt: RealTime }> { const code = generateBackupCode(); await tx .update(players) .set({ backupCodeHash: hashBackupCode(code), backupCodeCreatedAt: now }) .where(eq(players.id, playerId)); return { code, createdAt: now }; } export async function backupCodeStatus(tx: Queryable, playerId: string) { const [row] = await tx .select({ hash: players.backupCodeHash, createdAt: players.backupCodeCreatedAt }) .from(players) .where(eq(players.id, playerId)); return { hasCode: Boolean(row?.hash), createdAt: row?.hash ? (row.createdAt ?? null) : null }; } /** The player a backup code belongs to, or null. */ export async function playerForBackupCode(tx: Queryable, code: string): Promise { if (normalizeBackupCode(code).length === 0) return null; const [row] = await tx .select({ id: players.id }) .from(players) .where(eq(players.backupCodeHash, hashBackupCode(code))); return row?.id ?? null; } /** * Failed restore attempts per key (an IP, a device), in memory: at most * `limit` failures per `windowMs` for any key (decision 0.5.72). */ export class RestoreLimiter { private readonly failures = new Map(); constructor( readonly limit = 5, readonly windowMs = 15 * 60_000, ) {} /** Whether any of the keys has used up its attempts. */ blocked(keys: readonly string[], now: RealTime): boolean { return keys.some((key) => { const entry = this.failures.get(key); return entry !== undefined && entry.resetAt > now && entry.count >= this.limit; }); } fail(keys: readonly string[], now: RealTime): void { for (const key of keys) { const entry = this.failures.get(key); if (!entry || entry.resetAt <= now) this.failures.set(key, { count: 1, resetAt: now + this.windowMs }); else entry.count++; } } }