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:
parent
47963c9225
commit
add76be486
9 changed files with 414 additions and 72 deletions
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue