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