fix(19): WR-03 make scrypt hashing async (threadpool) to avoid event-loop starvation DoS
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
* Uses node:crypto scrypt under the hood; no external dependencies.
|
||||
* All tests run without MariaDB or any external service.
|
||||
*
|
||||
* WR-03: hashPassword / verifyPassword are now async (promisify(scrypt), threadpool) —
|
||||
* all assertions await them.
|
||||
*
|
||||
* Test suite (TDD RED → GREEN — Plan 19-01 Task 1):
|
||||
* Test 1: correct password verifies true
|
||||
* Test 2: wrong password verifies false
|
||||
@@ -16,33 +19,32 @@ import { describe, it, expect } from 'vitest';
|
||||
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'hunter2');
|
||||
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', async () => {
|
||||
const encoded = await hashPassword('hunter2');
|
||||
const result = await verifyPassword(encoded, 'hunter2');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', () => {
|
||||
const encoded = hashPassword('hunter2');
|
||||
const result = verifyPassword(encoded, 'wrong-password');
|
||||
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', async () => {
|
||||
const encoded = await hashPassword('hunter2');
|
||||
const result = await verifyPassword(encoded, 'wrong-password');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', () => {
|
||||
const encoded1 = hashPassword('x');
|
||||
const encoded2 = hashPassword('x');
|
||||
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', async () => {
|
||||
const encoded1 = await hashPassword('x');
|
||||
const encoded2 = await hashPassword('x');
|
||||
expect(encoded1).not.toBe(encoded2);
|
||||
});
|
||||
|
||||
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', () => {
|
||||
expect(() => verifyPassword('not-a-valid-hash', 'x')).not.toThrow();
|
||||
expect(verifyPassword('not-a-valid-hash', 'x')).toBe(false);
|
||||
expect(verifyPassword('', 'x')).toBe(false);
|
||||
expect(verifyPassword('scrypt$bad$data', 'x')).toBe(false);
|
||||
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', async () => {
|
||||
await expect(verifyPassword('not-a-valid-hash', 'x')).resolves.toBe(false);
|
||||
await expect(verifyPassword('', 'x')).resolves.toBe(false);
|
||||
await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', () => {
|
||||
const encoded = hashPassword('testpassword');
|
||||
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => {
|
||||
const encoded = await hashPassword('testpassword');
|
||||
const segments = encoded.split('$');
|
||||
expect(segments).toHaveLength(6);
|
||||
expect(segments[0]).toBe('scrypt');
|
||||
|
||||
@@ -890,7 +890,7 @@ describe('POST /api/admin/members', () => {
|
||||
.where(eq(localCredentials.userId, body.id))
|
||||
.limit(1);
|
||||
expect(credRow).toBeDefined();
|
||||
expect(verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
|
||||
expect(await verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => {
|
||||
@@ -967,8 +967,8 @@ describe('POST /api/admin/members', () => {
|
||||
.where(eq(localCredentials.userId, newMemberId))
|
||||
.limit(1);
|
||||
expect(credRow).toBeDefined();
|
||||
expect(verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
|
||||
expect(verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
|
||||
expect(await verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
|
||||
expect(await verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => {
|
||||
|
||||
@@ -164,7 +164,7 @@ async function getLocalCredentials() {
|
||||
describe('POST /api/auth/local/login', () => {
|
||||
it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => {
|
||||
const { hashPassword } = await getLocalCredentials();
|
||||
const hash = hashPassword('correcthorse');
|
||||
const hash = await hashPassword('correcthorse');
|
||||
mockCredRow = { userId: 5, passwordHash: hash };
|
||||
|
||||
const app = await getApp();
|
||||
@@ -179,7 +179,7 @@ describe('POST /api/auth/local/login', () => {
|
||||
|
||||
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
|
||||
const { hashPassword } = await getLocalCredentials();
|
||||
const hash = hashPassword('correcthorse');
|
||||
const hash = await hashPassword('correcthorse');
|
||||
mockCredRow = { userId: 5, passwordHash: hash };
|
||||
|
||||
const app = await getApp();
|
||||
|
||||
@@ -274,7 +274,7 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
|
||||
const oldPassword = 'old-password-correct-123';
|
||||
const newPassword = 'new-password-secure-456';
|
||||
const storedHash = hashPassword(oldPassword);
|
||||
const storedHash = await hashPassword(oldPassword);
|
||||
let updatedHash: string | null = null;
|
||||
|
||||
// Mock sequence: resolveUserId (devBypass sets user), then:
|
||||
@@ -326,15 +326,15 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
||||
// The updatedHash must verify the new password
|
||||
expect(updatedHash).not.toBeNull();
|
||||
const { verifyPassword } = await import('../../src/auth/localCredentials.js');
|
||||
expect(verifyPassword(updatedHash!, newPassword)).toBe(true);
|
||||
expect(verifyPassword(updatedHash!, oldPassword)).toBe(false);
|
||||
expect(await verifyPassword(updatedHash!, newPassword)).toBe(true);
|
||||
expect(await verifyPassword(updatedHash!, oldPassword)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => {
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
|
||||
const realPassword = 'real-password-correct-789';
|
||||
const storedHash = hashPassword(realPassword);
|
||||
const storedHash = await hashPassword(realPassword);
|
||||
let updateWasCalled = false;
|
||||
|
||||
let callCount = 0;
|
||||
|
||||
Reference in New Issue
Block a user