vinterliste/SECURITY.md

250 lines
12 KiB
Markdown
Raw Normal View History

Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
# 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**:
```
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
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
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
```
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
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.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
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.
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
## Why four salts?
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
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
Four independently random salts ensure that knowing one derivation tells you
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
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.
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
- `rec_auth_salt` — input to the **recovery verifier**, the proof-of-knowledge
the server checks before completing a recovery.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
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
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
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.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
## Signup flow (client-driven)
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
1. Client generates `dek` (32 bytes), `kek_salt`, `rec_salt`, `auth_salt`,
`rec_auth_salt` (16 bytes each).
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
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)`
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
- `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)
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
4. Client wraps:
- `wrapped_dek_pw = AEAD(kek_pw, dek, dek_pw_nonce)`
- `wrapped_dek_rec = AEAD(kek_rec, dek, dek_rec_nonce)`
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
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.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
## 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
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
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.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
1. Client posts `{ email }` to `/api/auth/recovery-challenge`; server returns
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
`{ rec_salt, wrapped_dek_rec, dek_rec_nonce, rec_auth_salt }`.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
2. Client derives `kek_rec = pwhash(recovery_code, rec_salt)` and unwraps the DEK.
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
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.
Scaffold Vinterliste — end-to-end encrypted winter activity list Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
2026-05-25 12:27:14 +02:00
## Private activity encryption
A `private` activity has a JSON payload:
```ts
{ 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
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
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.