spb/autoscholar
Public
1import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";2import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";34/** Argon2id parameters (OWASP 2024+: m=64MiB, t=3, p=4). */5const ARGON_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 4, outputLen: 32 } as const;67export async function hashPassword(password: string): Promise<string> {8 return argonHash(password, ARGON_OPTS);9}1011export async function verifyPassword(hash: string, password: string): Promise<boolean> {12 try {13 return await argonVerify(hash, password);14 } catch {15 return false;16 }17}1819export interface PasswordPolicyResult {20 ok: boolean;21 problems: string[];22}2324const COMMON = new Set([25 "admin123",26 "password",27 "password123",28 "admin",29 "letmein",30 "qwerty123456",31 "autoscholar",32 "123456789012",33]);3435export function checkPasswordPolicy(pw: string, username?: string): PasswordPolicyResult {36 const problems: string[] = [];37 if (pw.length < 12) problems.push("at least 12 characters");38 if (pw.length > 512) problems.push("at most 512 characters");39 if (COMMON.has(pw.toLowerCase())) problems.push("not a well-known password");40 if (username && pw.toLowerCase().includes(username.toLowerCase()))41 problems.push("must not contain the username");42 const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(pw)).length;43 if (classes < 3) problems.push("at least three character classes (lower, upper, digit, symbol)");44 return { ok: problems.length === 0, problems };45}4647/** Opaque session token: random 32 bytes (base64url). Only its SHA-256 is stored. */48export function newSessionToken(): { token: string; tokenHash: string } {49 const token = randomBytes(32).toString("base64url");50 return { token, tokenHash: hashToken(token) };51}5253export function hashToken(token: string): string {54 return createHash("sha256").update(token).digest("hex");55}5657export function newCsrfSecret(): string {58 return randomBytes(32).toString("hex");59}6061/** Double-submit CSRF: HMAC(csrfSecret, sessionTokenHash). The browser must echo it in `x-csrf-token`. */62export function csrfTokenFor(csrfSecret: string, sessionTokenHash: string): string {63 return createHmac("sha256", csrfSecret).update(sessionTokenHash).digest("base64url");64}6566export function verifyCsrf(67 csrfSecret: string,68 sessionTokenHash: string,69 presented: string | null | undefined,70): boolean {71 if (!presented) return false;72 const expected = Buffer.from(csrfTokenFor(csrfSecret, sessionTokenHash));73 const got = Buffer.from(presented);74 return expected.length === got.length && timingSafeEqual(expected, got);75}7677/** Worker bearer tokens: `asw_` prefix for easy redaction; SHA-256 stored. */78export function newWorkerToken(): { token: string; tokenHash: string } {79 const token = `asw_${randomBytes(32).toString("base64url")}`;80 return { token, tokenHash: hashToken(token) };81}8283export function constantTimeEqual(a: string, b: string): boolean {84 const ab = Buffer.from(a);85 const bb = Buffer.from(b);86 return ab.length === bb.length && timingSafeEqual(ab, bb);87}8889/** Sliding-window in-memory rate limiter (per process). Persisted lockout lives in the DB. */90export class RateLimiter {91 private hits = new Map<string, number[]>();92 constructor(93 private readonly limit: number,94 private readonly windowMs: number,95 ) {}96 check(key: string): { allowed: boolean; remaining: number; retryAfterMs: number } {97 const now = Date.now();98 const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);99 if (arr.length >= this.limit) {100 const retryAfterMs = this.windowMs - (now - (arr[0] ?? now));101 this.hits.set(key, arr);102 return { allowed: false, remaining: 0, retryAfterMs };103 }104 arr.push(now);105 this.hits.set(key, arr);106 if (this.hits.size > 50_000) this.hits.clear();107 return { allowed: true, remaining: this.limit - arr.length, retryAfterMs: 0 };108 }109 reset(key: string): void {110 this.hits.delete(key);111 }112}113114/** Exponential backoff for failed logins: 5 failures → 1 min, doubling, capped at 1 h. */115export function lockoutDurationMs(failedAttempts: number): number {116 if (failedAttempts < 5) return 0;117 return Math.min(60_000 * 2 ** (failedAttempts - 5), 3_600_000);118}119120export const SESSION_TTL_MS = 12 * 60 * 60 * 1000;121export const SESSION_IDLE_MS = 2 * 60 * 60 * 1000;122export const SESSION_COOKIE = "as_session";123