Close the only exploitable auth bypass: validate VerifyStatus signal sender against fprintd's unique bus name. Fix fingerprint D-Bus lifecycle so devices are properly released on verify-match and async restarts check the running flag between awaits. Security: num_msg guard in PAM callback, symlink rejection for background_path, peek icon disabled, TOML parse errors logged, panic hook before logging. Performance: blur and avatar textures cached across monitors, release profile with LTO/strip.
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
// ABOUTME: fprintd D-Bus integration for fingerprint authentication.
|
|
// ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy.
|
|
|
|
use gio::prelude::*;
|
|
use gtk4::gio;
|
|
use std::cell::{Cell, RefCell};
|
|
use std::rc::Rc;
|
|
|
|
const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint";
|
|
const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager";
|
|
const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
|
|
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
|
|
|
const MAX_FP_ATTEMPTS: u32 = 10;
|
|
const DBUS_TIMEOUT_MS: i32 = 3000;
|
|
|
|
/// Retry-able statuses — finger not read properly, try again.
|
|
const RETRY_STATUSES: &[&str] = &[
|
|
"verify-swipe-too-short",
|
|
"verify-finger-not-centered",
|
|
"verify-remove-and-retry",
|
|
"verify-retry-scan",
|
|
];
|
|
|
|
/// Fingerprint listener state.
|
|
pub struct FingerprintListener {
|
|
device_proxy: Option<gio::DBusProxy>,
|
|
signal_id: Option<glib::SignalHandlerId>,
|
|
running: bool,
|
|
/// Shared flag for async tasks to detect stop() between awaits.
|
|
running_flag: Rc<Cell<bool>>,
|
|
failed_attempts: u32,
|
|
on_success: Option<Box<dyn Fn() + 'static>>,
|
|
on_failure: Option<Box<dyn Fn() + 'static>>,
|
|
on_exhausted: Option<Box<dyn Fn() + 'static>>,
|
|
}
|
|
|
|
impl FingerprintListener {
|
|
/// Create a lightweight FingerprintListener without any D-Bus calls.
|
|
/// Call `init_async().await` afterwards to connect to fprintd.
|
|
pub fn new() -> Self {
|
|
FingerprintListener {
|
|
device_proxy: None,
|
|
signal_id: None,
|
|
running: false,
|
|
running_flag: Rc::new(Cell::new(false)),
|
|
failed_attempts: 0,
|
|
on_success: None,
|
|
on_failure: None,
|
|
on_exhausted: None,
|
|
}
|
|
}
|
|
|
|
/// Connect to fprintd and get the default device asynchronously.
|
|
pub async fn init_async(&mut self) {
|
|
let manager = match gio::DBusProxy::for_bus_future(
|
|
gio::BusType::System,
|
|
gio::DBusProxyFlags::NONE,
|
|
None,
|
|
FPRINTD_BUS_NAME,
|
|
FPRINTD_MANAGER_PATH,
|
|
FPRINTD_MANAGER_IFACE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
log::debug!("fprintd manager not available: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Call GetDefaultDevice
|
|
let result = match manager
|
|
.call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
log::debug!("fprintd GetDefaultDevice failed: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Extract device path from variant tuple
|
|
let device_path = match result.child_value(0).get::<String>() {
|
|
Some(p) => p,
|
|
None => {
|
|
log::debug!("fprintd: unexpected GetDefaultDevice response type");
|
|
return;
|
|
}
|
|
};
|
|
if device_path.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match gio::DBusProxy::for_bus_future(
|
|
gio::BusType::System,
|
|
gio::DBusProxyFlags::NONE,
|
|
None,
|
|
FPRINTD_BUS_NAME,
|
|
&device_path,
|
|
FPRINTD_DEVICE_IFACE,
|
|
)
|
|
.await
|
|
{
|
|
Ok(proxy) => {
|
|
self.device_proxy = Some(proxy);
|
|
}
|
|
Err(e) => {
|
|
log::debug!("fprintd device proxy failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if fprintd is available and the user has enrolled fingerprints (async).
|
|
pub async fn is_available_async(&self, username: &str) -> bool {
|
|
let proxy = match &self.device_proxy {
|
|
Some(p) => p,
|
|
None => return false,
|
|
};
|
|
|
|
let args = glib::Variant::from((&username,));
|
|
match proxy
|
|
.call_future("ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
// Result is a tuple of (array of strings)
|
|
match result.child_value(0).get::<Vec<String>>() {
|
|
Some(fingers) => !fingers.is_empty(),
|
|
None => {
|
|
log::debug!("fprintd: unexpected ListEnrolledFingers response type");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Start listening for fingerprint verification.
|
|
/// Claims the device and starts verification using async D-Bus calls.
|
|
/// Connects the D-Bus g-signal handler internally. The `listener` parameter
|
|
/// must be the same `Rc<RefCell<FingerprintListener>>` that owns `self`.
|
|
pub async fn start_async<F, G, H>(
|
|
listener: &Rc<RefCell<FingerprintListener>>,
|
|
username: &str,
|
|
on_success: F,
|
|
on_failure: G,
|
|
on_exhausted: H,
|
|
) where
|
|
F: Fn() + 'static,
|
|
G: Fn() + 'static,
|
|
H: Fn() + 'static,
|
|
{
|
|
let proxy = {
|
|
let inner = listener.borrow();
|
|
match inner.device_proxy.clone() {
|
|
Some(p) => p,
|
|
None => return,
|
|
}
|
|
};
|
|
|
|
{
|
|
let mut inner = listener.borrow_mut();
|
|
inner.on_success = Some(Box::new(on_success));
|
|
inner.on_failure = Some(Box::new(on_failure));
|
|
inner.on_exhausted = Some(Box::new(on_exhausted));
|
|
}
|
|
|
|
// Claim the device
|
|
let args = glib::Variant::from((&username,));
|
|
if let Err(e) = proxy
|
|
.call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await
|
|
{
|
|
log::error!("Failed to claim fingerprint device: {e}");
|
|
return;
|
|
}
|
|
|
|
// Start verification
|
|
let start_args = glib::Variant::from((&"any",));
|
|
if let Err(e) = proxy
|
|
.call_future("VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await
|
|
{
|
|
log::error!("Failed to start fingerprint verification: {e}");
|
|
let _ = proxy
|
|
.call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
// Capture the unique bus name of fprintd for sender validation.
|
|
// D-Bus signals carry the sender's unique name (e.g. ":1.42"), not the
|
|
// well-known name. We validate this to prevent signal spoofing.
|
|
let expected_sender = proxy.name_owner();
|
|
|
|
// Connect the g-signal handler on the proxy to dispatch VerifyStatus
|
|
let listener_weak = Rc::downgrade(listener);
|
|
let signal_id = proxy.connect_local("g-signal", false, move |values| {
|
|
// g-signal arguments: (proxy, sender_name, signal_name, parameters)
|
|
let sender: String = match values[1].get() {
|
|
Ok(s) => s,
|
|
Err(_) => return None,
|
|
};
|
|
if expected_sender.as_ref().map(|s| s.as_str()) != Some(sender.as_str()) {
|
|
log::warn!("Ignoring D-Bus signal from unexpected sender: {sender}");
|
|
return None;
|
|
}
|
|
|
|
let signal_name: String = match values[2].get() {
|
|
Ok(v) => v,
|
|
Err(_) => return None,
|
|
};
|
|
if signal_name.as_str() != "VerifyStatus" {
|
|
return None;
|
|
}
|
|
|
|
let params = match values[3].get::<glib::Variant>() {
|
|
Ok(v) => v,
|
|
Err(_) => return None,
|
|
};
|
|
|
|
let status = params
|
|
.child_value(0)
|
|
.get::<String>()
|
|
.unwrap_or_default();
|
|
let done = params
|
|
.child_value(1)
|
|
.get::<bool>()
|
|
.unwrap_or(false);
|
|
|
|
if let Some(listener_rc) = listener_weak.upgrade() {
|
|
listener_rc.borrow_mut().on_verify_status(&status, done);
|
|
}
|
|
|
|
None
|
|
});
|
|
|
|
let mut inner = listener.borrow_mut();
|
|
inner.signal_id = Some(signal_id);
|
|
inner.running = true;
|
|
inner.running_flag.set(true);
|
|
}
|
|
|
|
/// Process a VerifyStatus signal from fprintd.
|
|
pub fn on_verify_status(&mut self, status: &str, done: bool) {
|
|
if !self.running {
|
|
return;
|
|
}
|
|
|
|
if status == "verify-match" {
|
|
self.cleanup_dbus();
|
|
if let Some(ref cb) = self.on_success {
|
|
cb();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if RETRY_STATUSES.contains(&status) {
|
|
if done {
|
|
self.restart_verify_async();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if status == "verify-no-match" {
|
|
self.failed_attempts += 1;
|
|
if self.failed_attempts >= MAX_FP_ATTEMPTS {
|
|
log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping");
|
|
if let Some(ref cb) = self.on_exhausted {
|
|
cb();
|
|
}
|
|
self.stop();
|
|
return;
|
|
}
|
|
if let Some(ref cb) = self.on_failure {
|
|
cb();
|
|
}
|
|
if done {
|
|
self.restart_verify_async();
|
|
}
|
|
return;
|
|
}
|
|
|
|
log::debug!("Unhandled fprintd status: {status}");
|
|
}
|
|
|
|
/// Restart fingerprint verification asynchronously after a completed attempt.
|
|
/// Checks running_flag after VerifyStop to avoid restarting on a released device.
|
|
fn restart_verify_async(&self) {
|
|
if let Some(ref proxy) = self.device_proxy {
|
|
let proxy = proxy.clone();
|
|
let running = self.running_flag.clone();
|
|
glib::spawn_future_local(async move {
|
|
// VerifyStop before VerifyStart to avoid D-Bus errors
|
|
let _ = proxy
|
|
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await;
|
|
if !running.get() {
|
|
return;
|
|
}
|
|
let args = glib::Variant::from((&"any",));
|
|
if let Err(e) = proxy
|
|
.call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await
|
|
{
|
|
log::error!("Failed to restart fingerprint verification: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Disconnect the signal handler and send VerifyStop + Release to fprintd.
|
|
/// Signal disconnect is synchronous to prevent further callbacks.
|
|
/// D-Bus cleanup is fire-and-forget to avoid blocking the UI.
|
|
fn cleanup_dbus(&mut self) {
|
|
self.running = false;
|
|
self.running_flag.set(false);
|
|
|
|
if let Some(ref proxy) = self.device_proxy {
|
|
if let Some(id) = self.signal_id.take() {
|
|
proxy.disconnect(id);
|
|
}
|
|
let proxy = proxy.clone();
|
|
glib::spawn_future_local(async move {
|
|
let _ = proxy
|
|
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await;
|
|
let _ = proxy
|
|
.call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
|
|
.await;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Stop listening and release the device. Idempotent — safe to call multiple times.
|
|
pub fn stop(&mut self) {
|
|
if !self.running {
|
|
return;
|
|
}
|
|
self.cleanup_dbus();
|
|
}
|
|
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn retry_statuses_are_defined() {
|
|
assert!(RETRY_STATUSES.contains(&"verify-swipe-too-short"));
|
|
assert!(RETRY_STATUSES.contains(&"verify-finger-not-centered"));
|
|
assert!(!RETRY_STATUSES.contains(&"verify-match"));
|
|
assert!(!RETRY_STATUSES.contains(&"verify-no-match"));
|
|
}
|
|
|
|
#[test]
|
|
fn max_attempts_constant() {
|
|
assert_eq!(MAX_FP_ATTEMPTS, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn verify_match_sets_running_false_and_calls_success() {
|
|
let called = Rc::new(Cell::new(false));
|
|
let called_clone = called.clone();
|
|
let mut listener = FingerprintListener::new();
|
|
listener.running = true;
|
|
listener.running_flag.set(true);
|
|
listener.on_success = Some(Box::new(move || { called_clone.set(true); }));
|
|
|
|
listener.on_verify_status("verify-match", false);
|
|
assert!(called.get());
|
|
assert!(!listener.running);
|
|
assert!(!listener.running_flag.get());
|
|
}
|
|
|
|
#[test]
|
|
fn verify_no_match_calls_failure_and_stays_running() {
|
|
let called = Rc::new(Cell::new(false));
|
|
let called_clone = called.clone();
|
|
let mut listener = FingerprintListener::new();
|
|
listener.running = true;
|
|
listener.on_failure = Some(Box::new(move || { called_clone.set(true); }));
|
|
|
|
listener.on_verify_status("verify-no-match", false);
|
|
assert!(called.get());
|
|
assert!(listener.running);
|
|
assert_eq!(listener.failed_attempts, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn max_attempts_stops_listener_and_calls_exhausted() {
|
|
let exhausted = Rc::new(Cell::new(false));
|
|
let exhausted_clone = exhausted.clone();
|
|
let mut listener = FingerprintListener::new();
|
|
listener.running = true;
|
|
listener.on_failure = Some(Box::new(|| {}));
|
|
listener.on_exhausted = Some(Box::new(move || { exhausted_clone.set(true); }));
|
|
|
|
for _ in 0..MAX_FP_ATTEMPTS {
|
|
listener.on_verify_status("verify-no-match", true);
|
|
}
|
|
assert!(!listener.running);
|
|
assert!(exhausted.get());
|
|
assert_eq!(listener.failed_attempts, MAX_FP_ATTEMPTS);
|
|
}
|
|
|
|
#[test]
|
|
fn not_running_ignores_signals() {
|
|
let called = Rc::new(Cell::new(false));
|
|
let called_clone = called.clone();
|
|
let mut listener = FingerprintListener::new();
|
|
listener.running = false;
|
|
listener.on_success = Some(Box::new(move || { called_clone.set(true); }));
|
|
|
|
listener.on_verify_status("verify-match", false);
|
|
assert!(!called.get());
|
|
}
|
|
}
|