git.lucas.co / cce-authenticator
login authentication (PAM + fingerprint)
git clone https://git.lucas.co/cce-authenticator.git

CLAUDE.md (9.5K)

  1 # CLAUDE.md
  2 
  3 > This is the `cce-authenticator` crate, inside the larger **`cce` Cargo workspace** —
  4 > read `../cce-compositor/WORKSPACE.md` first for the multi-repo layout, the
  5 > standalone-build rule, `ccebuild`, and the `cce-ui` toolkit. This file covers only
  6 > what is specific to this crate.
  7 
  8 `cce-authenticator` **is the cce session's polkit authentication agent.** It is not a
  9 demo or a convenience: before it shipped, the session had no agent registered at all,
 10 so *every* `pkexec` in the desktop failed instantly and silently — settings-app sysfs
 11 writes, bluetooth power, storage backup, package updates. The only visible symptom was
 12 optimistic UIs quietly reverting. If this crate is broken or its unit is down, that is
 13 the failure you get back, and nothing prints to the screen to say so.
 14 
 15 The whole crate is one `src/main.rs` implementing the `cce-ui` `Application` trait.
 16 
 17 ## Two modes
 18 
 19 - **No args — agent mode.** Registers `org.freedesktop.PolicyKit1.AuthenticationAgent`
 20   at `/org/cce/AuthenticatorAgent` for this login session and serves requests until
 21   SIGTERM, when it unregisters. Shipped as `cce-polkit-agent.service`
 22   (`WantedBy=graphical-session.target`). That unit name predates this implementation —
 23   it was Soteria's, kept across the swap so the user's `systemctl --user enable`
 24   carried over.
 25 - **`--standalone` / `-s`** — the window on its own, no D-Bus, authenticating against
 26   PAM (`PAM_SERVICE`) and fprintd directly. This is the test vehicle; see Verifying.
 27 
 28 **A native cce-ui window is load-bearing, not a preference.** The interim agent
 29 (Soteria, GTK) put up a dialog that never took keyboard focus under `cce-fx`, so it
 30 sat there accepting nothing until polkit timed it out — "window disappeared by
 31 itself". Native windows get normal map-focus. If a foreign toolkit's dialog ever needs
 32 to work here, that focus path is the thing to debug.
 33 
 34 The session id comes from `XDG_SESSION_ID`, then `/proc/self/sessionid`, then logind's
 35 `GetSessionByPID` — three sources because user units live *outside* the login session
 36 and inherit no session id. `startcce`'s `systemctl --user import-environment` list had
 37 to learn `XDG_SESSION_ID` for the first source to exist at all. Soteria hard-required
 38 that variable and crash-looped 26 times without it; this agent only prefers it, which
 39 is why the fallbacks are worth keeping.
 40 
 41 ## How a request flows
 42 
 43 polkitd calls `BeginAuthentication` on the tokio/zbus thread; the GUI runs
 44 `cce_ui::engine::run` on the **main** thread, one window at a time, so requests hand
 45 off over an mpsc channel and **queue**. Three statics are that seam:
 46 
 47 - `ACTIVE_REQUEST` — the request the window being built belongs to. Its presence *is*
 48   polkit mode (`polkit_mode = active_req.is_some()`), and taking it is how success is
 49   reported exactly once.
 50 - `ACTIVE_SENDER` — the running window's message sender, for D-Bus-initiated cancels.
 51 - `COOKIES` — the active cookie plus pending cancellations. See Cancellation.
 52 
 53 Inside a request the agent drives `/usr/lib/polkit-1/polkit-agent-helper-1 <user>
 54 <cookie>`: it writes the password to the helper's stdin, and a reader thread turns the
 55 helper's stdout protocol (`PAM_PROMPT_ECHO_OFF`, `PAM_PROMPT_ECHO_ON`,
 56 `PAM_ERROR_MSG`, `PAM_TEXT_INFO`) into `AppMessage`s. The **exit status is the
 57 verdict** — there is no success line to parse.
 58 
 59 **The helper runs one PAM conversation and exits**, so a retry is a new process, not
 60 another write to the old stdin (which is a closed pipe the moment it fails). That is
 61 what `spawn_helper` and `RETRIES` exist for; the bound is not politeness, it stops a
 62 helper that fails *instantly* — a cookie polkitd no longer recognises — from spawning
 63 in a tight loop.
 64 
 65 ## Two invariants worth stating outright
 66 
 67 **Simulation must never be reachable under a live request.** A simulated success sends
 68 `Ok(())` to polkitd, which *grants the privileged action* having checked no credential
 69 at all. `CCE_AUTH_SIMULATE` once did exactly that, because the guard only disabled
 70 simulation when the variable was *absent*. It is now gated on the unsafe state — a
 71 request is in flight — rather than on how simulation was asked for, and the password
 72 and fingerprint paths exclude it again on `polkit_mode` instead of trusting the flag.
 73 Keep that shape: gate on the dangerous condition, not on an allowlist of the ways in.
 74 
 75 The decision itself is `simulate_allowed`, a pure function, and `a_live_request_vetoes_simulation`
 76 covers its inputs exhaustively. That is deliberate, and it is the *only* way this
 77 invariant should be checked: verifying it live would mean setting `CCE_AUTH_SIMULATE`
 78 on the running agent — standing up a working authentication bypass on the machine and
 79 then confirming it doesn't fire. Don't. A pure function settles it without the desktop
 80 ever being in that state, and it is enforced on every `cargo test` instead of by
 81 someone remembering to repeat a manual check. The test is known to fail against the
 82 historical bug (`if polkit_mode { env_requested }`), which is what makes it worth having.
 83 
 84 **Cancellations are recorded for every cookie, then consumed by their owner.** Because
 85 requests queue, a `CancelAuthentication` can name a cookie whose window has not opened
 86 yet, or one that is still starting and has no `ACTIVE_SENDER` to deliver to. The
 87 handler therefore records unconditionally and *then* tries to deliver; the main loop
 88 claims the cookie and checks for a record before opening a window, and `new()` checks
 89 again once a sender exists. A single active-cookie slot got all three orderings wrong
 90 and stranded dialogs. `cancellation_survives_every_ordering` locks those orderings in —
 91 one test covering all three, run in sequence because `COOKIES` is process-global.
 92 
 93 ## Verifying (the safe envelope is narrow)
 94 
 95 - **`--standalone` spawned in the shadow session** (`cce-shadow spawn env
 96   CCE_AUTH_SIMULATE=1 …`) touches no polkit D-Bus and is the only way to exercise the
 97   window end to end without a prompt. `CCE_AUTH_SIMULATE=1` drives the auto-success
 98   path there (2s success, 1s exit).
 99 - **Never live-test `pkexec` from the shadow session** — the D-Bus *system* bus is
