diff --git a/Cargo.lock b/Cargo.lock index b0c5a92..6793c2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ name = "ahfail-display" version = "0.1.0" dependencies = [ "ahfail-ui", + "cairo-rs", "cc", "gdk", "glib", diff --git a/crates/ahfail-display/Cargo.toml b/crates/ahfail-display/Cargo.toml index bf99b2a..688eaa1 100644 --- a/crates/ahfail-display/Cargo.toml +++ b/crates/ahfail-display/Cargo.toml @@ -12,6 +12,7 @@ gtk = { version = "0.15", package = "gtk", features = ["v3_24"] } gdk = { version = "0.15", package = "gdk", features = ["v3_24"] } gstreamer = { version = "0.18", package = "gstreamer", features = ["v1_18"] } glib = { version = "0.15", package = "glib" } +cairo = { version = "0.15", package = "cairo-rs" } libc = "0.2" [build-dependencies] diff --git a/crates/ahfail-display/build.rs b/crates/ahfail-display/build.rs index 21d5824..48ef5ad 100644 --- a/crates/ahfail-display/build.rs +++ b/crates/ahfail-display/build.rs @@ -49,4 +49,8 @@ fn main() { // Link against gio-2.0 for GResource support println!("cargo:rustc-link-lib=gio-2.0"); + // Link against X11 for XGrabKeyboard (Linux only) + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + println!("cargo:rustc-link-lib=X11"); + } } diff --git a/crates/ahfail-display/src/main.rs b/crates/ahfail-display/src/main.rs index edb7ea4..623ab70 100644 --- a/crates/ahfail-display/src/main.rs +++ b/crates/ahfail-display/src/main.rs @@ -2,6 +2,7 @@ use gtk::prelude::*; use gtk::gdk; use gstreamer as gst; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; const AUDIO_URI: &str = "resource:///ahfail/audio/magic-word.mp3"; const FAILSAFE_MINUTES: u32 = 15; @@ -12,63 +13,148 @@ extern "C" fn handle_sigterm(_: libc::c_int) { SIGTERM_RECEIVED.store(true, Ordering::Relaxed); } +#[cfg(target_os = "linux")] +mod x11_grab { + use std::os::raw::{c_char, c_int, c_ulong, c_void}; + type XDisplay = c_void; + type Window = c_ulong; + type Atom = c_ulong; + + const GRAB_SUCCESS: c_int = 0; + const GRAB_MODE_ASYNC: c_int = 1; + const XA_CARDINAL: Atom = 6; + const PROP_MODE_REPLACE: c_int = 0; + + extern "C" { + fn XOpenDisplay(name: *const c_char) -> *mut XDisplay; + fn XCloseDisplay(dpy: *mut XDisplay) -> c_int; + fn XDefaultRootWindow(dpy: *mut XDisplay) -> Window; + fn XGrabKeyboard( + dpy: *mut XDisplay, grab_window: Window, owner_events: c_int, + pointer_mode: c_int, keyboard_mode: c_int, time: c_ulong, + ) -> c_int; + fn XUngrabKeyboard(dpy: *mut XDisplay, time: c_ulong) -> c_int; + fn XInternAtom(dpy: *mut XDisplay, name: *const c_char, only_if_exists: c_int) -> Atom; + fn XChangeProperty( + dpy: *mut XDisplay, w: Window, property: Atom, type_: Atom, + format: c_int, mode: c_int, data: *const u8, nelements: c_int, + ) -> c_int; + fn XFlush(dpy: *mut XDisplay) -> c_int; + fn gdk_x11_window_get_xid(window: *mut c_void) -> c_ulong; + } + + pub fn is_screen_unlocked() -> bool { + unsafe { + let dpy = XOpenDisplay(std::ptr::null()); + if dpy.is_null() { return false; } + let root = XDefaultRootWindow(dpy); + let result = XGrabKeyboard(dpy, root, 0, GRAB_MODE_ASYNC, GRAB_MODE_ASYNC, 0); + if result == GRAB_SUCCESS { + XUngrabKeyboard(dpy, 0); + } + XCloseDisplay(dpy); + result == GRAB_SUCCESS + } + } + + /// Tell picom/compton not to draw a shadow around this window. + /// Works by setting _COMPTON_SHADOW=0 directly on the X window, + /// which the compositor respects regardless of its config. + pub fn disable_compositor_shadow(gdk_window: &gdk::Window) { + use glib::ObjectType; + unsafe { + let xid = gdk_x11_window_get_xid(gdk_window.as_ptr() as *mut c_void); + let dpy = XOpenDisplay(std::ptr::null()); + if dpy.is_null() { return; } + let atom = XInternAtom(dpy, b"_COMPTON_SHADOW\0".as_ptr() as *const c_char, 0); + let value: u32 = 0; + XChangeProperty( + dpy, xid, atom, XA_CARDINAL, 32, + PROP_MODE_REPLACE, &value as *const u32 as *const u8, 1, + ); + XFlush(dpy); + XCloseDisplay(dpy); + } + } +} + fn main() { unsafe { - // SAFETY: handle_sigterm only stores to an AtomicBool — async-signal-safe. - // The *const () intermediate avoids a "direct cast to integer" warning because - // libc::sighandler_t is size_t on Linux. libc::signal(libc::SIGTERM, handle_sigterm as *const () as libc::sighandler_t); } - if gtk::init().is_err() { - eprintln!("[ahfail-display] GTK init failed"); + // Single-instance guard + let lock_file = std::fs::OpenOptions::new() + .create(true).write(true) + .open("/tmp/ahfail-display.lock"); + let lock_file = match lock_file { + Ok(f) => f, + Err(_) => return, + }; + use std::os::unix::io::AsRawFd; + if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return; } - if gst::init().is_err() { - eprintln!("[ahfail-display] GStreamer init failed"); + + // Give PAM a moment to complete, then bail if auth already succeeded. + std::thread::sleep(Duration::from_millis(200)); + #[cfg(target_os = "linux")] + if x11_grab::is_screen_unlocked() { return; } + if gtk::init().is_err() { return; } + if gst::init().is_err() { return; } + let animation = unsafe { ahfail_ui::animation::load_animation() }; - let Some(animation) = animation else { - eprintln!("[ahfail-display] No animation frames found"); - return; - }; + let Some(animation) = animation else { return; }; - let Some(display) = gdk::Display::default() else { - eprintln!("[ahfail-display] No display"); - return; - }; + let Some(display) = gdk::Display::default() else { return; }; let Some(monitor) = display.primary_monitor().or_else(|| display.monitor(0)) else { - eprintln!("[ahfail-display] No monitor"); return; }; let geom = monitor.geometry(); - let screen_w = geom.width(); - let screen_h = geom.height(); + let (screen_w, screen_h) = (geom.width(), geom.height()); - // On X11, WindowType::Popup creates an unmanaged override-redirect window (desired). - // On Wayland, GTK3 falls back to a normal xdg_toplevel; the compositor controls stacking. + let config = parse_args(); + let (sprite_x, sprite_y) = ahfail_ui::display::sprite_position( + &animation, screen_w, screen_h, &config, + ); + let sprite_w = animation.width(); + let sprite_h = animation.height(); + + // Window sized exactly to the sprite — no full-screen background to worry about let window = gtk::Window::new(gtk::WindowType::Popup); window.set_decorated(false); window.set_keep_above(true); window.set_skip_taskbar_hint(true); - window.move_(geom.x(), geom.y()); - window.set_default_size(screen_w, screen_h); + window.set_accept_focus(false); + window.set_type_hint(gdk::WindowTypeHint::Notification); + window.move_(geom.x() + sprite_x, geom.y() + sprite_y); + window.set_default_size(sprite_w, sprite_h); - let fixed = gtk::Fixed::new(); - fixed.set_size_request(screen_w, screen_h); - window.add(&fixed); + // RGBA visual so sprite edges (from PNG alpha) composite correctly + if let Some(screen) = window.screen() { + if let Some(visual) = screen.rgba_visual() { + window.set_visual(Some(&visual)); + } + } + window.set_app_paintable(true); + window.connect_draw(|_, cr| { + cr.set_source_rgba(0.0, 0.0, 0.0, 0.0); + cr.set_operator(cairo::Operator::Source); + let _ = cr.paint(); + gtk::Inhibit(false) + }); - let config = parse_args(); - ahfail_ui::display::place_sprite(&fixed, &animation, screen_w, screen_h, &config); + let image = gtk::Image::from_animation(&animation); + window.add(&image); let player = ahfail_ui::audio::create_player(AUDIO_URI); player.play(); std::thread::spawn(|| ahfail_ui::update::check_for_update(ahfail_ui::VERSION)); - // All setup succeeded — acquire volume lock now so early-exit paths above don't leave it held. let volume_state = ahfail_ui::volume::save_and_set_max(); glib::timeout_add_seconds(FAILSAFE_MINUTES * 60, || { @@ -76,7 +162,7 @@ fn main() { glib::Continue(false) }); - glib::timeout_add(std::time::Duration::from_millis(200), move || { + glib::timeout_add(Duration::from_millis(200), || { if SIGTERM_RECEIVED.load(Ordering::Relaxed) { gtk::main_quit(); return glib::Continue(false); @@ -84,7 +170,21 @@ fn main() { glib::Continue(true) }); + #[cfg(target_os = "linux")] + glib::timeout_add(Duration::from_millis(500), || { + if x11_grab::is_screen_unlocked() { + gtk::main_quit(); + return glib::Continue(false); + } + glib::Continue(true) + }); + window.show_all(); + #[cfg(target_os = "linux")] + if let Some(gdk_win) = window.window() { + x11_grab::disable_compositor_shadow(&gdk_win); + } + gtk::main(); player.stop(); @@ -99,9 +199,7 @@ fn parse_args() -> ahfail_ui::config::ModuleConfig { .and_then(|a| { let val = a.trim_start_matches("--deadzone="); let p: Vec<&str> = val.split(',').collect(); - if p.len() != 4 { - return None; - } + if p.len() != 4 { return None; } let x: i32 = p[0].parse().ok()?; let y: i32 = p[1].parse().ok()?; let w: i32 = p[2].parse().ok()?; diff --git a/crates/ahfail-pam/src/lib.rs b/crates/ahfail-pam/src/lib.rs index f3ebd3d..3ac0705 100644 --- a/crates/ahfail-pam/src/lib.rs +++ b/crates/ahfail-pam/src/lib.rs @@ -1,18 +1,9 @@ use libc::{c_char, c_int, c_void, pid_t}; use std::ffi::CString; -// --- PAM constants (platform-specific) --- - pub const PAM_SUCCESS: c_int = 0; pub const PAM_IGNORE: c_int = 25; -#[cfg(target_os = "linux")] -pub const PAM_AUTH_ERR: c_int = 7; -#[cfg(target_os = "macos")] -pub const PAM_AUTH_ERR: c_int = 9; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -pub const PAM_AUTH_ERR: c_int = 7; - #[cfg(target_os = "linux")] pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32; #[cfg(target_os = "macos")] @@ -20,19 +11,10 @@ pub const PAM_DATA_REPLACE: c_int = 0x00000002; #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub const PAM_DATA_REPLACE: c_int = 0x20000000u32 as i32; -// Default install path for ahfail-display — baked in at build time via AHFAIL_INSTALL_DIR, -// which build.rs derives from AHFAIL_LIBDIR (passed by Meson or the install script). -// This is correct on multiarch Linux and on both Intel and Apple Silicon macOS. const DEFAULT_PATH: &str = concat!(env!("AHFAIL_INSTALL_DIR"), "/ahfail-display"); pub fn default_display_path() -> &'static str { DEFAULT_PATH } -pub fn is_failure(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_AUTH_ERR } -pub fn is_success(s: c_int) -> bool { s & !PAM_DATA_REPLACE == PAM_SUCCESS } -pub fn is_replace(s: c_int) -> bool { s & PAM_DATA_REPLACE != 0 } - -// --- PAM opaque handle --- - #[repr(C)] pub struct PamHandle { _private: [u8; 0] } @@ -47,36 +29,16 @@ extern "C" { ) -> c_int; } -// --- Cleanup: fires on pam_end() or when data is replaced --- unsafe extern "C" fn ahfail_cleanup( _pamh: *mut PamHandle, data: *mut c_void, - error_status: c_int, + _error_status: c_int, ) { - // Reclaim any stored path string to avoid a leak (PAM guarantees this fires exactly once). - let path_override: Option = if data.is_null() { - None - } else { - Some(*Box::from_raw(data as *mut String)) - }; - - if is_replace(error_status) { - // Data replaced — a new pam_set_data call overwrote ours. Only spawn on failure; - // on success (PAM_SUCCESS | PAM_DATA_REPLACE) we do nothing. - if is_failure(error_status) { - spawn_display(path_override); - } - return; - } - if is_failure(error_status) { - spawn_display(path_override); - } else if is_success(error_status) { - kill_display(); + if !data.is_null() { + drop(Box::from_raw(data as *mut String)); } } -// --- Exported PAM module entry points --- - #[no_mangle] pub unsafe extern "C" fn pam_sm_authenticate( pamh: *mut PamHandle, @@ -89,20 +51,23 @@ pub unsafe extern "C" fn pam_sm_authenticate( let args = argc_argv(argc, argv); let display_path = read_display_path_arg(args); + // /proc/self/comm is the name of the current process (the locker that loaded us). + let locker_name = std::fs::read_to_string("/proc/self/comm") + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + spawn_display(display_path.clone(), &locker_name); + let key = match CString::new("dk.weircon.ahfail") { Ok(k) => k, Err(_) => return PAM_IGNORE, }; - - // Store optional path override as heap data for the cleanup function. let path_ptr: *mut c_void = match display_path { Some(p) => Box::into_raw(Box::new(p)) as *mut c_void, None => std::ptr::null_mut(), }; - let ret = pam_set_data(pamh, key.as_ptr(), path_ptr, Some(ahfail_cleanup)); if ret != PAM_SUCCESS && !path_ptr.is_null() { - // pam_set_data failed — cleanup will never fire, so free the Box ourselves. drop(Box::from_raw(path_ptr as *mut String)); } PAM_IGNORE @@ -133,8 +98,6 @@ pub unsafe extern "C" fn pam_sm_chauthtok( _pamh: *mut PamHandle, _flags: c_int, _argc: c_int, _argv: *const *const c_char, ) -> c_int { PAM_IGNORE } -// --- Helpers --- - unsafe fn argc_argv<'a>(argc: c_int, argv: *const *const c_char) -> &'a [*const c_char] { if argv.is_null() || argc <= 0 { return &[]; } std::slice::from_raw_parts(argv, argc as usize) @@ -152,40 +115,32 @@ fn read_display_path_arg(args: &[*const c_char]) -> Option { None } -fn spawn_display(path_override: Option) { +fn spawn_display(path_override: Option, locker_name: &str) { let path = path_override.unwrap_or_else(|| DEFAULT_PATH.to_string()); let cpath = match CString::new(path.as_str()) { Ok(p) => p, Err(_) => return, }; - // Double-fork: grandchild adopted by init, PAM stack not blocked. + let name_arg = format!("--locker-name={}", locker_name); + let cname_arg = match CString::new(name_arg.as_str()) { + Ok(p) => p, + Err(_) => return, + }; unsafe { let pid: pid_t = libc::fork(); if pid < 0 { return; } if pid > 0 { - // Parent: wait for intermediate to exit immediately. libc::waitpid(pid, std::ptr::null_mut(), 0); return; } - // Intermediate: fork again then exit immediately. let pid2: pid_t = libc::fork(); if pid2 != 0 { libc::_exit(0); } - // Grandchild: detach from PAM daemon's session, close all inherited fds, exec. + // Grandchild: detach and exec. libc::setsid(); let max_fd = libc::sysconf(libc::_SC_OPEN_MAX) as c_int; - for fd in 0..max_fd.min(4096) { - libc::close(fd); - } - let args: [*const c_char; 2] = [cpath.as_ptr(), std::ptr::null()]; + for fd in 0..max_fd.min(4096) { libc::close(fd); } + let args: [*const c_char; 3] = [cpath.as_ptr(), cname_arg.as_ptr(), std::ptr::null()]; libc::execv(cpath.as_ptr(), args.as_ptr()); libc::_exit(1); } } - -fn kill_display() { - // SIGTERM all ahfail-display processes owned by the current user. - let uid = unsafe { libc::getuid() }.to_string(); - let _ = std::process::Command::new("pkill") - .args(["-SIGTERM", "-u", &uid, "ahfail-display"]) - .spawn(); -} diff --git a/crates/ahfail-ui/src/display.rs b/crates/ahfail-ui/src/display.rs index de50667..e6a146a 100644 --- a/crates/ahfail-ui/src/display.rs +++ b/crates/ahfail-ui/src/display.rs @@ -5,13 +5,12 @@ use crate::config::ModuleConfig; const SPRITE_MARGIN: i32 = 100; const RETRY_ATTEMPTS: usize = 10; -pub fn place_sprite( - fixed: >k::Fixed, +pub fn sprite_position( animation: &gdk_pixbuf::PixbufAnimation, screen_w: i32, screen_h: i32, config: &ModuleConfig, -) -> gtk::Image { +) -> (i32, i32) { let sprite_w = animation.width(); let sprite_h = animation.height(); @@ -34,6 +33,17 @@ pub fn place_sprite( } } + (x, y) +} + +pub fn place_sprite( + fixed: >k::Fixed, + animation: &gdk_pixbuf::PixbufAnimation, + screen_w: i32, + screen_h: i32, + config: &ModuleConfig, +) -> gtk::Image { + let (x, y) = sprite_position(animation, screen_w, screen_h, config); let image = gtk::Image::from_animation(animation); image.show(); fixed.put(&image, x, y); diff --git a/meson.build b/meson.build index d7861bf..42f763a 100644 --- a/meson.build +++ b/meson.build @@ -38,8 +38,9 @@ pam_cargo = custom_target( output: ['libahfail_pam.so'], command: [ 'sh', '-c', - # Pass libdir so build.rs emits the correct AHFAIL_INSTALL_DIR (needed for multiarch). - 'AHFAIL_LIBDIR=' + get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"' + # Pass the absolute libdir so build.rs emits the correct AHFAIL_INSTALL_DIR. + # get_option('libdir') is relative ("lib"); prefix it to get an absolute path. + 'AHFAIL_LIBDIR=' + get_option('prefix') / get_option('libdir') + ' cargo build --release -p ahfail-pam --target-dir "@OUTDIR@/target-pam" && cp "@OUTDIR@/target-pam/release/libahfail_pam.so" "@OUTPUT@"' ], build_by_default: true, install: true, diff --git a/readme.md b/readme.md index a5e8a81..f9aa1cc 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,16 @@ On a failed lock-screen unlock attempt, this spawns a looping animation of **the author's face photoshopped onto Dennis Nedry's body** and plays the "ah ah ah, you didn't say the magic word" clip from Jurassic Park. Each wrong guess adds another sprite at a random screen position. Volume is forced to 100% on the first failure and restored when the screen unlocks. +--- + +> **Security disclaimer** +> +> This project hooks into PAM and/or your screen locker's plugin API. PAM sits directly in the authentication critical path — a bug in this module could lock you out of your system or, in the worst case, weaken authentication. +> +> The author makes no guarantees about the security or correctness of this software. By installing it you accept that you are modifying a security-sensitive component of your system and take full responsibility for any vulnerabilities or instability that result. **Use at your own discretion.** + +--- + ## Platform support The integration method differs by display server: @@ -84,28 +94,48 @@ gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200 ## Usage Linux - X11 (i3lock, xscreensaver, etc.) -Add a line to your screen locker's PAM service file (e.g. `/etc/pam.d/i3lock`). This is a config file entry — use `tee -a` to append it, not `sudo` directly: +The PAM module works with any X11 locker that authenticates via PAM. Supported lockers and their service file names: + +| Locker | PAM service file | +|---|---| +| i3lock | `/etc/pam.d/i3lock` | +| i3lock-color | `/etc/pam.d/i3lock-color` | +| betterlockscreen | `/etc/pam.d/betterlockscreen` | +| xscreensaver | `/etc/pam.d/xscreensaver` | +| lightdm | `/etc/pam.d/lightdm` | + +Add a line to the relevant file. + +**Important:** The `ahfail` line must appear **before** `auth include system-auth` (or any equivalent include). If it comes after, PAM's internal flow control inside `system-auth` (`pam_faillock` with `[default=die]`) means our module is never reached on failed attempts — it only gets called on success. + +Your PAM service file should look like this: **Arch / standard (`--prefix=/usr`):** -```bash -echo 'auth optional /usr/lib/ahfail/libahfail_pam.so' | sudo tee -a /etc/pam.d/i3lock +``` +#%PAM-1.0 +auth optional /usr/lib/ahfail/libahfail_pam.so +auth include system-auth ``` **Fedora / RHEL (multilib):** -```bash -echo 'auth optional /usr/lib64/ahfail/libahfail_pam.so' | sudo tee -a /etc/pam.d/i3lock +``` +#%PAM-1.0 +auth optional /usr/lib64/ahfail/libahfail_pam.so +auth include system-auth ``` **Debian / Ubuntu (multiarch):** -```bash -echo 'auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so' | sudo tee -a /etc/pam.d/i3lock +``` +#%PAM-1.0 +auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so +auth include system-auth ``` -Replace `i3lock` with your locker's service name (`xscreensaver`, `lightdm`, etc.). The full path is required — `$(libdir)/ahfail` is not in PAM's default search path. +The full path is required — `$(libdir)/ahfail` is not in PAM's default search path. If the display binary is not at the default location, add `display_path=`: -```bash -echo 'auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display' | sudo tee -a /etc/pam.d/i3lock +``` +auth optional /usr/lib/ahfail/libahfail_pam.so display_path=/usr/lib/ahfail/ahfail-display ``` --- @@ -143,7 +173,7 @@ sudo mkdir -p /usr/local/lib/ahfail sudo cp builddir/libahfail_pam.so builddir/ahfail-display /usr/local/lib/ahfail/ ``` -Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` after the existing `auth` entries: +Add to `/etc/pam.d/screensaverui` (macOS 13+) or `/etc/pam.d/screensaver` as the **first** `auth` line (before any `auth include` or `auth required` entries): ``` auth optional /usr/local/lib/ahfail/libahfail_pam.so @@ -209,3 +239,17 @@ cargo clippy # lint meson setup builddir && meson compile -C builddir # full build including .so xvfb-run meson test -C builddir --verbose # Meson tests headless ``` + +--- + +## Roadmap + +### swaylock (Wayland) + +The PAM module can be added to `/etc/pam.d/swaylock` and will fire on each failed attempt — but on Wayland the `ahfail-display` binary cannot draw on top of the lock screen. The Wayland session-lock protocol (`ext-session-lock-v1`) restricts rendering to the locker process itself, so the animation is suppressed by the compositor and only the audio plays. + +Full support requires swaylock to expose a plugin API similar to gtklock's. There is an open request for this upstream. Once available, a dedicated swaylock module can be added alongside the existing gtklock one. + +### hyprlock (Wayland) + +Same situation as swaylock — PAM fires correctly but the display is blocked by the compositor. hyprlock does not currently have a plugin/module API. Full animation support is pending upstream plugin support.