fix(19): WR-03 make scrypt hashing async (threadpool) to avoid event-loop starvation DoS
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user