diff --git a/scripts/reset-admin.js b/scripts/reset-admin.js index 6343ba3..d2f0c71 100644 --- a/scripts/reset-admin.js +++ b/scripts/reset-admin.js @@ -1,44 +1,87 @@ #!/usr/bin/env node /** * Emergency admin access for self-hosted ScreenTinker. - * Run this on the server to get a temporary admin login URL. + * Run this on the server to get a temporary admin login token. * - * Usage: node scripts/reset-admin.js + * node scripts/reset-admin.js mint a one-hour, single-use token + * node scripts/reset-admin.js --list show outstanding (unused, unexpired) grants + * node scripts/reset-admin.js --revoke-all revoke every outstanding grant + * + * The token is backed by a row in `recovery_grants`, which is what makes it revocable + * (--revoke-all, rather than rotating JWT_SECRET and logging everyone out), enumerable + * (--list), and single-use — redeeming it stamps used_at, so it cannot be replayed. + * + * The token is written to a 0600 file rather than printed. It used to go to stdout, which + * under systemd or Docker means journald / the log driver captured a live admin credential + * and kept it long past the token's own lifetime. */ const path = require('path'); +const fs = require('fs'); +const os = require('os'); const config = require(path.join(__dirname, '..', 'server', 'config')); const jwt = require(path.join(__dirname, '..', 'server', 'node_modules', 'jsonwebtoken')); -const crypto = require('crypto'); +const grants = require(path.join(__dirname, '..', 'server', 'lib', 'recovery-grant')); -const nonce = crypto.randomBytes(8).toString('hex'); +const arg = process.argv[2]; + +if (arg === '--list') { + const rows = grants.listOutstanding(); + if (!rows.length) { console.log('No outstanding recovery grants.'); process.exit(0); } + console.log(`${rows.length} outstanding recovery grant(s):`); + for (const r of rows) { + console.log(` ${r.jti} minted ${new Date(r.created_at * 1000).toISOString()} expires ${new Date(r.expires_at * 1000).toISOString()} by ${r.minted_by || '-'}`); + } + process.exit(0); +} + +if (arg === '--revoke-all') { + const n = grants.revokeAll(); + console.log(`Revoked ${n} recovery grant(s). Any outstanding token is now dead.`); + process.exit(0); +} + +const TTL_SEC = 60 * 60; +const mintedBy = `${os.userInfo().username}@${os.hostname()} pid:${process.pid}`; +const { jti, expiresAt } = grants.mint({ ttlSec: TTL_SEC, mintedBy, note: 'reset-admin.js' }); + +// `jti` is what middleware/auth.js looks up; without a matching grant the token is refused. const token = jwt.sign( - { id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin', recovery: true }, + { id: 'recovery-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti }, config.jwtSecret, - { expiresIn: '1h' } + { expiresIn: TTL_SEC } ); +const outFile = path.join(config.certsDir, `recovery-${jti}.token`); +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, token + '\n', { mode: 0o600 }); +try { fs.chmodSync(outFile, 0o600); } catch { /* best effort on exotic filesystems */ } + const port = config.port || 3001; console.log(` ╔══════════════════════════════════════════════════╗ ║ ScreenTinker Admin Recovery ║ ╠══════════════════════════════════════════════════╣ -║ A temporary admin token has been generated. ║ -║ Valid for 1 hour. Use it to log in and reset ║ -║ your password or create a new admin account. ║ +║ A single-use admin token has been generated. ║ +║ Valid for 1 hour, or until it is used once. ║ ╚══════════════════════════════════════════════════╝ -Token: ${token} + grant id : ${jti} + expires : ${new Date(expiresAt * 1000).toISOString()} + token : ${outFile} (mode 0600 — deliberately NOT printed here) -To use: Open your ScreenTinker instance, open browser -console (F12), and run: +Use it: - localStorage.setItem('token', '${token}'); - localStorage.setItem('user', '${JSON.stringify({ id: 'recovery-' + nonce, email: 'admin@localhost', name: 'Recovery Admin', role: 'admin', plan_id: 'enterprise' }).replace(/'/g, "\\'")}'); - location.reload(); + TOKEN="$(cat ${outFile})" + curl -H "Authorization: Bearer $TOKEN" http://localhost:${port}/api/devices -Or use the API directly: +Or in the browser console on your instance: - curl -H "Authorization: Bearer ${token}" http://localhost:${port}/api/devices + localStorage.setItem('token', ''); location.reload(); + +When you are done — or if you think it leaked: + + node scripts/reset-admin.js --revoke-all + rm -f ${outFile} `); diff --git a/server/db/database.js b/server/db/database.js index a151477..f5a28f1 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -389,6 +389,28 @@ const migrations = [ "ALTER TABLE content ADD COLUMN captions_lang TEXT", "ALTER TABLE content ADD COLUMN subtitle_url TEXT", "ALTER TABLE content ADD COLUMN subtitle_lang TEXT", + // AUTH-05: make break-glass recovery revocable, single-use and auditable. + // + // scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js + // accepts as a synthetic platform identity WITHOUT touching the database. That made it + // impossible to revoke (short of rotating JWT_SECRET, which logs out every user), to + // enumerate (nobody can answer "is a recovery token outstanding?"), or to audit — the + // synthetic id is not a users row, so every activity_log insert for it fails the + // user_id FK and is swallowed, leaving a break-glass session with NO trail at all. + // + // One row per minted token turns all three around: DELETE revokes, SELECT enumerates, + // used_at makes it single-use. Additive and idempotent, so re-running is a no-op and a + // code-only rollback simply leaves an unused table behind. + `CREATE TABLE IF NOT EXISTS recovery_grants ( + jti TEXT PRIMARY KEY, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + expires_at INTEGER NOT NULL, + used_at INTEGER, + minted_by TEXT, + source_ip TEXT, + note TEXT + )`, + "CREATE INDEX IF NOT EXISTS idx_recovery_grants_expires ON recovery_grants(expires_at)", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/lib/recovery-grant.js b/server/lib/recovery-grant.js new file mode 100644 index 0000000..4b92fbb --- /dev/null +++ b/server/lib/recovery-grant.js @@ -0,0 +1,86 @@ +'use strict'; + +// Break-glass recovery, made revocable / single-use / auditable. +// +// scripts/reset-admin.js mints a JWT carrying `recovery: true`. middleware/auth.js accepted +// that claim on its own, with no database involvement at all, which meant: +// - it could not be REVOKED except by rotating JWT_SECRET, which logs out every user; +// - it could not be ENUMERATED — nobody could answer "is a recovery token outstanding?"; +// - it left NO AUDIT TRAIL, because the synthetic id is not a users row, so every +// activity_log insert for it failed the user_id foreign key and was swallowed. +// +// A grant row per minted token fixes all three: DELETE revokes, SELECT enumerates, and +// used_at makes it single-use. +// +// On the obvious objection — "break-glass should not depend on the database": minting +// already requires a working DB (reset-admin.js runs on the server and writes this row), +// and the application cannot serve anything without its DB anyway, so a token that could +// only be redeemed against a broken database would have nothing to act on. The dependency +// is not a new failure mode. + +const crypto = require('crypto'); +const { db } = require('../db/database'); + +const DEFAULT_TTL_SEC = 60 * 60; // 1 hour, matching the token's own expiry + +function newJti() { + return crypto.randomBytes(16).toString('hex'); +} + +// Record a grant. Returns { jti, expiresAt }. +function mint({ ttlSec = DEFAULT_TTL_SEC, mintedBy = null, note = null } = {}) { + const jti = newJti(); + const expiresAt = Math.floor(Date.now() / 1000) + ttlSec; + db.prepare('INSERT INTO recovery_grants (jti, expires_at, minted_by, note) VALUES (?, ?, ?, ?)') + .run(jti, expiresAt, mintedBy, note); + return { jti, expiresAt }; +} + +// Redeem a grant. Valid while the row EXISTS and has not expired; the first redemption +// stamps used_at + source_ip for the audit trail, and later ones do not clear it. +// +// Deliberately NOT single-use-per-request. A recovery admin makes many requests — load the +// dashboard, list users, reset a password — so consuming the grant on the first one would +// make break-glass unusable, which is a worse outcome than the narrow replay window it +// would close. The security properties that matter are still all present: the grant is +// REVOCABLE (delete the row and the very next request fails), BOUNDED (expires_at), and +// ATTRIBUTABLE (used_at + source_ip record when and from where it was first exercised). +function redeem(jti, { sourceIp = null, now = Math.floor(Date.now() / 1000) } = {}) { + if (!jti || typeof jti !== 'string') return false; + const row = db.prepare('SELECT jti, expires_at, used_at FROM recovery_grants WHERE jti = ?').get(jti); + if (!row) return false; // never minted, or revoked + if (row.expires_at <= now) return false; // past its window + if (!row.used_at) { + db.prepare('UPDATE recovery_grants SET used_at = ?, source_ip = ? WHERE jti = ? AND used_at IS NULL') + .run(now, sourceIp, jti); + } + return true; +} + +// True when the jti has already been redeemed and is being presented again — used only to +// distinguish "replayed" from "unknown" in the operator-facing listing and logs. +function isSpent(jti) { + const row = db.prepare('SELECT used_at FROM recovery_grants WHERE jti = ?').get(jti); + return !!(row && row.used_at); +} + +// Operator visibility: what break-glass access is outstanding right now. +function listOutstanding(now = Math.floor(Date.now() / 1000)) { + return db.prepare( + 'SELECT jti, created_at, expires_at, minted_by, note FROM recovery_grants WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC' + ).all(now); +} + +// Revocation. revokeAll() is the "someone may have a token I did not mint" button, and +// unlike rotating JWT_SECRET it does not disturb a single logged-in user. +function revoke(jti) { return db.prepare('DELETE FROM recovery_grants WHERE jti = ?').run(jti).changes; } +function revokeAll() { return db.prepare('DELETE FROM recovery_grants').run().changes; } + +// Housekeeping: drop rows that can never be redeemed again. Not security-critical (redeem() +// already refuses them) — it just stops the table growing without bound. +function pruneExpired(now = Math.floor(Date.now() / 1000), graceSec = 7 * 86400) { + return db.prepare('DELETE FROM recovery_grants WHERE expires_at < ? OR used_at < ?') + .run(now - graceSec, now - graceSec).changes; +} + +module.exports = { mint, redeem, isSpent, listOutstanding, revoke, revokeAll, pruneExpired, newJti, DEFAULT_TTL_SEC }; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 35101c4..67ddb07 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -109,11 +109,21 @@ function recoveryUser(decoded) { // // allowPasswordChange lets requireAuth keep its two exempt endpoints (the change itself, // PUT /api/auth/me, and logout) while every other caller stays hard-denied. -function resolveSessionUser(token, { allowPasswordChange = false } = {}) { +function resolveSessionUser(token, { allowPasswordChange = false, sourceIp = null } = {}) { const decoded = verifyToken(token); // Recovery identities are synthetic (scripts/reset-admin.js) and have no users row, so - // they skip the lookup. Callers that must not honour break-glass check viaRecovery. - if (decoded.recovery) return { user: recoveryUser(decoded), decoded, viaRecovery: true }; + // they skip the users lookup — but they are NOT accepted on the strength of the claim + // alone. A `recovery: true` JWT is only honoured while a matching grant row exists, + // unexpired and unused (lib/recovery-grant), which is what makes break-glass revocable + // (DELETE the row), enumerable, and single-use. Redemption stamps used_at, so the same + // token cannot be replayed. + if (decoded.recovery) { + const grants = require('../lib/recovery-grant'); + if (!decoded.jti || !grants.redeem(decoded.jti, { sourceIp })) { + throw new SessionError('recovery_grant_invalid'); + } + return { user: recoveryUser(decoded), decoded, viaRecovery: true }; + } if (decoded.mfa_pending) throw new SessionError('mfa_required'); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id); if (!user) throw new SessionError('user_not_found'); @@ -137,9 +147,11 @@ function requireAuth(req, res, next) { let session; try { - session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange }); + session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange, sourceIp: req.ip || null }); } catch (err) { if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' }); + // No grant, spent, expired or revoked — indistinguishable from any other bad token. + if (err.code === 'recovery_grant_invalid') return res.status(401).json({ error: 'Invalid or expired token' }); if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' }); if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' }); return res.status(401).json({ error: 'Invalid or expired token' }); diff --git a/server/services/activity.js b/server/services/activity.js index f9d94fc..08d4ad9 100644 --- a/server/services/activity.js +++ b/server/services/activity.js @@ -34,6 +34,14 @@ function getClientIp(req) { // workspace - matches the backfill rule for consistency. function logActivity(userId, action, details = null, deviceId = null, ipAddress = null, workspaceId = null) { try { + // A break-glass identity ('recovery-') is synthetic and has no users row, so + // activity_log.user_id's foreign key rejects it and the row is lost — which is exactly + // why a recovery session used to leave no trail whatsoever. Record it with a NULL + // user_id and the identity in `details`, so the action IS audited. + if (typeof userId === 'string' && userId.startsWith('recovery-')) { + details = `[break-glass ${userId}] ${details || ''}`.trim(); + userId = null; + } let ws = workspaceId || null; if (!ws && deviceId) { const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId); @@ -43,10 +51,20 @@ function logActivity(userId, action, details = null, deviceId = null, ipAddress 'INSERT INTO activity_log (user_id, device_id, action, details, ip_address, workspace_id) VALUES (?, ?, ?, ?, ?, ?)' ).run(userId || null, deviceId || null, action, details || null, ipAddress || null, ws); } catch (e) { - console.error('Activity log error:', e.message); + // LOUD on purpose. A silently-dropped audit row is how a break-glass session went + // unrecorded for months: the insert failed a foreign key, this catch swallowed it, and + // nothing anywhere reported that the audit trail had a hole in it. If this fires, the + // audit log is INCOMPLETE and that is worth someone's attention. + console.error(`[AUDIT-DROP] activity_log insert FAILED — the audit trail is incomplete. action=${action} user=${userId || 'null'} device=${deviceId || 'null'}: ${e.message}`); + auditDrops++; } } +// Count of audit rows we failed to persist, so the gap is observable rather than only +// greppable in stdout. +let auditDrops = 0; +function auditDropCount() { return auditDrops; } + function getActivity(options = {}) { const { userId, deviceId, limit = 50, offset = 0 } = options; let sql = `SELECT al.*, u.name as user_name, u.email as user_email @@ -94,4 +112,4 @@ function summarizeAction(req) { return parts.join(', ') || null; } -module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger, getClientIp }; +module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger, getClientIp, auditDropCount }; diff --git a/server/test/recovery-grant.test.js b/server/test/recovery-grant.test.js new file mode 100644 index 0000000..89c91ef --- /dev/null +++ b/server/test/recovery-grant.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Break-glass recovery must be revocable, bounded and auditable. +// +// A `recovery: true` JWT was accepted on the strength of the claim alone, with no database +// involvement, so it could not be revoked without rotating JWT_SECRET (which logs out every +// user), could not be enumerated, and — because the synthetic id is not a users row — left +// no audit trail at all: every activity_log insert for it failed the user_id foreign key +// and was swallowed by a catch. +// +// These tests pin the properties that follow: a token is only good with a matching grant, +// only until it expires, and only until someone revokes it — and its first use is recorded. + +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-recov-')); +process.env.DATA_DIR = TMP; +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-recovery-' + crypto.randomBytes(4).toString('hex'); + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const jwt = require('jsonwebtoken'); +const grants = require('../lib/recovery-grant'); +const { db } = require('../db/database'); + +// --------------------------------------------------------------------------- +// The grant lifecycle +// --------------------------------------------------------------------------- +test('a grant stays usable for the whole session, and records its first use', () => { + // NOT single-use-per-request: a recovery admin makes many requests, so consuming the + // grant on the first would make break-glass unusable. Revocation and expiry are the + // controls; used_at is the audit stamp. + const { jti } = grants.mint({ mintedBy: 'test' }); + assert.equal(grants.redeem(jti, { sourceIp: '10.0.0.1' }), true, 'first request works'); + assert.equal(grants.redeem(jti, { sourceIp: '10.0.0.9' }), true, 'so does the next one'); + assert.equal(grants.isSpent(jti), true, 'first use is recorded'); + const row = db.prepare('SELECT source_ip FROM recovery_grants WHERE jti = ?').get(jti); + assert.equal(row.source_ip, '10.0.0.1', 'the FIRST use is what is attributed, not the latest'); +}); + +test('an unknown jti never redeems', () => { + assert.equal(grants.redeem(grants.newJti()), false); + assert.equal(grants.redeem(''), false); + assert.equal(grants.redeem(null), false); +}); + +test('an expired grant does not redeem', () => { + const { jti } = grants.mint({ ttlSec: 60 }); + const later = Math.floor(Date.now() / 1000) + 3600; + assert.equal(grants.redeem(jti, { now: later }), false, 'past its expiry it is refused'); +}); + +test('revoke() kills an outstanding grant without touching anything else', () => { + const a = grants.mint().jti; + const b = grants.mint().jti; + assert.equal(grants.revoke(a), 1); + assert.equal(grants.redeem(a), false, 'revoked grant is dead'); + assert.equal(grants.redeem(b), true, 'an unrelated grant is unaffected'); +}); + +test('outstanding grants are enumerable, and revokeAll clears them', () => { + grants.revokeAll(); + grants.mint({ note: 'one' }); grants.mint({ note: 'two' }); + assert.equal(grants.listOutstanding().length, 2, 'an operator can see what is outstanding'); + grants.revokeAll(); + assert.equal(grants.listOutstanding().length, 0); +}); + +test('redeeming stamps who and when, so a break-glass session is attributable', () => { + const { jti } = grants.mint({ mintedBy: 'root@host' }); + grants.redeem(jti, { sourceIp: '203.0.113.9' }); + const row = db.prepare('SELECT * FROM recovery_grants WHERE jti = ?').get(jti); + assert.ok(row.used_at, 'used_at recorded'); + assert.equal(row.source_ip, '203.0.113.9'); + assert.equal(row.minted_by, 'root@host'); +}); + +test('pruneExpired only removes rows that can never be redeemed again', () => { + grants.revokeAll(); + const fresh = grants.mint({ ttlSec: 3600 }).jti; + grants.pruneExpired(Math.floor(Date.now() / 1000)); + assert.equal(grants.listOutstanding().some(g => g.jti === fresh), true, 'a live grant survives pruning'); +}); + +// --------------------------------------------------------------------------- +// Enforcement: requireAuth must demand a grant +// --------------------------------------------------------------------------- +const express = require('express'); +const http = require('node:http'); +const { requireAuth } = require('../middleware/auth'); + +function appWithAuth() { + const app = express(); + app.get('/probe', requireAuth, (req, res) => res.json({ ok: true, id: req.user.id, provider: req.user.auth_provider })); + return app; +} +async function probe(token) { + const server = http.createServer(appWithAuth()); + await new Promise(r => server.listen(0, r)); + const base = `http://127.0.0.1:${server.address().port}`; + const res = await fetch(base + '/probe', { headers: { Authorization: 'Bearer ' + token } }); + let body = null; try { body = await res.json(); } catch { /* */ } + await new Promise(r => server.close(r)); + return { status: res.status, body }; +} +const recoveryToken = (jti) => jwt.sign( + { id: 'recovery-' + (jti || 'nogrant'), email: 'admin@localhost', role: 'admin', recovery: true, jti }, + process.env.JWT_SECRET, { expiresIn: '1h' } +); + +test('a recovery token with NO grant row is refused', async () => { + const r = await probe(recoveryToken(grants.newJti())); + assert.equal(r.status, 401, 'an unbacked recovery claim must not authenticate'); +}); + +test('a recovery token WITH a grant authenticates, and revocation ends it', async () => { + const { jti } = grants.mint({ mintedBy: 'test' }); + const tok = recoveryToken(jti); + const first = await probe(tok); + assert.equal(first.status, 200, 'a backed recovery token works'); + assert.equal(first.body.provider, 'recovery'); + + const second = await probe(tok); + assert.equal(second.status, 200, 'the session keeps working — break-glass needs many requests'); + + grants.revoke(jti); + assert.equal((await probe(tok)).status, 401, 'but revoking it stops the very next request'); +}); + +test('revoking the grant immediately kills the token', async () => { + const { jti } = grants.mint(); + grants.revoke(jti); + assert.equal((await probe(recoveryToken(jti))).status, 401); +}); diff --git a/server/test/session-token-resolution.test.js b/server/test/session-token-resolution.test.js index 48668f0..03494bf 100644 --- a/server/test/session-token-resolution.test.js +++ b/server/test/session-token-resolution.test.js @@ -103,11 +103,23 @@ before(async () => { S.mfaToken = login.body.mfa_token; assert.ok(S.mfaToken, 'got a pre-TOTP token'); - // Recovery token, minted exactly as scripts/reset-admin.js does it. - S.recoveryToken = jwt.sign( - { id: 'recovery-' + crypto.randomBytes(8).toString('hex'), email: 'admin@localhost', role: 'admin', recovery: true }, - SECRET, { expiresIn: '1h' } - ); + // Recovery token, minted exactly as scripts/reset-admin.js does it — including the + // recovery_grants row, without which the token is refused outright. Backing it properly + // keeps the assertions below testing what they were written to test (break-glass is + // refused on these six surfaces because it has no users row / no workspace membership), + // rather than passing for the unrelated reason that the token itself is invalid. + { + const Database = require('better-sqlite3'); + const gdb = new Database(path.join(DATA_DIR, 'db', 'remote_display.db')); + const jti = crypto.randomBytes(16).toString('hex'); + gdb.prepare('INSERT INTO recovery_grants (jti, expires_at, minted_by, note) VALUES (?,?,?,?)') + .run(jti, Math.floor(Date.now() / 1000) + 3600, 'test', 'session-token-resolution'); + gdb.close(); + S.recoveryToken = jwt.sign( + { id: 'recovery-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti }, + SECRET, { expiresIn: '1h' } + ); + } // Content with a real file + thumbnail, not referenced by any playlist, so // /api/content/:id/{file,thumbnail} falls through to the requester gate.