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

commit7978f796f06c5c6f688412e0d6cc0f26ccaee29a
parent1f1bcf5293
authorLucas Galante <[email protected]>
date2026-08-22 11:40
fix: resolve the user from passwd, drop eight unused deps, document the crate

Follow-up hygiene pass over the same crate, none of it behavioral in polkit mode
except the fingerprint column.

The username fell back to a login name hardcoded to this developer's machine at
four sites. It now comes from the passwd database via the process's own uid, with
$USER only as a second choice — the opposite of the old order, and the one that
survives a user unit whose environment never carried $USER. Where no user can be
resolved the paths report that instead of guessing, and a polkit request naming an
identity we cannot resolve is refused rather than answered as somebody else. The
root check moves from comparing $USER to asking for the uid, and the standalone
PAM service name is a documented const rather than a string in the middle of an
unsafe block.

Eight dependencies were declared and never referenced: pam (pam-sys is the binding
this actually calls), toml, chrono, serde, serde_json, xkeysym,
smithay-client-toolkit and calloop-wayland-source. Removing the latter two costs
no feature unification — cce-ui declares both itself, and every client depends on
cce-ui. This finishes the thread of the previous three build commits. Cargo.lock is
gitignored in this crate, so nothing to refresh.

The fingerprint column claimed "Fingerprint scanner ready" and offered a "Scan"
button during polkit prompts, where pam_fprintd owns the reader and the click
handler drops presses on the floor. It now reads "Reader", is painted inert, and
says it is handled by PAM until a PAM message shows a finger is actually being
asked for.

Adds CLAUDE.md: the two modes, why a native window rather than GTK, why the
session id has three sources, the helper's one-conversation-per-process protocol
behind the retry bound, the two invariants (never simulate under a live request;
record cancellations then consume them), and the narrow envelope this crate can
safely be verified in.

Verified: cargo test, and a standalone run screenshotted in a shadow session. The
polkit-mode presentation is code-verified only — rendering it needs a real request.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md   | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 Cargo.toml  |  11 ++-----
 src/main.rs |  90 +++++++++++++++++++++++++++++++++++++++++++++-------
 3 files changed, 184 insertions(+), 20 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5478a0e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,103 @@
