git.lucas.co / cce-display-manager
login greeter
git clone https://git.lucas.co/cce-display-manager.git

commitd72f0984aac03bab2d8253714aef67ac4e139f98
parent37eb7994ed
authorLucas Galante <[email protected]>
date2026-08-17 12:53
Stop fingerprint misses from locking the password out (pam_faillock)

Three fingerprint misses in a row locked the account for ten minutes and
the correct password was then silently rejected (2026-08-17 12:25). Cause:
the greeter's background fingerprint attempt used the cce-display-manager
service, where pam_fprintd was `sufficient` above an include of
system-local-login, so every miss fell through into pam_unix with an empty
password — one pam_faillock strike each. Greeter respawns fired more
attempts on their own, and a respawned greeter found the sensor still
claimed by its predecessor's PAM thread, which failed instantly and was
counted too.

- New fingerprint-only PAM service cce-display-manager-fprint with
  `auth requisite pam_fprintd.so` and no auth-stack include: a miss ends
  the stack, nothing reaches pam_unix/faillock. pam_fprintd removed from
  the legacy cce-display-manager service.
- The fingerprint attempt now runs in a helper process (`--fprint-auth
  <user>`, INFO|/OK/FAIL| line protocol) instead of a thread, so typing a
  password actually cancels it: kill → D-Bus connection drops → fprintd
  releases the claim. PR_SET_PDEATHSIG so a crashed/respawned greeter
  cannot leave a helper holding the sensor. Verified: kill mid-scan, next
  helper claims the sensor immediately.
- Respawn guard: a greeter starting within 20s of the last auto-start
  (stamp /run/cce-display-manager/fprint-autostart) does not auto-start;
  Enter on an empty password box starts it explicitly.

Deploy needs root: install pam/cce-display-manager{,-fprint} to
/etc/pam.d, the binary to /usr/bin, restart cce-display-manager@tty1.

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

 pam/cce-display-manager        |   5 +-
 pam/cce-display-manager-fprint |  14 +++
 src/main.rs                    | 217 ++++++++++++++++++++++++++++++++++++-----
 3 files changed, 213 insertions(+), 23 deletions(-)

diff --git a/pam/cce-display-manager b/pam/cce-display-manager
index 6a75db6..e91a513 100644
--- a/pam/cce-display-manager
+++ b/pam/cce-display-manager
@@ -1,6 +1,9 @@
 #%PAM-1.0
+# Legacy service name. The greeter no longer authenticates against it: the
+# fingerprint attempt uses cce-display-manager-fprint and typed passwords use
+# cce-display-manager-password. pam_fprintd was removed from here so that
+# nothing can turn a fingerprint miss into a pam_unix/pam_faillock failure.
 
-auth       sufficient   pam_fprintd.so
 auth       include      system-local-login
 -auth      optional     pam_gnome_keyring.so
 -auth      optional     pam_kwallet5.so
