Files
familysync/apps/api/src/auth/localCredentials.ts
T

121 lines
4.7 KiB
TypeScript

/**
* localCredentials.ts — password hashing and verification using node:crypto scrypt.
*
* D-08: Uses node:crypto scrypt — zero new npm dependencies; no native node-gyp build
* in the Docker image. PHC-style encoded format allows parameter evolution without
* a separate DB migration.
*
* Security properties:
* - 16-byte per-hash random salt — unique salt per password prevents rainbow table attacks
* - scrypt parameters: N=16384 (2^14), r=8, p=1 — OWASP-compatible
* - 32-byte (256-bit) output key
* - timingSafeEqual for constant-time comparison — prevents timing oracle attacks
* - verifyPassword never throws — returns false on any parse/format/crypto error
* - Passwords are never logged
*
* Encoded format: scrypt$N$r$p$<salt_base64url>$<hash_base64url>
* Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url>
* Max length: ~83 chars — fits in varchar(256) password_hash column
*/
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import type { BinaryLike, ScryptOptions } from 'node:crypto';
// WR-03: use the ASYNC scrypt (libuv threadpool) so password hashing does NOT block the
// single Node event loop. scryptSync ran on the main thread, so a burst of unauthenticated
// POST /local/login requests (each running scrypt N=16384, ~tens of ms of CPU, including the
// always-run dummy-hash path) could pin the loop and stall ALL other API traffic — a cheap
// unauthenticated DoS. This async wrapper offloads the CPU to the threadpool, preserving the
// timing-defense property while keeping the loop responsive.
//
// A hand-rolled Promise wrapper is used (rather than promisify) because promisify's typings
// do not cover the options-carrying scrypt overload (N/r/p).
function scryptAsync(
password: BinaryLike,
salt: BinaryLike,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
}
// OWASP-compatible scrypt parameters for password hashing
const SCRYPT_N = 16384; // CPU/memory cost factor (2^14)
const SCRYPT_R = 8; // block size
const SCRYPT_P = 1; // parallelization factor
const KEY_LEN = 32; // 256-bit derived key output
/**
* Hash a password using scrypt with a random 16-byte salt.
*
* Returns a self-describing PHC-style encoded string:
* scrypt$N$r$p$<salt_base64url>$<hash_base64url>
*
* The encoded format embeds all parameters so verifyPassword can re-derive
* the hash without relying on hardcoded constants — supports future parameter
* migration without a DB schema change.
*
* WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
*/
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const hash = await scryptAsync(password, salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
});
return [
'scrypt',
SCRYPT_N,
SCRYPT_R,
SCRYPT_P,
salt.toString('base64url'),
hash.toString('base64url'),
].join('$');
}
/**
* Verify a password against a stored PHC-encoded hash.
*
* Parses the algorithm parameters from the stored string, re-derives the
* candidate hash using scryptSync, and compares with timingSafeEqual to
* prevent timing-oracle attacks.
*
* Security:
* - timingSafeEqual: requires equal-length buffers; storedHash.length as keylen
* ensures this regardless of the stored KEY_LEN at hash time.
* - Returns false (never throws) on any parse error, invalid base64url, or
* scrypt parameter error — safe to call with untrusted input.
* - Never logs the candidate password.
*
* WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
*
* @returns Promise<true> if candidate matches the stored hash; Promise<false> otherwise
* (including all parse/format/crypto errors — never rejects).
*/
export async function verifyPassword(storedEncoded: string, candidate: string): Promise<boolean> {
try {
const parts = storedEncoded.split('$');
if (parts.length !== 6) return false;
const [, n, r, p, saltB64, hashB64] = parts;
const salt = Buffer.from(saltB64, 'base64url');
const storedHash = Buffer.from(hashB64, 'base64url');
if (salt.length === 0 || storedHash.length === 0) return false;
const candidateHash = await scryptAsync(candidate, salt, storedHash.length, {
N: Number(n),
r: Number(r),
p: Number(p),
});
return timingSafeEqual(storedHash, candidateHash);
} catch {
// Catch any scrypt parameter errors, buffer errors, or other crypto exceptions.
// Never propagate — return false for all error cases.
return false;
}
}