#!/usr/bin/env node // Local recovery only: requires filesystem/container access to the controller. import Database from 'better-sqlite3'; import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { emitKeypressEvents } from 'node:readline'; import { fileURLToPath } from 'node:url'; import { hashPassword } from '../src/lib/server/auth/passwords.js'; /** @param {string} prompt @returns {Promise} */ function secret(prompt) { return new Promise((resolve, reject) => { let value = ''; process.stdout.write(prompt); emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); const cleanup = () => { process.stdin.off('keypress', listener); process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\n'); }; /** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */ const listener = (text, key) => { if (key?.ctrl && key.name === 'c') { cleanup(); reject(new Error('Cancelled.')); } else if (key?.name === 'return' || key?.name === 'enter') { cleanup(); resolve(value); } else if (key?.name === 'backspace') value = [...value].slice(0, -1).join(''); else if ( text && !key?.ctrl && !key?.meta && // eslint-disable-next-line no-control-regex -- Reject terminal control characters while collecting password input. !/[\x00-\x1f\x7f]/.test(text) && [...value].length < 128 ) value += text; }; process.stdin.on('keypress', listener); }); } /** @param {import('better-sqlite3').Database} database @param {string} username */ function administrator(database, username) { const user = /** @type {{ id: string, username: string } | undefined} */ ( database .prepare( "SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0" ) .get(username) ); if (!user) throw new Error('An enabled administrator with that username was not found.'); return user; } /** * Local recovery shared with regression tests; the CLI only accepts passwords on its TTY. * @param {import('better-sqlite3').Database} database * @param {string} username * @param {string} password */ export async function recoverAdministrator(database, username, password) { const user = administrator(database, username); const passwordHash = await hashPassword(password); const digest = (/** @type {string} */ value) => createHash('sha256').update(value).digest('hex'); database .transaction(() => { const changed = database .prepare( "UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0" ) .run(passwordHash, Date.now(), user.id); if (changed.changes !== 1) throw new Error('Administrator changed during recovery. Try again.'); database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id); // Older databases may not have device credentials yet; recovery still works. if ( database .prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'" ) .get() ) { database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id); } database .prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)') .run( `login:account:${digest(user.username.toLowerCase())}:ip:*`, digest(`login:account:${user.username.toLowerCase()}`), digest(`password:account:${user.id.toLowerCase()}`) ); database .prepare( 'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)' ) .run('password.recovered', user.id, '{"source":"local-cli"}', Date.now()); }) .immediate(); } async function main() { const username = process.argv[2]?.toLowerCase(); if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username)) throw new Error('Usage: node scripts/reset-admin.mjs '); if (!process.stdin.isTTY) throw new Error( 'Run this command in an interactive terminal. Passwords are never accepted as command arguments.' ); const database = new Database(process.env.DB_PATH || './data/cherry.db', { fileMustExist: true }); database.pragma('busy_timeout=5000'); try { administrator(database, username); const password = await secret('New administrator password (8–128 characters): '); const confirmation = await secret('Repeat password: '); if (password !== confirmation) throw new Error('Passwords did not match.'); await recoverAdministrator(database, username, password); console.log( 'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.' ); } finally { database.close(); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { await main(); } catch (cause) { console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.'); process.exitCode = 1; } }