fix: audit fixes — CString zeroize, FP account check, PAM timeout, blur downscale (v0.6.5)
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:
2026-03-30 00:24:43 +02:00
parent 465a19811a
commit 65ea523b36
9 changed files with 205 additions and 25 deletions
+112 -8
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