Files
proof-of-work/proof-of-work/tests/auth-pure.test.ts
T
Keysat f540a473ef
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.2.0:3 — close login timing oracle, enforce exerciseId ownership on workout writes
Two P3 multi-user hardening fixes from the 2026-06-13 full-eval.

Login timing oracle: both login paths (the UI server action and
POST /api/auth) returned immediately on an unknown email but ran
bcrypt.compare when the email matched a user, so response latency
revealed which emails have accounts. New verifyPasswordOrDummy() in
lib/auth runs bcrypt against a fixed dummy hash when there is no user,
so every attempt spends exactly one bcrypt; the two error branches in
each route collapse into one.

exerciseId ownership: exercises are per-user, but the workout
create / PATCH (set-replace) / add-sets and CSV import-save routes wrote
SetLogs from a client-supplied exerciseId with no ownership check —
letting a user attach another user's exercise to their own workout,
which leaks that exercise's name/notes on fetch and wires up a
cross-user onDelete: Cascade link. All four now reject unowned ids with
400 via the shared lib/exerciseOwnership helper; the pre-existing inline
checks in both programs routes are refactored onto the same helper.

App-code only — no schema, no API contract change, no data migration.
2026-06-15 18:30:08 -05:00

59 lines
2.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
hashPassword,
verifyPassword,
verifyPasswordOrDummy,
} from '@/lib/auth';
// Pure-function bits of lib/auth.ts (no Prisma, no cookies).
describe('hashPassword / verifyPassword', () => {
it('verifies a correct password', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword('correct horse battery staple', hash)).toBe(true);
});
it('rejects an incorrect password', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(await verifyPassword('wrong horse', hash)).toBe(false);
});
it('produces different hashes for the same password (salt-randomness)', async () => {
const a = await hashPassword('same input');
const b = await hashPassword('same input');
expect(a).not.toBe(b);
expect(await verifyPassword('same input', a)).toBe(true);
expect(await verifyPassword('same input', b)).toBe(true);
});
it('produces a bcrypt-format hash starting with $2', async () => {
const hash = await hashPassword('whatever');
expect(hash.startsWith('$2')).toBe(true);
});
});
describe('verifyPasswordOrDummy', () => {
it('verifies a correct password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('hunter2', hash)).toBe(true);
});
it('rejects a wrong password against a real hash', async () => {
const hash = await hashPassword('hunter2');
expect(await verifyPasswordOrDummy('wrong', hash)).toBe(false);
});
it('returns false without throwing when there is no user (null hash)', async () => {
expect(await verifyPasswordOrDummy('anything', null)).toBe(false);
});
it('still spends bcrypt time on the null-hash path (timing-oracle guard)', async () => {
// A real cost-10 bcrypt.compare is tens of ms; a path that skipped
// bcrypt would return in well under 1ms. 5ms is a safe lower bound,
// so this fails if someone removes the dummy compare.
const start = Date.now();
await verifyPasswordOrDummy('anything', null);
expect(Date.now() - start).toBeGreaterThan(5);
});
});