Repository Public
1/**2 * Password hashing. argon2id through Node's built-in `crypto.argon2` when this Node has it,3 * otherwise scrypt (N=2^17, r=8, p=1). Stored as a PHC-style string carrying its parameters.4 */5import * as crypto from 'node:crypto';6import type { PasswordCost } from '../config.js';78export const MIN_PASSWORD_LENGTH = 12;9const TAG_LEN = 32;10const b64 = (b: Buffer): string => b.toString('base64').replace(/=+$/, '');11const unb64 = (s: string): Buffer => Buffer.from(s, 'base64');1213type Argon2Fn = (14 alg: 'argon2id',15 params: { message: Buffer; nonce: Buffer; parallelism: number; tagLength: number; memory: number; passes: number },16 cb: (err: Error | null, key: Buffer) => void,17) => void;18const argon2Fn: Argon2Fn | null =19 typeof (crypto as unknown as { argon2?: unknown }).argon2 === 'function'20 ? ((crypto as unknown as { argon2: Argon2Fn }).argon2)21 : null;22export const hasArgon2 = argon2Fn !== null;2324function argon2id(password: string, salt: Buffer, m: number, t: number, p: number): Promise<Buffer> {25 return new Promise((resolve, reject) => {26 if (!argon2Fn) return reject(new Error('argon2 unavailable'));27 argon2Fn(28 'argon2id',29 { message: Buffer.from(password, 'utf8'), nonce: salt, parallelism: p, tagLength: TAG_LEN, memory: m, passes: t },30 (err, key) => (err ? reject(err) : resolve(key)),31 );32 });33}34function scrypt(password: string, salt: Buffer, ln: number, r: number, p: number): Promise<Buffer> {35 return new Promise((resolve, reject) => {36 crypto.scrypt(password, salt, TAG_LEN, { N: 2 ** ln, r, p, maxmem: 256 * 2 ** ln * r }, (err, key) =>37 err ? reject(err) : resolve(key),38 );39 });40}4142export async function hashPassword(password: string, cost: PasswordCost, forceScrypt = false): Promise<string> {43 const salt = crypto.randomBytes(16);44 if (hasArgon2 && !forceScrypt) {45 const key = await argon2id(password, salt, cost.argonMemoryKiB, cost.argonPasses, 1);46 return `$argon2id$v=19$m=${cost.argonMemoryKiB},t=${cost.argonPasses},p=1$${b64(salt)}$${b64(key)}`;47 }48 const key = await scrypt(password, salt, cost.scryptLogN, 8, 1);49 return `$scrypt$ln=${cost.scryptLogN},r=8,p=1$${b64(salt)}$${b64(key)}`;50}5152interface Parsed {53 alg: 'argon2id' | 'scrypt';54 params: Record<string, number>;55 salt: Buffer;56 hash: Buffer;57}58function parse(phc: string): Parsed | null {59 const parts = phc.split('$');60 if (parts[0] !== '') return null;61 const alg = parts[1];62 const fields = alg === 'argon2id' ? parts.slice(3) : parts.slice(2);63 if ((alg !== 'argon2id' && alg !== 'scrypt') || fields.length !== 3) return null;64 if (alg === 'argon2id' && parts[2] !== 'v=19') return null;65 const params: Record<string, number> = {};66 for (const kv of (fields[0] ?? '').split(',')) {67 const [k, v] = kv.split('=');68 if (!k || v === undefined || !/^\d+$/.test(v)) return null;69 params[k] = Number(v);70 }71 return { alg, params, salt: unb64(fields[1] ?? ''), hash: unb64(fields[2] ?? '') };72}7374/** Constant-time verification. Returns false (never throws) on malformed hashes. */75export async function verifyPassword(password: string, phc: string): Promise<boolean> {76 const p = parse(phc);77 if (!p || p.hash.length !== TAG_LEN) return false;78 try {79 const { m, t, p: par, ln, r } = p.params;80 let key: Buffer;81 if (p.alg === 'argon2id') {82 if (!m || !t || !par || m > 2 ** 21 || t > 16) return false;83 key = await argon2id(password, p.salt, m, t, par);84 } else {85 if (!ln || !r || !par || ln > 20) return false;86 key = await scrypt(password, p.salt, ln, r, par);87 }88 return crypto.timingSafeEqual(key, p.hash);89 } catch {90 return false;91 }92}9394export function needsRehash(phc: string, cost: PasswordCost): boolean {95 const p = parse(phc);96 if (!p) return true;97 if (p.alg === 'argon2id') return p.params.m !== cost.argonMemoryKiB || p.params.t !== cost.argonPasses;98 return hasArgon2 || p.params.ln !== cost.scryptLogN;99}100101/** Burns the same work as a real verification when the account does not exist (no timing oracle). */102const dummies = new Map<string, Promise<string>>();103export async function dummyVerify(password: string, cost: PasswordCost): Promise<void> {104 const k = JSON.stringify(cost);105 let d = dummies.get(k);106 if (!d) {107 d = hashPassword(crypto.randomBytes(18).toString('base64'), cost);108 dummies.set(k, d);109 }110 await verifyPassword(password, await d);111}112