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:
+182
-39
@@ -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: >k::Label,
|
||||
password_entry: >k::PasswordEntry,
|
||||
error_label: >k::Label,
|
||||
fp_label: >k::Label,
|
||||
session_dropdown: >k::DropDown,
|
||||
sessions: &[Session],
|
||||
window: >k::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: >k::Label,
|
||||
password_entry: >k::PasswordEntry,
|
||||
error_label: >k::Label,
|
||||
fp_label: >k::Label,
|
||||
session_dropdown: >k::DropDown,
|
||||
sessions: &[Session],
|
||||
window: >k::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();
|
||||
|
||||
Reference in New Issue
Block a user