mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:
- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
a break-glass session left no trace at all.
A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.
The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.
Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.
The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.
In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.
test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
4.4 KiB
JavaScript
87 lines
4.4 KiB
JavaScript
'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 };
|