deadhead-space-freight-tycoon/apps/server/src/game/backup.ts

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++;
}
}
}