Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/session-authority-boot-order.md

10 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 856c6e5souveraine-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).

What actually happens at boot today

  1. souveraine-splash takes 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).
  2. 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.
  3. hyprland.start hook: wallpaper, dt2w, hypridle restart, hyprpm reload (+ sleeps), then sleep 3 && qs -c souveraine. The shell arrives seconds after the compositor, unsupervised (raw exec child, no restart).
  4. Inside the shell, every panel activates on Config.ready, but the Lock panel additionally waits for Persistent.ready before initIfReady() requests the session lock (LockScreen.qml). Between those two gates, any other surface — bar, background, the NetworkManager secret-agent dialog — can render first. GlobalStates.screenLocked starts false: the shell boots fail-open and locks after the fact.
  5. splash-signal fires 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.

  1. Hoist the Lock scope out of the panel family into shell.qml, first child, not behind the family's LazyLoader.
  2. Gate the panel family on the lock decision: SouveraineFamily loads on Config.ready && lockResolved, where lockResolved means initIfReady() 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.
  3. Default-closed start on the phone. screenLocked cannot 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 — screenLocked starts true when set, and initIfReady() 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.
  4. splash-signal moves to the secure ack (screenLockSecureChanged), so the bloom dissolves onto a mapped lock surface, never a racing one.
  5. misc:allow_session_lock_restore = true in 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 in hyprland.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-button talks to it instead of qs ipc), the PrepareForSleep lock race (doctrine §8), the souveraine-stepup PAM 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

  1. 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).
  2. 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)

  1. On-device build: cargo build --release --features sessiond --bin souveraine-sessiond in ~/build/souveraine (nice 15, -j 3), install to /usr/local/bin.
  2. Sync Pixel3Arch rootfs-overlay bits: /etc/pam.d/souveraine-sessiond, /etc/systemd/user/souveraine-sessiond.service; systemctl --user daemon-reload.
  3. Deploy hyprland.lua (restore flag + hook line) and deploy.sh --phone for the shell (bridge + LockScreen patch).
  4. Cold boot. Expected: splash → sessiond PIN surface (spartan) → shell lock replaces it seamlessly once quickshell is up. journalctl --user -u souveraine-sessiond shows 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 -9 the 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).