378 lines
19 KiB
Markdown
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.
|