Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-07-14 20:48:46 -04:00
commit a8fd510dc7
10 changed files with 845 additions and 32 deletions

View file

@ -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.

View file

@ -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]

View 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.

View file

@ -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

View file

@ -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
}

View file

@ -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");
}
}

View file

@ -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.")
}
}
}
}

View file

@ -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();
}
}
}

View 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);
}
}

View 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}`);
}
});
}
}
}