The original spec stored only `kek_salt`, `wrapped_dek_pw`+nonce,
`rec_salt`, and `wrapped_dek_rec`+nonce. Under that model, anyone who
knew a user's email could POST to /auth/recovery-complete with junk
material and overwrite the password-side wrap, locking the legitimate
user out. The data stayed safe (the attacker couldn't decrypt
anything) but the account was effectively DoS'd until the user dug up
their recovery code.
Fix: add a recovery-side verifier mirroring the password-side one.
Storage: two new columns on `users`:
- rec_auth_salt BLOB NOT NULL — independent of rec_salt
- rec_auth_verifier_hash TEXT NOT NULL — Bun.password.hash output
The migration adds them via ensureColumn() for forward-compat with
scaffold DBs that pre-date this commit; new tables get them via the
CREATE TABLE statement.
Wire protocol:
- SignupRequest gains rec_auth_salt + rec_auth_verifier
- RecoveryChallengeResponse gains rec_auth_salt
- RecoveryCompleteRequest gains rec_auth_verifier
Server (server/auth.ts):
- signup hashes the recovery verifier alongside the auth verifier
and stores both
- recovery-challenge returns rec_auth_salt so the client can derive
the verifier; refuses with 409 for pre-fix accounts that have a
NULL rec_auth_salt
- recovery-complete calls Bun.password.verify against the stored
hash BEFORE touching any state. Always runs verify even for
unknown emails (against a dummy hash) so timing doesn't leak
existence — same pattern we already used for /auth/login.
Client (frontend/src/lib/auth.ts):
- signup() generates a fourth salt and derives the recovery
verifier from the recovery code
- recover() fetches the new rec_auth_salt and submits the derived
verifier as part of recovery-complete
Recovery.svelte distinguishes the new 401 ("Feil gjenopprettingskode")
and 409 ("Denne kontoen mangler gjenopprettingsverifikator") cases.
Regression test (tests/auth.test.ts) asserts the gate is real:
- junk recovery verifier → 401, no state changes
- unknown email → 401 (constant-time)
- challenge response includes rec_auth_salt
- correctly-derived verifier passes the gate
SECURITY.md is updated to describe four salts instead of three, the
new key-model storage, and the closed lockout DoS. CLAUDE.md flags
the rec_auth_* columns as load-bearing — removing them re-opens the
hole.
This is the only deviation from the spec's stated storage model;
documented as such in both SECURITY.md and CLAUDE.md.