diff --git a/pam/cce-display-manager-fprint b/pam/cce-display-manager-fprint
new file mode 100644
index 0000000..24c7baf
--- /dev/null
+++ b/pam/cce-display-manager-fprint
@@ -0,0 +1,14 @@
+#%PAM-1.0
+# Fingerprint-only service used by the greeter for its background / empty-
+# password attempt. `requisite` ends the auth stack the moment pam_fprintd
+# fails, so a fingerprint miss (or a timeout, or a "device already claimed"
+# error) can never fall through into pam_unix with an empty password. That
+# fall-through was a pam_faillock strike each time: three fingerprint misses
+# locked the account, and then the correct password was silently rejected
+# for ten minutes (2026-08-17). No system-local-login include on purpose.
+
+auth       requisite    pam_fprintd.so
+
+account    include      system-local-login
+
+session    include      system-local-login
diff --git a/src/main.rs b/src/main.rs
index 4df187b..13f7b0f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -292,7 +292,11 @@ struct State {
     auth_request_id: u64,
     auth_sender: channel::Sender<AuthEvent>,
     auth_receiver: Option<channel::Channel<AuthEvent>>,
-
+    // The fingerprint attempt runs in a helper *process* (`--fprint-auth`),
+    // not a thread: pam_authenticate blocks inside pam_fprintd and cannot be
+    // interrupted, but a process can be killed — and killing it drops its
+    // D-Bus connection, which is what makes fprintd release the sensor claim.
+    fprint_child: Option<std::process::Child>,
 }
 
 impl State {
@@ -474,12 +478,7 @@ impl State {
             self.username_box.focus();
         } else if password.is_empty() {
             if is_fprint_enabled() {
-                self.auth_request_id += 1;
-                self.status_lbl.text = "Scan finger to login or type password".to_string();
-                self.status_lbl.is_error = false;
-                self.is_authenticating = true;
-                self.login_btn.base_mut().label = Some("Authenticating...".to_string());
-                authenticate_user(self.auth_request_id, username, password, self.auth_sender.clone());
+                self.start_fprint_auth();
             } else {
                 self.status_lbl.text = "Password cannot be empty".to_string();
                 self.status_lbl.is_error = true;
@@ -487,6 +486,9 @@ impl State {
                 self.password_box.focus();
             }
         } else {
+            // A typed password supersedes any fingerprint attempt still
+            // running; release the sensor so it is not left claimed.
+            self.cancel_fprint_auth();
             self.auth_request_id += 1;
             self.status_lbl.text = "Authenticating...".to_string();
             self.status_lbl.is_error = false;
@@ -495,6 +497,38 @@ impl State {
             authenticate_user(self.auth_request_id, username, password, self.auth_sender.clone());
         }
     }
+
+    /// Start (or restart) the fingerprint attempt for the username in the box.
+    fn start_fprint_auth(&mut self) {
+        let username = self.username_box.text.trim().to_string();
+        self.cancel_fprint_auth();
+        self.auth_request_id += 1;
+        self.status_lbl.text = "Scan finger to login or type password".to_string();
+        self.status_lbl.is_error = false;
+        self.is_authenticating = true;
+        self.login_btn.base_mut().label = Some("Authenticating...".to_string());
+        match spawn_fprint_helper(self.auth_request_id, &username, self.auth_sender.clone()) {
+            Ok(child) => self.fprint_child = Some(child),
+            Err(e) => {
+                log::error!("Failed to spawn fingerprint helper: {}", e);
+                self.is_authenticating = false;
+                self.login_btn.base_mut().label = Some("Log In".to_string());
+                self.status_lbl.text = "Fingerprint unavailable — type password".to_string();
+                self.status_lbl.is_error = true;
+            }
+        }
+    }
+
+    /// Kill a running fingerprint helper, if any. Its exit drops the D-Bus
+    /// connection pam_fprintd used to claim the sensor, so fprintd releases the
+    /// device for the next attempt. Any late events it already queued are
+    /// dropped by the request-id check in the auth event handler.
+    fn cancel_fprint_auth(&mut self) {
+        if let Some(mut child) = self.fprint_child.take() {
+            let _ = child.kill();
+            let _ = child.wait();
+        }
+    }
 }
 
 impl cce_ui::engine::Application for State {
@@ -565,6 +599,7 @@ impl cce_ui::engine::Application for State {
             auth_request_id: 0,
             auth_sender,
             auth_receiver: Some(auth_receiver),
+            fprint_child: None,
         };
 
         // The widget tree is NOT linked here: `app` is a stack local inside new(), so any
@@ -580,15 +615,22 @@ impl cce_ui::engine::Application for State {
             app.username_box.focus();
         }
 
-        // Start background fingerprint/empty-password authentication if username is prepopulated and fprintd is enabled!
+        // Start the background fingerprint attempt if the username is
+        // prepopulated and fprintd is enabled — unless this greeter is a rapid
+        // respawn of one that just did the same. The daemon relaunches the
+        // greeter whenever it exits without AUTH_SUCCESS (crash, F5, Ctrl+C),
+        // and every relaunch used to fire a fresh fingerprint attempt on its
+        // own: three respawns in 90s were three attempts nobody asked for.
+        // After a respawn the user starts it explicitly (Enter on an empty
+        // password box).
         let username = app.username_box.text.trim().to_string();
-        if !username.is_empty() {
-            if is_fprint_enabled() {
-                app.auth_request_id += 1;
-                app.is_authenticating = true;
-                app.login_btn.base_mut().label = Some("Authenticating...".to_string());
-                app.status_lbl.text = "Scan finger to login or type password".to_string();
-                authenticate_user(app.auth_request_id, username, String::new(), app.auth_sender.clone());
+        if !username.is_empty() && is_fprint_enabled() {
+            if fprint_autostart_recently() {
+                log::info!("Greeter respawned within {}s of the last fingerprint auto-start; not auto-starting", FPRINT_AUTOSTART_COOLDOWN.as_secs());
+                app.status_lbl.text = "Press Enter to scan finger, or type password".to_string();
+            } else {
+                mark_fprint_autostart();
+                app.start_fprint_auth();
             }
         }
 
@@ -725,6 +767,7 @@ impl cce_ui::engine::Application for State {
 
                         match msg {
                             AuthEvent::Success { username, .. } => {
+                                app.fprint_child = None;
                                 app.is_authenticating = false;
                                 app.login_btn.base_mut().label = Some("Log In".to_string());
                                 app.status_lbl.text = format!("Welcome, {}!", username);
@@ -736,6 +779,9 @@ impl cce_ui::engine::Application for State {
                                 }
                             }
                             AuthEvent::Failure { err_msg, .. } => {
+                                if let Some(mut child) = app.fprint_child.take() {
+                                    let _ = child.wait();
+                                }
                                 app.is_authenticating = false;
                                 app.login_btn.base_mut().label = Some("Log In".to_string());
                                 app.status_lbl.text = err_msg;
@@ -824,6 +870,7 @@ impl cce_ui::engine::Application for State {
                         _ => false,
                     };
                     if is_typing {
+                        self.cancel_fprint_auth();
                         self.auth_request_id += 1;
                         self.is_authenticating = false;
                         self.login_btn.base_mut().label = Some("Log In".to_string());
@@ -911,8 +958,39 @@ enum AuthEvent {
     Info { request_id: u64, msg: String },
 }
 
+/// PAM service for the fingerprint attempt. It must be fingerprint-ONLY
+/// (`auth requisite pam_fprintd.so`, no system-local-login include in the auth
+/// stack): the old layout had pam_fprintd `sufficient` above the include, so a
+/// miss fell through into pam_unix with an empty password — one pam_faillock
+/// strike per miss, and after three the correct password was rejected too.
+const FPRINT_PAM_SERVICE: &str = "cce-display-manager-fprint";
+const PASSWORD_PAM_SERVICE: &str = "cce-display-manager-password";
+
+/// A greeter that starts within this window of the previous auto-start is a
+/// respawn; it does not auto-start the fingerprint attempt again.
+const FPRINT_AUTOSTART_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(20);
+const FPRINT_AUTOSTART_STAMP: &str = "/run/cce-display-manager/fprint-autostart";
+
+fn fprint_autostart_recently() -> bool {
+    std::fs::metadata(FPRINT_AUTOSTART_STAMP)
+        .and_then(|m| m.modified())
+        .ok()
+        .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
+        .map(|age| age < FPRINT_AUTOSTART_COOLDOWN)
+        .unwrap_or(false)
+}
+
+fn mark_fprint_autostart() {
+    if let Some(dir) = std::path::Path::new(FPRINT_AUTOSTART_STAMP).parent() {
+        let _ = std::fs::create_dir_all(dir);
+    }
+    if let Err(e) = std::fs::write(FPRINT_AUTOSTART_STAMP, b"") {
+        log::warn!("Could not write {}: {}", FPRINT_AUTOSTART_STAMP, e);
+    }
+}
+
 fn is_fprint_enabled() -> bool {
-    std::fs::read_to_string("/etc/pam.d/cce-display-manager")
+    std::fs::read_to_string(format!("/etc/pam.d/{}", FPRINT_PAM_SERVICE))
         .map(|content| {
             content.lines().any(|line| {
                 let trimmed = line.trim();
@@ -922,14 +1000,13 @@ fn is_fprint_enabled() -> bool {
         .unwrap_or(false)
 }
 
+/// Password authentication, in a thread. Fingerprint goes through
+/// `spawn_fprint_helper` instead — never call this with an empty password.
 fn authenticate_user(request_id: u64, username: String, password: String, sender: channel::Sender<AuthEvent>) {
+    debug_assert!(!password.is_empty(), "empty password must go through the fingerprint helper");
     std::thread::spawn(move || {
-        let service = if password.is_empty() {
-            "cce-display-manager"
-        } else {
-            "cce-display-manager-password"
-        };
-        
+        let service = PASSWORD_PAM_SERVICE;
+
         let mut auth = match PamSession::new(service, &username, &password, request_id, Some(sender.clone())) {
             Ok(a) => a,
             Err(e) => {
@@ -952,6 +1029,100 @@ fn authenticate_user(request_id: u64, username: String, password: String, sender
     });
 }
 
+/// Line protocol between the greeter and its `--fprint-auth` helper (on the
+/// helper's stdout — which is a pipe to the greeter, NOT the greeter's own
+/// stdout, which carries AUTH_SUCCESS to the daemon).
+const FPRINT_LINE_INFO: &str = "INFO|";
+const FPRINT_LINE_OK: &str = "OK";
+const FPRINT_LINE_FAIL: &str = "FAIL|";
+
+/// Spawn `<self> --fprint-auth <user>` and forward its result lines as
+/// AuthEvents tagged with `request_id`. The helper is bound to the greeter with
+/// PR_SET_PDEATHSIG so a crashed or respawned greeter cannot leave it running
+/// with the sensor claimed (that "Device was already claimed" state made every
+/// later attempt fail instantly).
+fn spawn_fprint_helper(request_id: u64, username: &str, sender: channel::Sender<AuthEvent>) -> std::io::Result<std::process::Child> {
+    use std::os::unix::process::CommandExt;
+    let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
+    let mut cmd = std::process::Command::new(exe);
+    cmd.arg("--fprint-auth")
+        .arg(username)
+        .stdin(std::process::Stdio::null())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::inherit());
+    unsafe {
+        cmd.pre_exec(|| {
+            // Runs in the child between fork and exec; PDEATHSIG survives exec.
+            if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
+                return Err(std::io::Error::last_os_error());
+            }
+            // Parent already gone (raced between fork and prctl)? Then die now.
+            if libc::getppid() == 1 {
+                libc::_exit(1);
+            }
+            Ok(())
+        });
+    }
+    let mut child = cmd.spawn()?;
+    let stdout = child.stdout.take().expect("piped stdout");
+    let username = username.to_string();
+    std::thread::spawn(move || {
+        use std::io::BufRead;
+        let mut concluded = false;
+        for line in std::io::BufReader::new(stdout).lines() {
+            let line = match line { Ok(l) => l, Err(_) => break };
+            if let Some(msg) = line.strip_prefix(FPRINT_LINE_INFO) {
+                let _ = sender.send(AuthEvent::Info { request_id, msg: msg.to_string() });
+            } else if line == FPRINT_LINE_OK {
+                concluded = true;
+                let _ = sender.send(AuthEvent::Success { request_id, username: username.clone() });
+            } else if let Some(msg) = line.strip_prefix(FPRINT_LINE_FAIL) {
+                concluded = true;
+                let _ = sender.send(AuthEvent::Failure { request_id, err_msg: msg.to_string() });
+            }
+        }
+        if !concluded {
+            // EOF without a verdict: killed (cancelled) or crashed. A cancel
+            // has already bumped auth_request_id, so this is dropped there.
+            let _ = sender.send(AuthEvent::Failure { request_id, err_msg: "Fingerprint helper exited".to_string() });
+        }
+    });
+    Ok(child)
+}
+
+/// `--fprint-auth <user>`: run the fingerprint-only PAM service to a verdict
+/// and report it on stdout. Authenticate + account check only — the greeter
+/// prints AUTH_SUCCESS with an empty password and the daemon opens the real
+/// session on cce-display-manager-autologin, so opening one here would just
+/// register a throwaway logind session under cage.
+fn run_fprint_helper(username: &str) -> ! {
+    use std::io::Write;
+    let (sender, receiver) = channel::channel::<AuthEvent>();
+    let user = username.to_string();
+    let worker = std::thread::spawn(move || {
+        let mut auth = PamSession::new(FPRINT_PAM_SERVICE, &user, "", 0, Some(sender.clone()))
+            .map_err(|e| format!("{:?}", e))?;
+        auth.authenticate().map_err(|e| format!("{:?}", e))
+    });
+    let mut out = std::io::stdout();
+    // Forward conversation messages (e.g. "Place your finger on the sensor")
+    // until the worker's sender is dropped, i.e. the verdict is in.
+    while let Ok(ev) = receiver.recv() {
+        if let AuthEvent::Info { msg, .. } = ev {
+            let _ = writeln!(out, "{}{}", FPRINT_LINE_INFO, msg.replace('\n', " "));
+            let _ = out.flush();
+        }
+    }
+    let verdict = match worker.join() {
+        Ok(Ok(())) => FPRINT_LINE_OK.to_string(),
+        Ok(Err(e)) => format!("{}{}", FPRINT_LINE_FAIL, e),
+        Err(_) => format!("{}fingerprint worker panicked", FPRINT_LINE_FAIL),
+    };
+    let _ = writeln!(out, "{}", verdict);
+    let _ = out.flush();
+    std::process::exit(if verdict == FPRINT_LINE_OK { 0 } else { 1 });
+}
+
 #[derive(serde::Deserialize, Debug, Default)]
 struct SystemConfig {
     scale: Option<f64>,
@@ -1605,6 +1776,8 @@ fn main() {
     let args: Vec<String> = std::env::args().collect();
     if args.len() > 1 && args[1] == "--greeter" {
         run_greeter();
+    } else if args.len() > 2 && args[1] == "--fprint-auth" {
+        run_fprint_helper(&args[2]);
     } else {
         run_daemon();
     }