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

@ -60,6 +60,7 @@ authRoutes.post('/signup', async (c) => {
'email', 'auth_salt', 'auth_verifier', 'kek_salt',
'wrapped_dek_pw', 'dek_pw_nonce',
'rec_salt', 'wrapped_dek_rec', 'dek_rec_nonce',
'rec_auth_salt', 'rec_auth_verifier',
]);
if (miss) return c.json({ error: miss }, 400);
const email = body.email.trim().toLowerCase();
@ -72,17 +73,21 @@ authRoutes.post('/signup', async (c) => {
// Server-side hash of the client-derived verifier. The verifier is already
// expensive to brute-force (Argon2id-MODERATE), so Bun.password adds a second
// hardening layer in case the DB leaks but the verifier salt is still public.
const verifierHash = await Bun.password.hash(body.auth_verifier, {
algorithm: 'argon2id',
});
// The recovery verifier is hashed the same way for the same reasons — it's
// the proof-of-recovery-code on /auth/recovery-complete.
const [verifierHash, recVerifierHash] = await Promise.all([
Bun.password.hash(body.auth_verifier, { algorithm: 'argon2id' }),
Bun.password.hash(body.rec_auth_verifier, { algorithm: 'argon2id' }),
]);
const id = newId();
const now = Date.now();
db.prepare(`
INSERT INTO users
(id, email, auth_salt, auth_verifier_hash, kek_salt,
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce,
rec_auth_salt, rec_auth_verifier_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
email,
@ -94,6 +99,8 @@ authRoutes.post('/signup', async (c) => {
b64ToBuffer(body.wrapped_dek_rec),
b64ToBuffer(body.rec_salt),
b64ToBuffer(body.dek_rec_nonce),
b64ToBuffer(body.rec_auth_salt),
recVerifierHash,
now,
);
@ -224,38 +231,65 @@ authRoutes.post('/recovery-challenge', async (c) => {
const email = body.email.trim().toLowerCase();
const row = getDb()
.prepare('SELECT rec_salt, wrapped_dek_rec, dek_rec_nonce FROM users WHERE email = ?')
.prepare(`
SELECT rec_salt, wrapped_dek_rec, dek_rec_nonce, rec_auth_salt
FROM users WHERE email = ?
`)
.get(email) as
| { rec_salt: Uint8Array; wrapped_dek_rec: Uint8Array; dek_rec_nonce: Uint8Array }
| {
rec_salt: Uint8Array;
wrapped_dek_rec: Uint8Array;
dek_rec_nonce: Uint8Array;
rec_auth_salt: Uint8Array | null;
}
| null;
if (!row) return c.json({ error: 'no_such_user' }, 404);
if (!row.rec_auth_salt) {
// Account from a pre-fix scaffold session — no recovery verifier set.
// Refuse rather than silently downgrade.
return c.json({ error: 'recovery_not_provisioned' }, 409);
}
const resp: RecoveryChallengeResponse = {
rec_salt: bufferToB64(row.rec_salt),
wrapped_dek_rec: bufferToB64(row.wrapped_dek_rec),
dek_rec_nonce: bufferToB64(row.dek_rec_nonce),
rec_auth_salt: bufferToB64(row.rec_auth_salt),
};
return c.json(resp);
});
// --- POST /auth/recovery-complete -------------------------------------------
// Replaces password-side material AND auth verifier. Recovery wrap is
// untouched (same recovery code keeps working).
// Replaces password-side material AND auth verifier. Recovery wrap and the
// rec_auth_* columns are untouched — the same recovery code keeps working.
//
// Known limitation: this endpoint has no proof-of-recovery-code; see SECURITY.md.
// We require a recovery-side verifier proof: an attacker who knows the email
// but not the recovery code can no longer lock the user out (or read data —
// reading was already impossible). See SECURITY.md.
authRoutes.post('/recovery-complete', async (c) => {
const body = (await c.req.json().catch(() => null)) as RecoveryCompleteRequest | null;
if (!body) return c.json({ error: 'invalid_json' }, 400);
const miss = missingKey(body, [
'email', 'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce',
'email', 'rec_auth_verifier',
'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce',
]);
if (miss) return c.json({ error: miss }, 400);
const email = body.email.trim().toLowerCase();
const row = getDb()
.prepare('SELECT id FROM users WHERE email = ?')
.get(email) as { id: string } | null;
if (!row) return c.json({ error: 'no_such_user' }, 404);
.prepare('SELECT id, rec_auth_verifier_hash FROM users WHERE email = ?')
.get(email) as { id: string; rec_auth_verifier_hash: string | null } | null;
// Same constant-work pattern as /auth/login: always run Bun.password.verify
// against *some* hash so an attacker can't distinguish "no such user" from
// "wrong recovery code" by timing the response.
const hashToVerify = row?.rec_auth_verifier_hash
?? '$argon2id$v=19$m=65536,t=3,p=1$YWFhYWFhYWE$AAAAAAAAAAAAAAAAAAAAAA';
const ok = await Bun.password.verify(body.rec_auth_verifier, hashToVerify).catch(() => false);
if (!row || !row.rec_auth_verifier_hash || !ok) {
return c.json({ error: 'invalid_recovery' }, 401);
}
const verifierHash = await Bun.password.hash(body.auth_verifier, {
algorithm: 'argon2id',

View file

@ -22,6 +22,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
wrapped_dek_rec BLOB NOT NULL,
rec_salt BLOB NOT NULL,
dek_rec_nonce BLOB NOT NULL,
-- Recovery-side verifier: closes the recovery lockout DoS by proving
-- knowledge of the recovery code before recovery-complete updates anything.
-- See SECURITY.md § "Recovery flow".
rec_auth_salt BLOB NOT NULL,
rec_auth_verifier_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS activities (
@ -77,6 +82,20 @@ function applyStatements(db: Database, statements: readonly string[]): void {
}
}
/**
* Idempotently add a column. SQLite has no `ALTER TABLE … ADD COLUMN IF NOT
* EXISTS`, so we probe `PRAGMA table_info`. Added columns are NULLABLE — SQLite
* can't add NOT NULL columns without a DEFAULT, and we don't have a sensible
* default. New rows still come through the schema in `SCHEMA_STATEMENTS` and
* fill the column; existing rows from older scaffold sessions remain NULL
* and the application-level handlers reject operations that need the column.
*/
function ensureColumn(db: Database, table: string, column: string, type: string): void {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (cols.some((c) => c.name === column)) return;
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run();
}
export function getDb(): Database {
if (dbInstance) return dbInstance;
@ -88,6 +107,12 @@ export function getDb(): Database {
applyStatements(db, PRAGMAS);
applyStatements(db, SCHEMA_STATEMENTS);
// Forward-compat: a scaffold DB created before the recovery-verifier fix
// won't have these columns. Add them as nullable so the server still boots.
// Old users will need to re-sign-up to fully use recovery; see CLAUDE.md.
ensureColumn(db, 'users', 'rec_auth_salt', 'BLOB');
ensureColumn(db, 'users', 'rec_auth_verifier_hash', 'TEXT');
dbInstance = db;
return db;
}