vinterliste/SECURITY.md
Ole-Morten Duesund add76be486 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.
2026-05-25 12:28:26 +02:00

12 KiB

SECURITY.md — Vinterliste key & trust model

This document is the authoritative description of what the server can and cannot see, and how keys are derived, wrapped, and rotated. The crypto code in shared/crypto.ts is written against this document; if behaviour diverges from what's described here, the document is the source of truth and the code must be fixed.

Threat model

We assume:

  • The server operator is honest-but-curious. They may inspect the database file, the request logs, and memory of the server process at any point.
  • The TLS terminator is trusted not to MITM. (For local podman deployment behind a reverse proxy, this is operator-controlled.)
  • The user's browser session is trusted while the user is logged in (the DEK lives in memory there).
  • An attacker may know the user's email address.

We protect against:

  • Server-side disclosure of private activity contents (title, tags, location, scheduled time).
  • Server-side disclosure of the user's password.
  • Account takeover by an attacker who has the database but not the password or the recovery code.

We do not protect against:

  • Compromise of the user's browser (XSS, malicious extensions). A logged-in client holds the DEK in JS memory — anything in the same origin can read it. Mitigated but not eliminated by a strict CSP.
  • Targeted denial-of-service via password resets. See "Recovery flow" below.
  • Side-channel attacks against libsodium's WASM build (we use the SUMO build of libsodium-wrappers-sumo; the standard build omits crypto_pwhash).
  • Traffic analysis (number/timing/size of private activities is observable).

Primitives (locked — do not substitute)

