vinterliste/winter-list-claude-code-prompt.md

177 lines
8.5 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
# Build: "Winter List" — an end-to-end encrypted activity list (Bun + TypeScript)
## Goal
Scaffold a small web app for collecting *winter activities* — things to do when winter
feels long. Users add activities that can be **private** (end-to-end encrypted),
**semi-public**, or **public**. Build the foundation: repo structure, DB schema +
migration, the client-side crypto module, auth (signup / login / recovery), activity
CRUD with correct visibility handling, tag autocomplete, and a podman-deployable
container. Favor a small, readable, well-tested foundation over breadth.
## Visibility model
Set per activity:
- `private` — end-to-end encrypted. Only the owner can read it. The server stores
ciphertext only.
- `semi` — plaintext, readable by everyone, but the creator is **not** shown.
`owner_id` is stored (for editing/moderation) but **never serialized** in API responses.
- `public` — plaintext, readable by everyone, **and attributed** to the creator
(owner shown / linkable).
An activity has: a title, optional tags, an optional location, and an optional date/time.
## Tech stack (locked — do not substitute)
- **Runtime:** Bun (server). TypeScript everywhere.
- **DB:** `bun:sqlite` (built-in), WAL mode. No external DB server. A thin query layer is
fine; no ORM required.
- **HTTP:** Hono on Bun. (Plain `Bun.serve` is acceptable if it ends up simpler, but
prefer Hono for routing/middleware.)
- **Server password hashing:** `Bun.password` with argon2id — for the auth verifier
*only* (see crypto section).
- **Client crypto:** `libsodium-wrappers` (WASM) in the browser. Argon2id via
`crypto_pwhash`; AEAD via XChaCha20-Poly1305 (`crypto_aead_xchacha20poly1305_ietf`).
- **Frontend:** TypeScript SPA. Default to Svelte + Vite (React is acceptable if you keep
it light). Use IndexedDB for the client-side private tag/search index.
- **Build:** `bun build` for the frontend bundle; Bun runs the backend. One toolchain.
- **Deploy:** a single container (`oven/bun`) serving API + static frontend, with one
volume for the SQLite file. Target: podman.
## Crypto & auth model — correctness-critical, read carefully
This is an end-to-end encrypted app. **The server must never see: the user's raw
password, any private activity's plaintext, or any encryption key in usable form.** All
key operations happen in the browser.
**Key model (per user):**
1. On signup, the client generates a random 256-bit **DEK** (data encryption key).
2. The DEK is wrapped (encrypted) **twice**, with two independently derived keys:
- **Password path:** `KEK_pw = Argon2id(password, kek_salt)` (libsodium
`crypto_pwhash`, raw key bytes). `wrapped_dek_pw = AEAD(KEK_pw, DEK)`.
- **Recovery path:** generate a high-entropy recovery code client-side, shown to the
user exactly once and **never sent to the server**.
`KEK_rec = Argon2id(recovery_code, rec_salt)`. `wrapped_dek_rec = AEAD(KEK_rec, DEK)`.
3. The server stores only: `kek_salt`, `wrapped_dek_pw` + its nonce, `rec_salt`,
`wrapped_dek_rec` + its nonce. Salts and nonces are not secret.
4. **Unlock:** client fetches the wrapped DEK + salt, derives `KEK_pw` from the entered
password, unwraps the DEK locally.
5. **Password change:** unwrap DEK with the old password, re-wrap with a new `KEK_pw`
(new salt/nonce). **Never re-encrypts activity data.** The recovery wrap is untouched.
6. **Recovery:** user enters the recovery code → derive `KEK_rec` → unwrap DEK → re-wrap
with a new password.
**Authentication (separate from the encryption key; same password is fine):**
- The raw password must **not** be sent to the server. The client derives an **auth
verifier** from the password using a *different* salt (`auth_salt`) than the KEK salt,
and sends only the verifier.
- The server stores `Bun.password.hash(verifier, { algorithm: "argon2id" })` and verifies
logins with `Bun.password.verify`. On success, issue a session (httpOnly cookie).
- Because the verifier salt ≠ KEK salt, the server learning the verifier never lets it
derive the KEK.
**Private activity encryption:**
- For `private` activities, the entire meaningful payload — title, tags, location,
date/time — is serialized to JSON, encrypted with the DEK (XChaCha20-Poly1305, fresh
nonce per write), and stored as `ciphertext` + `nonce`. No plaintext fields on private
rows.
- Private tags and locations therefore **never** reach the server's `tags` /
`activity_tags` tables. They are indexed only client-side in IndexedDB.
## Data model
```sql
users(
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
auth_salt BLOB NOT NULL, -- for the auth verifier
auth_verifier_hash TEXT NOT NULL, -- Bun.password argon2id hash of the verifier
kek_salt BLOB NOT NULL, -- for KEK derivation (not secret)
wrapped_dek_pw BLOB NOT NULL,
dek_pw_nonce BLOB NOT NULL,
wrapped_dek_rec BLOB NOT NULL,
rec_salt BLOB NOT NULL,
dek_rec_nonce BLOB NOT NULL,
created_at INTEGER NOT NULL
);
activities(
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')),
-- private only; NULL for semi/public
ciphertext BLOB,
nonce BLOB,
-- semi/public only; NULL for private
title TEXT,
scheduled_at INTEGER, -- epoch seconds; display in 24h
loc_label TEXT,
loc_lat REAL,
loc_lng REAL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
tags(
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
usage_count INTEGER NOT NULL DEFAULT 0
);
activity_tags( -- semi/public ONLY
activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id),
PRIMARY KEY (activity_id, tag_id)
);
```
## Functional scope (this pass)
1. Project scaffold, repo structure, README with run instructions.
2. `bun:sqlite` schema migration (idempotent), WAL enabled.
3. Crypto module (client): DEK generation, KEK derivation (password + recovery),
DEK wrap/unwrap, AEAD encrypt/decrypt for activity payloads, recovery-code generation.
Pure, well-commented, unit-tested.
4. Auth: signup (creates user + both DEK wraps), login (verifier + session), password
change, recovery flow.
5. Activity CRUD:
- Create / read / update / delete with visibility handling.
- Private: encrypt/decrypt client-side.
- Semi/public: plaintext; `owner_id` stored, stripped from responses when `semi`.
- Visibility transitions are explicit operations: private→public decrypts client-side,
re-uploads as plaintext, deletes the blob; public→private encrypts client-side,
deletes the plaintext.
6. Tags + autocomplete:
- Server endpoint: autocomplete over public/semi tags.
- Client-side: autocomplete over the user's private tags from IndexedDB.
- The UI may merge or keep the two sources separate — your call; document the choice.
7. Location: structured for semi/public (`loc_label` + optional lat/lng). Optional
`scheduled_at` stored as epoch seconds, displayed in 24h.
8. `Containerfile` for podman: single image, serves API + static frontend, one volume for
the DB. Include a short `podman build` / `podman run` snippet in the README.
## Non-negotiable invariants
- The server never receives raw passwords, plaintext of private activities, or unwrapped
keys.
- Do **not** roll your own crypto. Use the libsodium primitives as specified. Do not swap
the AEAD or the KDF.
- `Bun.password` is for the server-side auth verifier **only** — never for KEK derivation
(the KEK needs raw key bytes from `crypto_pwhash`, client-side).
- Private payload fields never appear in any server-side plaintext column or tag table.
- `owner_id` is always set; it is never serialized for `semi`.
## Out of scope (don't build yet)
- Sharing/permissions beyond the three visibility levels.
- Comments, notifications, other social features.
- Native/mobile apps.
- Server-side full-text search over private data.
## Before implementing the crypto module
Write a short `SECURITY.md` describing the key model exactly as above, then implement
against it. If anything in this spec seems cryptographically unsound, flag it first rather
than silently changing it.
## Verification
- `bun test` passes, including crypto round-trips: wrap/unwrap via both password and
recovery paths, AEAD encrypt/decrypt, password change preserves data, recovery unlocks.
- The app builds and runs with a single `podman run` plus a mounted volume; the DB
persists across container restarts.
- Manual check: inspect a stored `private` row in the DB file and confirm only ciphertext
is present — no plaintext title, tags, or location.