+# CLAUDE.md
+
+> This is the `cce-authenticator` crate, inside the larger **`cce` Cargo workspace** —
+> read `../cce-compositor/WORKSPACE.md` first for the multi-repo layout, the
+> standalone-build rule, `ccebuild`, and the `cce-ui` toolkit. This file covers only
+> what is specific to this crate.
+
+`cce-authenticator` **is the cce session's polkit authentication agent.** It is not a
+demo or a convenience: before it shipped, the session had no agent registered at all,
+so *every* `pkexec` in the desktop failed instantly and silently — settings-app sysfs
+writes, bluetooth power, storage backup, package updates. The only visible symptom was
+optimistic UIs quietly reverting. If this crate is broken or its unit is down, that is
+the failure you get back, and nothing prints to the screen to say so.
+
+The whole crate is one `src/main.rs` implementing the `cce-ui` `Application` trait.
+
+## Two modes
+
+- **No args — agent mode.** Registers `org.freedesktop.PolicyKit1.AuthenticationAgent`
+  at `/org/cce/AuthenticatorAgent` for this login session and serves requests until
+  SIGTERM, when it unregisters. Shipped as `cce-polkit-agent.service`
+  (`WantedBy=graphical-session.target`). That unit name predates this implementation —
+  it was Soteria's, kept across the swap so the user's `systemctl --user enable`
+  carried over.
+- **`--standalone` / `-s`** — the window on its own, no D-Bus, authenticating against
+  PAM (`PAM_SERVICE`) and fprintd directly. This is the test vehicle; see Verifying.
+
+**A native cce-ui window is load-bearing, not a preference.** The interim agent
+(Soteria, GTK) put up a dialog that never took keyboard focus under `cce-fx`, so it
+sat there accepting nothing until polkit timed it out — "window disappeared by
+itself". Native windows get normal map-focus. If a foreign toolkit's dialog ever needs
+to work here, that focus path is the thing to debug.
+
+The session id comes from `XDG_SESSION_ID`, then `/proc/self/sessionid`, then logind's
+`GetSessionByPID` — three sources because user units live *outside* the login session
+and inherit no session id. `startcce`'s `systemctl --user import-environment` list had
+to learn `XDG_SESSION_ID` for the first source to exist at all. Soteria hard-required
+that variable and crash-looped 26 times without it; this agent only prefers it, which
+is why the fallbacks are worth keeping.
+
+## How a request flows
+
+polkitd calls `BeginAuthentication` on the tokio/zbus thread; the GUI runs
+`cce_ui::engine::run` on the **main** thread, one window at a time, so requests hand
+off over an mpsc channel and **queue**. Four statics are that seam:
+
+- `ACTIVE_REQUEST` — the request the window being built belongs to. Its presence *is*
+  polkit mode (`polkit_mode = active_req.is_some()`), and taking it is how success is
+  reported exactly once.
+- `ACTIVE_SENDER` — the running window's message sender, for D-Bus-initiated cancels.
+- `COOKIES` — the active cookie plus pending cancellations. See Cancellation.
+
+Inside a request the agent drives `/usr/lib/polkit-1/polkit-agent-helper-1 <user>
+<cookie>`: it writes the password to the helper's stdin, and a reader thread turns the
+helper's stdout protocol (`PAM_PROMPT_ECHO_OFF`, `PAM_PROMPT_ECHO_ON`,
+`PAM_ERROR_MSG`, `PAM_TEXT_INFO`) into `AppMessage`s. The **exit status is the
+verdict** — there is no success line to parse.
+
+**The helper runs one PAM conversation and exits**, so a retry is a new process, not
+another write to the old stdin (which is a closed pipe the moment it fails). That is
+what `spawn_helper` and `RETRIES` exist for; the bound is not politeness, it stops a
+helper that fails *instantly* — a cookie polkitd no longer recognises — from spawning
+in a tight loop.
+
+## Two invariants worth stating outright
+
+**Simulation must never be reachable under a live request.** A simulated success sends
+`Ok(())` to polkitd, which *grants the privileged action* having checked no credential
+at all. `CCE_AUTH_SIMULATE` once did exactly that, because the guard only disabled
+simulation when the variable was *absent*. It is now gated on the unsafe state — a
+request is in flight — rather than on how simulation was asked for, and the password
+and fingerprint paths exclude it again on `polkit_mode` instead of trusting the flag.
+Keep that shape: gate on the dangerous condition, not on an allowlist of the ways in.
+
+**Cancellations are recorded for every cookie, then consumed by their owner.** Because
+requests queue, a `CancelAuthentication` can name a cookie whose window has not opened
+yet, or one that is still starting and has no `ACTIVE_SENDER` to deliver to. The
+handler therefore records unconditionally and *then* tries to deliver; the main loop
+claims the cookie and checks for a record before opening a window, and `new()` checks
+again once a sender exists. A single active-cookie slot got all three orderings wrong
+and stranded dialogs. The crate's one test locks those orderings in.
+
+## Verifying (the safe envelope is narrow)
+
+- **`--standalone` spawned in the shadow session** (`cce-shadow spawn env
+  CCE_AUTH_SIMULATE=1 …`) touches no polkit D-Bus and is the only way to exercise the
+  window end to end without a prompt. `CCE_AUTH_SIMULATE=1` drives the auto-success
+  path there (2s success, 1s exit).
+- **Never live-test `pkexec` from the shadow session** — the D-Bus *system* bus is
+  shared, so the prompt lands on the real screen.
+- **Never run `polkit-agent-helper-1` by hand.** It drives real PAM: it can light up
+  the fingerprint reader and trip `pam_faillock`, which has locked this machine out
+  before. The helper and retry paths are consequently code-verified only.
+- Agent health: `systemctl --user status cce-polkit-agent`, and the journal should say
+  `Successfully registered`. `RUST_LOG=info` (set by the unit) narrates every request.
+
+## Build
+
+`make install` → `ccebuild install --no-build cce-authenticator`, which installs the
+binary *and* `cce-polkit-agent.service`. Never hand-list binaries in the Makefile —
+`cargo metadata` already knows them. This directory is its own git repository with a
+fetch-only origin; committing locally is publishing, via gitsite. `Cargo.lock` is
+gitignored here, so it needs no refresh when dependencies change.
diff --git a/Cargo.toml b/Cargo.toml
index 33fdec9..64cea9f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,21 +5,16 @@ edition = "2021"
 
 [dependencies]
 cce-ui = { path = "../cce-ui" }
-pam = "0.7.0"
+# The raw PAM binding, for the standalone password check. The higher-level `pam`
+# crate was declared next to it and never called.
 pam-sys = "0.5.6"
 libc = "0.2"
+# passwd lookups: our own identity, and the uid polkit names in its identity list.
 users = "0.8.1"
-toml = "0.8"
-smithay-client-toolkit = { version = "0.19.2", features = ["calloop"] }
 calloop = "0.13.0"
-calloop-wayland-source = "0.3.0"
 wayland-client = { version = "0.31", features = ["system"] }
-xkeysym = "0.2"
 tokio = { version = "1", features = ["full"] }
-serde = { version = "1", features = ["derive"] }
-serde_json = "1"
 zbus = "5"
