fix(19): WR-03 make scrypt hashing async (threadpool) to avoid event-loop starvation DoS
This commit is contained in:
@@ -18,7 +18,31 @@
|
||||
* Max length: ~83 chars — fits in varchar(256) password_hash column
|
||||
*/
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
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)
|
||||
@@ -36,12 +60,15 @@ const KEY_LEN = 32; // 256-bit derived key output
|
||||
* 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.
|
||||
* WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||||
const hash = await scryptAsync(password, salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
});
|
||||
return [
|
||||
'scrypt',
|
||||
SCRYPT_N,
|
||||
@@ -66,9 +93,12 @@ export function hashPassword(password: string): string {
|
||||
* 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)
|
||||
* 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 function verifyPassword(storedEncoded: string, candidate: string): boolean {
|
||||
export async function verifyPassword(storedEncoded: string, candidate: string): Promise<boolean> {
|
||||
try {
|
||||
const parts = storedEncoded.split('$');
|
||||
if (parts.length !== 6) return false;
|
||||
@@ -76,7 +106,7 @@ export function verifyPassword(storedEncoded: string, candidate: string): boolea
|
||||
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, {
|
||||
const candidateHash = await scryptAsync(candidate, salt, storedHash.length, {
|
||||
N: Number(n),
|
||||
r: Number(r),
|
||||
p: Number(p),
|
||||
|
||||
Reference in New Issue
Block a user