177 lines
8.5 KiB
Markdown
177 lines
8.5 KiB
Markdown
|
|
# 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.
|