The current user unit wins the boot race but still runs as the human whose Personal key the lock is meant to evict. Record the target souveraine-session principal, its narrow Wayland/PAM/data reach, and the fact that the rich shell is presentation—not release authority.
12 KiB
Session authority — boot order and crash survival
Companion to SESSION-AUTHORITY-DOCTRINE.md (§11) and
SESSION-TRUST-ARCHITECTURE.md. Scope: the lock surface must exist before any
other surface at boot, and the lock must outlive the shell process. Written
2026-07-16 after the boot where an NM password dialog rendered before the
lockscreen.
Status: Phase C implemented 2026-07-16 (Casey's call: skip A/B, build the
authority). souveraine 856c6e5 — souveraine-sessiond (feature sessiond),
src/sessiond/{protocol,auth,draw,lock,server}.rs, plus the shell-side
SessiondBridge.qml and the LockScreen.initIfReady handshake.
Pixel3Arch 7a85376 — pam.d service, user unit, hook start line,
allow_session_lock_restore. The handoff uses the abandon+restore trick: a
lock client that drops its connection WITHOUT unlocking leaves the compositor
holding the session locked, and the restore flag lets the next client inherit
it — so sessiond→shell handoff and shell-crash→sessiond retake both pass
through zero unlocked instants. NOT yet deployed to the phone (was
unreachable); deploy checklist below. Phases A/B remain skipped, except the
restore flag and splash timing which Phase C absorbed (sessiond pokes
splash-signal from its own first frame).
Ownership correction, 2026-08-17: the zero-gap handoff below describes the
current implementation, not the final principal boundary. The rich shell is a
presentation client; it must not become the authority merely because it draws
the normal lock face. The target keeps lock policy and release under sessiond
running as dedicated souveraine-session, while Casey is only the PAM subject
and Personal-key owner. The living design is
../../souveraine/saf/authority/01-session.md.
What actually happens at boot today
souveraine-splashtakes DRM master (Before=greetd.service) and covers the panel. It is visual cover only — its 30 s watchdog fails open (fades and hands off regardless of whether a lock exists).- Hyprland starts under the splash. Whether libinput events reach (invisible) surfaces while the splash still holds DRM is unverified — worth one test, because if they do, the pre-lock window is interactive, not just visible.
hyprland.starthook: wallpaper, dt2w, hypridle restart,hyprpm reload(+ sleeps), thensleep 3 && qs -c souveraine. The shell arrives seconds after the compositor, unsupervised (raw exec child, no restart).- Inside the shell, every panel activates on
Config.ready, but the Lock panel additionally waits forPersistent.readybeforeinitIfReady()requests the session lock (LockScreen.qml). Between those two gates, any other surface — bar, background, the NetworkManager secret-agent dialog — can render first.GlobalStates.screenLockedstartsfalse: the shell boots fail-open and locks after the fact. splash-signalfires when the lock is requested (onScreenLockedChanged), not when the compositor acks (secure). The 1.2 s fade can beat the lock surface mapping. Same reading-vs-reality shape the doctrine already bans (§4, §9).
Crash truth table today
| Scenario | What happens |
|---|---|
| Shell dies while locked | Compositor keeps the session locked (ext-session-lock fail-closed) and shows the fallback screen. But misc:allow_session_lock_restore is false on the phone, so no new client may take over the dead client's lock — the device is a locked brick until someone ssh-es in. Nothing restarts the shell. |
| Shell dies while unlocked | No lock authority exists at all. blueline-power-button logs the IPC failure and blanks anyway (interim by design). |
| Shell restarts by hand while locked | Persistent.states.lock.locked re-locks it — but only because allow_session_lock_restore would permit the new lock, which it currently does not. Untested path. |
Phase A — fail-closed ordering inside the shell
Small, current architecture, closes the observed boot hole.
- Hoist the Lock scope out of the panel family into
shell.qml, first child, not behind the family'sLazyLoader. - Gate the panel family on the lock decision:
SouveraineFamilyloads onConfig.ready && lockResolved, wherelockResolvedmeansinitIfReady()has run — either the session is locked (and ideally secure) or it decided startup should be unlocked. No shell surface can precede the lock decision; the NM dialog lands after unlock instead of before lock. - Default-closed start on the phone.
screenLockedcannot read config at instantiation (Config loads async). A synchronous signal is needed; an env var in the launch line (SOUVERAINE_LOCK_AT_START=1 qs …) is the candidate —screenLockedstarts true when set, andinitIfReady()demotes if the persisted/config state says otherwise. Alternative: default true everywhere and accept a lock-flash on every dev-loop shell restart on the laptop. Decision below. splash-signalmoves to thesecureack (screenLockSecureChanged), so the bloom dissolves onto a mapped lock surface, never a racing one.misc:allow_session_lock_restore = truein hyprland.lua. One line; prerequisite for every recovery story below and for the existing Persistent re-lock path to work at all.
Phase B — supervision
- The shell becomes a systemd user unit, same pattern as
hypridle.service(dies and respawns with the Hyprland instance; env already exported to the user manager inhyprland.start).Restart=on-failure. The earlier systemd-run failure was env stripping (XDG_RUNTIME_DIR); a real unit under the user manager does not have that problem — verify on device before trusting it. - With Phase A ordering + the Persistent lock mirror + lock restore enabled, a shell that crashes while locked self-restores to the lock surface with no ssh intervention.
- The splash watchdog stays (nothing may hold DRM hostage), but by the time it can fire, either the lock is up or the compositor is showing bare wallpaper — not an unlocked desktop with dialogs.
Phase C — the authority below the shell (souveraine-sessiond)
Not greenfield. This is the session-authority face of the unified authority
doctrine §11 already names — capability tiers, capability gate (RedFlag's
attested-binary / capability-token pattern pointed inward, §10), seed identity
as signer, memfs as audit trail. The souveraine services family this daemon
joins already exists: souveraine-machined owns the machine Ed25519 seed
and serves pubkey/sign over /run/souveraine/machined.sock (clients get
signatures, never key material); souveraine-secrets backs
org.freedesktop.secrets from the same seed lineage. sessiond is the next
binary in that family, and per §11 the session authority and the binary
authority are the same authority — sessiond verifies capability tokens on the
things that ask it to unlock/inhibit/step-up, and its own binary carries the
same attestation it checks (RedFlag constraint #5 applies: policy before
kernel stops; eBPF raises bypass cost later).
Shape:
- A small Rust daemon in the substrate (peer of
souveraine-machined/souveraine-secrets), systemd user unit ordered before the shell. Signing needs go through machined's socket like every other service. - At session start it takes ext-session-lock immediately — the lock exists before the shell process does. The splash becomes purely aesthetic.
- It renders a minimal lock surface (spartan PIN entry) and owns the PAM conversation. Enough to unlock a phone whose shell is a corpse.
- Steady state stays shell-owned. The shell remains the normal lock
client in-session (rich surface: swipe-to-unlock, widgets — the lockscreen
recreation queue item). sessiond intervenes exactly twice:
- Boot: holds the lock until the shell announces ready, then hands off (one unlock→lock gap per boot, before any user interaction; mitigated by doing the handoff while the splash still covers).
- Crash while locked: shell heartbeat drops, sessiond takes over the dead
client's lock (
allow_session_lock_restore) and offers minimal unlock.
- sessiond is then the natural home for the rest of the authority surface:
the lock IPC endpoint (
blueline-power-buttontalks to it instead ofqs ipc), the PrepareForSleep lock race (doctrine §8), thesouveraine-stepupPAM service, capability-token verification for session verbs (§10), and eventually the portal Inhibit backend (§6). - This lands inside the services-infrastructure review the security audit register already queued (SECURITY-AUDIT.md "Next thread"): session authority, secrets, federation transport, sensor service, machined — reviewed as one composition, not five builds. sessiond's design belongs to that review, not to a standalone lockscreen fix.
Open question, deliberately not answered here: does the rich lockscreen eventually move into sessiond (lock surface never depends on shell health, but widgets need shell data over IPC), or does the two-client boot/crash-fallback model above stay permanent? The two-client model is consistent with doctrine §4 and vastly cheaper; revisit only if the handoff gap or heartbeat proves flaky in practice.
Decisions needed
- Phase A default-closed mechanism: env var in the phone launch line vs lock-at-start everywhere (laptop dev-loop pays a lock-flash per restart).
- Phase ordering vs the rest of the queue (favorites, lockscreen recreation). Note lockscreen recreation and Phase C interact: where the rich surface lives decides where that work lands — and Phase C itself belongs to the services-infrastructure review.
Deploy checklist (phone, when reachable)
Superseded 2026-08-15 — steps 1–3 below are the pre-packaging route and three of them are now banned. Kept as the record of how Phase C first reached the phone; do not follow them. The current route:
pacman -Syuon the phone. sessiond, its unit and (since TASK-76) its PAM file are all package-owned.- Cold boot, and step 4 below is still the expected sequence.
The original, and why each step retired:
On-device build:Banned 2026-07-24 —cargo build --release --features sessiond --bin souveraine-sessiondin~/build/souveraine(nice 15, -j 3), install to/usr/local/bin.souveraine/CLAUDE.md: binaries hand-copied to/usr/local/binare owned by no package and silently never update; sessiond sat five days behind and its state machine never ran. archdev cross-compiles, CI packages, pacman delivers.Sync Pixel3Arch rootfs-overlay bits.The overlay reaches a device only on flash and the phone does not flash (TASK-28). Both files are packaged now: the unit fromPKGBUILD.prebuilt, the PAM file added 2026-08-15. The overlay copies are a shadowing hazard, not a delivery route (TASK-76).deploy.sh --phonefor the shell.START-HERE.md§5: never. TASK-25 demoted it to a recovery instrument for an explicitly quiesced target, after it killed a live conversation mid-use.- Cold boot. Expected: splash → sessiond PIN surface (spartan) → shell lock
replaces it seamlessly once quickshell is up.
journalctl --user -u souveraine-sessiondshows acquire → handoff → locked_ack.
Verification owed when implemented
- Boot with Phase A: no surface (including NM dialog) before the lock; splash dissolves onto a mapped lock surface.
kill -9the shell while locked → systemd restarts it → lock surface returns without ssh (Phases A+B, restore flag on).- Taps during the splash window: verify whether input reaches surfaces while splash holds DRM master.
- Laptop path unchanged: SDDM boot, no lock-flash regression on shell restart (unless decision 1 chooses lock-at-start everywhere).