Purpose Primitive libsodium API
Password / recovery-code key derivation Argon2id, 32-byte raw output crypto_pwhash
Authenticated encryption (AEAD) XChaCha20-Poly1305-IETF, 24-byte nonce crypto_aead_xchacha20poly1305_ietf_*
Random bytes (DEK, salts, nonces, code) CSPRNG randombytes_buf
Server-side verifier hash Argon2id (Bun's default tuning) Bun.password.hash / .verify

Argon2id parameters for crypto_pwhash use the libsodium MODERATE profile (crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE). They are recorded as constants in shared/crypto.ts and must be kept consistent between signup and any future unlock — if parameters are tuned upward in the future, the old parameters must be stored per-user so unlock still works.

Per-user state (server-stored)

For each user the server stores exactly:

auth_salt                 (16 bytes, public)  -- distinct from kek_salt
auth_verifier_hash        (text)              -- Bun.password.hash of the auth verifier
kek_salt                  (16 bytes, public)  -- for password-derived KEK
wrapped_dek_pw            (48 bytes)          -- DEK encrypted under KEK_pw
dek_pw_nonce              (24 bytes)
rec_salt                  (16 bytes, public)  -- for recovery-code-derived KEK
wrapped_dek_rec           (48 bytes)          -- DEK encrypted under KEK_rec
dek_rec_nonce             (24 bytes)
rec_auth_salt             (16 bytes, public)  -- distinct from rec_salt
rec_auth_verifier_hash    (text)              -- Bun.password.hash of the recovery verifier

The recovery verifier is the proof-of-recovery-code that /api/auth/recovery-complete requires before it changes any state. Without it (the original spec's storage model), an attacker who knows only the email could submit a junk new password wrap and lock the legitimate user out — the data would still be safe but the account would be DoS'd. With it, recovery requires knowledge of the recovery code; an attacker can no longer cause a lockout.

The server never sees, derives, or stores:

  • The user's raw password.
  • The recovery code.
  • The DEK itself.
  • Plaintext title / tags / location / scheduled time for any private activity.

Why four salts?

Four independently random salts ensure that knowing one derivation tells you nothing about another:

  • auth_salt — input to the auth verifier the server holds.
  • kek_salt — input to KEK_pw, which unwraps the DEK.
  • rec_salt — input to KEK_rec, the recovery-code-derived unwrap key.
  • rec_auth_salt — input to the recovery verifier, the proof-of-knowledge the server checks before completing a recovery.

In particular, auth_salt ≠ kek_salt guarantees that even if a server-side breach leaks the verifier hash and an attacker brute-forces it, they still need to redo Argon2id against kek_salt to derive the KEK. The same property holds for rec_auth_salt ≠ rec_salt: brute-forcing the recovery verifier hash doesn't directly hand the attacker KEK_rec.

Signup flow (client-driven)

  1. Client generates dek (32 bytes), kek_salt, rec_salt, auth_salt, rec_auth_salt (16 bytes each).
  2. Client generates a high-entropy recovery_code (≥120 bits), shows it to the user, and never sends it.
  3. Client derives:
    • kek_pw = pwhash(password, kek_salt)
    • kek_rec = pwhash(recovery_code, rec_salt)
    • auth_verifier = pwhash(password, auth_salt) (≠ kek_pw because salts differ)
    • rec_auth_verifier = pwhash(recovery_code, rec_auth_salt) (≠ kek_rec because salts differ)
  4. Client wraps:
    • wrapped_dek_pw = AEAD(kek_pw, dek, dek_pw_nonce)
    • wrapped_dek_rec = AEAD(kek_rec, dek, dek_rec_nonce)
  5. Client posts the salts, wraps, nonces, auth_verifier, and rec_auth_verifier to the server.
  6. Server hashes both verifiers with Bun.password.hash and stores the row.

Unlock / login flow

  1. Client posts { email } to /api/auth/challenge; server returns the public parameters: { auth_salt, kek_salt, wrapped_dek_pw, dek_pw_nonce }.
  2. Client derives kek_pw and auth_verifier locally (two pwhash calls).
  3. Client posts { email, auth_verifier } to /api/auth/login; server verifies via Bun.password.verify and, on success, issues an httpOnly session cookie.
  4. Client unwraps the DEK locally. DEK lives in JS memory for the session.

Password change

  1. Client unwraps DEK with the old kek_pw.
  2. Client generates kek_salt_new, dek_pw_nonce_new, auth_salt_new, derives kek_pw_new and auth_verifier_new, and produces wrapped_dek_pw_new.
  3. Client posts the new material to /api/auth/password. The server updates the password wrap and verifier in a single transaction.
  4. The recovery wrap (wrapped_dek_rec, rec_salt) is not touched — the recovery code still works.
  5. Activity ciphertexts are not re-encrypted — they're still under the same DEK.

Recovery flow

The recovery code path is symmetric to the password path. The server cannot tell whether the submitted new wrap is "of the same DEK" — it just stores what the client sends. Trust is anchored in the recovery-code holder, with the server using a recovery verifier as a proof-of-knowledge gate before any state change.

  1. Client posts { email } to /api/auth/recovery-challenge; server returns { rec_salt, wrapped_dek_rec, dek_rec_nonce, rec_auth_salt }.
  2. Client derives kek_rec = pwhash(recovery_code, rec_salt) and unwraps the DEK.
  3. Client also derives rec_auth_verifier = pwhash(recovery_code, rec_auth_salt) — the proof the server will check.
  4. Client chooses a new password, derives new password-side salts/verifier/wrap as in signup.
  5. Client posts to /api/auth/recovery-complete with rec_auth_verifier plus the new password-side material.
  6. Server first calls Bun.password.verify(rec_auth_verifier, rec_auth_verifier_hash). If it fails, the request is rejected with 401 and no state changes. To keep the timing of "no such user" indistinguishable from "wrong code", the server runs Bun.password.verify against a dummy hash even when the email isn't found.
  7. On success, the server replaces password-side material in a single transaction. The recovery wrap, rec_salt, rec_auth_salt, and rec_auth_verifier_hash are unchanged — the same recovery code keeps working.
  8. The server deletes all existing sessions for the user so any hijacked session is invalidated.

Why the recovery verifier closes the DoS

The original spec stored only the recovery wrap. Anyone who knew the email could submit a new password wrap and lock the legitimate user out (the data itself remained safe — only the recovery-code holder could read it).

With the recovery verifier in place, the server has a cryptographic proof that the submitter knows the recovery code before any write happens. An attacker without the code can no longer cause a lockout. The auth_salt ≠ kek_salt property carries over: rec_auth_salt ≠ rec_salt, so brute-forcing the verifier hash doesn't give the attacker the unwrap key.

Remaining recovery-flow caveats

  • Per-IP and per-email rate limiting is still desirable on the recovery endpoints (and on login) to slow online brute-force; out of scope for the scaffold.
  • Email-confirmation flows would add a second factor for additional defense in depth; also out of scope.

Private activity encryption

A private activity has a JSON payload:

{ title: string; tags: string[]; loc_label?: string; loc_lat?: number; loc_lng?: number; scheduled_at?: number }

The payload is JSON-serialized, encoded as UTF-8, and encrypted with crypto_aead_xchacha20poly1305_ietf_encrypt(payload, /* additional data */ null, nonce, dek). A fresh nonce is generated for every write (including updates).

The row stores only ciphertext and nonce. The title, loc_*, and scheduled_at columns are NULL and not used. No row in tags or activity_tags is created for a private activity — those tables hold only public/semi tag data.

Visibility transitions

Visibility transitions are explicit, client-driven operations rather than server flags:

  • private → semi/public: client decrypts locally, then issues a normal update that sets plaintext columns and clears ciphertext/nonce.
  • semi/public → private: client reads the plaintext, encrypts locally, then issues an update that sets ciphertext/nonce and clears plaintext columns. Server also deletes any rows in activity_tags for that activity.
  • semi ↔ public: server-side toggle. owner_id is unchanged; the API simply starts or stops including the owner in serialized responses.

Serialization rules

  • For semi activities, API responses must not include owner_id or any field that identifies the creator. The server still has owner_id for authorization (only the owner can edit/delete), but it is stripped from responses.
  • For public activities, owner_id (or a derived public handle) is serialized.
  • For private activities, responses are only returned to the owner and contain ciphertext + nonce; the client decrypts.

Tags

  • Server-side tags and activity_tags tables hold only tags for semi and public activities. They are normalized to lowercase trimmed strings, joined to activities via activity_tags.
  • Private tags are stored only inside the encrypted activity payload, and indexed client-side in IndexedDB for autocomplete. They never reach the server.
  • The autocomplete endpoint returns matches from the server-side tags table only. The frontend may merge those results with the IndexedDB index, clearly labelled.

Things to flag, not silently change

The spec invites flagging anything cryptographically unsound. The original spec stored only kek_salt, wrapped_dek_pw+nonce, rec_salt, wrapped_dek_rec+nonce. We deviate by additionally storing rec_auth_salt and rec_auth_verifier_hash to close the lockout DoS on /auth/recovery-complete. The deviation is documented above. Salts and verifier hashes are not secret; the storage shape is otherwise unchanged.