// Password hashing + verification. Argon2id is the only path for new hashes; // bcrypt remains as a verify-only fallback for legacy rows. A successful // bcrypt verify produces an argon2 rehash that the caller is responsible for // persisting — that's how we drain the bcrypt corpus without a one-shot // bulk-rehash (which isn't possible since bcrypt is one-way). import argon2 from "argon2"; import bcryptjs from "bcryptjs"; // OWASP 2024 recommendation for argon2id at modest server cost. // memoryCost is in KiB (19456 = 19 MiB). const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, } as const; const ARGON2_PREFIX_RE = /^\$argon2(?:i|d|id)\$/; const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/; export type VerifyPasswordResult = { valid: boolean; /** * Present only when the input was successfully verified against a bcrypt * legacy hash. The caller MUST persist this value back into the user row * (or whatever store the hash came from) so the next login takes the * argon2 path. Absent for argon2 hits and for any failed verify. */ rehashedHash?: string; }; export async function hashPassword(plain: string): Promise { return await argon2.hash(plain, ARGON2_OPTIONS); } /** * Verify `plain` against an existing hash. Detects bcrypt vs argon2 by hash * prefix; on a successful bcrypt verify, also computes an argon2 rehash for * the caller to persist. * * Always rejects unknown hash formats. Treats decryption errors (e.g. * corrupted hashes) as a failed verify rather than surfacing the underlying * exception, so the call site can show a generic "invalid credentials" * message without leaking format details. */ export async function verifyPassword(plain: string, hash: string | null | undefined): Promise { // Phase 01 / #20 — `User.passwordHash` is nullable for magic-link-only // accounts. Callers that compare a submitted password against the row // hash now pass `string | null`; treat null/empty as "no comparable // hash" → invalid, never throw. if (typeof hash !== "string" || hash.length === 0) { return { valid: false }; } if (ARGON2_PREFIX_RE.test(hash)) { try { const valid = await argon2.verify(hash, plain); return { valid }; } catch { return { valid: false }; } } if (BCRYPT_PREFIX_RE.test(hash)) { let valid = false; try { valid = await bcryptjs.compare(plain, hash); } catch { return { valid: false }; } if (!valid) { return { valid: false }; } const rehashedHash = await hashPassword(plain); return { valid: true, rehashedHash }; } return { valid: false }; } /** * True iff the hash format is something verifyPassword understands. Useful * for migration scripts that want to identify legacy bcrypt rows. */ export function isKnownHashFormat(hash: string): "argon2" | "bcrypt" | "unknown" { if (ARGON2_PREFIX_RE.test(hash)) return "argon2"; if (BCRYPT_PREFIX_RE.test(hash)) return "bcrypt"; return "unknown"; }