# 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.