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.
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_idis 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.serveis acceptable if it ends up simpler, but prefer Hono for routing/middleware.) - Server password hashing:
Bun.passwordwith argon2id — for the auth verifier only (see crypto section). - Client crypto:
libsodium-wrappers(WASM) in the browser. Argon2id viacrypto_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 buildfor 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):
- On signup, the client generates a random 256-bit DEK (data encryption key).
- The DEK is wrapped (encrypted) twice, with two independently derived keys:
- Password path:
KEK_pw = Argon2id(password, kek_salt)(libsodiumcrypto_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).
- Password path:
- The server stores only:
kek_salt,wrapped_dek_pw+ its nonce,rec_salt,wrapped_dek_rec+ its nonce. Salts and nonces are not secret. - Unlock: client fetches the wrapped DEK + salt, derives
KEK_pwfrom the entered password, unwraps the DEK locally. - 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. - 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 withBun.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
privateactivities, 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 asciphertext+nonce. No plaintext fields on private rows. - Private tags and locations therefore never reach the server's
tags/activity_tagstables. 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)
- Project scaffold, repo structure, README with run instructions.
bun:sqliteschema migration (idempotent), WAL enabled.- 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.
- Auth: signup (creates user + both DEK wraps), login (verifier + session), password change, recovery flow.
- Activity CRUD:
- Create / read / update / delete with visibility handling.
- Private: encrypt/decrypt client-side.
- Semi/public: plaintext;
owner_idstored, stripped from responses whensemi. - 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.
- 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.
- Location: structured for semi/public (
loc_label+ optional lat/lng). Optionalscheduled_atstored as epoch seconds, displayed in 24h. Containerfilefor podman: single image, serves API + static frontend, one volume for the DB. Include a shortpodman build/podman runsnippet 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.passwordis for the server-side auth verifier only — never for KEK derivation (the KEK needs raw key bytes fromcrypto_pwhash, client-side).- Private payload fields never appear in any server-side plaintext column or tag table.
owner_idis always set; it is never serialized forsemi.
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 testpasses, 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 runplus a mounted volume; the DB persists across container restarts. - Manual check: inspect a stored
privaterow in the DB file and confirm only ciphertext is present — no plaintext title, tags, or location.