-chrono = "0.4"
 futures = "0.3"
 log = "0.4"
 env_logger = "0.11"
diff --git a/src/main.rs b/src/main.rs
index 65183a1..ca894f4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,6 +12,8 @@ use std::ops::Deref;
 
 const ACCENT: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
+/// `TOGGLE_OFF` for a control that is not a control — see `fingerprint_interactive`.
+const TOGGLE_INERT: [f32; 4] = [0.11, 0.11, 0.15, 1.0];
 
 
 #[derive(Clone, Debug)]
@@ -57,6 +59,24 @@ static COOKIES: Mutex<CookieState> = Mutex::new(CookieState {
     cancelled: Vec::new(),
 });
 
+/// PAM service backing the standalone password check. Polkit mode never reaches it:
+/// `polkit-agent-helper-1` runs its own `polkit-1` service inside the helper process.
+const PAM_SERVICE: &str = "system-local-login";
+
+/// Who we authenticate as when nothing more specific is known.
+///
+/// The passwd database is asked first and `$USER` is only a fallback, which is the
+/// opposite of what this used to do: a user unit's environment is whatever
+/// `systemctl --user import-environment` was told to carry, so `$USER` can simply be
+/// absent here — and the old code answered that by authenticating as a login name
+/// hardcoded to this developer's machine.
+fn current_username() -> Option<String> {
+    users::get_current_username()
+        .map(|name| name.to_string_lossy().into_owned())
+        .or_else(|| std::env::var("USER").ok())
+        .filter(|name| !name.is_empty())
+}
+
 /// Consume a pending cancellation for `cookie`, reporting whether one was there.
 fn take_cancelled(cookie: &str) -> bool {
     let mut st = COOKIES.lock().unwrap();
@@ -83,6 +103,11 @@ struct AuthenticatorApp {
     fingerprint_msg: String,
     fingerprint_active: bool,
     fingerprint_success: bool,
+    /// Whether the fingerprint button does anything if pressed. In polkit mode it
+    /// does not: `pam_fprintd` inside the helper owns the reader, and whether it is
+    /// even in the stack is PAM's business, not ours — so the column stays dimmed
+    /// and unclaimed until a PAM message shows it is asking for a finger.
+    fingerprint_interactive: bool,
     
     rx_auth: std::sync::mpsc::Receiver<AuthResult>,
     tx_auth: std::sync::mpsc::Sender<AuthResult>,
@@ -187,7 +212,7 @@ impl Application for AuthenticatorApp {
             
         let verify_btn = Button::new(0.0, 0.0, 100.0, 32.0).with_label("Verify Password");
         let cancel_btn = Button::new(0.0, 0.0, 100.0, 32.0).with_label("Cancel");
-        let fingerprint_btn = Button::new(0.0, 0.0, 120.0, 120.0).with_label("Scan");
+        let mut fingerprint_btn = Button::new(0.0, 0.0, 120.0, 120.0).with_label("Scan");
         
         let (tx_auth, rx_auth) = std::sync::mpsc::channel();
         
@@ -204,12 +229,17 @@ impl Application for AuthenticatorApp {
         // stop, whatever CCE_AUTH_SIMULATE says. The password and fingerprint paths
         // below exclude it a second time on the same condition.
         let simulate_mode = !polkit_mode
-            && (std::env::var("CCE_AUTH_SIMULATE").is_ok()
-                || std::env::var("USER").unwrap_or_default() == "root");
+            && (std::env::var("CCE_AUTH_SIMULATE").is_ok() || users::get_current_uid() == 0);
 
         let mut username = String::new();
         let mut cookie = String::new();
 
+        // In polkit mode the button reports the reader rather than driving it, so it
+        // should not read as something to press.
+        if polkit_mode {
+            fingerprint_btn.set_label("Reader");
+        }
+
         if let Some(ref req) = *active_req {
             if std::env::var("CCE_AUTH_SIMULATE").is_ok() {
                 log::warn!(
@@ -252,9 +282,14 @@ impl Application for AuthenticatorApp {
             status_is_error: false,
             status_is_success: false,
             
-            fingerprint_msg: "Fingerprint scanner ready".to_string(),
+            fingerprint_msg: if polkit_mode {
+                "Handled by PAM — follow the prompt".to_string()
+            } else {
+                "Fingerprint scanner ready".to_string()
+            },
             fingerprint_active: false,
             fingerprint_success: false,
+            fingerprint_interactive: !polkit_mode,
             
             rx_auth,
             tx_auth,
@@ -286,8 +321,11 @@ impl Application for AuthenticatorApp {
                 let _ = tx_clone.send(AuthResult::Success);
             });
         } else if !app.polkit_mode {
+            let Some(username) = current_username() else {
+                app.fingerprint_msg = "Cannot determine the current user".to_string();
+                return app;
+            };
             tokio::spawn(async move {
-                let username = std::env::var("USER").unwrap_or_else(|_| "lsgalante".to_string());
                 if let Err(e) = run_dbus_fingerprint(username, tx.clone()).await {
                     let _ = tx.send(AuthResult::FingerprintStatus(format!("No reader: {}", e)));
                     tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
@@ -349,7 +387,12 @@ impl Application for AuthenticatorApp {
                                 let _ = tx.send(AuthResult::Failure("Invalid password (use 'password' or empty)".to_string()));
                             }
                         } else {
-                            let username = std::env::var("USER").unwrap_or_else(|_| "lsgalante".to_string());
+                            let Some(username) = current_username() else {
+                                let _ = tx.send(AuthResult::Failure(
+                                    "Cannot determine the current user".to_string(),
+                                ));
+                                return;
+                            };
                             match tokio::task::spawn_blocking(move || run_pam_auth(&username, &password)).await {
                                 Ok(Ok(())) => {
                                     let _ = tx.send(AuthResult::Success);
@@ -382,7 +425,12 @@ impl Application for AuthenticatorApp {
                         tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
                         let _ = tx.send(AuthResult::Success);
                     } else {
-                        let username = std::env::var("USER").unwrap_or_else(|_| "lsgalante".to_string());
+                        let Some(username) = current_username() else {
+                            let _ = tx.send(AuthResult::FingerprintStatus(
+                                "Cannot determine the current user".to_string(),
+                            ));
+                            return;
+                        };
                         if let Err(e) = run_dbus_fingerprint(username, tx.clone()).await {
                             let _ = tx.send(AuthResult::FingerprintStatus(format!("Scan error: {}", e)));
                         }
@@ -571,8 +619,12 @@ impl Application for AuthenticatorApp {
         } else if self.fingerprint_active {
             let alpha = 0.4 + 0.3 * self.glow_timer.sin();
             [0.16, 0.41, 0.18, alpha]
-        } else {
+        } else if self.fingerprint_interactive {
             TOGGLE_OFF
+        } else {
+            // PAM owns the reader here, and the click handler drops presses on the
+            // floor — so don't paint this like something that responds to one.
+            TOGGLE_INERT
         };
         quad(&mut pc, fp_btn_x, fp_btn_y, fp_btn_w, fp_btn_h, fp_bg);
 
@@ -606,7 +658,13 @@ impl Application for AuthenticatorApp {
         // ── Text (the old text_items assembly, now prims shaped by the engine) ──
         pc.text_with("CCE AUTHENTICATOR".to_string(), card_x + 30.0, card_y + 30.0, 15.0, [0xee, 0xee, 0xf5], None, None);
         pc.text_with("FINGERPRINT AUTHENTICATION".to_string(), fp_col_x, fp_col_y - 15.0, 10.0, [0x83, 0x83, 0x8a], None, None);
-        let fp_msg_color = if self.fingerprint_success { [0xa0, 0xee, 0xa0] } else { [0xbb, 0xbb, 0xbf] };
+        let fp_msg_color = if self.fingerprint_success {
+            [0xa0, 0xee, 0xa0]
+        } else if self.fingerprint_interactive || self.fingerprint_active {
+            [0xbb, 0xbb, 0xbf]
+        } else {
+            [0x83, 0x83, 0x8a]
+        };
         pc.text_with(
             self.fingerprint_msg.clone(),
             fp_col_x,
@@ -758,7 +816,7 @@ impl AuthenticatorApp {
 
 fn run_pam_auth(username: &str, password: &str) -> Result<(), String> {
     unsafe {
-        let service = "system-local-login"; 
+        let service = PAM_SERVICE;
         let pass_c = std::ffi::CString::new(password).map_err(|e| e.to_string())?;
         
         extern "C" fn pam_conv_simple(
@@ -910,10 +968,18 @@ impl PolkitAgent {
                 }
             }
         }
+        // polkit names the identity it wants authenticated. If it named one we could
+        // not resolve, fall back to our own — but refuse rather than guess a name,
+        // because the wrong identity here means prompting for a password that cannot
+        // authorize the action.
         if username.is_empty() {
-            username = std::env::var("USER").unwrap_or_else(|_| "lsgalante".to_string());
+            username = current_username().ok_or_else(|| {
+                zbus::fdo::Error::Failed("no resolvable unix-user identity".to_string())
+            })?;
+            log::warn!("no unix-user identity in the request; falling back to {}", username);
         }
-        
+
+
         let (tx_result, rx_result) = tokio::sync::oneshot::channel();
         let req = GuiRequest {
             username,