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.