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.
198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# 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
|
||
|
||
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)
|
||
|
||
**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:
|
||
|
||
1. `pacman -Syu` on the phone. sessiond, its unit and (since TASK-76) its PAM
|
||
file are all package-owned.
|
||
2. Cold boot, and step 4 below is still the expected sequence.
|
||
|
||
The original, and why each step retired:
|
||
|
||
1. ~~On-device build: `cargo build --release --features sessiond --bin
|
||
souveraine-sessiond` in `~/build/souveraine` (nice 15, -j 3), install to
|
||
`/usr/local/bin`.~~ Banned 2026-07-24 — `souveraine/CLAUDE.md`: binaries
|
||
hand-copied to `/usr/local/bin` are 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.
|
||
2. ~~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 from `PKGBUILD.prebuilt`, the PAM file added 2026-08-15.
|
||
The overlay copies are a shadowing hazard, not a delivery route (TASK-76).
|
||
3. ~~`deploy.sh --phone` for 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.
|
||
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).
|