feat: add fprintd fingerprint authentication via greetd multi-stage PAM (v0.6.0)

Fingerprint auth was missing because moongreet rejected multi-stage
auth_message sequences from greetd. With pam_fprintd.so in the PAM
stack, greetd sends non-secret prompts for fingerprint and secret
prompts for password — moongreet now handles both in a loop.

- Replace single-pass auth with multi-stage auth_message loop
- fprintd D-Bus probe (gio::DBusProxy) for UI feedback only
- Fingerprint label shown when device available and fingers enrolled
- 60s socket timeout when fingerprint available (pam_fprintd scan time)
- Config option: [appearance] fingerprint-enabled (default: true)
- Fix: password entry focus loss after auth error (grab_focus while
  widget was insensitive — now re-enable before grab_focus)
This commit is contained in:
2026-03-29 13:47:57 +02:00
parent 77b94a560d
commit a462b2cf06
10 changed files with 381 additions and 47 deletions
+182 -39
View File
@@ -194,6 +194,7 @@ struct GreeterState {
failed_attempts: HashMap<String, u32>,
greetd_sock: Arc<Mutex<Option<UnixStream>>>,
login_cancelled: Arc<std::sync::atomic::AtomicBool>,
fingerprint_available: bool,
}
/// Create the main greeter window with login UI.
@@ -224,6 +225,7 @@ pub fn create_greeter_window(
}
let strings = load_strings(None);
let fingerprint_enabled = config.fingerprint_enabled;
let all_users = users::get_users(None);
let all_sessions = sessions::get_sessions(None, None);
log::debug!("Greeter window: {} user(s), {} session(s)", all_users.len(), all_sessions.len());
@@ -238,6 +240,7 @@ pub fn create_greeter_window(
failed_attempts: HashMap::new(),
greetd_sock: Arc::new(Mutex::new(None)),
login_cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fingerprint_available: false,
}));
// Root overlay for layering
@@ -308,6 +311,12 @@ pub fn create_greeter_window(
error_label.set_visible(false);
login_box.append(&error_label);
// Fingerprint label (hidden until probe confirms availability)
let fp_label = gtk::Label::new(None);
fp_label.add_css_class("fingerprint-label");
fp_label.set_visible(false);
login_box.append(&fp_label);
login_box.set_halign(gtk::Align::Center);
main_box.append(&login_box);
@@ -348,6 +357,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -364,9 +375,12 @@ pub fn create_greeter_window(
&username_label,
&password_entry,
&error_label,
&fp_label,
&session_dropdown,
&sessions_rc,
&window,
fingerprint_enabled,
strings,
);
}
));
@@ -497,6 +511,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -514,6 +530,8 @@ pub fn create_greeter_window(
#[weak]
error_label,
#[weak]
fp_label,
#[weak]
session_dropdown,
#[weak]
window,
@@ -525,9 +543,12 @@ pub fn create_greeter_window(
&username_label,
&password_entry,
&error_label,
&fp_label,
&session_dropdown,
&sessions_rc,
&window,
fingerprint_enabled,
strings,
);
}
));
@@ -545,9 +566,12 @@ fn select_initial_user(
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
fp_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
fingerprint_enabled: bool,
strings: &'static Strings,
) {
if users.is_empty() {
return;
@@ -567,9 +591,12 @@ fn select_initial_user(
username_label,
password_entry,
error_label,
fp_label,
session_dropdown,
sessions,
window,
fingerprint_enabled,
strings,
);
}
@@ -581,19 +608,24 @@ fn switch_to_user(
username_label: &gtk::Label,
password_entry: &gtk::PasswordEntry,
error_label: &gtk::Label,
fp_label: &gtk::Label,
session_dropdown: &gtk::DropDown,
sessions: &[Session],
window: &gtk::ApplicationWindow,
fingerprint_enabled: bool,
strings: &'static Strings,
) {
log::debug!("Switching to user: {}", user.username);
{
let mut s = state.borrow_mut();
s.selected_user = Some(user.clone());
s.fingerprint_available = false;
}
username_label.set_text(user.display_name());
password_entry.set_text("");
error_label.set_visible(false);
fp_label.set_visible(false);
// Update avatar
let cached = {
@@ -618,6 +650,27 @@ fn switch_to_user(
// Pre-select last used session for this user
select_last_session(&user.username, session_dropdown, sessions);
// Probe fprintd for fingerprint availability
if fingerprint_enabled {
let username = user.username.clone();
glib::spawn_future_local(clone!(
#[weak]
fp_label,
#[strong]
state,
async move {
let mut probe = crate::fingerprint::FingerprintProbe::new();
probe.init_async().await;
let available = probe.is_available_async(&username).await;
state.borrow_mut().fingerprint_available = available;
fp_label.set_visible(available);
if available {
fp_label.set_text(strings.fingerprint_prompt);
}
}
));
}
password_entry.grab_focus();
}
@@ -885,6 +938,7 @@ fn attempt_login(
let session_name = session.name.clone();
let greetd_sock = state.borrow().greetd_sock.clone();
let login_cancelled = state.borrow().login_cancelled.clone();
let fingerprint_available = state.borrow().fingerprint_available;
glib::spawn_future_local(clone!(
#[weak]
@@ -908,6 +962,7 @@ fn attempt_login(
&greetd_sock,
&login_cancelled,
strings,
fingerprint_available,
)
})
.await;
@@ -925,6 +980,7 @@ fn attempt_login(
let warning = faillock_warning(*count, strings);
drop(s);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_greetd_error(
&error_label,
&password_entry,
@@ -935,24 +991,23 @@ fn attempt_login(
let current = error_label.text().to_string();
error_label.set_text(&format!("{current}\n{w}"));
}
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Ok(LoginResult::Error { message })) => {
show_error(&error_label, &password_entry, &message);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, &message);
}
Ok(Ok(LoginResult::Cancelled)) => {
set_login_sensitive(&password_entry, &session_dropdown, true);
}
Ok(Err(e)) => {
log::error!("Login worker error: {e}");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, strings.socket_error);
}
Err(_) => {
log::error!("Login worker panicked");
show_error(&error_label, &password_entry, strings.socket_error);
set_login_sensitive(&password_entry, &session_dropdown, true);
show_error(&error_label, &password_entry, strings.socket_error);
}
}
}
@@ -983,6 +1038,7 @@ fn login_worker(
greetd_sock: &Arc<Mutex<Option<UnixStream>>>,
login_cancelled: &Arc<std::sync::atomic::AtomicBool>,
strings: &Strings,
fingerprint_available: bool,
) -> Result<LoginResult, String> {
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
log::debug!("Login cancelled before connect");
@@ -991,7 +1047,9 @@ fn login_worker(
log::debug!("Connecting to greetd socket: {sock_path}");
let mut sock = UnixStream::connect(sock_path).map_err(|e| e.to_string())?;
if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(10))) {
// Longer timeout when fingerprint is available — pam_fprintd waits for scan
let read_timeout_secs = if fingerprint_available { 60 } else { 10 };
if let Err(e) = sock.set_read_timeout(Some(std::time::Duration::from_secs(read_timeout_secs))) {
log::warn!("Failed to set read timeout: {e}");
}
if let Err(e) = sock.set_write_timeout(Some(std::time::Duration::from_secs(10))) {
@@ -1023,11 +1081,40 @@ fn login_worker(
}
}
// Step 2: Send password if auth message received
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
log::debug!("Sending auth response for {username}");
response =
ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?;
// Step 2: Handle auth_message loop (supports multi-stage PAM, e.g. fprintd + password)
const MAX_AUTH_ROUNDS: u32 = 5;
let mut auth_round = 0;
while response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
auth_round += 1;
if auth_round > MAX_AUTH_ROUNDS {
log::warn!("Too many auth rounds ({auth_round}), aborting");
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.auth_failed.to_string(),
});
}
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
}
let msg_type = response
.get("auth_message_type")
.and_then(|v| v.as_str())
.unwrap_or("secret");
if msg_type == "secret" {
log::debug!("Sending password for {username} (round {auth_round})");
response =
ipc::post_auth_response(&mut sock, Some(password)).map_err(|e| e.to_string())?;
} else {
// Non-secret prompt (e.g. fprintd "Place finger on reader")
// PAM handles the actual verification; this blocks until resolved
log::debug!("Acknowledging non-secret auth prompt (round {auth_round})");
response =
ipc::post_auth_response(&mut sock, None).map_err(|e| e.to_string())?;
}
if login_cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(LoginResult::Cancelled);
@@ -1040,14 +1127,6 @@ fn login_worker(
username: username.to_string(),
});
}
if response.get("type").and_then(|v| v.as_str()) == Some("auth_message") {
// Multi-stage auth is not supported
let _ = ipc::cancel_session(&mut sock);
return Ok(LoginResult::Error {
message: strings.multi_stage_unsupported.to_string(),
});
}
}
// Step 3: Start session
@@ -1475,7 +1554,7 @@ mod tests {
let result = login_worker(
"alice", "wrongpass", "/usr/bin/niri",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1517,7 +1596,7 @@ mod tests {
let result = login_worker(
"alice", "correct", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1526,40 +1605,104 @@ mod tests {
}
#[test]
fn login_worker_multi_stage_rejected() {
fn login_worker_multi_stage_fingerprint_then_password() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Place your finger on the reader",
})).unwrap();
// post_auth_response with None (fingerprint prompt acknowledged)
let msg = ipc::recv_message(stream).unwrap();
assert!(msg["response"].is_null());
// Fingerprint failed, PAM falls through to password
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "secret",
"auth_message": "Password: ",
})).unwrap();
// post_auth_response → another auth_message (TOTP)
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "TOTP: ",
})).unwrap();
// post_auth_response with password
let msg = ipc::recv_message(stream).unwrap();
assert_eq!(msg["response"], "correctpass");
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// cancel_session
// start_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"alice", "correctpass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), true,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_multi_stage_fingerprint_success() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Place your finger on the reader",
})).unwrap();
// post_auth_response with None → fingerprint matched via PAM
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
// start_session
let _msg = ipc::recv_message(stream).unwrap();
ipc::send_message(stream, &serde_json::json!({"type": "success"})).unwrap();
});
let result = login_worker(
"alice", "", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")), true,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Success { .. }));
handle.join().unwrap();
}
#[test]
fn login_worker_max_auth_rounds_exceeded() {
let (sock_path, handle) = fake_greetd(|stream| {
// create_session
let _msg = ipc::recv_message(stream).unwrap();
// Send 6 auth_messages (exceeds MAX_AUTH_ROUNDS=5)
for _ in 0..6 {
ipc::send_message(stream, &serde_json::json!({
"type": "auth_message",
"auth_message_type": "visible",
"auth_message": "Prompt",
})).unwrap();
let _msg = ipc::recv_message(stream).unwrap();
}
});
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")), false,
);
let result = result.unwrap();
assert!(matches!(result, LoginResult::Error { .. }));
if let LoginResult::Error { message } = result {
assert!(message.contains("Multi-stage"));
}
handle.join().unwrap();
}
@@ -1589,7 +1732,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/bash",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1604,7 +1747,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1617,7 +1760,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "/usr/bin/niri",
"/nonexistent/sock", &default_greetd_sock(), &cancelled,
load_strings(Some("en")),
load_strings(Some("en")), false,
);
assert!(result.is_err());
@@ -1647,7 +1790,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "../../../etc/evil",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();
@@ -1679,7 +1822,7 @@ mod tests {
let result = login_worker(
"alice", "pass", "niri-session",
&sock_path, &default_greetd_sock(), &default_cancelled(),
load_strings(Some("en")),
load_strings(Some("en")), false,
);
let result = result.unwrap();