fix(19): WR-03 make scrypt hashing async (threadpool) to avoid event-loop starvation DoS

This commit is contained in:
Lucas Berger
2026-06-17 20:30:28 -04:00
parent 322929aebe
commit 30ad25c026
8 changed files with 81 additions and 42 deletions
+38 -8
View File
@@ -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),
+6 -2
View File
@@ -141,6 +141,10 @@ adminRouter.post(
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
// threadpool) scrypt work does not hold the DB transaction open for its duration.
const initialPasswordHash = await hashPassword(initialPassword);
try {
let newUserId: number;
@@ -163,7 +167,7 @@ adminRouter.post(
await tx.insert(localCredentials).values({
userId: newUserId,
username,
passwordHash: hashPassword(initialPassword),
passwordHash: initialPasswordHash,
});
});
@@ -236,7 +240,7 @@ adminRouter.post(
try {
await db
.update(localCredentials)
.set({ passwordHash: hashPassword(newPassword) })
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, targetId));
// CR-04: an admin password reset must immediately clear any rate-limit / lockout
+7 -4
View File
@@ -99,8 +99,10 @@ export function resetLoginAttempts(username: string): void {
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
// Computed once at module load time; the actual value is never used for auth.
const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy');
// WR-03: hashPassword is now async. Kick off the computation once at module load and keep
// the PROMISE; the login handler awaits it. The value is never used for auth — only to make
// the unknown-username path perform the same scrypt work as the known-username path.
const dummyHashPromise: Promise<string> = hashPassword('dummy-constant-time-filler-xyzzy');
// ---------------------------------------------------------------------------
// POST /local/login → POST /api/auth/local/login
@@ -160,9 +162,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
// ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle
// username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash
// so the scrypt work is always performed regardless of whether username was found.
// WR-03: verifyPassword is async (threadpool scrypt) — await it.
const valid = cred
? verifyPassword(cred.passwordHash, password)
: verifyPassword(DUMMY_HASH, password);
? await verifyPassword(cred.passwordHash, password)
: await verifyPassword(await dummyHashPromise, password);
if (!valid || !cred) {
// Increment failure counter (keyed on username)
+3 -3
View File
@@ -255,8 +255,8 @@ meRouter.post(
return c.json({ error: 'No local credential found' }, 404);
}
// T-19-07: verify current password before any update
const isCorrect = verifyPassword(credRow.passwordHash, currentPassword);
// T-19-07: verify current password before any update (WR-03: async scrypt)
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
if (!isCorrect) {
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
// MutationCache treats any 401 as "session expired" and arms the re-auth
@@ -269,7 +269,7 @@ meRouter.post(
try {
await db
.update(localCredentials)
.set({ passwordHash: hashPassword(newPassword) })
.set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, currentUserId));
return c.json({ ok: true }, 200);