SPB Git

Repository Public

password.ts 4.3 KB 112 lines typescript

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
Files main
Menu

Theme