feat(19-01): implement hashPassword/verifyPassword with scrypt + timingSafeEqual
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08) - 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url - timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01) - verifyPassword returns false on any error (never throws); passwords never logged - All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
This commit is contained in:
@@ -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$<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 { 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$<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.
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user