/** * @module password * @description One-way password hashing built on Node.js native `crypto.scrypt` * (OWASP-recommended memory-hard KDF), complementing the reversible AES cipher of * the {@link Crypto} class. * * These helpers are for **password storage only**: {@link hashPassword} derives a * self-describing hash string and {@link verifyPassword} checks a candidate against * it in constant time. They are intentionally stateless (no class), mirroring the * single-call nature of the task, and add no runtime dependencies beyond * `node:crypto`. * * Stored format — a single self-describing string that carries the algorithm, a * format version, the scrypt parameters and the per-record salt, so the profile can * change over time without a storage migration and the value is distinguishable from * other schemes (e.g. legacy bcrypt `$2a$…` or a future `argon2id$…`): * * ```text * scrypt$1$N=131072,r=8,p=1$$ * ``` * * The reversible {@link Crypto} `encrypt`/`decrypt` API and this password API are * **not** interchangeable: use the cipher for recoverable data (e.g. PII) and this * module for passwords, which must never be recoverable. */ import { scrypt as scryptCb, randomBytes, timingSafeEqual } from 'crypto'; /** Algorithm identifier stored as the first field of the hash string. */ const ALGORITHM = 'scrypt'; /** Current stored-format version (bumped only on a breaking layout change). */ const FORMAT_VERSION = 1; /** * Tunable scrypt cost parameters. * * @property {number} N - CPU/memory cost, a power of two (the primary difficulty * lever). Memory per hash is approximately `128 * N * r` bytes. * @property {number} r - Block size. * @property {number} p - Parallelization factor. * @property {number} keyLength - Derived key length in bytes. */ export interface ScryptParams { N: number; r: number; p: number; keyLength: number; } /** * Default OWASP scrypt profile (ADR, PRD-9): `N=2^17, r=8, p=1, keyLength=32`. * * Memory per hash is about 128 MiB; runtime on a modern CPU is roughly 100-250 ms. * Callers may lower `N` (e.g. for test suites or constrained CPUs) via the * {@link hashPassword} options; the chosen parameters are recorded in the output, so * verification never needs to know the current defaults. */ export const DEFAULT_SCRYPT_PARAMS: Readonly = Object.freeze({ N: 131072, // 2^17 r: 8, p: 1, keyLength: 32, }); /** Salt length in bytes (per-record, generated with `crypto.randomBytes`). */ const SALT_BYTES = 16; /** Options accepted by {@link hashPassword}. */ export interface HashPasswordOptions { /** * Override the scrypt cost parameters. Any omitted field falls back to * {@link DEFAULT_SCRYPT_PARAMS}. Lower `N` to trade strength for speed. */ params?: Partial; } /** * Node throws if scrypt's working memory exceeds `maxmem`. Its requirement grows * with `128 * N * r`, so derive a generous ceiling from the parameters (with a * floor at Node's 32 MiB default) instead of relying on the default, which the * OWASP profile would overflow. * * @param {ScryptParams} params - The scrypt parameters in effect. * @returns {number} The `maxmem` value in bytes to pass to `crypto.scrypt`. */ function computeMaxmem({ N, r, p }: ScryptParams): number { return Math.max(32 * 1024 * 1024, 128 * N * r * p * 2); } /** * Promise wrapper around the callback-based `crypto.scrypt`. * * @param {string} password - The plaintext to derive from. * @param {Buffer} salt - The per-record salt. * @param {ScryptParams} params - The scrypt cost parameters. * @returns {Promise} The derived key of length `params.keyLength`. */ function scryptAsync(password: string, salt: Buffer, params: ScryptParams): Promise { const { N, r, p, keyLength } = params; return new Promise((resolve, reject) => { scryptCb( password, salt, keyLength, { N, r, p, maxmem: computeMaxmem(params) }, (err, derivedKey) => (err ? reject(err) : resolve(derivedKey as Buffer)), ); }); } /** * Serialize scrypt parameters into the compact `N=…,r=…,p=…` segment of the hash * string. `keyLength` is omitted because it is recoverable from the hash length. * * @param {ScryptParams} params - The parameters to encode. * @returns {string} The parameter segment, e.g. `N=131072,r=8,p=1`. */ function encodeParams({ N, r, p }: ScryptParams): string { return `N=${N},r=${r},p=${p}`; } /** * Parse the `N=…,r=…,p=…` parameter segment. `keyLength` comes from the caller * (derived from the stored hash length), not from this segment. * * @param {string} segment - The parameter segment from a stored hash. * @param {number} keyLength - The derived-key length recovered from the hash. * @returns {ScryptParams | null} The parsed parameters, or `null` when malformed. */ function parseParams(segment: string, keyLength: number): ScryptParams | null { const result: Record = {}; for (const part of segment.split(',')) { const [key, value] = part.split('='); const n = Number(value); if (!key || value === undefined || !Number.isFinite(n)) return null; result[key] = n; } if (result.N === undefined || result.r === undefined || result.p === undefined) return null; return { N: result.N, r: result.r, p: result.p, keyLength }; } /** * Hash a plaintext password for storage using scrypt. * * A fresh 16-byte salt is generated per call, so hashing the same password twice * yields different strings. The result is fully self-describing (see the module * docs) and safe to store in a single text column. * * @param {string} plain - The plaintext password to hash. * @param {HashPasswordOptions} [options] - Optional scrypt parameter overrides. * @returns {Promise} The stored hash: `scrypt$1$$$`. */ export async function hashPassword(plain: string, options?: HashPasswordOptions): Promise { const params: ScryptParams = { ...DEFAULT_SCRYPT_PARAMS, ...options?.params }; const salt = randomBytes(SALT_BYTES); const derived = await scryptAsync(plain, salt, params); return [ ALGORITHM, FORMAT_VERSION, encodeParams(params), salt.toString('hex'), derived.toString('hex'), ].join('$'); } /** * Verify a plaintext password against a hash produced by {@link hashPassword}. * * The stored parameters and salt are read back from `stored`, the candidate is * re-derived with them, and the two keys are compared with `timingSafeEqual` to * avoid leaking information through comparison timing. Any structurally invalid or * non-scrypt `stored` value (empty string, wrong field count, unknown algorithm, * bad hex) yields `false` rather than throwing, so it is safe on an auth path. * * @param {string} plain - The candidate plaintext password. * @param {string} stored - The stored hash string to check against. * @returns {Promise} `true` when the password matches, `false` otherwise. */ export async function verifyPassword(plain: string, stored: string): Promise { if (typeof stored !== 'string') return false; const fields = stored.split('$'); if (fields.length !== 5) return false; const [algorithm, version, paramSegment, saltHex, hashHex] = fields; if (algorithm !== ALGORITHM || version !== String(FORMAT_VERSION)) return false; // Buffer.from(_, 'hex') never throws — it silently drops invalid pairs — so // reject any value that did not round-trip to its declared hex length (odd // length / illegal characters / empty). const salt = Buffer.from(saltHex, 'hex'); const expected = Buffer.from(hashHex, 'hex'); if (salt.length === 0 || expected.length === 0) return false; if (salt.length * 2 !== saltHex.length || expected.length * 2 !== hashHex.length) return false; const params = parseParams(paramSegment, expected.length); if (!params) return false; let candidate: Buffer; try { candidate = await scryptAsync(plain, salt, params); } catch { return false; } return candidate.length === expected.length && timingSafeEqual(candidate, expected); }