login authentication (PAM + fingerprint)
git clone https://git.lucas.co/cce-authenticator.git
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,