100   shared, so the prompt lands on the real screen.
101 - **A real prompt is cheap and safe to raise: `pkexec true`.** Kill that client and
102   polkitd sends `CancelAuthentication`, which is how the cancel path gets exercised
103   end to end. `grim -g "<x>,<y> <w>x<h>"` (geometry from `ccectl windows`) captures
104   the dialog.
105 - **The success path is verifiable too, and `true` is the whole point of the command.**
106   Raise `pkexec true`, touch the reader, and let it through: **`pkexec`'s exit status is
107   the oracle** — 0 means `/usr/bin/true` actually ran as root, i.e. polkitd accepted the
108   agent's `Ok(())` and granted the action, which no amount of reading the agent's own
109   logs can establish. The journal should show `Sending Ok to tx_result` → `ExitWindow`
110   → `ACTIVE_REQUEST was already taken (success/done)`; that last line is the one worth
111   reading, because it proves the success path consumed the request and the main loop did
112   not then report a spurious `Cancelled` over the top of a granted authorization. This
113   costs nothing and never touches faillock — the only ingredient it needs is a human
114   finger (or password), which is why it is the one check that cannot be scripted.
115 - **Do not test a failed attempt by typing a wrong password**, and never run
116   `polkit-agent-helper-1` by hand. Both drive real PAM: `deny=3` / `unlock_time=600`
117   in `faillock.conf` means three wrong answers lock the account for ten minutes, and
118   this machine has been locked out that way before. **Kill the live helper instead** —
119   the process exits non-zero exactly as a rejected password makes it, so the retry path
120   runs identically with no authentication ever attempted. Killing it three times walks
121   `RETRIES` down and should give three distinct helper pids, two `restarted helper`
122   lines, then `no attempts left` and no fourth spawn.
123 - **Finding the helper defeats both usual tricks.** `polkit-agent-helper-1` is 22
124   characters, so `comm` truncates to `polkit-agent-he` and `pgrep -x` matches nothing;
125   it is setuid root, so `/proc/<pid>/exe` is unreadable and matching on the exe link
126   silently finds *no* helper while several are running. Enumerate the agent's children
127   — `/proc/$(systemctl --user show -p MainPID --value cce-polkit-agent)/task/<pid>/children`
128   — and read state from `/proc/<pid>/stat` to tell the live helper from reaped corpses.
129 - **Zombie count is a standing regression check.** Cancel a few prompts and confirm no
130   child stays in state `Z`: killing a `Child` does not reap it, and taking it out of
131   the shared slot stops the reader thread from waiting on it, which leaked one zombie
132   per cancelled prompt for the life of the session until it was fixed.
133 - Agent health: `systemctl --user status cce-polkit-agent`, and the journal should say
134   `Successfully registered`. `RUST_LOG=info` (set by the unit) narrates every request.
135 
136 ## Build
137 
138 `make install` → `ccebuild install --no-build cce-authenticator`, which installs the
139 binary *and* `cce-polkit-agent.service`. Never hand-list binaries in the Makefile —
140 `cargo metadata` already knows them. This directory is its own git repository whose
141 `origin` is the local *bare* repo `~/git/cce-authenticator.git`, a real pushable
142 remote: **committing is not publishing — `git push origin main` is**, after which
143 `gitsite.timer` mirrors it to `https://git.lucas.co/cce-authenticator.git` (kept as the
144 `published` remote; it is the old static mirror and never accepted a push).
145 `Cargo.lock` is gitignored here, so it needs no refresh when dependencies change.