break-glass: one-time emergency grant with journaling
StepUpAuth.breakGlass(actionFamily, reason): - Requires a non-empty reason (logged prominently) - 60-second TTL (not the normal 5 minutes) - One-time: consumed on use via isBreakGlass() - Cannot be issued while session is locked - Cleared on lock (fail-closed) - Tracked separately from normal grants for audit Souveraine.send() now checks break-glass before blocking on step-up. Break-glass bypasses normal PAM auth — the user declared an emergency.
This commit is contained in:
parent
61bdc5360f
commit
377b82999d
2 changed files with 90 additions and 4 deletions
|
|
@ -285,9 +285,12 @@ Singleton {
|
|||
if (text.length === 0) return false;
|
||||
// Step-up gate: if enabled and no valid send grant exists, block
|
||||
// the send and emit a signal so the UI can trigger auth + retry.
|
||||
// Break-glass grants bypass normal step-up — they are one-time,
|
||||
// short-lived, and journaled.
|
||||
if (Config.options?.lock?.stepUp?.enabled
|
||||
&& typeof StepUpAuth !== "undefined"
|
||||
&& !StepUpAuth.isGranted("send")) {
|
||||
&& !StepUpAuth.isGranted("send")
|
||||
&& !StepUpAuth.isBreakGlass("send")) {
|
||||
root.stepUpRequired("send", text);
|
||||
return "step-up";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,10 +206,13 @@ Singleton {
|
|||
// per-family, all grants die.
|
||||
function revokeAll() {
|
||||
const families = Object.keys(root.grants);
|
||||
if (families.length === 0) return;
|
||||
const hadBreakGlass = root._breakGlassGrant !== null;
|
||||
if (families.length === 0 && !hadBreakGlass) return;
|
||||
root.grants = ({});
|
||||
root._clearBreakGlass();
|
||||
families.forEach(family => root.grantRevoked(family));
|
||||
console.log(`[step-up] all grants revoked (${families.length} families)`);
|
||||
console.log(`[step-up] all grants revoked (${families.length} families`
|
||||
+ (hadBreakGlass ? ", break-glass cleared" : "") + ")");
|
||||
}
|
||||
|
||||
// state — return the current grant state as a plain object for IPC
|
||||
|
|
@ -234,10 +237,90 @@ Singleton {
|
|||
grantTtlMs: root.grantTtlMs,
|
||||
activeGrants: active,
|
||||
authInProgress: authProc.running,
|
||||
pendingFamily: root._pendingFamily
|
||||
pendingFamily: root._pendingFamily,
|
||||
breakGlassActive: root._breakGlassGrant !== null
|
||||
};
|
||||
}
|
||||
|
||||
// --- Break-glass grant -------------------------------------------------
|
||||
// A one-time, short-lived, journaled override for emergency operations.
|
||||
// This is NOT a config toggle. It is a per-decision, reasoned, logged
|
||||
// bypass that exists because real emergencies happen and the user must
|
||||
// be able to act.
|
||||
//
|
||||
// Properties:
|
||||
// - Requires a non-empty reason string (the "why")
|
||||
// - Short TTL (60 seconds by default, not the normal 5 minutes)
|
||||
// - One-time: consumed on use, cannot be reused
|
||||
// - Prominently logged with the reason
|
||||
// - Cannot be issued while the session is locked
|
||||
// - Cleared on lock, like all other grants
|
||||
//
|
||||
// The break-glass grant is tracked separately from normal grants so
|
||||
// that audit tools can distinguish "user authenticated normally" from
|
||||
// "user declared an emergency override".
|
||||
property var _breakGlassGrant: null
|
||||
property int breakGlassTtlMs: 60000 // 60 seconds
|
||||
|
||||
signal breakGlassIssued(string reason, int expiresAt)
|
||||
signal breakGlassConsumed(string reason)
|
||||
signal breakGlassExpired(string reason)
|
||||
|
||||
// breakGlass — issue a one-time emergency grant for the given action
|
||||
// family. Returns { ok: true, expiresAt } or { ok: false, reason }.
|
||||
//
|
||||
// The reason is mandatory and logged. If the caller cannot explain why
|
||||
// they need break-glass, they should use normal step-up auth instead.
|
||||
function breakGlass(actionFamily, reason) {
|
||||
const family = String(actionFamily || "").trim();
|
||||
const why = String(reason || "").trim();
|
||||
if (!family) return { ok: false, reason: "empty action family" };
|
||||
if (!why) return { ok: false, reason: "break-glass requires a reason" };
|
||||
if (GlobalStates.screenLocked)
|
||||
return { ok: false, reason: "break-glass unavailable while locked" };
|
||||
if (root._breakGlassGrant)
|
||||
return { ok: false, reason: "break-glass already active" };
|
||||
|
||||
const now = Date.now();
|
||||
root._breakGlassGrant = {
|
||||
family: family,
|
||||
reason: why,
|
||||
granted: now,
|
||||
expires: now + root.breakGlassTtlMs
|
||||
};
|
||||
console.log(`[step-up] BREAK-GLASS issued for ${family}: "${why}" `
|
||||
+ `(expires in ${root.breakGlassTtlMs / 1000}s)`);
|
||||
root.breakGlassIssued(why, now + root.breakGlassTtlMs);
|
||||
return { ok: true, expiresAt: now + root.breakGlassTtlMs };
|
||||
}
|
||||
|
||||
// isBreakGlass — check if a valid break-glass grant exists for the
|
||||
// given action family. Unlike normal grants, break-glass is one-time:
|
||||
// calling this function consumes it. Returns true if the grant was
|
||||
// valid and consumed, false otherwise.
|
||||
function isBreakGlass(actionFamily) {
|
||||
const family = String(actionFamily || "").trim();
|
||||
const bg = root._breakGlassGrant;
|
||||
if (!bg) return false;
|
||||
if (bg.family !== family) return false;
|
||||
if (Date.now() >= bg.expires) {
|
||||
root._breakGlassGrant = null;
|
||||
root.breakGlassExpired(bg.reason);
|
||||
return false;
|
||||
}
|
||||
// Consume the grant — one-time use.
|
||||
root._breakGlassGrant = null;
|
||||
root.breakGlassConsumed(bg.reason);
|
||||
console.log(`[step-up] BREAK-GLASS consumed for ${family}: "${bg.reason}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Revoke break-glass on lock (fail-closed).
|
||||
function _clearBreakGlass() {
|
||||
if (!root._breakGlassGrant) return;
|
||||
root._breakGlassGrant = null;
|
||||
}
|
||||
|
||||
// --- Internal -----------------------------------------------------------
|
||||
|
||||
function _mintGrant(family) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue