97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
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<string | null> {
|
|
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<string, { count: number; resetAt: RealTime }>();
|
|
|
|
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++;
|
|
}
|
|
}
|
|
}
|