12 KiB
Souveraine surfaces/quickshell — session-trust work dump (2026-07-14)
Branch: experimental/session-trust-phase1
Tip: 6532572 — all committed except Audio.qml (M) and volumeMixer/ (untracked).
Scope: the QuickShell shell that runs on both the Pixel 3 (blueline) and the laptop.
TL;DR — what landed today
Two things shipped in one day:
-
Shell restructure — "retire the pill." The separate pill nav shell is gone. The shell now runs as one config (
qs -c souveraine), with an owned first-party lock/nav/session layer on top of the borrowediishell. Three guarded IPC surfaces for the agent (dock.*,shell.*,apps.*), and a real IPC bug fixed (returns were silently dropping their payload — see below). -
Session Trust Phase 1 — the security model. Step-up auth, lock-time response redaction, break-glass override, sleep-before-lock coordination, and a hash-chained audit trail. This is phase 1 of the SouveraineOS homogenization (one shell, laptop + phone).
Caveat for whoever this goes to: the feature stabilized right up to the last
commit. Five consecutive QML-runtime fixes close the day, and the very last one
(6532572, 22:26) fixed a ReferenceError that was crashing the lock-redaction
path itself. So: freshly-green, on an experimental/ branch, not yet
battle-tested. Verification checklist at the bottom is not all ticked.
The core security model (from SESSION-TRUST-ARCHITECTURE.md + TRUST-BOUNDARY-MATRIX.md)
Authorities we consume, never replace
| Question | Authority | Our role |
|---|---|---|
| Can this machine suspend/hibernate/poweroff/reboot? | logind Can* |
query + expose + execute matching verb |
| Has the compositor secured the display? | WlSessionLock.secure |
mirror as screenLockSecure; never infer from a button |
| Does the shell want its lock shown? | GlobalStates.screenLocked |
maintain request state, persist across crash |
| Has the user authenticated? | PAM + lock's LockContext |
start PAM; only it releases WlSessionLock |
| Why is idle sleep suppressed? | active idle mechanism | reason/cookie registry; report only applied mechanisms |
The central invariant: screenLocked (shell's request) and screenLockSecure
(compositor's acknowledgement) are kept distinct. A failed/delayed Wayland lock
shows as lockRequested: true, locked: false — never mistaken for success.
Personal-data disclosure gates on screenLockSecure, not on a button press.
Capability tiers (one policy system for lock screen, media, and agent)
| Tier | Requirement | Examples |
|---|---|---|
ambient |
none | clock, weather, timers, transport controls, non-personal answers |
personal |
!screenLocked && !screenLockSecure (live check, not cached) |
messages, calendar, memories, fleet state, history |
stepUp |
recent successful reauth (TTL window) | sending, pushes, deletion, payments, physical access |
On lock, in-flight personal/step-up agent output is withheld/redacted; only ambient remains.
Trust boundary matrix — enforcement status
- enforced: lock/unlock, suspend/hibernate/poweroff/reboot (capability-probed),
idle+sleep inhibitors (reason required), all read-only projections, step-up PAM
conversation, personal-context send gate (
LockContentPolicy), in-flight response redaction on lock, media controls/metadata on lock, sleep transition (delay inhibitor +WlSessionLock.secure), audit trail. session.unlock()is refused over IPC by design — the lock is the credential gate; no IPC caller routes around the PIN pad.- gaps (not yet gated): agent delete/push and physical-access ops —
StepUpAuthis built but the per-opminTiermetadata isn't wired yet. - Matrix is local-only: all callers are same-machine over the quickshell IPC socket. If a network-reachable caller is ever added, every row needs re-evaluation.
Commit-by-commit (chronological, full messages)
c78c551 15:03 — quickshell: run as qs -c souveraine + dock/shell/apps method surfaces
Own config name composed by deploy.sh from our files + pristine-ii dir borrows; ii
tree stays untouched. One SouveraineFamily, two modes gated on souveraine.phone.
Three guarded IPC surfaces for the agent (via Souveraine's harness):
dock.manifest/pin/unpin/restack, shell.surfaces/state, apps.list/get/find/categories.
Refusals are result shapes, not errors. Settings split into per-domain pages.
cecde3b 18:31 — quickshell: session arbiter + fix IPC returns silently dropping payloads
The IPC bug is the important half. Quickshell marshals exactly five types
(string/int/bool/double/color) and maps a var return to VOID, discarding the
payload with no error. dock.*, shell.*, apps.* were all : var → registered
as (): void and returned nothing. The {ok, reason} contract had never once
reached a caller. All now return JSON as a string.
Session: upstream fires systemctl X || loginctl X detached and throws the exit
code away — fine on a desktop, not on a phone where the shell is the session
manager. Now: probe once, run verbs through a Process that logs exit codes, refuse
honestly (swapless phone → hibernate says so instead of no-opping). Inhibits carry a
mandatory reason + cookie; state() lists who holds the machine awake and why.
unlock() refused by design. Named sessionctl (ii owns session).
e31c3aa 20:00 — quickshell: first-party lock/nav/session layer, retire the pill
Session arbiter probes logind Can* over busctl (carries polkit tier
yes/challenge/na) — swapless phone reports hibernate na and refuses. busctl parsed
with awk, not a sed regex (the sed version returned nothing on the phone, left
every capability "unknown" — invisible on laptop where timing masked it).
Lock trust: screenLocked distinct from screenLockSecure; cards gate on secure.
LockContentPolicy centralizes ambient/personal/step-up tiers. New first-party
modules/souveraine/: LockMediaCard, LockSurfaceHost, SystemGestureRail. Pill
retired: pill/shell.qml + PillConfig gone → NavigationConfig + gesture rail.
Hyprland starts qs -c souveraine directly.
Verified on phone: session.* reports challenge/na, hibernate+unlock refuse,
inhibit round-trips with its reason.
4d5690b 20:17 — quickshell: Idle & sleep settings page
Live idle-stage readout (polled over session IPC), native-coordinator toggle,
dim/lock timers (greyed while coordinator off). New Audio.qml service.
a8fd510 20:48 — session trust phase 1: logind ingress, sleep inhibitor, step-up auth
The three highest-priority gaps. New: SessionEvents.qml (logind PrepareForSleep +
session Lock via gdbus monitor; delay-mode sleep inhibitor held from startup,
released only after WlSessionLock.secure), StepUpAuth.qml (PAM step-up, short-
lived in-memory grants per action family), TRUST-BOUNDARY-MATRIX.md. Modified:
IdleCoordinator (+Suspending/Asleep/Waking), Session.qml (sleep inhibitor kind,
stepUp/sleepInhibitor in IPC projection, boot-time IPC audit), Config (lock.stepUp),
LockConfig, deploy.sh.
61bdc53 21:13 — session trust: step-up send gating + lock-time response redaction
Souveraine.send() returns 'step-up' when auth required; Ai.sendUserMessage()
triggers StepUpAuth.requestAuth('send'), retries on success. Lock-time redaction:
Ai.qml watches screenLocked; on lock mid-stream, replaces displayed content with
[content hidden until unlock], preserves rawContent for post-unlock.
377b829 21:24 — break-glass: one-time emergency grant with journaling
StepUpAuth.breakGlass(family, reason): mandatory reason (logged prominently),
60s TTL (not 5min), one-time (consumed on use), can't be issued while locked,
cleared on lock (fail-closed), tracked separately for audit. send() checks
break-glass before blocking on step-up; it bypasses PAM — user declared an emergency.
ae9ef41 21:31 — hash-chained audit trail for session transitions
SessionAudit.qml: tamper-evident JSONL of all state changes (lock/unlock,
secure/insecure, idle transitions, PrepareForSleep, inhibitor lifecycle, step-up
success/failure, break-glass issued/consumed/expired, grant expiry/revocation).
Each entry carries md5 of the previous; sequence numbers detect gaps; chain validated
on startup. Uses Qt.md5() (no SHA256 in QML) — advisory tamper-evidence, not
crypto. Log: ~/.local/share/souveraine/session-audit.jsonl.
ffe4157 21:39 — settings: show sleep inhibitor state, update stage names
IdleConfig shows full stage names (incl. Suspending/Asleep/Waking), sleep inhibitor
held/released, stepUp grant TTL. hypridle.conf changes are NOT in repo (apply to
~/.config/hypr/): lock_cmd → loginctl lock-session; before_sleep_cmd REMOVED;
inhibit_sleep REMOVED; suspend_cmd prefers loginctl (SessionEvents owns these now).
The closing five fixes (21:58 → 22:26) — it didn't run clean
10c206cComponent.onCompleteddoesn't fire on QML Singletons →Timer{interval:0}in Session/SessionAudit/SessionEventsbc08149missingpragma Singletonon SessionAudite17f02bmissingQtQuickimport (Timer) in Session13cded0Timer unavailable in a functions module → use Process6532572Ai.qml missingimport qs— GlobalStates ReferenceError was crashing lock redaction (the security-critical path was down until this)
Key mechanisms (how the important bits actually work)
Sleep-before-lock (SessionEvents.qml): one systemd-inhibit --what=sleep --mode=delay sleep infinity held from startup. On PrepareForSleep(true): if
already screenLockSecure, release immediately; else Session.lock() and wait for
WlSessionLock.secure, then release. Reacquire on PrepareForSleep(false). Fail
policy: fail-closed — if not secure before logind's InhibitDelayMaxUSec
(~5s) deadline, leave a diagnostic and don't pretend it locked. Lock persists across
suspend/resume; only the human PIN pad unlocks. External loginctl lock-session
caught via a per-session-path gdbus monitor (echo-guarded against our own lock).
Step-up (StepUpAuth.qml): action families send/delete/payment/physical/admin.
Grant = {granted, expires}, 5min default TTL (Config.lock.stepUp.grantTtlMs).
One PAM conversation at a time. Backend: prefers souveraine-pam-auth helper
(password via env SOUVERAINE_STEPUP_PASSWORD, not argv), falls back to pkcheck
(polkit) at runtime. The /etc/pam.d/souveraine-stepup file is NOT shipped —
root-owned, install/audit separately. Grants cleared on lock, session end, PAM
failure, expiry (30s coarse timer). Re-request within window does NOT refresh clock.
Audit chain (SessionAudit.qml): append-only JSONL, md5(prev) chained, seq
numbers for gap detection, validated on startup. Explicitly advisory, not crypto —
production should swap for sha256sum/dedicated binary.
Verification status (from SESSION-TRUST-ARCHITECTURE.md)
session.capabilities()matches logindCan*— verified phonesession.lock()reacheslocked:trueonly afterWlSessionLock.secure— needs timing on Pixelloginctl lock-sessionand suspend take same Wayland lock path- step-up grants can't survive lock/timeout/restart/family-change (StepUpAuth revokes on lock, 30s expiry)
- lock-during-personal-output hides it; lock-during-ambient-timer leaves timer — redaction path only stopped crashing at last commit; re-verify
- delay budget (~5s) timed on Pixel 3 — required before this is a security claim, not yet done
Open items for next session
- Wire per-op
minTiermetadata so agent delete/push/physical actually hit stepUp (built, not connected) - Time the suspend delay budget on the Pixel 3
- Ship/audit
/etc/pam.d/souveraine-stepup; confirm one polkit agent owns the session - Verify native Wayland idle-notify on the Pixel compositor before flipping
nativeCoordinatorEnabled - Uncommitted:
Audio.qml, untrackedvolumeMixer/