Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/DUMP-session-trust-2026-07-14.md

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:

  1. 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 borrowed ii shell. Three guarded IPC surfaces for the agent (dock.*, shell.*, apps.*), and a real IPC bug fixed (returns were silently dropping their payload — see below).

  2. 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 — StepUpAuth is built but the per-op minTier metadata 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

  • 10c206c Component.onCompleted doesn't fire on QML Singletons → Timer{interval:0} in Session/SessionAudit/SessionEvents
  • bc08149 missing pragma Singleton on SessionAudit
  • e17f02b missing QtQuick import (Timer) in Session
  • 13cded0 Timer unavailable in a functions module → use Process
  • 6532572 Ai.qml missing import 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 logind Can* — verified phone
  • session.lock() reaches locked:true only after WlSessionLock.secure — needs timing on Pixel
  • loginctl lock-session and 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 minTier metadata 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, untracked volumeMixer/