Files
nanodrop/tests/unit/dummy-hash.test.ts
Brendan Chen ad36b23061
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
feat(auth): add shared login handler with constant-time clamp
Single attemptLogin() orchestrates lockout check, bcrypt verify (against
real or dummy hash), success/failure logging, and a configurable minimum
response-time clamp. Both login routes will share this — no duplication.

Adds three logger events for the operator's escalation pipeline:
AUTH_LOCKOUT_TRIGGERED, AUTH_LOCKED_ATTEMPT, and AUTH_RATE_LIMITED.
fail2ban filters can pick these up to escalate persistent attackers from
in-app lockout to IP ban.

Constant-time defense: unknown users still pay bcrypt cost (via dummy hash)
and the clamp ensures locked-vs-unknown-vs-wrong-password aren't
distinguishable by response time. Username is canonicalized (lowercase + trim)
before lookup so attackers can't bypass lockout via case variation.
2026-05-03 03:34:15 -07:00

59 lines
2.2 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import bcrypt from 'bcrypt';
import { verifyAgainstDummy, _resetDummyHashForTests } from '../../src/services/dummy-hash.ts';
import { hashPassword, verifyPassword } from '../../src/services/auth.ts';
describe('dummy hash', () => {
beforeEach(() => {
_resetDummyHashForTests();
});
it('always returns false', async () => {
expect(await verifyAgainstDummy('whatever')).toBe(false);
expect(await verifyAgainstDummy('')).toBe(false);
expect(await verifyAgainstDummy('admin')).toBe(false);
});
it('takes comparable time to verifying a real bcrypt hash (within 5x)', async () => {
// Warm dummy hash so the cache is hot.
await verifyAgainstDummy('warmup');
const realHash = await hashPassword('actual-password');
const start1 = Date.now();
await verifyPassword('actual-password', realHash);
const realMs = Date.now() - start1;
const start2 = Date.now();
await verifyAgainstDummy('any-password');
const dummyMs = Date.now() - start2;
// Both should be in the same ballpark — bcrypt cost factor is the same.
// Generous bound to avoid flakes on slow CI.
expect(dummyMs).toBeGreaterThan(realMs / 5);
expect(dummyMs).toBeLessThan(realMs * 5);
}, 10_000);
it('memoizes the dummy hash across calls', async () => {
// First call computes, subsequent calls reuse — covered by cache hit
// being noticeably faster than a fresh hash. Just assert the function
// is callable repeatedly without error.
await verifyAgainstDummy('a');
await verifyAgainstDummy('b');
await verifyAgainstDummy('c');
expect(true).toBe(true);
});
it('runs a real bcrypt comparison (does not short-circuit)', async () => {
// Spy by counting bcrypt.compare calls would be nice, but bcrypt
// is a compiled module. Indirect check: the call must actually take
// bcrypt-comparison time after warmup.
await verifyAgainstDummy('warmup');
const start = Date.now();
await verifyAgainstDummy('test');
const elapsed = Date.now() - start;
// bcrypt 12 rounds takes >50ms on any modern CPU
expect(elapsed).toBeGreaterThan(20);
expect(bcrypt).toBeDefined();
}, 10_000);
});