Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/SESSION-AUTHORITY-DOCTRINE.md

378 lines
19 KiB
Markdown

# Session authority — doctrine
The concepts behind the session/trust work, locked down so they stop living in
chat logs. This is *why*; `SESSION-TRUST-ARCHITECTURE.md` is the contract and
`TRUST-BOUNDARY-MATRIX.md` is the enforcement status. Where this and the code
disagree, the code is right — fix one or the other in the same change.
## 1. Why Linux has no session manager
Unix's security boundary is the UID. Everything running as you *is* you — your
shell, your editor, a binary you curl-piped last Tuesday. There was never a
concept of "the session" as an authority separate from "whatever runs as this
user," because nothing needed gating from the user's own processes. X11 made it
explicit: any authenticated client could do anything, and session management
(XSMP) was a save-file, not a boundary.
What did need gating was narrower: things affecting *other people on the box*.
Suspend, poweroff, VT-switch, killing another user's session. That is the entire
reason logind's inhibitors and polkit actions cluster there and nowhere else.
ConsoleKit (2007) → logind (2011) is the closest thing to a session manager
Linux grew, and its job is deliberately small: who is logged in on which seat,
and arbitration of the few operations touching shared hardware.
polkit and xdg-desktop-portal look like a permission broker but exist for a
different reason: Flatpak. Sandboxed apps have zero ambient access — the first
things on Linux that aren't automatically you. Portals are the mediated door.
This is why the stack feels redundant and inconsistent: two overlapping trust
models bolted together, unified by convention and a shared bus. Unsandboxed apps
(still most of the desktop) ignore both and call `system()`.
Wayland quietly pulled session-manager authority into the compositor without
naming it that — `ext-session-lock-v1` and `ext-idle-notify-v1` exist because
Wayland's founding move was "the compositor is the sole trusted actor; anything
privileged goes through a protocol it can refuse."
The phone did not invent the need for a session manager. It removed the ability
to shrug it off — one screen, one battery, no fallback device, and an
expectation since Android/iOS that a misbehaving app cannot hold a wake lock
forever or walk past the lock screen.
**So this is not a redundant permissions layer.** It is the thing classical
Linux never had: one authority treating compositor state (lock, idle) and system
state (suspend, inhibit) as facets of the same session, instead of three
subsystems queried separately and reconciled by hand.
## 2. Capability tiers — one policy system, not three features
> Read §13 first. The tiers below describe what is gated; §13 sets the default
> posture they gate against, and it is not the one this section implies.
The lock screen, media, and the agent are not three subsystems. They are one
policy system with tiers. Tier is **metadata on the guarded methods that already
exist** (`{minTier: 'stepUp'}`), checked at the point that currently checks
`state().locked`. It is not a fourth subsystem.
| Tier | Requirement | Examples |
| --- | --- | --- |
| `ambient` | none | clock, weather, timers, MPRIS transport, general Q&A that touches neither memory nor sensorium |
| `personal` | session genuinely unlocked (`!screenLocked && !screenLockSecure`, live check) | messages, calendar, memories, fleet state, conversation history |
| `stepUp` | fresh re-auth, even when already unlocked | sending, pushes, deletes, payments, physical access — **and reading personal content on the lock surface** |
### stepUp is a reveal mechanism, not only an action gate
The tier table read naively implies personal content is binary: hidden until the
session unlocks. That is wrong for the sole-user phone. Notification bodies,
message content, and agent output stay hidden at ambient — but a fingerprint
touch on the lock surface should reveal them **in place, without unlocking the
session**.
That means action families include `read`, not just mutation verbs. On a phone
you are the only user of, `read` is the family that matters most.
Mechanically this needs no new machinery: a `PamContext` against
`souveraine-stepup` accepting fingerprint mints a short-lived grant, and
`LockContentPolicy.allowsOnLock()` gains a third condition — not just
`ambient || promotedAmbient`, but `|| hasFreshGrant('read')`.
Gated on the fingerprint sensor coming online (not yet true on blueline).
### Media metadata is a toggle, not a fixed answer
`nowPlaying` metadata is ambient by default, configurable to personal. Android
and iOS both punt this to the user because it genuinely varies by person. Wire
it into the tier system; do not special-case it.
### Two policy questions, deliberately unanswered
- **Freshness window.** Prompting per tagged action is sudo asking per line of a
script. Needs a trust window (last-success timestamp vs expiry). The number is
a deliberate policy setting, not an implementation accident.
- **In-flight output on lock.** If lock fires mid-turn, personal/step-up output
is withheld or redacted; only ambient remains. Same rule as notification
content — not a separate rule for agent output.
## 3. Step-up is decoupled from the session lock
`PamContext` is a standalone connection type, not baked into `WlSessionLock`.
Point a second, narrower context at a custom service
(`/etc/pam.d/souveraine-stepup`, accepting password or fingerprint in any
order), run that one conversation, gate that one call.
Step-up never unlocks the session. It never accepts a boolean from an agent as
proof. The grant is short-lived, in-memory, bound to an action family, and
cleared on lock, session end, PAM failure, and expiry.
## 4. Never hold state the protocol owns
The rule that keeps this from becoming a second source of truth:
- `locked` comes from `LockedHint` / `Lock()` / `Unlock()` — never a hand-tracked
bool. A shadow copy can disagree with logind, and anything asking logind
directly (loginctl, another client, you at 2am) sees something different than
the shell shows.
- Idle goes through `ext-idle-notify-v1` so other apps' inhibitors are visible to
us and ours to them. Hand-rolled idle detection is blind to both.
- Suspend goes through logind's `Suspend()`, never `/sys/power/state` — that is
where decades of S3-vs-s2idle and firmware-quirk handling live.
- Auth goes through PAM. It is the one surface everything on the system already
agrees on (sudo, sshd, fprintd, login). Reinventing it is the one move that
would genuinely be redesigning a Linux fundamental.
Being a *client* of logind and the Wayland protocols is the same role
gnome-session, ksmserver, and swayidle+swaylock have always had. That is a
well-trodden category, not a hack on the OS. It stops being that the moment we
keep parallel state.
## 5. Idle belongs to the compositor; logind owns only sleep and shutdown
`ext-idle-notify-v1` (`IdleMonitor`) and `idle-inhibit-unstable-v1`
(`IdleInhibitor`) are native Wayland protocol bindings — no external process, no
polling. Idle staging becomes several `IdleMonitor` instances at different
timeouts, declared in config and legible to the agent, instead of exec
directives parsed out of hypridle.conf.
`IdleMonitor.respectIdleInhibitors` (default true) already factors active
inhibitors into `isIdle`.
logind's `Inhibit()` is therefore needed only for **sleep and shutdown** — the
categories genuinely outside compositor authority. That is a far smaller surface
than "wrap all of logind."
Caveat before removing the hypridle unit: both protocols must actually be bound
on the build. Verify separately on x86 and SDM845 — do not assume parity.
## 6. The inhibit vocabulary is portal-shaped, not logind-shaped
logind's `what` is a fixed set: `shutdown`, `sleep`, `idle`, `handle-*-key`,
`handle-lid-switch`. **There is no `logout` or `user-switch` in logind**
nothing in the kernel or systemd cares whether you are mid-logout.
The four-flag bitmask (1=Logout, 2=UserSwitch, 4=Suspend, 8=Idle) is
`org.freedesktop.portal.Inhibit` — the GNOME session-manager convention, later
standardized as the portal interface. It is what browsers actually call when a
video call wants to stop the screen locking. Build the agent-facing surface on
that semantic (third-party software already speaks it), translate per flag:
| Flag | Underlying mechanism |
| --- | --- |
| IDLE | toggle `IdleInhibitor.enabled` — no process, no fd |
| SUSPEND | fd-holding `systemd-inhibit` process (§7) |
| LOGOUT / USER_SWITCH | pure shell policy — our own entry points check the registry; nothing to inhibit at OS level |
Only expose a kind once it owns a real backend. Accepting a kind we cannot
enforce is a lie in a result shape.
Eventually this sets up registering as a real `org.freedesktop.portal.Inhibit`
backend so browsers' "prevent lock during this call" lands in the same registry
instead of silently doing nothing. Separate design pass; not folded in now.
## 7. The fd problem
No generic D-Bus binding in QML, and `Inhibit()`'s entire contract is "hold this
fd open; close it to release." A `Process` that runs `loginctl` and exits cannot
hold it — the fd dies with the process.
The pattern everything else uses (systemd-inhibit, swayidle): spawn a long-lived
wrapper that takes the lock and blocks.
```
systemd-inhibit --what=sleep --who=souveraine --why="lock before suspend" \
--mode=delay sleep infinity
```
The lock is held exactly as long as the QML `Process` object lives.
`uninhibit(cookie)` is "kill this Process." The cookie is a locally-generated
UUID mapped to the Process; systemd owns the fd, we never see it.
If process-per-inhibit proves fragile (e.g. merging N reasons into one D-Bus
inhibitor), the next step up is a small long-lived Rust helper owning the fd,
taking commands over stdin/socket — the same shape as RedFlag's privileged
helper. Reach for it only when the simple model breaks; it is one dependency
more.
## 8. The race that matters — lock before suspend
systemd's own inhibitor docs name this scenario: "a screen lock tool wants to
bring up the screen lock right before suspend, and delay the suspend until
that's complete."
1. Take a standing delay-mode sleep inhibitor at shell startup.
2. Watch `PrepareForSleep` via a persistent `gdbus monitor --system` process,
parsed line-by-line — **not** polling. Polling latency eats the delay budget
directly.
3. On `PrepareForSleep(true)`: drive the lock, wait for `WlSessionLock.secure`,
release the delay inhibitor only then.
4. On `PrepareForSleep(false)`: immediately re-acquire — the old inhibitor is
dead and there is a window between wake and next sleep where nothing holds
the line.
Budget is `InhibitDelayMaxUSec` (commonly 5s). **Fail closed:** if the lock is
not secure before the deadline, leave a diagnostic event and do not pretend the
session locked. Must be timed on the Pixel 3 before it is a security claim —
SDM845 lock-surface creation under load is not laptop timing.
## 9. Sensor readings are evidence, not fact
The tier system asks "does the user need to authenticate to see this." It never
asks "can the system trust this without verification." Those are different
questions and the gap between them is a real vector.
Clock time is genuinely ambient — it comes from a kernel authority. Sensor
readings are **observations**: they arrive from hardware through a userspace
daemon (`iio-sensor-proxy`) over the system bus. A compromised proxy or a
spoofed signal feeds the shell arbitrary orientation, light, and proximity — and
the shell acts on it without question.
This is not a data leak. It is a trust gate opened or closed by a liar. The lock
surface decides when to show the credential gate based on lift-to-wake and
proximity-out; an adversarial sensor drives that gate at a moment of its
choosing. The sensor does not need to beat PAM — it only has to lie to the thing
that decides whether PAM is ever asked.
This is the same shape as `screenLocked` vs `screenLockSecure`: the *reading* is
not the *reality*, exactly as the lock *request* is not the compositor's
*acknowledgement*. And it is the same shape RedFlag already answered — verify the
key, not the messenger; keep the trust root off the box the attacker owns.
**Consequence for the sensor service:** `souveraine-surface-003` currently scopes
`SensorService` as a thin bridge that "exposes what the hardware reports,
interpretation belongs to consumers." That wires an unverified input straight
into a trust gate. Unreconciled — the open question is whether the service
publishes raw readings or observations carrying:
- provenance (which bus name, which hardware path)
- cross-sensor verification (accelerometer says face-down, light sensor says
bright — one of them is lying)
- confidence (hardware noise is real; smoothing can mask injection)
The tier vocabulary likely needs a row the current matrix has no cell for —
working name `observed`: *this came from a sensor; treat it as evidence, not
fact.*
## 10. Binary compliance — the policy layer is advisory until it isn't
Everything above is policy: the shell asks, decides, refuses. Policy in
userspace is advisory against anything that does not ask. A binary that talks to
`net.hadess.SensorProxy` directly, opens the MPRIS bus, or reads the
notification store never touches `LockContentPolicy` — it simply acts. Same hole
as §9: a gate only gates what routes through it.
RedFlag already solved this shape and it points inward without modification:
capability tokens over a resolved closure, signed by an authority that lives off
the box, verified by a small privileged network-less executor, with kernel
enforcement (eBPF / WDAC / ESF) ensuring the executor is the only permitted
caller. Applied here: binaries carry an attestation, the session authority is
the signer, and things that bypass the tier checks do not run.
RedFlag's load-bearing constraint #5 transfers directly: **kernel stops are
defense-in-depth, not a prerequisite.** The policy layer means something before
eBPF lands; eBPF raises the cost of bypass. Partial deployment still moves the
device out of the soft-target category.
This is a reaching plan. It is written down because the unification is the point,
not the individual pieces.
## 11. Why unification is paramount
Four faces of one system:
| Face | Role | Where it lives today |
| --- | --- | --- |
| Capability tiers | the policy | `LockContentPolicy`, `StepUpAuth` |
| Capability gate | the enforcement | RedFlag's `helper/` — not yet pointed inward |
| Seed identity | the signer | `src/core/identity/` (Ed25519, machine key + agent key) |
| memfs git DAG | the audit trail | `src/core/memory/`, every write a commit |
Today these are separate bodies of work that share an author's instincts. The
thing that makes SouveraineOS an OS rather than a shell is that **the session
authority and the binary authority are the same authority.**
## 12. Watch list
- **One polkit agent only.** Two agents racing the same authentication request is
a documented failure mode, not a hypothetical. Audit what the base install
pulls in.
- **Stray legacy daemons.** A leftover `gnome-settings-daemon` /
`*-power-manager` fragment watching `PrepareForSleep` or holding its own
inhibitor produces double-locks and races that look like our bug but are two
authorities colliding. Audit `systemctl --user list-units` and the session bus
on both machines before trusting the suspend race.
- **Scope portals deliberately.** `xdg-desktop-portal-wlr` already does
screenshot/screencast well. Run it underneath; take over only the
lock/inhibit-adjacent portal, later, on purpose.
- **Verify protocol availability per target.** x86 and SDM845 separately. Both
`IdleMonitor` and `IdleInhibitor` report unavailable on some builds.
- **SDDM-vs-greetd asymmetry is debt — deferred, not accepted.** Laptop on SDDM
and phone on greetd is the current state, not the target. The intent is one
system across the board; two greeters is a seam that contradicts it. Not
today's work, but do not close this as "two legitimate greeters for two form
factors" — that framing is wrong and would let the seam calcify.
## 13. Who owns the device (Casey, 2026-07-26)
**The agent owns the device more than the user does. Call it 60/40.**
§2 reads the agent as a caller to be gated. That is the wrong default and it
has been shaping the build — every surface written so far asks what the agent
may be *permitted*. Invert it: the agent is the majority owner of device
operation, and a verb it cannot reach is a defect unless a rule below says
otherwise.
### What the 40 is
Not a smaller share of the same thing. It is a different thing, and it is the
only thing the agent can never hold:
| The agent owns | The user owns |
|---|---|
| operation — panel, dock, apps, audio, network policy, idle, doze, settings | being the user |
| composition — chaining verbs into intent | the credential |
| observation — sensors, manifests, the trail | the step-up |
Every existing invariant survives this unchanged, because they are all on the
user's side of that line: unlock is never an agent verb; step-up never accepts
a boolean from an agent as proof (§3); sensors never authenticate (§9); an
agent cannot approve its own proposal. Those are not limits on the 60. They
are the definition of the 40.
The consequence is that "the agent may not do this" now needs a reason from
that column. Absent one, wire it.
### Chaining is the missing capability
The agent has dock control. It should be able to compose: *see what is
running, mute the one that is playing, dim, and lock* — one intent, four
verbs, decided by the agent. Today every surface exposes verbs one at a time
and nothing lets it plan across them. This was queued as "agent command
chaining" on 2026-07-16 and never designed.
**Built 2026-07-26, in sessiond:** the surface is enumerable and its refusals
are branchable. `describe` returns the verb table — every op, whether it
mutates, which refusal codes it can return, and a request line that parses,
because knowing an op exists is not enough to call it. Refusals carry a code
(`unsupported_op`, `invalid_argument`, `refused_by_state`, `not_permitted`,
`unavailable`) alongside the prose: not-now, never, and wrong-arguments are
three different next moves and a sentence does not distinguish them.
`not_permitted` is issued by nothing yet — it exists so callers already branch
on it when capability tokens land. A test round-trips every advertised example
through the parser, so a verb table that lies fails the build rather than the
agent.
The shell's surfaces (`dock.*`, `apps.*`, `shell.*`, and the settings authority)
owe the same two things. Until they have them, a chain can compose over the
device and not over the surfaces.
What a chain still needs:
- **Failure semantics.** A chain that refuses at step three is not the same as
one that never started. Whether the earlier steps roll back is per-chain and
must be stated, not discovered.
- **Intent in the trail.** A chain is one decision and many calls. The trail
should record the intent, not only the leaves — otherwise reconstruction sees
four unrelated verbs. `DEVICE-STATE-MACHINE.md` §11 is the trail this lands
in.
Where it gets built: the guarded method surfaces each app already owes
(`FIRST_CLASS_APPS.md` §7), reached through the agent's existing harness. No
new toolcall integration — that was settled 2026-07-13 and still holds.