vinterliste/winter-list-claude-code-prompt.md
Ole-Morten Duesund 47963c9225 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

8.5 KiB

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

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.