diff --git a/README.md b/README.md index 7fa82a7..f713b40 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ The server serves the SPA from `frontend/dist` in production. All non-`/api/*`, non-`/assets/*` requests fall through to `index.html` so client-side routing still works. -## Container (podman) +## Deployment + +### Container (podman) The provided `Containerfile` builds a single image that serves API + frontend and persists the SQLite database in `/app/data` (one volume). @@ -124,7 +126,107 @@ podman run --replace --name vinterliste \ ``` The container exposes `/api/health` for healthchecks and bakes the build date / -git revision into both OCI labels and `/etc/build-info`. +git revision into both OCI labels and `/etc/build-info`. Use `podman run +--replace ...` for redeploys — it's atomic and avoids the "container exists" +race. + +### Environment variables + +| Variable | Default | Notes | +|--------------------|------------------------|---------------------------------------------------------------| +| `PORT` | `3000` | TCP port the server listens on. | +| `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. | +| `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. | +| `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. | + +There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite +file; session tokens are generated per process and stored server-side, not +signed. + +### TLS termination + +The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx, +Traefik). The session cookie is marked `Secure` when the request was HTTPS +(`X-Forwarded-Proto: https`), so make sure the proxy sets that header. + +Sample Caddyfile: + +```caddyfile +vinterliste.example.org { + encode zstd gzip + reverse_proxy localhost:3000 +} +``` + +Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert +configured manually. + +### Backup and restore + +The SQLite database is the entire app state — user accounts, DEK wraps, +activity ciphertexts, sessions, the lot. Backing it up while the server is +running is safe because of WAL mode: + +```bash +# Atomic backup using SQLite's built-in copy +sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'" + +# Or via the container's volume +podman exec vinterliste sqlite3 /app/data/vinterliste.db \ + ".backup '/app/data/backup-$(date +%F).db'" +``` + +Plain file copy of the `.db` works too if the server is stopped first. With WAL +files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`. + +To restore: replace the file on disk and restart the server. There are no +out-of-band caches. + +### Healthcheck + +`GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with +HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint. + +### Upgrading + +1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args. +2. `podman run --replace` — schema migrations are idempotent + (`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns + without touching existing data). +3. Verify `/api/health` returns the new `revision`. +4. The `activities` table's CHECK constraint includes all visibility values; + the `friends` visibility added later is migrated in via + `ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on + first boot if needed. Take a backup beforehand the first time you upgrade + past a CHECK-constraint change. + +### Emergency password reset (CLI) + +If an admin has lost access (forgotten password, lost recovery code, etc.) and +can't recover via the UI, the server box has a CLI tool: + +```bash +# Inside the container: +podman exec -it vinterliste bun run reset-password admin@example.org + +# Or on the host if you're running the server directly: +bun run reset-password admin@example.org +``` + +It asks one question first: **do you still have this user's recovery code?** + +- **Yes → recovery mode.** Behaves exactly like the in-app recovery flow: + unwraps the existing DEK with the recovery code, re-wraps it with the new + password. No data is lost. The recovery code stays valid afterwards. +- **No → nuke mode.** Generates a brand-new DEK + new recovery code and + prints the new code to stdout (write it down — it's shown once). The + user's **private activities are deleted** because their ciphertext was + encrypted with the now-unrecoverable old DEK. Public, semi, friends-only + activities, plus hearts / bookmarks / "gjort" marks, are kept. + +Both modes invalidate every existing session for the user, matching the +hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires +direct DB access — there is no network exposure of this code path. ## Registration: open, invite-only, or both diff --git a/package.json b/package.json index 6cbb1a7..28bb896 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build:frontend": "vite build --config frontend/vite.config.ts", "start": "NODE_ENV=production bun run server/index.ts", "test": "bun test", - "typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json" + "typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json", + "reset-password": "bun run server/reset-password.ts" }, "dependencies": { "hono": "^4.6.0", diff --git a/server/reset-password.ts b/server/reset-password.ts new file mode 100644 index 0000000..112d976 --- /dev/null +++ b/server/reset-password.ts @@ -0,0 +1,282 @@ +/** + * CLI: emergency password reset. + * + * bun run reset-password + * + * Run on the server box (requires direct DB access). Two modes: + * + * 1. RECOVERY mode (no data loss). If you still have the user's recovery + * code, supply it interactively. This mirrors the regular recovery + * flow: we unwrap the existing DEK with the recovery code, then + * re-wrap it with the new password's KEK. Private activities stay + * readable. wrapped_dek_rec/rec_salt/rec_auth_* are NOT touched, so + * the recovery code remains valid afterwards (same as + * /auth/recovery-complete). + * + * 2. NUKE mode (last resort). If both the password AND recovery code are + * gone, the user's DEK is permanently unrecoverable. This mode + * generates a fresh DEK + new recovery code, writes new auth/KEK + * material, and DELETES the user's private activities (their + * ciphertext can never be opened again). Public/semi/friends rows, + * hearts, bookmarks, "gjort" marks all stay intact. + * + * Both modes invalidate all existing sessions for the user (a logged-in + * tab might have been compromised — same hygiene as /auth/recovery-complete). + * + * For routine "I forgot my password" flow, use the in-app recovery page — + * this CLI is for cases where the user can't reach the UI at all (admin + * lockout on a fresh deployment, etc.). + */ +import { getDb } from './db'; +import { + ready, + generateDek, + generateSalt, + generateRecoveryCode, + normalizeRecoveryCode, + deriveKey, + deriveAuthVerifier, + wrapDek, + unwrapDek, + zero, +} from '../shared/crypto'; + +function usage(): never { + console.error('Usage: bun run reset-password '); + console.error(''); + console.error('Interactive flow. Asks whether you have a recovery code:'); + console.error(' - Yes → preserves all data; just rewires the password.'); + console.error(' - No → wipes the user\'s private activities (their DEK'); + console.error(' is unrecoverable without the recovery code).'); + process.exit(2); +} + +// Hand-rolled line reader. Bun's node:readline (both promises and callback +// forms) only delivers the first answer when stdin is piped/heredoc — the +// subsequent question() never resolves. We read the stdin stream directly +// and pull lines from a growing buffer. Works the same way for piped input +// and an interactive TTY (user types, hits Enter, line is delivered). +const decoder = new TextDecoder(); +const reader: ReadableStreamDefaultReader = Bun.stdin.stream().getReader(); +let lineBuf = ''; +let stdinEnded = false; + +async function readLine(prompt: string): Promise { + process.stdout.write(prompt); + while (true) { + const nl = lineBuf.indexOf('\n'); + if (nl >= 0) { + const line = lineBuf.slice(0, nl).replace(/\r$/, ''); + lineBuf = lineBuf.slice(nl + 1); + return line.trim(); + } + if (stdinEnded) return lineBuf.trim(); + const { value, done } = await reader.read(); + if (done) { stdinEnded = true; continue; } + lineBuf += decoder.decode(value); + } +} + +interface UserRow { + id: string; + email: string; + is_admin: number; + rec_salt: Buffer; + wrapped_dek_rec: Buffer; + dek_rec_nonce: Buffer; +} + +function loadUser(email: string): UserRow | null { + return getDb() + .prepare(` + SELECT id, email, is_admin, rec_salt, wrapped_dek_rec, dek_rec_nonce + FROM users WHERE email = ? + `) + .get(email) as UserRow | null; +} + +async function recoveryReset(user: UserRow, recoveryCode: string, newPassword: string): Promise { + const db = getDb(); + + // Unwrap the existing DEK using the recovery code's KEK. + const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), new Uint8Array(user.rec_salt)); + let dek: Uint8Array; + try { + dek = unwrapDek( + { ciphertext: new Uint8Array(user.wrapped_dek_rec), nonce: new Uint8Array(user.dek_rec_nonce) }, + kekRec, + ); + } catch { + zero(kekRec); + console.error('Recovery code did not unwrap the DEK. Wrong code? Aborting (no DB changes).'); + process.exit(1); + } + zero(kekRec); + + // Build fresh password-side material; recovery side stays put so the + // existing recovery code keeps working. + const authSalt = generateSalt(); + const kekSalt = generateSalt(); + const kekPw = deriveKey(newPassword, kekSalt); + const authVerifier = deriveAuthVerifier(newPassword, authSalt); + const wrappedPw = wrapDek(dek, kekPw); + zero(kekPw); + zero(dek); + + const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' }); + + const txn = db.transaction(() => { + db.prepare(` + UPDATE users SET + auth_salt = ?, + auth_verifier_hash = ?, + kek_salt = ?, + wrapped_dek_pw = ?, + dek_pw_nonce = ? + WHERE id = ? + `).run( + Buffer.from(authSalt), + authVerifierHash, + Buffer.from(kekSalt), + Buffer.from(wrappedPw.ciphertext), + Buffer.from(wrappedPw.nonce), + user.id, + ); + return db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id).changes; + }); + + const sessDeleted = txn(); + console.log(''); + console.log('Password reset complete (recovery mode — no data loss).'); + console.log(` - Sessions invalidated: ${sessDeleted}`); + console.log(' - Private activities: preserved'); + console.log(' - Recovery code: unchanged (still valid)'); +} + +async function nukeReset(user: UserRow, newPassword: string): Promise { + const db = getDb(); + + // Brand-new DEK + recovery code. The old wraps are now garbage. + const dek = generateDek(); + const recoveryCode = generateRecoveryCode(); + const normCode = normalizeRecoveryCode(recoveryCode); + + const authSalt = generateSalt(); + const kekSalt = generateSalt(); + const recSalt = generateSalt(); + const recAuthSalt = generateSalt(); + + const kekPw = deriveKey(newPassword, kekSalt); + const kekRec = deriveKey(normCode, recSalt); + const authVerifier = deriveAuthVerifier(newPassword, authSalt); + const recAuthVerifier = deriveAuthVerifier(normCode, recAuthSalt); + const wrappedPw = wrapDek(dek, kekPw); + const wrappedRec = wrapDek(dek, kekRec); + + zero(kekPw); + zero(kekRec); + zero(dek); + + const authVerifierHash = await Bun.password.hash(authVerifier, { algorithm: 'argon2id' }); + const recAuthVerifierHash = await Bun.password.hash(recAuthVerifier, { algorithm: 'argon2id' }); + + const txn = db.transaction(() => { + db.prepare(` + UPDATE users SET + auth_salt = ?, + auth_verifier_hash = ?, + kek_salt = ?, + wrapped_dek_pw = ?, + dek_pw_nonce = ?, + rec_salt = ?, + wrapped_dek_rec = ?, + dek_rec_nonce = ?, + rec_auth_salt = ?, + rec_auth_verifier_hash = ? + WHERE id = ? + `).run( + Buffer.from(authSalt), + authVerifierHash, + Buffer.from(kekSalt), + Buffer.from(wrappedPw.ciphertext), + Buffer.from(wrappedPw.nonce), + Buffer.from(recSalt), + Buffer.from(wrappedRec.ciphertext), + Buffer.from(wrappedRec.nonce), + Buffer.from(recAuthSalt), + recAuthVerifierHash, + user.id, + ); + const privDeleted = db + .prepare(`DELETE FROM activities WHERE owner_id = ? AND visibility = 'private'`) + .run(user.id).changes; + const sessDeleted = db + .prepare('DELETE FROM sessions WHERE user_id = ?') + .run(user.id).changes; + return { privDeleted, sessDeleted }; + }); + + const { privDeleted, sessDeleted } = txn(); + + console.log(''); + console.log('Password reset complete (nuke mode — private data lost).'); + console.log(` - Private activities deleted: ${privDeleted}`); + console.log(` - Sessions invalidated: ${sessDeleted}`); + console.log(''); + console.log('=== NEW RECOVERY CODE — write this down NOW. It will never be shown again. ==='); + console.log(recoveryCode); + console.log('=== END RECOVERY CODE ==='); +} + +async function main(): Promise { + const email = process.argv[2]?.trim().toLowerCase(); + if (!email) usage(); + + await ready(); + const user = loadUser(email); + if (!user) { + console.error(`No user found with email "${email}".`); + process.exit(1); + } + + console.log(`User: ${user.email} (is_admin=${user.is_admin})`); + console.log(''); + + const hasCode = (await readLine('Do you still have this user\'s recovery code? [y/N] ')).toLowerCase(); + const useRecovery = hasCode === 'y' || hasCode === 'yes'; + + if (useRecovery) { + console.log('Recovery mode selected. Private activities will be preserved.'); + const code = await readLine('Recovery code: '); + const password = await readLine('New password (visible while typing): '); + if (password.length < 12) { + console.error('Password must be at least 12 characters (matches the signup/recovery rule).'); + process.exit(1); + } + await recoveryReset(user, code, password); + return; + } + + console.log('No recovery code → nuke mode. This will:'); + console.log(' - Generate a brand-new recovery code (printed once below)'); + console.log(' - DELETE all private activities owned by this user'); + console.log(' (their ciphertext is unrecoverable without the old code)'); + console.log(' - Invalidate all existing sessions for this user'); + console.log(''); + const confirm = await readLine('Type DELETE to confirm: '); + if (confirm !== 'DELETE') { + console.error('Confirmation did not match. Aborting (no DB changes).'); + process.exit(1); + } + const password = await readLine('New password (visible while typing): '); + if (password.length < 8) { + console.error('Password must be at least 8 characters.'); + process.exit(1); + } + await nukeReset(user, password); +} + +main().catch((err) => { + console.error('Reset failed:', err); + process.exit(1); +});