/** * @factory/auth/passwords — password hashing + policy. * * The scaffold target is bcrypt cost-12 (.env.example BCRYPT_ROUNDS=12, and * prisma.seed.ts already stores `$2b$12$…` hashes). This module defines the * `PasswordHasher` interface the API uses, so bcrypt can be dropped in at * Phase-1 without touching call sites. For environments without native * bcrypt (like this source-first scaffold, and CI unit tests), the default * implementation is PBKDF2-SHA512 via node:crypto with a self-describing * format: * * $factory$pbkdf2-sha512$$$ * * It ALSO verifies legacy `$2b$/$2a$/$2y$` bcrypt strings by delegating to an * injected `bcryptVerifier` when one is registered (the API registers the * real bcrypt there). verifyPassword never throws on unknown formats — it * returns false (constant-time where possible). */ import { pbkdf2Sync, randomBytes, createHash, timingSafeEqual } from "node:crypto"; import { LIMITS } from "@factory/shared/constants"; const DEFAULT_COST = 12; const SALT_BYTES = 16; const KEY_BYTES = 32; /** Policy — mirrors @factory/shared passwordSchema exactly (min 10, max 72, * at least one letter and one number). Keep in sync with auth.dto.ts. */ export interface PasswordPolicyResult { ok: boolean; errors: string[]; } export function passwordPolicy(plain: string): PasswordPolicyResult { const errors: string[] = []; if (plain.length < LIMITS.MIN_PASSWORD) errors.push("Password must be at least 10 characters"); if (plain.length > LIMITS.MAX_PASSWORD) errors.push(`Password must be at most ${LIMITS.MAX_PASSWORD} characters`); if (!/[A-Za-z]/.test(plain)) errors.push("Password must contain at least one letter"); if (!/[0-9]/.test(plain)) errors.push("Password must contain at least one number"); return { ok: errors.length === 0, errors }; } /** * Abstraction over the hashing algorithm. The API registers a bcrypt * implementation; tests and this scaffold use the built-in PBKDF2 one. */ export interface PasswordHasher { hash(plain: string, cost?: number): Promise; verify(plain: string, stored: string): Promise; readonly algorithm: string; } class Pbkdf2Hasher implements PasswordHasher { readonly algorithm = "pbkdf2-sha512"; async hash(plain: string, cost: number = DEFAULT_COST): Promise { const salt = randomBytes(SALT_BYTES); const key = pbkdf2Sync(plain, salt, cost, KEY_BYTES, "sha512"); return `$factory$${this.algorithm}$${cost}$${salt.toString("base64")}$${key.toString("base64")}`; } async verify(plain: string, stored: string): Promise { const parts = stored.split("$"); // "$factory$pbkdf2-sha512$cost$salt$hash" → ["", "factory", alg, cost, salt, hash] if (parts.length !== 6 || parts[1] !== "factory" || parts[2] !== this.algorithm) { // Not ours — delegate (legacy bcrypt etc.) via the registered verifier. if (globalThis.__factoryBcryptVerifier) { return globalThis.__factoryBcryptVerifier(plain, stored); } return false; } const cost = Number(parts[3]); const salt = Buffer.from(parts[4]!, "base64"); const expected = Buffer.from(parts[5]!, "base64"); if (!Number.isInteger(cost) || cost < 1 || cost > 20 || salt.length !== SALT_BYTES || expected.length !== KEY_BYTES) { return false; } const actual = pbkdf2Sync(plain, salt, cost, KEY_BYTES, "sha512"); return actual.length === expected.length && timingSafeEqual(actual, expected); } } declare global { // Registered by the API at bootstrap when native bcrypt is available. // eslint-disable-next-line no-var var __factoryBcryptVerifier: ((plain: string, stored: string) => boolean) | undefined; } /** Default hasher (PBKDF2). Swap by assigning `passwordHasher` or registering * a bcrypt verifier on globalThis at bootstrap. */ export const passwordHasher: PasswordHasher = new Pbkdf2Hasher(); export const hashPassword = (plain: string, cost?: number): Promise => passwordHasher.hash(plain, cost); export const verifyPassword = (plain: string, stored: string): Promise => passwordHasher.verify(plain, stored); /** Detect the stored format (useful for migration reports). */ export function storedHashAlgorithm(stored: string): "pbkdf2-sha512" | "bcrypt" | "unknown" { if (stored.startsWith("$factory$pbkdf2-sha512$")) return "pbkdf2-sha512"; if (/^\$2[aby]\$\d{2}\$/.test(stored)) return "bcrypt"; return "unknown"; } // A tiny helper used in tests / migration tooling to compute sha256 of a // plain password (NEVER store plain or sha256 — hashes only). export const sha256Hex = (s: string): string => createHash("sha256").update(s).digest("hex");