feat(ops): emergency password-reset CLI + deployment docs

New CLI: bun run reset-password <email>

Two modes selected interactively:

- Recovery mode: if you still have the user's recovery code, unwrap
  the existing DEK with it and re-wrap with the new password. No data
  loss; the recovery code stays valid (mirrors /auth/recovery-complete).
- Nuke mode: if both password AND recovery code are gone, generate a
  fresh DEK + new recovery code (printed once), and DELETE the user's
  private activities — their ciphertext is permanently unrecoverable.
  Public/semi/friends rows and engagement (hearts/bookmarks/done) are
  preserved.

Both modes invalidate the user's sessions.

Password length matches the signup/recovery rule (12 chars min).
Wrong-recovery-code path aborts before any DB writes. Hand-rolled
line reader sidesteps a Bun quirk where node:readline only delivers
the first answer when stdin is piped.

Also expand README's "Deployment" section: container snippet stays,
plus new subsections for env vars, TLS termination (with a Caddyfile
example), backup/restore via sqlite3 .backup, the /api/health
healthcheck, upgrade flow, and a walkthrough of the reset CLI.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 20:04:57 +02:00
commit ef02b3f585
3 changed files with 388 additions and 3 deletions

106
README.md
View file

@ -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

View file

@ -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",

282
server/reset-password.ts Normal file
View file

@ -0,0 +1,282 @@
/**
* CLI: emergency password reset.
*
* bun run reset-password <email>
*
* 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 <email>');
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<Uint8Array> = Bun.stdin.stream().getReader();
let lineBuf = '';
let stdinEnded = false;
async function readLine(prompt: string): Promise<string> {
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<void> {
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<void> {
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<void> {
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);
});