import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'; const VERSION = 1; const KEY_LENGTH = 32; const DEFAULT_N = 32768; const DEFAULT_R = 8; const DEFAULT_P = 1; const MAX_MEMORY = 64 * 1024 * 1024; const MAX_LEGACY_WRITE_WINDOW_MS = 2 * 60 * 60 * 1000; const LEGACY_SHA256 = /^[a-f0-9]{64}$/i; export async function hashPassword(password: string) { if (legacyTransitionWriteEnabled()) return legacyHashPassword(password); const salt = randomBytes(16); const derived = await deriveScrypt(password, salt, KEY_LENGTH, { N: DEFAULT_N, r: DEFAULT_R, p: DEFAULT_P, maxmem: MAX_MEMORY, }); return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`; } function legacyTransitionWriteEnabled() { if (process.env.PASSWORD_HASH_LEGACY_TRANSITION !== 'true') return false; const until = Date.parse(process.env.PASSWORD_HASH_LEGACY_WRITE_UNTIL ?? ''); const remaining = until - Date.now(); return Number.isFinite(until) && remaining > 0 && remaining <= MAX_LEGACY_WRITE_WINDOW_MS; } export async function verifyPassword(password: string, encoded: string) { if (isLegacySha256(encoded)) { const candidate = Buffer.from(legacyHashPassword(password), 'hex'); const expected = Buffer.from(encoded, 'hex'); return candidate.length === expected.length && timingSafeEqual(candidate, expected); } const parsed = parseScryptHash(encoded); if (!parsed) return false; const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, { N: parsed.N, r: parsed.r, p: parsed.p, maxmem: MAX_MEMORY, }); return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash); } export function passwordNeedsRehash(encoded: string) { if (isLegacySha256(encoded)) return true; const parsed = parseScryptHash(encoded); return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P; } export function isLegacySha256(encoded: string) { return LEGACY_SHA256.test(encoded); } export function legacyHashPassword(password: string) { return createHash('sha256').update(password).digest('hex'); } function parseScryptHash(encoded: string) { const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded); if (!match) return undefined; const [, version, N, r, p, salt, hash] = match; const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) }; if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N || !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R || !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined; try { const decodedSalt = Buffer.from(salt, 'base64url'); const decodedHash = Buffer.from(hash, 'base64url'); if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined; return { ...params, salt: decodedSalt, hash: decodedHash }; } catch { return undefined; } } function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) { return new Promise((resolve, reject) => { scryptCallback(password, salt, keyLength, options, (error, derivedKey) => { if (error) reject(error); else resolve(derivedKey); }); }); }