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:
parent
fb193b4914
commit
ef02b3f585
3 changed files with 388 additions and 3 deletions
106
README.md
106
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
282
server/reset-password.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue