Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/StepUpAuth.qml

400 lines
18 KiB
QML

// Step-up authentication singleton for the Souveraine shell.
//
// Provides short-lived, in-memory grants for sensitive operations (send, push,
// delete, payment, physical access, admin). A grant is minted after a separate
// PAM conversation succeeds via the `souveraine-stepup` PAM service. This
// service never unlocks the session and never accepts a boolean from an agent
// as proof.
//
// Architecture reference: SESSION-TRUST-ARCHITECTURE.md — "Step-up
// authentication is a separate PamContext, using a dedicated PAM service such
// as souveraine-stepup. It never unlocks the session and it never accepts a
// boolean from an agent as proof. A successful result mints a short-lived,
// in-memory grant bound to the local action family."
//
// The grant TTL is a deliberate policy setting (grantTtlMs), not an
// implementation accident. It defaults to 5 minutes and is configurable via
// Config.options.lock.stepUp.grantTtlMs.
//
// Grants are cleared on: lock, session end, PAM failure, and expiry. The
// expiry timer runs every 30 seconds; the precision of expiry is intentionally
// coarse because step-up is a convenience layer, not a security kernel.
//
// Integration:
// - GlobalStates.onScreenLockedChanged -> revokeAll()
// - Session actionFailed / lock verbs -> revokeAll()
// - 30-second Timer -> expire stale grants
//
// The PAM service file `/etc/pam.d/souveraine-stepup` is NOT shipped by the
// shell — it is root-owned system config. Until a native PAM binary lands, the
// authentication flow falls through to polkit (pkexec/pkcheck) as a backend.
// The Process passes the password via the SOUVERAINE_STEPUP_PASSWORD
// environment variable, matching LockScreen.qml's unlockKeyring pattern.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
// --- Action families ----------------------------------------------------
// These are the semantic groupings of operations that require step-up.
// A grant for "send" covers message sends and content pushes; "delete"
// covers irreversible removals; "payment" covers financial transactions;
// "physical" covers door locks, device unlock beyond session; "admin"
// covers system administration that session-level lock does not gate.
readonly property string familySend: "send"
readonly property string familyDelete: "delete"
readonly property string familyPayment: "payment"
readonly property string familyPhysical: "physical"
readonly property string familyAdmin: "admin"
// --- Grant model --------------------------------------------------------
// actionFamily -> { granted: timestamp_ms, expires: timestamp_ms }
// Grants are plain objects, not QML types, because the set of families is
// open-ended and callers only need the two timestamps.
property var grants: ({})
// Default 5 minutes. Overridable via Config.options.lock.stepUp.grantTtlMs
// so the policy knob lives in the user's config, not in source.
property int grantTtlMs: Config.options?.lock?.stepUp?.grantTtlMs ?? 300000
// --- Signals ------------------------------------------------------------
signal authSucceeded(string actionFamily)
signal authFailed(string actionFamily)
signal grantExpired(string actionFamily)
signal grantRevoked(string actionFamily)
// --- Internal state -----------------------------------------------------
// The callback for the in-flight auth request. Only one auth conversation
// runs at a time; a second requestAuth while one is pending is refused.
// This matches PAM's own serial conversation model — you cannot interleave
// two pam_authenticate calls on the same handle.
property var _pendingCallback: null
property string _pendingFamily: ""
// --- PAM / polkit process -----------------------------------------------
// The helper binary `souveraine-pam-auth` is the preferred backend. It
// reads the password from SOUVERAINE_STEPUP_PASSWORD (environment, not
// argv — argv leaks via /proc/PID/cmdline) and calls pam_authenticate
// against the souveraine-stepup PAM service. Exit 0 = authenticated,
// exit 1 = denied, exit 2 = PAM error.
//
// Until that binary exists on the target machine, the fallback is
// pkcheck(1) against org.souveraine.stepup, which triggers the polkit
// agent's password dialog. pkcheck does not accept a password on stdin;
// the polkit agent owns the prompt. This means the fallback flow is:
// 1. pkcheck --process $$ --action-id org.souveraine.stepup
// 2. polkit agent prompts the user
// 3. pkcheck exits 0 (authorized) or 1 (denied / cancelled)
//
// The Process runs one auth at a time. If a second request arrives while
// one is in flight, it is refused with { ok: false, reason: "auth in
// progress" }.
Process {
id: authProc
property string activeFamily: ""
onExited: (exitCode, exitStatus) => {
const family = authProc.activeFamily;
authProc.activeFamily = "";
if (family.length === 0) {
console.log("[step-up] auth process exited with no active family (stale?)");
return;
}
const callback = root._pendingCallback;
root._pendingCallback = null;
root._pendingFamily = "";
if (exitCode === 0) {
root._mintGrant(family);
root.authSucceeded(family);
console.log(`[step-up] auth succeeded for ${family}`);
if (callback) callback(true);
} else {
root._clearGrant(family);
root.authFailed(family);
console.log(`[step-up] auth failed for ${family} (exit ${exitCode})`);
if (callback) callback(false);
}
}
}
// --- Public API ---------------------------------------------------------
// requestAuth — initiate a step-up authentication for the given action
// family. The callback receives a boolean: true if the user authenticated
// successfully and a grant was minted, false otherwise.
//
// Returns { ok: true } if the auth flow started, or { ok: false, reason }
// if it was refused (auth already in progress, or empty family).
function requestAuth(actionFamily, callback) {
const family = String(actionFamily || "").trim();
if (!family) return { ok: false, reason: "empty action family" };
if (authProc.running) return { ok: false, reason: "auth in progress" };
// If a valid grant already exists, skip the PAM conversation entirely.
// The caller gets an immediate true and the TTL is not extended — this
// is intentional: repeated requests within the window do not refresh
// the clock, so the grant expires on schedule regardless of how often
// it is checked. A caller that wants a fresh window must revoke first.
if (root.isGranted(family)) {
console.log(`[step-up] grant for ${family} still valid, skipping auth`);
if (callback) callback(true);
return { ok: true, reason: "already granted" };
}
root._pendingCallback = callback;
root._pendingFamily = family;
// Try the dedicated PAM helper first. If the binary is not installed,
// fall through to pkcheck (polkit). This is a runtime detection, not a
// compile-time choice — the same shell binary runs on machines with
// and without souveraine-pam-auth.
authProc.activeFamily = family;
authProc.command = ["sh", "-c",
"if command -v souveraine-pam-auth >/dev/null 2>&1; then " +
"souveraine-pam-auth; " +
"else " +
"pkcheck --process $$ --action-id org.souveraine.stepup --allow-user-interaction; " +
"fi"];
authProc.running = true;
console.log(`[step-up] auth started for ${family}`);
return { ok: true };
}
// isGranted — check if a valid (non-expired) grant exists for the given
// action family. Returns true only if the grant exists and has not yet
// exceeded its TTL. Does NOT trigger re-auth; it is a pure read.
function isGranted(actionFamily) {
const family = String(actionFamily || "").trim();
const grant = root.grants[family];
if (!grant) return false;
const now = Date.now();
if (now - grant.granted >= root.grantTtlMs) {
// Grant has expired. Clear it synchronously so the next call does
// not re-read a stale entry, and emit the signal so surfaces can
// react (e.g. disable a send button).
root._clearGrant(family);
root.grantExpired(family);
return false;
}
return true;
}
// revokeGrant — explicitly revoke a single grant. Used when an operation
// completes (the grant served its purpose) or when the caller decides the
// context has changed. Does nothing if no grant exists for that family.
function revokeGrant(actionFamily) {
const family = String(actionFamily || "").trim();
if (!root.grants[family]) return;
root._clearGrant(family);
root.grantRevoked(family);
console.log(`[step-up] grant revoked for ${family}`);
}
// revokeAll — clear every active grant. Called on lock, session end, and
// any condition that invalidates the entire trust surface. This is the
// fail-closed path: if something goes wrong that we cannot characterize
// per-family, all grants die.
function revokeAll() {
const families = Object.keys(root.grants);
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`
+ (hadBreakGlass ? ", break-glass cleared" : "") + ")");
}
// state — return the current grant state as a plain object for IPC
// projection. This is what an agent reads when it needs to know which
// action families are currently authorized. Every field is re-derived at
// call time; nothing is cached trust.
function state() {
const now = Date.now();
const active = {};
const families = Object.keys(root.grants);
families.forEach(family => {
const grant = root.grants[family];
if (grant && (now - grant.granted < root.grantTtlMs)) {
active[family] = {
granted: grant.granted,
expires: grant.granted + root.grantTtlMs,
remainingMs: (grant.granted + root.grantTtlMs) - now
};
}
});
return {
grantTtlMs: root.grantTtlMs,
activeGrants: active,
authInProgress: authProc.running,
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) {
const now = Date.now();
const next = Object.assign({}, root.grants);
next[family] = { granted: now, expires: now + root.grantTtlMs };
root.grants = next;
}
function _clearGrant(family) {
if (!root.grants[family]) return;
const next = Object.assign({}, root.grants);
delete next[family];
root.grants = next;
}
// --- Lock integration ---------------------------------------------------
// On lock, every grant dies. The session is no longer in a state where
// step-up can meaningfully authorize anything — the user is behind a
// credential gate and any grant minted before the lock would be
// meaningless after it. This is the fail-closed path.
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
root.revokeAll();
}
}
}
// --- Session end integration --------------------------------------------
// Session.logout() terminates the compositor session. Grants are
// in-memory only and die with the process, but explicit revocation on
// session end ensures the signal fires so surfaces can update their state
// before the session tears down, rather than discovering the grants are
// gone only when they try to read them after the fact.
Connections {
target: Session
function onActionFailed(action, exitCode) {
// If a lock action failed, that means we might be in an
// ambiguous state — the session intended to lock but did not.
// Revoking all grants is the conservative choice: a failed lock
// is a trust anomaly.
if (action === "lock") {
root.revokeAll();
}
}
}
// --- Expiry timer -------------------------------------------------------
// Checks every 30 seconds for grants that have exceeded their TTL. The
// granularity is deliberately coarse: step-up is a convenience layer for
// the user, not a security kernel. Sub-second precision would add
// complexity for no meaningful security gain — the TTL itself is a policy
// setting with a 5-minute default, and 30 seconds of drift on a
// 300-second window is acceptable.
Timer {
id: expiryTimer
interval: 30000
repeat: true
running: true
onTriggered: {
const now = Date.now();
const families = Object.keys(root.grants);
families.forEach(family => {
const grant = root.grants[family];
if (grant && (now - grant.granted >= root.grantTtlMs)) {
root._clearGrant(family);
root.grantExpired(family);
console.log(`[step-up] grant expired for ${family}`);
}
});
}
}
}