Close the recovery lockout-DoS hole on /auth/recovery-complete

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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 12:28:26 +02:00
commit add76be486
9 changed files with 414 additions and 72 deletions

View file

@ -32,10 +32,16 @@
onAuthed();
} catch (err) {
// libsodium throws a generic decrypt error if the code is wrong.
// The server returns 401 if the code derives a wrong verifier (defense
// in depth — usually the local decrypt fails first).
if (err instanceof Error && err.message.toLowerCase().includes('decrypt')) {
error = 'Feil gjenopprettingskode.';
} else if (err instanceof ApiError && err.status === 401) {
error = 'Feil gjenopprettingskode.';
} else if (err instanceof ApiError && err.status === 404) {
error = 'Ingen bruker med den eposten.';
} else if (err instanceof ApiError && err.status === 409) {
error = 'Denne kontoen mangler gjenopprettingsverifikator. Opprett konto på nytt.';
} else {
error = 'Gjenoppretting feilet.';
}

View file

@ -37,14 +37,19 @@ export async function signup(email: string, password: string): Promise<SignupRes
const dek = generateDek();
const recoveryCode = generateRecoveryCode();
const normalisedCode = normalizeRecoveryCode(recoveryCode);
const kekSalt = generateSalt();
const recSalt = generateSalt();
const authSalt = generateSalt();
// Fourth salt: for the recovery verifier. Must differ from rec_salt so
// learning the verifier hash doesn't give the attacker KEK_rec material.
const recAuthSalt = generateSalt();
const kekPw = deriveKey(password, kekSalt);
const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), recSalt);
const kekRec = deriveKey(normalisedCode, recSalt);
const authVerifier = deriveAuthVerifier(password, authSalt);
const recAuthVerifier = deriveAuthVerifier(normalisedCode, recAuthSalt);
const wrappedPw = wrapDek(dek, kekPw);
const wrappedRec = wrapDek(dek, kekRec);
@ -63,6 +68,8 @@ export async function signup(email: string, password: string): Promise<SignupRes
rec_salt: bytesToBase64(recSalt),
wrapped_dek_rec: bytesToBase64(wrappedRec.ciphertext),
dek_rec_nonce: bytesToBase64(wrappedRec.nonce),
rec_auth_salt: bytesToBase64(recAuthSalt),
rec_auth_verifier: recAuthVerifier,
});
setSession(user, dek);
@ -151,10 +158,9 @@ export async function recover(
await ready();
const challenge = await api.recoveryChallenge(email);
const kekRec = deriveKey(
normalizeRecoveryCode(recoveryCode),
base64ToBytes(challenge.rec_salt),
);
const normalisedCode = normalizeRecoveryCode(recoveryCode);
const kekRec = deriveKey(normalisedCode, base64ToBytes(challenge.rec_salt));
const dek = unwrapDek(
{
ciphertext: base64ToBytes(challenge.wrapped_dek_rec),
@ -164,6 +170,12 @@ export async function recover(
);
zero(kekRec);
// Derive the recovery verifier the server will check before doing anything.
const recAuthVerifier = deriveAuthVerifier(
normalisedCode,
base64ToBytes(challenge.rec_auth_salt),
);
const kekSalt = generateSalt();
const authSalt = generateSalt();
const newKek = deriveKey(newPassword, kekSalt);
@ -174,6 +186,7 @@ export async function recover(
await api.recoveryComplete({
email,
rec_auth_verifier: recAuthVerifier,
auth_salt: bytesToBase64(authSalt),
auth_verifier: verifier,
kek_salt: bytesToBase64(kekSalt),