import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2"; /** Argon2id parameters (OWASP 2024+: m=64MiB, t=3, p=4). */ const ARGON_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 4, outputLen: 32 } as const; export async function hashPassword(password: string): Promise { return argonHash(password, ARGON_OPTS); } export async function verifyPassword(hash: string, password: string): Promise { try { return await argonVerify(hash, password); } catch { return false; } } export interface PasswordPolicyResult { ok: boolean; problems: string[]; } const COMMON = new Set([ "admin123", "password", "password123", "admin", "letmein", "qwerty123456", "autoscholar", "123456789012", ]); export function checkPasswordPolicy(pw: string, username?: string): PasswordPolicyResult { const problems: string[] = []; if (pw.length < 12) problems.push("at least 12 characters"); if (pw.length > 512) problems.push("at most 512 characters"); if (COMMON.has(pw.toLowerCase())) problems.push("not a well-known password"); if (username && pw.toLowerCase().includes(username.toLowerCase())) problems.push("must not contain the username"); const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(pw)).length; if (classes < 3) problems.push("at least three character classes (lower, upper, digit, symbol)"); return { ok: problems.length === 0, problems }; } /** Opaque session token: random 32 bytes (base64url). Only its SHA-256 is stored. */ export function newSessionToken(): { token: string; tokenHash: string } { const token = randomBytes(32).toString("base64url"); return { token, tokenHash: hashToken(token) }; } export function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } export function newCsrfSecret(): string { return randomBytes(32).toString("hex"); } /** Double-submit CSRF: HMAC(csrfSecret, sessionTokenHash). The browser must echo it in `x-csrf-token`. */ export function csrfTokenFor(csrfSecret: string, sessionTokenHash: string): string { return createHmac("sha256", csrfSecret).update(sessionTokenHash).digest("base64url"); } export function verifyCsrf( csrfSecret: string, sessionTokenHash: string, presented: string | null | undefined, ): boolean { if (!presented) return false; const expected = Buffer.from(csrfTokenFor(csrfSecret, sessionTokenHash)); const got = Buffer.from(presented); return expected.length === got.length && timingSafeEqual(expected, got); } /** Worker bearer tokens: `asw_` prefix for easy redaction; SHA-256 stored. */ export function newWorkerToken(): { token: string; tokenHash: string } { const token = `asw_${randomBytes(32).toString("base64url")}`; return { token, tokenHash: hashToken(token) }; } export function constantTimeEqual(a: string, b: string): boolean { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } /** Sliding-window in-memory rate limiter (per process). Persisted lockout lives in the DB. */ export class RateLimiter { private hits = new Map(); constructor( private readonly limit: number, private readonly windowMs: number, ) {} check(key: string): { allowed: boolean; remaining: number; retryAfterMs: number } { const now = Date.now(); const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs); if (arr.length >= this.limit) { const retryAfterMs = this.windowMs - (now - (arr[0] ?? now)); this.hits.set(key, arr); return { allowed: false, remaining: 0, retryAfterMs }; } arr.push(now); this.hits.set(key, arr); if (this.hits.size > 50_000) this.hits.clear(); return { allowed: true, remaining: this.limit - arr.length, retryAfterMs: 0 }; } reset(key: string): void { this.hits.delete(key); } } /** Exponential backoff for failed logins: 5 failures → 1 min, doubling, capped at 1 h. */ export function lockoutDurationMs(failedAttempts: number): number { if (failedAttempts < 5) return 0; return Math.min(60_000 * 2 ** (failedAttempts - 5), 3_600_000); } export const SESSION_TTL_MS = 12 * 60 * 60 * 1000; export const SESSION_IDLE_MS = 2 * 60 * 60 * 1000; export const SESSION_COOKIE = "as_session";