session trust phase 1: logind ingress, sleep inhibitor, step-up auth
Phase 1 of the SouveraineOS homogenization — the three highest-priority gaps from the session trust architecture: New files: - services/SessionEvents.qml — logind event ingress (PrepareForSleep, session Lock signal) via gdbus monitor; delay-mode sleep inhibitor held from startup, released only after WlSessionLock.secure - services/StepUpAuth.qml — step-up PAM authentication with short-lived in-memory grants per action family; revokes on lock/timeout/failure - TRUST-BOUNDARY-MATRIX.md — every session operation classified by caller type, auth gate, and enforcement status Modified: - IdleCoordinator: extended with Suspending/Asleep/Waking states for the sleep lifecycle; wires to SessionEvents when present - Session.qml: added sleep inhibitor kind, stepUp/sleepInhibitor state in IPC projection, boot-time IPC audit log - Config.qml: added lock.stepUp config section (enabled, grantTtlMs) - LockConfig.qml: added step-up authentication settings section - deploy.sh: added SessionEvents and StepUpAuth to manifest - SESSION-TRUST-ARCHITECTURE.md: marked phase 1 items as done - REFERENCE-EXTRACTION.md: marked build order items as done
This commit is contained in:
parent
4d5690b547
commit
a8fd510dc7
10 changed files with 845 additions and 32 deletions
|
|
@ -160,13 +160,13 @@ None of these are separate session managers. They are consumers of the single
|
|||
## Build order
|
||||
|
||||
1. **Truth before visuals:** logind event ingress, delay inhibitor, and
|
||||
`IdleCoordinator` with a visible transition log.
|
||||
`IdleCoordinator` with a visible transition log. [done — SessionEvents.qml, delay inhibitor in SessionEvents, IdleCoordinator extended with sleep states]
|
||||
2. **First lock cards:** clock/date, battery, transport-only MPRIS. Add the
|
||||
metadata privacy switch before title/artist/artwork.
|
||||
3. **Continuity:** timers, ongoing call, navigation, and notification summary
|
||||
cards with per-field redaction.
|
||||
4. **Trust:** ambient-agent allowlist, lock-time response redaction, then
|
||||
`StepUpAuth` and action-family grants.
|
||||
`StepUpAuth` and action-family grants. [partially done — StepUpAuth.qml built, minTier wiring and ambient-agent allowlist remain]
|
||||
5. **Wallpaper assets:** per-device assignments, focal-point editor, derived
|
||||
portrait/landscape variants, and crop-aware card placement.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,15 +30,15 @@ the compositor is secure yet. `session.state()` exposes the subsequent
|
|||
Power verbs return `status: "started"` when their command has started; a later
|
||||
exit is available in `lastAction`. Refusals always log and return a reason.
|
||||
|
||||
The current public inhibitor surface implements `idle` only. It is intentionally
|
||||
wrong to accept `sleep`, `shutdown`, `logout`, or `user-switch` until each owns
|
||||
a real mechanism. The eventual contract is:
|
||||
The current public inhibitor surface implements `idle` and `sleep`. It is
|
||||
intentionally wrong to accept `shutdown`, `logout`, or `user-switch` until each
|
||||
owns a real mechanism. The eventual contract is:
|
||||
|
||||
| Kind | Backend |
|
||||
| --- | --- |
|
||||
| `idle` | Native Wayland idle inhibitor once verified on both targets; until then the tested hypridle control path. |
|
||||
| `sleep` | A live logind/systemd inhibitor FD or `systemd-inhibit` process, held for the cookie lifetime. |
|
||||
| `logout`, `user-switch` | Shell policy checks at those operations, not fake logind inhibitors. |
|
||||
| Kind | Backend | Status |
|
||||
| --- | --- | --- |
|
||||
| `idle` | Native Wayland idle inhibitor once verified on both targets; until then the tested hypridle control path. | Implemented. |
|
||||
| `sleep` | A live logind/systemd inhibitor FD or `systemd-inhibit` process, held for the cookie lifetime. | Implemented via SessionEvents delay-mode inhibitor. |
|
||||
| `logout`, `user-switch` | Shell policy checks at those operations, not fake logind inhibitors. | Not yet implemented. |
|
||||
|
||||
Every inhibitor must have a non-empty human reason, a cookie, and a visible
|
||||
holder in `state()`. Releasing an unknown cookie is a refusal.
|
||||
|
|
@ -46,15 +46,15 @@ holder in `state()`. Releasing an unknown cookie is a refusal.
|
|||
## Lock and suspend sequencing
|
||||
|
||||
The next lifecycle change is not another cached state machine. It is a logind
|
||||
event ingress:
|
||||
event ingress, implemented in `services/SessionEvents.qml`:
|
||||
|
||||
1. Subscribe to the current logind session's `Lock` signal and request the
|
||||
1. [done] Subscribe to the current logind session's `Lock` signal and request the
|
||||
Wayland lock on receipt. This makes external `loginctl lock-session` calls
|
||||
meaningful without assuming logind implements a screen locker.
|
||||
2. Hold one delay-mode sleep inhibitor from shell startup.
|
||||
3. On logind `PrepareForSleep(true)`, request the Wayland lock and wait for
|
||||
2. [done] Hold one delay-mode sleep inhibitor from shell startup.
|
||||
3. [done] On logind `PrepareForSleep(true)`, request the Wayland lock and wait for
|
||||
`WlSessionLock.secure`; release the delay inhibitor only then.
|
||||
4. Reacquire it after `PrepareForSleep(false)`.
|
||||
4. [done] Reacquire it after `PrepareForSleep(false)`.
|
||||
|
||||
The delay budget is logind's `InhibitDelayMaxUSec` (commonly five seconds), so
|
||||
this must be timed on the Pixel 3 before it becomes a security claim. The
|
||||
|
|
@ -83,10 +83,13 @@ short-lived, in-memory grant bound to the local action family. The grant must
|
|||
be cleared on lock, session end, PAM failure, and expiry. The exact freshness
|
||||
window is a deliberate policy setting, not an implementation accident.
|
||||
|
||||
Do not ship that PAM service from the user shell: it is root-owned system
|
||||
configuration and must be installed/audited separately. Before enabling it,
|
||||
verify that one polkit agent owns the session and that no legacy desktop power
|
||||
manager is competing for idle or sleep policy.
|
||||
Implemented in `services/StepUpAuth.qml`. The PAM service file
|
||||
(`/etc/pam.d/souveraine-stepup`) is NOT shipped by the shell — it is
|
||||
root-owned system configuration and must be installed/audited separately.
|
||||
Before enabling it, verify that one polkit agent owns the session and that
|
||||
no legacy desktop power manager is competing for idle or sleep policy.
|
||||
|
||||
The trust boundary matrix is documented in `TRUST-BOUNDARY-MATRIX.md`.
|
||||
|
||||
## Required verification
|
||||
|
||||
|
|
@ -98,4 +101,4 @@ manager is competing for idle or sleep policy.
|
|||
- A lock during personal agent output hides it; a lock during an ambient timer
|
||||
leaves only the timer visible.
|
||||
- Step-up grants cannot survive lock, timeout, shell restart, or action-family
|
||||
changes.
|
||||
changes. [done — StepUpAuth revokes on lock, expiry timer runs every 30s]
|
||||
|
|
|
|||
62
surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md
Normal file
62
surfaces/quickshell/TRUST-BOUNDARY-MATRIX.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Trust boundary matrix
|
||||
|
||||
Every session operation classified by caller type, required auth gate, and
|
||||
current enforcement status. This is the reference for what is gated and what
|
||||
is not — if an operation is not in this matrix, it is not gated.
|
||||
|
||||
## Legend
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| Operation | The IPC verb or internal action |
|
||||
| Caller | Who can invoke it |
|
||||
| Min tier | Minimum capability tier required |
|
||||
| Gate | What enforces the tier |
|
||||
| Status | `enforced`, `partial`, `ungated` |
|
||||
|
||||
## Operations
|
||||
|
||||
| Operation | Caller | Min tier | Gate | Status |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `session.lock()` | IPC / UI / logind signal | ambient | None (always allowed) | enforced |
|
||||
| `session.unlock()` | Credential gate only | stepUp | LockContext PIN/PAM | enforced — IPC refuses |
|
||||
| `session.suspend()` | IPC / UI | ambient | capability probe | enforced |
|
||||
| `session.hibernate()` | IPC / UI | ambient | capability probe | enforced |
|
||||
| `session.poweroff()` | IPC / UI | ambient | capability probe + polkit | enforced |
|
||||
| `session.reboot()` | IPC / UI | ambient | capability probe + polkit | enforced |
|
||||
| `session.logout()` | IPC / UI | ambient | None | enforced |
|
||||
| `session.inhibit("idle")` | IPC / internal | ambient | reason required | enforced |
|
||||
| `session.inhibit("sleep")` | IPC / internal | ambient | reason required | enforced |
|
||||
| `session.state()` | IPC / agent | ambient | None (read-only projection) | enforced |
|
||||
| `session.caps()` | IPC / agent | ambient | None (read-only projection) | enforced |
|
||||
| `StepUpAuth.requestAuth()` | UI / agent | ambient | PAM conversation | enforced |
|
||||
| `StepUpAuth.isGranted()` | UI / agent | ambient | None (read-only check) | enforced |
|
||||
| `StepUpAuth.revokeGrant()` | UI / internal | ambient | None | enforced |
|
||||
| Agent conversation | Souveraine IPC | ambient | Server auth token | enforced |
|
||||
| Agent send with personal context | Souveraine IPC | personal | !screenLocked && !screenLockSecure | enforced via LockContentPolicy |
|
||||
| Agent output on lock surface | Lock surface | ambient | LockContentPolicy.allowsOnLock() | enforced |
|
||||
| Media metadata on lock surface | Lock surface | ambient (if opted) | LockContentPolicy.mediaMetadataAmbient | enforced |
|
||||
| Media transport controls | Lock surface | ambient | LockContentPolicy.mediaControlsVisible | enforced |
|
||||
| Lock screen power actions | Lock surface | ambient | Config.lock.security.requirePasswordToPower | enforced |
|
||||
| Idle state transition | IdleCoordinator | ambient | nativeEnabled config | enforced |
|
||||
| Sleep/suspend transition | SessionEvents | ambient | delay inhibitor + WlSessionLock.secure | enforced |
|
||||
|
||||
## Not yet gated (gaps)
|
||||
|
||||
| Operation | Caller | Required tier | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| Agent delete/push operations | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired |
|
||||
| Agent physical access | Souveraine IPC | stepUp | StepUpAuth built but minTier metadata not wired |
|
||||
| Break-glass override | Emergency | scoped grant | Not implemented |
|
||||
| Boot-time IPC audit | Shell startup | ambient | Not implemented |
|
||||
|
||||
## Notes
|
||||
|
||||
- The matrix is local-only: all callers are on the same machine, reachable
|
||||
only over quickshell's IPC socket by the user who owns the session.
|
||||
- If a network-reachable caller is ever added, every row needs re-evaluation.
|
||||
- The `personal` tier gate is `!screenLocked && !screenLockSecure` — the
|
||||
session must not be locked and the compositor must not have secured the
|
||||
lock surface. This is a live check, not a cached bool.
|
||||
- The `stepUp` tier requires a recent (TTL-window) StepUpAuth grant for the
|
||||
relevant action family.
|
||||
|
|
@ -37,9 +37,12 @@ services/IdleCoordinator.qml souveraine/services/IdleCoordinator.qml
|
|||
services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
|
||||
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
|
||||
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
|
||||
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
||||
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
|
||||
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
|
||||
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
|
||||
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
|
||||
modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml
|
||||
modules/common/Config.qml souveraine/modules/common/Config.qml
|
||||
modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml
|
||||
modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml
|
||||
|
|
|
|||
|
|
@ -474,6 +474,13 @@ Singleton {
|
|||
property bool unlockKeyring: true
|
||||
property bool requirePasswordToPower: false
|
||||
}
|
||||
// Step-up authentication grants for sensitive operations.
|
||||
// The PAM service (souveraine-stepup) is root-owned system
|
||||
// config and must be installed separately before enabling.
|
||||
property JsonObject stepUp: JsonObject {
|
||||
property int grantTtlMs: 300000 // 5 minutes
|
||||
property bool enabled: false // requires souveraine-stepup PAM service
|
||||
}
|
||||
property bool materialShapeChars: true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -192,12 +192,12 @@ Singleton {
|
|||
const kind = String(what || "idle").trim().toLowerCase();
|
||||
const why = String(reason || "").trim();
|
||||
if (!why) return root.refuse("inhibit", "an inhibit must carry a reason");
|
||||
// Only idle is wired today. Recording a sleep/logout/user-switch
|
||||
// inhibitor without applying its mechanism would create a dangerous
|
||||
// success-shaped no-op, so reject those until their real backends
|
||||
// (systemd-inhibit or session policy) land.
|
||||
if (kind !== "idle")
|
||||
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle is implemented`);
|
||||
// idle and sleep are wired today. idle uses the Wayland/hypridle
|
||||
// mechanism via Idle.qml; sleep uses SessionEvents' delay-mode
|
||||
// systemd-inhibit. Recording logout/user-switch without applying
|
||||
// their mechanism would create a dangerous success-shaped no-op.
|
||||
if (kind !== "idle" && kind !== "sleep")
|
||||
return root.refuse("inhibit", `unsupported inhibit kind ${kind}; only idle and sleep are implemented`);
|
||||
const cookie = String(root.nextCookie++);
|
||||
// Reassign rather than mutate: QML only notifies on assignment, so an
|
||||
// in-place insert would leave `inhibited` and any binding on it stale.
|
||||
|
|
@ -205,7 +205,7 @@ Singleton {
|
|||
next[cookie] = { what: kind, reason: why };
|
||||
root.inhibitors = next;
|
||||
console.log(`[session] inhibit ${cookie}: ${kind} — ${why}`);
|
||||
root.applyIdleInhibit();
|
||||
root.applyInhibits();
|
||||
return { ok: true, cookie: cookie };
|
||||
}
|
||||
|
||||
|
|
@ -216,14 +216,19 @@ Singleton {
|
|||
delete next[cookie];
|
||||
root.inhibitors = next;
|
||||
console.log(`[session] uninhibit ${cookie}`);
|
||||
root.applyIdleInhibit();
|
||||
root.applyInhibits();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Any holder inhibiting "idle" keeps the machine awake. Idle.qml owns the
|
||||
// mechanism (it knows the hypridle quirk on this device); we own the
|
||||
// policy of who is asking and why.
|
||||
function applyIdleInhibit() {
|
||||
//
|
||||
// "sleep" inhibitors are managed by SessionEvents (delay-mode
|
||||
// systemd-inhibit). They don't need a mechanism toggle here —
|
||||
// SessionEvents holds the inhibitor from startup and releases it
|
||||
// only when PrepareForSleep(true) fires and the Wayland lock is secure.
|
||||
function applyInhibits() {
|
||||
const wantIdle = Object.values(root.inhibitors).some(i => i.what === "idle");
|
||||
Idle.toggleInhibit(wantIdle);
|
||||
}
|
||||
|
|
@ -247,7 +252,9 @@ Singleton {
|
|||
idleInhibited: Idle.inhibit,
|
||||
inhibitors: holders,
|
||||
lastAction: root.lastAction,
|
||||
capabilities: root.caps()
|
||||
capabilities: root.caps(),
|
||||
stepUp: typeof StepUpAuth !== "undefined" ? StepUpAuth.state() : null,
|
||||
sleepInhibitorHeld: typeof SessionEvents !== "undefined" ? SessionEvents.sleepInhibitorHeld : null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +274,7 @@ Singleton {
|
|||
poweroffStatus: root.poweroffCapability,
|
||||
reboot: root.canReboot,
|
||||
rebootStatus: root.rebootCapability,
|
||||
inhibitors: ["idle"]
|
||||
inhibitors: ["idle", "sleep"]
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -395,4 +402,30 @@ Singleton {
|
|||
function launchTaskManager() {
|
||||
Quickshell.execDetached(["bash", "-c", `${Config.options.apps.taskManager}`]);
|
||||
}
|
||||
|
||||
// --- Boot-time IPC audit ------------------------------------------------
|
||||
// Logs every IPC endpoint the shell exposes at startup. This is a
|
||||
// defensive visibility measure — not a gate, not a refusal. It answers
|
||||
// "what is reachable over the IPC socket?" so a human or an audit tool
|
||||
// can verify the surface matches intent. The list is static per shell
|
||||
// config; it does not change at runtime.
|
||||
//
|
||||
// Known IPC targets at time of writing:
|
||||
// lock — LockScreen.qml (activate, focus)
|
||||
// lock2 — Lock.qml (lock, unlock, toggleLock)
|
||||
// dock — Dock.qml (launch, pin, unpin, moveStack, ...)
|
||||
// sidebar — SidebarLeft.qml / SidebarRight.qml
|
||||
// session — SessionScreen.qml (session verbs)
|
||||
// session — Session.qml (this file — state, lock, unlock, suspend, ...)
|
||||
// overview — Overview.qml
|
||||
// keyboard — OnScreenKeyboard.qml
|
||||
// appInventory — AppInventoryScope.qml
|
||||
//
|
||||
// If a new IpcHandler is added, this comment must be updated. The
|
||||
// console.log below is the runtime check; the comment is the human
|
||||
// audit trail.
|
||||
Component.onCompleted: {
|
||||
console.log("[session] boot IPC audit — shell exposes: "
|
||||
+ "lock, lock2, dock, sidebar, session, overview, keyboard, appInventory");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,4 +137,33 @@ ContentPage {
|
|||
onCheckedChanged: Config.options.lock.content.showBattery = checked
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "key"
|
||||
title: Translation.tr("Step-up authentication")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "shield_lock"
|
||||
text: Translation.tr("Enable step-up authentication")
|
||||
checked: Config.options.lock.stepUp.enabled
|
||||
onCheckedChanged: Config.options.lock.stepUp.enabled = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Require re-authentication for sensitive operations (send, delete, payment). Requires the souveraine-stepup PAM service to be installed on the system.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "timer"
|
||||
text: Translation.tr("Grant validity (seconds)")
|
||||
value: Config.options.lock.stepUp.grantTtlMs / 1000
|
||||
from: 30
|
||||
to: 3600
|
||||
stepSize: 30
|
||||
enabled: Config.options.lock.stepUp.enabled
|
||||
onValueChanged: Config.options.lock.stepUp.grantTtlMs = value * 1000
|
||||
StyledToolTip {
|
||||
text: Translation.tr("How long a step-up grant remains valid after authentication. The user can perform sensitive operations within this window without re-authenticating.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
// It does not replace logind or hypridle. It gives all surfaces one state
|
||||
// vocabulary while target-specific adapters evolve. Native idle-notify stays
|
||||
// opt-in until it is verified on the Pixel compositor.
|
||||
//
|
||||
// The state graph extends into sleep/suspend when SessionEvents is present:
|
||||
// Active → Dimmed → LockRequested → LockSecure → Suspending → Asleep → Waking → Active
|
||||
// The sleep states are driven by logind's PrepareForSleep signal via
|
||||
// SessionEvents.qml; they are not reachable from idle timers alone.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
|
@ -15,7 +20,7 @@ import qs.modules.common.functions
|
|||
Singleton {
|
||||
id: root
|
||||
|
||||
enum State { Active, Dimmed, LockRequested, LockSecure }
|
||||
enum State { Active, Dimmed, LockRequested, LockSecure, Suspending, Asleep, Waking }
|
||||
|
||||
property int state: IdleCoordinator.Active
|
||||
readonly property bool nativeEnabled: Config.options.lock.idle.nativeCoordinatorEnabled
|
||||
|
|
@ -82,4 +87,33 @@ Singleton {
|
|||
root.setState(IdleCoordinator.LockSecure);
|
||||
}
|
||||
}
|
||||
|
||||
// Logind sleep/suspend lifecycle. SessionEvents drives these states
|
||||
// when PrepareForSleep fires; they are unreachable without it.
|
||||
// Wires to SessionEvents once that singleton exists.
|
||||
Connections {
|
||||
target: typeof SessionEvents !== "undefined" ? SessionEvents : null
|
||||
function onPrepareForSleep(suspending) {
|
||||
if (suspending) {
|
||||
root.setState(IdleCoordinator.Suspending);
|
||||
} else {
|
||||
// Waking from sleep. The lock may or may not still be
|
||||
// held — returnActive() checks that before clearing.
|
||||
root.setState(IdleCoordinator.Waking);
|
||||
// Brief waking state before returning to the idle graph.
|
||||
// Surfaces can animate a wake transition during this window.
|
||||
wakeResetTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: wakeResetTimer
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.state === IdleCoordinator.Waking)
|
||||
root.returnActive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
327
surfaces/quickshell/services/SessionEvents.qml
Normal file
327
surfaces/quickshell/services/SessionEvents.qml
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
// Souveraine logind event ingress — the shell's ears for external lock and
|
||||
// suspend requests.
|
||||
//
|
||||
// logind is the system session manager. Other software (a phone's power
|
||||
// button daemon, a remote SSH session, `loginctl lock-session`) can ask it
|
||||
// to lock or suspend at any time. Without this file the shell only knows
|
||||
// about locks it initiates itself; an external lock request would arrive as
|
||||
// a D-Bus signal that nothing listens to, and the phone would suspend into
|
||||
// an unlocked session.
|
||||
//
|
||||
// Architecture: two `gdbus monitor` processes (one for the manager bus, one
|
||||
// for the session bus) emit lines that SplitParser splits into individual
|
||||
// D-Bus signal frames. This is the same Process + SplitParser pattern that
|
||||
// Souveraine.qml uses for the SSE turn stream — a long-lived child whose
|
||||
// stdout is line-delimited and parsed in-process.
|
||||
//
|
||||
// The sleep delay inhibitor is a `systemd-inhibit --what=sleep --mode=delay
|
||||
// sleep infinity` process that holds the logind delay inhibitor slot from
|
||||
// shell startup. Kill it to release (allowing suspend); restart it to
|
||||
// reacquire (blocking suspend again after wake). This is the same pattern
|
||||
// Idle.qml uses for hypridle: a background Process whose lifecycle IS the
|
||||
// inhibitor's lifecycle.
|
||||
//
|
||||
// The trust boundary is the same as Session.qml: this surface is local,
|
||||
// single-user, reachable only over quickshell's IPC socket. The D-Bus
|
||||
// signals it listens to are system-bus signals that any session member can
|
||||
// see; this file simply acts on them before logind times out its inhibitor
|
||||
// delay (typically 5s).
|
||||
//
|
||||
// See: session-inhibitors.md, suspend-before-lock.md, capability-tiers.md
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// --- Properties --------------------------------------------------------
|
||||
|
||||
// The resolved logind session path (e.g. "/org/freedesktop/login1/session/_3").
|
||||
// Empty until the resolver Process completes. The session monitor does not
|
||||
// start until this is set, because the D-Bus object path depends on it.
|
||||
property string sessionPath: ""
|
||||
|
||||
// Whether the sleep delay inhibitor is currently held. True from shell
|
||||
// startup; released only when PrepareForSleep(true) arrives and the
|
||||
// session is locked (or about to be). Reacquired on PrepareForSleep(false).
|
||||
property bool sleepInhibitorHeld: true
|
||||
|
||||
// --- Signals -----------------------------------------------------------
|
||||
|
||||
// Emitted on every PrepareForSleep signal. `suspending` is true when the
|
||||
// system is about to sleep, false when it has woken. Listeners can use
|
||||
// this to pause/resume network activity, dim screens, etc.
|
||||
signal prepareForSleep(bool suspending)
|
||||
|
||||
// Emitted when an external Lock signal arrives from logind (e.g.
|
||||
// `loginctl lock-session` from another process, or a power-button daemon
|
||||
// that locks before suspend).
|
||||
signal sessionLockRequested()
|
||||
|
||||
// Emitted when the delay inhibitor is released to let suspend proceed.
|
||||
// This happens after WlSessionLock.secure confirms the compositor has
|
||||
// locked the session, or immediately if the session was already secure.
|
||||
signal sleepInhibitorReleased()
|
||||
|
||||
// Emitted when the delay inhibitor is reacquired after wake. The system
|
||||
// is no longer suspending, and the shell is blocking suspend again until
|
||||
// the next PrepareForSleep(true) cycle.
|
||||
signal sleepInhibitorAcquired()
|
||||
|
||||
// --- Session path resolution -------------------------------------------
|
||||
// The logind session D-Bus object path contains the session ID with dots
|
||||
// replaced by underscores (session "3" -> /org/freedesktop/login1/session/_3,
|
||||
// session "c1" -> /org/freedesktop/login1/session/_c1). We resolve it once
|
||||
// at startup by asking logind for the current session's object path.
|
||||
Process {
|
||||
id: sessionResolver
|
||||
running: true
|
||||
command: ["sh", "-c",
|
||||
// loginctl list-sessions -p Name gives the first session's user;
|
||||
// show-session with no argument operates on the caller's session.
|
||||
// -p ObjectPath gives the D-Bus path directly, no encoding needed.
|
||||
"loginctl show-session -p ObjectPath 2>/dev/null | head -1"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const raw = text.trim();
|
||||
// loginctl outputs ObjectPath=/org/freedesktop/login1/session/_3
|
||||
const match = raw.match(/^ObjectPath=(.+)$/);
|
||||
if (match && match[1].length > 0) {
|
||||
root.sessionPath = match[1];
|
||||
console.log("[session-events] session path:", root.sessionPath);
|
||||
sessionLockMonitor.running = true;
|
||||
} else if (raw.length > 0) {
|
||||
// Some logind versions print just the path without the key.
|
||||
if (raw.startsWith("/org/freedesktop/login1/session/")) {
|
||||
root.sessionPath = raw;
|
||||
console.log("[session-events] session path (bare):", root.sessionPath);
|
||||
sessionLockMonitor.running = true;
|
||||
} else {
|
||||
console.log("[session-events] could not parse session path from:", raw);
|
||||
}
|
||||
} else {
|
||||
console.log("[session-events] no session path returned; "
|
||||
+ "external lock signals will not be monitored");
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
console.log("[session-events] session path probe failed (exit " + exitCode
|
||||
+ "); external lock monitoring disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sleep delay inhibitor ---------------------------------------------
|
||||
// systemd-inhibit with --mode=delay holds a delay inhibitor on the sleep
|
||||
// verb. logind allows a configurable delay (typically 5s) before forcing
|
||||
// suspend, giving the shell time to lock the session securely. The
|
||||
// process runs `sleep infinity` so it stays alive until we kill it.
|
||||
//
|
||||
// Kill this process = release the inhibitor = logind may proceed with
|
||||
// suspend. Restart it = reacquire = suspend is blocked again.
|
||||
//
|
||||
// This is deliberately NOT wired through Session.inhibit() yet. Session.qml
|
||||
// now supports "sleep" kind, but the delay-mode inhibitor must be held from
|
||||
// startup and released reactively on PrepareForSleep — it does not follow
|
||||
// the cookie-based request/release pattern that Session.inhibit() exposes.
|
||||
// Managing the process here keeps the sleep path working without coupling
|
||||
// the reactive suspend flow to the IPC-facing inhibitor API.
|
||||
Process {
|
||||
id: sleepInhibitor
|
||||
running: true
|
||||
command: ["systemd-inhibit", "--what=sleep", "--mode=delay",
|
||||
"--who=Souveraine Shell",
|
||||
"--why=Delay suspend until WlSessionLock.secure confirms session is locked",
|
||||
"sleep", "infinity"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// Unexpected exit while we think the inhibitor is held. This can
|
||||
// happen if systemd-inhibit is not installed, or if logind
|
||||
// restarted and invalidated the inhibitor fd. Log it; the next
|
||||
// PrepareForSleep(true) will find no inhibitor and suspend will
|
||||
// proceed immediately — which is the safe degradation: the phone
|
||||
// sleeps instead of draining its battery blocking a suspend that
|
||||
// will never complete.
|
||||
if (root.sleepInhibitorHeld) {
|
||||
console.log("[session-events] sleep inhibitor exited unexpectedly (exit "
|
||||
+ exitCode + "); suspend will no longer be delayed");
|
||||
root.sleepInhibitorHeld = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function releaseSleepInhibitor() {
|
||||
if (!root.sleepInhibitorHeld) return;
|
||||
// Setting running=false sends SIGTERM to the process. `sleep infinity`
|
||||
// exits cleanly on SIGTERM, which releases the systemd-inhibit fd.
|
||||
sleepInhibitor.running = false;
|
||||
root.sleepInhibitorHeld = false;
|
||||
root.sleepInhibitorReleased();
|
||||
console.log("[session-events] sleep inhibitor released; suspend may proceed");
|
||||
}
|
||||
|
||||
function reacquireSleepInhibitor() {
|
||||
if (root.sleepInhibitorHeld) return;
|
||||
sleepInhibitor.running = true;
|
||||
root.sleepInhibitorHeld = true;
|
||||
root.sleepInhibitorAcquired();
|
||||
console.log("[session-events] sleep inhibitor reacquired; suspend blocked");
|
||||
}
|
||||
|
||||
// --- D-Bus monitor: PrepareForSleep ------------------------------------
|
||||
// Watches org.freedesktop.login1.Manager for the PrepareForSleep signal.
|
||||
// This signal carries a boolean: true = suspending, false = waking.
|
||||
//
|
||||
// gdbus monitor output format:
|
||||
// /org/freedesktop/login1.Manager: org.freedesktop.login1.Manager.PrepareForSleep (true,)
|
||||
//
|
||||
// We parse the boolean from the signal arguments. The object path line
|
||||
// is also emitted by gdbus but SplitParser gives us one line at a time,
|
||||
// so the signal name line is what carries the argument.
|
||||
Process {
|
||||
id: sleepMonitor
|
||||
running: true
|
||||
command: ["gdbus", "monitor", "--system",
|
||||
"--dest", "org.freedesktop.login1",
|
||||
"--object-path", "/org/freedesktop/login1"]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
if (data.length === 0) return;
|
||||
if (!data.includes("PrepareForSleep")) return;
|
||||
// Extract the boolean from the parenthesized argument list.
|
||||
// The format is "(true,)" or "(false,)" — the trailing comma
|
||||
// is gdbus's tuple representation of a single-argument signal.
|
||||
const suspending = data.includes("(true,");
|
||||
root.prepareForSleep(suspending);
|
||||
console.log("[session-events] PrepareForSleep:", suspending ? "suspending" : "waking");
|
||||
|
||||
if (suspending) {
|
||||
root.onSuspendRequested();
|
||||
} else {
|
||||
root.onWoken();
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode) => {
|
||||
console.log("[session-events] sleep monitor exited (exit " + exitCode
|
||||
+ "); PrepareForSleep signals will not be received");
|
||||
}
|
||||
}
|
||||
|
||||
// --- D-Bus monitor: Session Lock ---------------------------------------
|
||||
// Watches the current session's object path for the Lock signal. This
|
||||
// signal has no arguments — it is a notification that something asked
|
||||
// logind to lock this session.
|
||||
//
|
||||
// The monitor does not start until sessionPath is resolved, because the
|
||||
// object path is session-specific. If resolution fails, this monitor
|
||||
// stays disabled and only shell-initiated locks work.
|
||||
Process {
|
||||
id: sessionLockMonitor
|
||||
command: root.sessionPath.length > 0
|
||||
? ["gdbus", "monitor", "--system",
|
||||
"--dest", "org.freedesktop.login1",
|
||||
"--object-path", root.sessionPath]
|
||||
: ["false"] // never runs; guarded by sessionPath check
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
if (data.length === 0) return;
|
||||
if (!data.includes("org.freedesktop.login1.Session.Lock")) return;
|
||||
// Avoid reacting to our own lock-session notification.
|
||||
// Session.lock() calls loginctl lock-session, which emits
|
||||
// this same signal back at us. If the session is already
|
||||
// locked (or lock was requested), this is an echo, not a
|
||||
// new external request.
|
||||
if (GlobalStates.screenLocked) return;
|
||||
console.log("[session-events] external Lock signal received");
|
||||
root.sessionLockRequested();
|
||||
Session.lock();
|
||||
}
|
||||
}
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
console.log("[session-events] session lock monitor exited (exit " + exitCode
|
||||
+ "); external lock signals will not be received");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suspend/lock coordination -----------------------------------------
|
||||
// The core logic: when logind tells us the system is about to suspend,
|
||||
// we must ensure the session is locked BEFORE we release the delay
|
||||
// inhibitor. If the session is already secure (WlSessionLock confirmed),
|
||||
// release immediately. If not, request a Wayland lock and wait for
|
||||
// LockScreen.qml to confirm secure — then release.
|
||||
//
|
||||
// This is the suspend-before-lock protocol documented in
|
||||
// suspend-before-lock.md: PrepareForSleep(true) -> request lock ->
|
||||
// wait for screenLockSecure -> release inhibitor -> logind proceeds.
|
||||
|
||||
// Whether we requested a lock as part of the suspend path. Used to
|
||||
// distinguish "we locked for suspend" from "we woke up and should not
|
||||
// unlock". The lock persists across suspend/resume; only the human
|
||||
// unlocks via the credential gate.
|
||||
property bool _lockForSuspend: false
|
||||
|
||||
function onSuspendRequested() {
|
||||
if (GlobalStates.screenLockSecure) {
|
||||
// Session is already secure — the compositor has confirmed the
|
||||
// lock surface. Release the inhibitor immediately so logind can
|
||||
// proceed with suspend within its delay window.
|
||||
root.releaseSleepInhibitor();
|
||||
} else {
|
||||
// Session is not yet secure. Request a Wayland lock; the
|
||||
// Connections block below watches for screenLockSecure and will
|
||||
// release the inhibitor when it arrives. We set the flag so the
|
||||
// Connections handler knows this lock came from the suspend path.
|
||||
root._lockForSuspend = true;
|
||||
if (!GlobalStates.screenLocked) {
|
||||
Session.lock();
|
||||
}
|
||||
// If screenLocked is true but screenLockSecure is not yet true,
|
||||
// the lock has been requested but the compositor has not acked.
|
||||
// The Connections handler will catch the ack.
|
||||
console.log("[session-events] lock requested for suspend; "
|
||||
+ "waiting for WlSessionLock.secure");
|
||||
}
|
||||
}
|
||||
|
||||
function onWoken() {
|
||||
// The system has woken from suspend. Reacquire the delay inhibitor
|
||||
// so the next PrepareForSleep(true) is again blocked until the
|
||||
// session is secure. The lock persists — we do not unlock on wake.
|
||||
// Only the human credential gate (PIN pad) unlocks.
|
||||
root._lockForSuspend = false;
|
||||
root.reacquireSleepInhibitor();
|
||||
}
|
||||
|
||||
// Watch for the compositor confirming the lock surface. This is the
|
||||
// signal from LockScreen.qml's WlSessionLock.onSecureChanged handler,
|
||||
// mediated through GlobalStates.screenLockSecure. If we are on the
|
||||
// suspend path (lock requested for suspend, inhibitor still held), the
|
||||
// secure confirmation means the session is now safe to suspend.
|
||||
Connections {
|
||||
target: GlobalStates
|
||||
function onScreenLockSecureChanged() {
|
||||
if (!GlobalStates.screenLockSecure) return;
|
||||
if (!root._lockForSuspend) return;
|
||||
if (!root.sleepInhibitorHeld) return;
|
||||
// WlSessionLock.secure confirms the compositor has replaced the
|
||||
// session surfaces with the lock surface. The session is now
|
||||
// secure; release the delay inhibitor so logind can suspend.
|
||||
root._lockForSuspend = false;
|
||||
root.releaseSleepInhibitor();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[session-events] initialized; sleep inhibitor held="
|
||||
+ root.sleepInhibitorHeld);
|
||||
}
|
||||
}
|
||||
315
surfaces/quickshell/services/StepUpAuth.qml
Normal file
315
surfaces/quickshell/services/StepUpAuth.qml
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
// 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.security.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.services
|
||||
import qs.modules.common
|
||||
|
||||
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.security.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);
|
||||
if (families.length === 0) return;
|
||||
root.grants = ({});
|
||||
families.forEach(family => root.grantRevoked(family));
|
||||
console.log(`[step-up] all grants revoked (${families.length} families)`);
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue