diff --git a/apps/api/src/auth/localCredentials.ts b/apps/api/src/auth/localCredentials.ts new file mode 100644 index 0000000..7245fa4 --- /dev/null +++ b/apps/api/src/auth/localCredentials.ts @@ -0,0 +1,90 @@ +/** + * 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$$ + * Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url> + * Max length: ~83 chars — fits in varchar(256) password_hash column + */ + +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +// 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$$ + * + * 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. + * + * NOTE: scryptSync blocks the event loop. For a 2-person household with + * infrequent logins this is acceptable. Use promisify(scrypt) if async is needed. + */ +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(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. + * + * @returns true if candidate matches the stored hash; false otherwise (incl. errors) + */ +export function verifyPassword(storedEncoded: string, candidate: string): 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 = scryptSync(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; + } +}