Rewrite moonlock from Python to Rust (v0.4.0)

Complete rewrite of the Wayland lockscreen from Python/PyGObject to
Rust/gtk4-rs for memory safety in security-critical PAM code and
consistency with the moonset/moongreet Rust ecosystem.

Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus),
config, i18n, users, power. 37 unit tests.

Security: PAM conversation callback with Zeroizing password, panic hook
that never unlocks, root check, ext-session-lock-v1 compositor policy,
absolute loginctl path, avatar symlink rejection.
This commit is contained in:
2026-03-27 23:09:54 +01:00
parent 7de3737a61
commit 817a9547ad
41 changed files with 3075 additions and 2264 deletions
+299
View File
@@ -0,0 +1,299 @@
// ABOUTME: fprintd D-Bus integration for fingerprint authentication.
// ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy.
use gio::prelude::*;
use gtk4::gio;
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;
/// 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,
failed_attempts: u32,
on_success: Option<Box<dyn Fn() + 'static>>,
on_failure: Option<Box<dyn Fn() + 'static>>,
}
impl FingerprintListener {
/// Create a new FingerprintListener.
/// Connects to fprintd synchronously — call before creating GTK windows.
pub fn new() -> Self {
let mut listener = FingerprintListener {
device_proxy: None,
signal_id: None,
running: false,
failed_attempts: 0,
on_success: None,
on_failure: None,
};
listener.init_device();
listener
}
/// Connect to fprintd and get the default device.
fn init_device(&mut self) {
let manager = match gio::DBusProxy::for_bus_sync(
gio::BusType::System,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
FPRINTD_MANAGER_PATH,
FPRINTD_MANAGER_IFACE,
gio::Cancellable::NONE,
) {
Ok(m) => m,
Err(e) => {
log::debug!("fprintd manager not available: {e}");
return;
}
};
// Call GetDefaultDevice
let result = match manager.call_sync(
"GetDefaultDevice",
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
) {
Ok(r) => r,
Err(e) => {
log::debug!("fprintd GetDefaultDevice failed: {e}");
return;
}
};
// Extract device path from variant tuple
let device_path: String = result.child_get::<String>(0);
if device_path.is_empty() {
return;
}
match gio::DBusProxy::for_bus_sync(
gio::BusType::System,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
&device_path,
FPRINTD_DEVICE_IFACE,
gio::Cancellable::NONE,
) {
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.
pub fn is_available(&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_sync(
"ListEnrolledFingers",
Some(&args),
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
) {
Ok(result) => {
// Result is a tuple of (array of strings)
let fingers: Vec<String> = result.child_get::<Vec<String>>(0);
!fingers.is_empty()
}
Err(_) => false,
}
}
/// Whether the listener is currently running.
pub fn is_running(&self) -> bool {
self.running
}
/// Start listening for fingerprint verification.
pub fn start<F, G>(&mut self, username: &str, on_success: F, on_failure: G)
where
F: Fn() + 'static,
G: Fn() + 'static,
{
let proxy = match &self.device_proxy {
Some(p) => p,
None => return,
};
self.on_success = Some(Box::new(on_success));
self.on_failure = Some(Box::new(on_failure));
// Claim the device
let args = glib::Variant::from((&username,));
if let Err(e) = proxy.call_sync(
"Claim",
Some(&args),
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
) {
log::error!("Failed to claim fingerprint device: {e}");
return;
}
// Start verification
let start_args = glib::Variant::from((&"any",));
if let Err(e) = proxy.call_sync(
"VerifyStart",
Some(&start_args),
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
) {
log::error!("Failed to start fingerprint verification: {e}");
let _ = proxy.call_sync(
"Release",
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
);
return;
}
self.running = true;
// Note: Signal handling is set up by the caller via connect_g_signal()
// because FingerprintListener is not an Rc and the g-signal callback
// needs to reference mutable state. The caller (lockscreen.rs) will
// connect the proxy's "g-signal" and call on_verify_status().
}
/// 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" {
if let Some(ref cb) = self.on_success {
cb();
}
return;
}
if RETRY_STATUSES.contains(&status) {
if done {
self.restart_verify();
}
return;
}
if status == "verify-no-match" {
self.failed_attempts += 1;
if let Some(ref cb) = self.on_failure {
cb();
}
if self.failed_attempts >= MAX_FP_ATTEMPTS {
log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping");
self.stop();
return;
}
if done {
self.restart_verify();
}
return;
}
log::debug!("Unhandled fprintd status: {status}");
}
/// Restart fingerprint verification after a completed attempt.
fn restart_verify(&self) {
if let Some(ref proxy) = self.device_proxy {
let args = glib::Variant::from((&"any",));
if let Err(e) = proxy.call_sync(
"VerifyStart",
Some(&args),
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
) {
log::error!("Failed to restart fingerprint verification: {e}");
}
}
}
/// Stop listening and release the device.
pub fn stop(&mut self) {
if !self.running {
return;
}
self.running = false;
if let Some(ref proxy) = self.device_proxy {
if let Some(id) = self.signal_id.take() {
proxy.disconnect(id);
}
let _ = proxy.call_sync(
"VerifyStop",
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
);
let _ = proxy.call_sync(
"Release",
None,
gio::DBusCallFlags::NONE,
-1,
gio::Cancellable::NONE,
);
}
}
/// Get a reference to the device proxy for signal connection.
pub fn device_proxy(&self) -> Option<&gio::DBusProxy> {
self.device_proxy.as_ref()
}
/// Store the signal handler ID for cleanup.
pub fn set_signal_id(&mut self, id: glib::SignalHandlerId) {
self.signal_id = Some(id);
}
}
#[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);
}
}