fix: audit fixes — CString zeroize, FP account check, PAM timeout, blur downscale (v0.6.5)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 1s

Address findings from second triple audit (quality, performance, security):

- Wrap PAM CString password in Zeroizing<CString> to wipe on drop (S-H1)
- Add check_account() for pam_acct_mgmt after fingerprint unlock,
  with resume_async() to restart FP on transient failure (S-M1)
- 30s PAM timeout with generation counter to prevent stale result
  interference from parallel auth attempts (S-M3)
- Downscale wallpaper to max 1920px before GPU blur, reducing work
  by ~4x on 4K wallpapers (P-M1)
- exit(1) instead of return on no-monitor after lock.lock() (Q-2.1)
This commit is contained in:
nevaforget 2026-03-30 00:24:43 +02:00
parent 465a19811a
commit 65ea523b36
9 changed files with 205 additions and 25 deletions

View File

@ -37,13 +37,13 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock
## Architektur
- `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, msg_style-aware, Zeroizing<Vec<u8>>)
- `fingerprint.rs` — fprintd D-Bus Listener, async init/claim/verify via gio futures, sender-validated signal handler, cleanup_dbus() für sauberen D-Bus-Lifecycle, running_flag für Race-Safety in async restarts, on_exhausted callback after MAX_FP_ATTEMPTS
- `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, msg_style-aware, Zeroizing<Vec<u8>> + Zeroizing<CString>), check_account() für pam_acct_mgmt-Only-Checks nach Fingerprint-Unlock
- `fingerprint.rs` — fprintd D-Bus Listener, async init/claim/verify via gio futures, sender-validated signal handler, cleanup_dbus() für sauberen D-Bus-Lifecycle, running_flag für Race-Safety in async restarts, on_exhausted callback after MAX_FP_ATTEMPTS, resume_async() für Neustart nach transientem Fehler
- `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection
- `power.rs` — Reboot/Shutdown via /usr/bin/systemctl
- `i18n.rs` — Locale-Erkennung (OnceLock-cached) und String-Tabellen (DE/EN), faillock_warning mit konfigurierbarem max_attempts
- `config.rs` — TOML-Config (background_path, background_blur, fingerprint_enabled als Option<bool>) + Wallpaper-Fallback + Symlink-Rejection für background_path + Parse-Error-Logging
- `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking, FP-Label/Start separat verdrahtet, Zeroizing<String> für Passwort, Power-Confirm, GPU-Blur via GskBlurNode, Blur/Avatar-Cache für Multi-Monitor
- `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking mit 30s Timeout und Generation Counter, FP-Label/Start separat verdrahtet mit pam_acct_mgmt-Check und auto-resume, Zeroizing<String> für Passwort, Power-Confirm, GPU-Blur via GskBlurNode (Downscale auf max 1920px), Blur/Avatar-Cache für Multi-Monitor
- `main.rs` — Entry Point, Panic-Hook (vor Logging), Root-Check, ext-session-lock-v1 (Pflicht in Release), Multi-Monitor mit shared Blur/Avatar-Caches, systemd-Journal-Logging, Debug-Level per `MOONLOCK_DEBUG` Env-Var, async fprintd-Init nach window.present()
## Sicherheit
@ -53,7 +53,9 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock
- Panic-Hook: Bei Crash wird geloggt, aber NIEMALS unlock() aufgerufen — Screen bleibt schwarz. Hook wird vor Logging installiert.
- PAM-Callback: msg_style-aware (Passwort nur bei PAM_PROMPT_ECHO_OFF), strdup-OOM-sicher, num_msg-Guard gegen negative Werte
- fprintd: D-Bus Signal-Sender wird gegen fprintd's unique bus name validiert (Anti-Spoofing)
- Passwort: Zeroizing<String> ab GTK-Entry-Extraktion, Zeroizing<Vec<u8>> im PAM-FFI-Layer (bekannte Einschränkung: GLib-GString und CString werden nicht gezeroized — inhärente GTK/libc-Limitierung)
- Passwort: Zeroizing<String> ab GTK-Entry-Extraktion, Zeroizing<Vec<u8>> + Zeroizing<CString> im PAM-FFI-Layer (bekannte Einschränkung: GLib-GString und strdup-Kopie in PAM werden nicht gezeroized — inhärente GTK/libc-Limitierung)
- Fingerprint-Unlock: pam_acct_mgmt-Check nach verify-match erzwingt Account-Policies (Lockout, Ablauf), resume_async() startet FP bei transientem Fehler neu
- PAM-Timeout: 30s Timeout verhindert permanentes Aussperren bei hängenden PAM-Modulen, Generation Counter verhindert Interferenz paralleler Auth-Versuche
- Root-Check: Exit mit Fehler wenn als root gestartet
- Faillock: UI-Warnung nach 3 Fehlversuchen, aber PAM entscheidet über Lockout (Entry bleibt aktiv)
- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche PAM-Auth oder Fingerprint

2
Cargo.lock generated
View File

@ -575,7 +575,7 @@ dependencies = [
[[package]]
name = "moonlock"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"gdk-pixbuf",
"gdk4",

View File

@ -1,6 +1,6 @@
[package]
name = "moonlock"
version = "0.6.4"
version = "0.6.5"
edition = "2024"
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
license = "MIT"
@ -16,7 +16,7 @@ toml = "0.8"
serde = { version = "1", features = ["derive"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
nix = { version = "0.29", features = ["user"] }
zeroize = { version = "1", features = ["derive"] }
zeroize = { version = "1", features = ["derive", "std"] }
libc = "0.2"
log = "0.4"
systemd-journal-logger = "2.2"

View File

@ -2,6 +2,13 @@
Architectural and design decisions for Moonlock, in reverse chronological order.
## 2026-03-30 Second audit: zeroize CString, FP account check, PAM timeout, blur downscale
- **Who**: Nyx, Dom
- **Why**: Second triple audit (quality, performance, security) found: CString password copy not zeroized (HIGH), fingerprint unlock bypassing pam_acct_mgmt (MEDIUM), no PAM timeout leaving user locked out on hanging modules (MEDIUM), GPU blur on full wallpaper resolution (MEDIUM), no-monitor edge case doing `return` instead of `exit(1)` (MEDIUM).
- **Tradeoffs**: PAM timeout (30s) uses a generation counter to avoid stale result interference — adds complexity but prevents parallel PAM sessions. FP restart after failed account check re-claims the device, adding a D-Bus round-trip, but prevents permanent FP death on transient failures. Blur downscale to 1920px cap trades negligible quality for ~4x less GPU work on 4K wallpapers.
- **How**: (1) `Zeroizing<CString>` wraps password in auth.rs, `zeroize/std` feature enabled. (2) `check_account()` calls pam_acct_mgmt after FP match; `resume_async()` restarts FP on transient failure. (3) `auth_generation` counter invalidates stale PAM results; 30s timeout re-enables UI. (4) `MAX_BLUR_DIMENSION` caps blur input at 1920px, sigma scaled proportionally. (5) `exit(1)` on no-monitor after `lock.lock()`.
## 2026-03-28 Remove embedded wallpaper from binary
- **Who**: Nyx, Dom

View File

@ -152,7 +152,7 @@ pub fn authenticate(username: &str, password: &str) -> bool {
// Use Zeroizing to ensure password bytes are wiped on drop
let password_bytes = Zeroizing::new(password.as_bytes().to_vec());
let password_cstr = match CString::new(password_bytes.as_slice()) {
Ok(c) => c,
Ok(c) => Zeroizing::new(c),
Err(_) => return false, // Password contains null byte
};
@ -168,7 +168,7 @@ pub fn authenticate(username: &str, password: &str) -> bool {
let conv = PamConv {
conv: pam_conv_callback,
appdata_ptr: &password_cstr as *const CString as *mut libc::c_void,
appdata_ptr: std::ptr::from_ref::<CString>(&password_cstr) as *mut libc::c_void,
};
let mut handle: *mut libc::c_void = ptr::null_mut();
@ -207,6 +207,51 @@ pub fn authenticate(username: &str, password: &str) -> bool {
acct_ret == PAM_SUCCESS
}
/// Check account restrictions via PAM without authentication.
///
/// Used after fingerprint unlock to enforce account policies (lockout, expiry)
/// that would otherwise be bypassed when not going through pam_authenticate.
/// Returns true if the account is valid and allowed to log in.
pub fn check_account(username: &str) -> bool {
let service = match CString::new("moonlock") {
Ok(c) => c,
Err(_) => return false,
};
let username_cstr = match CString::new(username) {
Ok(c) => c,
Err(_) => return false,
};
// No password needed — we only check account status, not authenticate.
// PAM conv callback is required by pam_start but won't be called for acct_mgmt.
let empty_password = Zeroizing::new(CString::new("").unwrap());
let conv = PamConv {
conv: pam_conv_callback,
appdata_ptr: std::ptr::from_ref::<CString>(&empty_password) as *mut libc::c_void,
};
let mut handle: *mut libc::c_void = ptr::null_mut();
let ret = unsafe {
pam_start(
service.as_ptr(),
username_cstr.as_ptr(),
&conv,
&mut handle,
)
};
if ret != PAM_SUCCESS || handle.is_null() {
return false;
}
let acct_ret = unsafe { pam_acct_mgmt(handle, 0) };
unsafe { pam_end(handle, acct_ret) };
acct_ret == PAM_SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -154,6 +154,32 @@ impl FingerprintListener {
G: Fn() + 'static,
H: Fn() + 'static,
{
{
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));
}
Self::begin_verification(listener, username).await;
}
/// Resume fingerprint verification after a transient interruption (e.g. failed
/// PAM account check). Reuses previously stored callbacks. Re-claims the device
/// and restarts verification from scratch.
pub async fn resume_async(
listener: &Rc<RefCell<FingerprintListener>>,
username: &str,
) {
Self::begin_verification(listener, username).await;
}
/// Claim device, start verification, and connect D-Bus signal handler.
/// Assumes device_proxy is set and callbacks are already stored.
async fn begin_verification(
listener: &Rc<RefCell<FingerprintListener>>,
username: &str,
) {
let proxy = {
let inner = listener.borrow();
match inner.device_proxy.clone() {
@ -162,13 +188,6 @@ impl FingerprintListener {
}
};
{
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

View File

@ -28,6 +28,7 @@ pub struct Strings {
pub confirm_no: &'static str,
pub faillock_attempts_remaining: &'static str,
pub faillock_locked: &'static str,
pub auth_timeout: &'static str,
}
const STRINGS_DE: Strings = Strings {
@ -46,6 +47,7 @@ const STRINGS_DE: Strings = Strings {
confirm_no: "Abbrechen",
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked: "Konto ist möglicherweise gesperrt",
auth_timeout: "Authentifizierung abgelaufen — bitte erneut versuchen",
};
const STRINGS_EN: Strings = Strings {
@ -64,6 +66,7 @@ const STRINGS_EN: Strings = Strings {
confirm_no: "Cancel",
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
faillock_locked: "Account may be locked",
auth_timeout: "Authentication timed out — please try again",
};
fn parse_lang_prefix(lang: &str) -> String {

View File

@ -7,7 +7,7 @@ use glib::clone;
use graphene_rs as graphene;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::rc::Rc;
@ -32,6 +32,7 @@ pub struct LockscreenHandles {
const AVATAR_SIZE: i32 = 128;
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
const PAM_TIMEOUT_SECS: u64 = 30;
/// Shared mutable state for the lockscreen.
struct LockscreenState {
@ -221,13 +222,19 @@ pub fn create_lockscreen_window(
overlay.add_overlay(&power_box);
// Password entry "activate" handler
// Password entry "activate" handler.
// A generation counter tracks which auth attempt is current. When the user
// submits a new password, the generation increments — stale PAM results from
// prior attempts are ignored (except success: a correct password always unlocks).
let username = user.username.clone();
let auth_generation = Rc::new(Cell::new(0u32));
password_entry.connect_activate(clone!(
#[strong]
state,
#[strong]
unlock_callback,
#[strong]
auth_generation,
#[weak]
error_label,
#[weak]
@ -242,6 +249,35 @@ pub fn create_lockscreen_window(
let username = username.clone();
let unlock_cb = unlock_callback.clone();
// Invalidate stale timeouts/results from prior attempts
let auth_gen = auth_generation.get().wrapping_add(1);
auth_generation.set(auth_gen);
let gen_timeout = auth_generation.clone();
let gen_result = auth_generation.clone();
// If PAM hangs (e.g. broken LDAP module), the timeout re-enables the UI
glib::timeout_add_local_once(
std::time::Duration::from_secs(PAM_TIMEOUT_SECS),
clone!(
#[weak]
error_label,
#[weak]
password_entry,
move || {
if gen_timeout.get() != auth_gen {
return;
}
log::error!("PAM authentication timed out after {PAM_TIMEOUT_SECS}s");
let strings = load_strings(None);
password_entry.set_text("");
password_entry.set_sensitive(true);
password_entry.grab_focus();
error_label.set_text(strings.auth_timeout);
error_label.set_visible(true);
}
),
);
glib::spawn_future_local(clone!(
#[strong]
state,
@ -255,6 +291,20 @@ pub fn create_lockscreen_window(
auth::authenticate(&user, &password)
}).await;
// Stale result from a superseded attempt — only unlock on success
// (a correct password should always unlock, regardless of timing)
if gen_result.get() != auth_gen {
if matches!(result, Ok(true)) {
let s = state.borrow();
if let Some(ref fp_rc) = s.fp_listener_rc {
fp_rc.borrow_mut().stop();
}
drop(s);
unlock_cb();
}
return;
}
match result {
Ok(true) => {
let s = state.borrow();
@ -373,10 +423,12 @@ pub fn start_fingerprint(
let unlock_cb_fp = handles.unlock_callback.clone();
let fp_rc_success = fp_rc.clone();
let fp_username = handles.username.clone();
let on_success = move || {
let label = fp_label_success.clone();
let cb = unlock_cb_fp.clone();
let fp = fp_rc_success.clone();
let username = fp_username.clone();
glib::idle_add_local_once(move || {
let strings = load_strings(None);
label.set_text(strings.fingerprint_success);
@ -384,7 +436,39 @@ pub fn start_fingerprint(
// stop() is idempotent — cleanup_dbus() already ran inside on_verify_status,
// but this mirrors the PAM success path for defense-in-depth.
fp.borrow_mut().stop();
cb();
// Enforce PAM account policies (lockout, expiry) before unlocking.
// Fingerprint auth bypasses pam_authenticate, so we must explicitly
// check account restrictions via pam_acct_mgmt.
glib::spawn_future_local(async move {
let user = username.clone();
let result = gio::spawn_blocking(move || {
auth::check_account(&user)
}).await;
match result {
Ok(true) => cb(),
_ => {
log::error!("PAM account check failed after fingerprint auth");
let strings = load_strings(None);
label.set_text(strings.wrong_password);
label.remove_css_class("success");
label.add_css_class("failed");
// Restart FP verification after delay — the failure may be
// transient (e.g. PAM module timeout). If the account is truly
// locked, check_account will fail again on next match.
glib::timeout_add_local_once(
std::time::Duration::from_secs(2),
move || {
label.set_text(load_strings(None).fingerprint_prompt);
label.remove_css_class("failed");
glib::spawn_future_local(async move {
FingerprintListener::resume_async(&fp, &username).await;
});
},
);
}
}
});
});
};
@ -481,12 +565,20 @@ fn create_background_picture(
background
}
/// Maximum texture dimension for blur input. Textures larger than this are
/// downscaled before blurring — the blur destroys detail anyway, so there is
/// no visible quality loss, but GPU work is reduced significantly.
const MAX_BLUR_DIMENSION: f32 = 1920.0;
/// Render a blurred texture using the widget's GPU renderer.
/// Returns None if the renderer is not available.
///
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
/// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
@ -495,15 +587,27 @@ fn render_blurred_texture(
let native = widget.native()?;
let renderer = native.renderer()?;
let w = texture.width() as f32;
let h = texture.height() as f32;
let orig_w = texture.width() as f32;
let orig_h = texture.height() as f32;
// Downscale large textures to reduce GPU blur work
let max_dim = orig_w.max(orig_h);
let scale = if max_dim > MAX_BLUR_DIMENSION {
MAX_BLUR_DIMENSION / max_dim
} else {
1.0
};
let w = (orig_w * scale).round();
let h = (orig_h * scale).round();
let scaled_sigma = sigma * scale;
// Padding must cover the blur kernel radius (typically ~3x sigma)
let pad = (sigma * 3.0).ceil();
let pad = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to original texture size
// Clip output to scaled texture size
snapshot.push_clip(&graphene::Rect::new(pad, pad, w, h));
snapshot.push_blur(sigma as f64);
snapshot.push_blur(scaled_sigma as f64);
// Render texture with padding on all sides (edges repeat via oversized bounds)
snapshot.append_texture(texture, &graphene::Rect::new(0.0, 0.0, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur

View File

@ -115,7 +115,7 @@ fn activate_with_session_lock(
if !created_any {
log::error!("No lockscreen windows created — screen stays locked (compositor policy)");
return;
std::process::exit(1);
}
// Async fprintd initialization — runs after windows are visible