12 Commits

Author SHA1 Message Date
nevaforget 59c509dcbb fix: audit fixes — fprintd path validation, progressive faillock warning (v0.6.7)
Update PKGBUILD version / update-pkgver (push) Successful in 1s
- Validate fprintd device path prefix (/net/reactivated/Fprint/Device/) before
  creating D-Bus proxy (prevents use of unexpected object paths)
- faillock_warning now warns at remaining <= 2 attempts (not just == 1), improving
  UX for higher max_attempts configurations
2026-03-30 16:08:59 +02:00
nevaforget af5b7c8912 ci: also update .SRCINFO in pkgver workflow
Update PKGBUILD version / update-pkgver (push) Successful in 1s
paru reads .SRCINFO (not PKGBUILD) for version comparison during
sysupgrade. Without updating .SRCINFO, paru never detects upgrades
for PKGBUILD repository packages.
2026-03-30 13:49:03 +02:00
nevaforget 1d8921abee fix: audit fixes — blur offset, lock-before-IO, FP signal lifecycle, TOCTOU (v0.6.6)
Update PKGBUILD version / update-pkgver (push) Successful in 2s
Third triple audit (quality, performance, security). Key fixes:
- Blur padding offset: texture at (-pad,-pad) prevents edge darkening on all sides
- Wallpaper loads after lock.lock() — disk I/O no longer delays lock acquisition
- begin_verification disconnects old signal handler before registering new one
- resume_async resets failed_attempts to prevent premature exhaustion
- Unknown VerifyStatus with done=true triggers restart instead of hanging
- symlink_metadata() replaces separate is_file()+is_symlink() (TOCTOU)
- faillock_warning dead code removed, blur sigma clamped to [0,100]
- Redundant Zeroizing<Vec<u8>> removed, on_verify_status restricted to pub(crate)
- Warn logging for non-UTF-8 GECOS and avatar path errors
- Default impl for FingerprintListener, 3 new tests (47 total)
2026-03-30 13:09:02 +02:00
nevaforget 65ea523b36 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)
2026-03-30 00:24:43 +02:00
nevaforget 465a19811a ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
Update PKGBUILD version / update-pkgver (push) Successful in 1s
2026-03-29 23:05:09 +02:00
nevaforget 484e990c68 fix: elevate CSS priority to override GTK4 user theme (v0.6.4)
Colloid-Catppuccin theme loaded via ~/.config/gtk-4.0/gtk.css at
PRIORITY_USER (800) was overriding moonlock's PRIORITY_APPLICATION (600),
causing avatar to lose its circular border-radius.

- Use STYLE_PROVIDER_PRIORITY_USER for app CSS provider
- Replace border-radius: 50% with 9999px (GTK4 CSS percentage quirk)
2026-03-29 14:24:26 +02:00
nevaforget 77d6994b8f fix: prevent edge darkening on GPU-blurred wallpaper (v0.6.3)
GskBlurNode samples pixels outside texture bounds as transparent,
causing visible darkening at wallpaper edges. Fix renders the texture
with 3x-sigma padding before blur, then clips back to original size.
Symmetric fix with moonset v0.7.1.
2026-03-28 23:28:40 +01:00
nevaforget fff18bfb9d refactor: remove embedded wallpaper from binary (v0.6.2)
Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg.
Embedding a 374K JPEG in the binary was redundant. Without a wallpaper
file, GTK background color (Catppuccin Mocha base) shows through.
2026-03-28 23:23:02 +01:00
nevaforget ca934b8c36 feat: add MOONLOCK_DEBUG env var for debug-level logging (v0.6.1)
Align with moongreet/moonset logging pattern — set MOONLOCK_DEBUG to
enable debug-level journal output for troubleshooting.
2026-03-28 22:57:02 +01:00
nevaforget d11b6e634e fix: audit fixes — D-Bus sender validation, fp lifecycle, multi-monitor caching (v0.6.0)
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.
2026-03-28 22:47:09 +01:00
nevaforget 4026f6dafa fix: audit fixes — double-unlock guard, PAM OOM code, GPU blur, async fp stop (v0.5.1)
Security: prevent double unlock() when PAM and fingerprint succeed
simultaneously (ext-session-lock protocol error). Fix PAM callback
returning PAM_AUTH_ERR instead of PAM_BUF_ERR on calloc OOM.

Performance: replace CPU-side Gaussian blur (image crate) with GPU blur
via GskBlurNode + GskRenderer::render_texture(). Eliminates 500ms-2s
main-thread blocking on cold cache for 4K wallpapers. Remove image and
dirs dependencies (~15 transitive crates). Make fingerprint stop()
fire-and-forget async to avoid 6s UI block after successful auth.
2026-03-28 22:06:38 +01:00
nevaforget 48706e5a29 perf: cache blurred wallpaper to disk to avoid re-blur on startup
First launch with blur blurs and saves to ~/.cache/moonlock/.
Subsequent starts load the cached PNG directly. Cache invalidates
when wallpaper path, size, mtime, or sigma changes.
Adds dirs crate for cache directory resolution.
2026-03-28 21:23:43 +01:00
16 changed files with 661 additions and 307 deletions
+43
View File
@@ -0,0 +1,43 @@
# ABOUTME: Updates pkgver in moonarch-pkgbuilds after a push to main.
# ABOUTME: Ensures paru detects new versions of this package.
name: Update PKGBUILD version
on:
push:
branches:
- main
jobs:
update-pkgver:
runs-on: moonarch
steps:
- name: Checkout source repo
run: |
git clone --bare http://gitea:3000/nevaforget/moonlock.git source.git
cd source.git
PKGVER=$(git describe --long --tags | sed 's/^v//;s/-/.r/;s/-/./')
echo "New pkgver: $PKGVER"
echo "$PKGVER" > /tmp/pkgver
- name: Update PKGBUILD
run: |
PKGVER=$(cat /tmp/pkgver)
git clone http://gitea:3000/nevaforget/moonarch-pkgbuilds.git pkgbuilds
cd pkgbuilds
OLD_VER=$(grep '^pkgver=' moonlock-git/PKGBUILD | cut -d= -f2)
if [ "$OLD_VER" = "$PKGVER" ]; then
echo "pkgver already up to date ($PKGVER)"
exit 0
fi
sed -i "s/^pkgver=.*/pkgver=$PKGVER/" moonlock-git/PKGBUILD
sed -i "s/^\tpkgver = .*/\tpkgver = $PKGVER/" moonlock-git/.SRCINFO
echo "Updated pkgver: $OLD_VER → $PKGVER"
git config user.name "pkgver-bot"
git config user.email "gitea@moonarch.de"
git add moonlock-git/PKGBUILD moonlock-git/.SRCINFO
git commit -m "chore(moonlock-git): bump pkgver to $PKGVER"
git -c http.extraHeader="Authorization: token ${{ secrets.PKGBUILD_TOKEN }}" push
+14 -9
View File
@@ -19,7 +19,7 @@ Teil des Moonarch-Ökosystems.
## Projektstruktur ## Projektstruktur
- `src/` — Rust-Quellcode (main.rs, lockscreen.rs, auth.rs, fingerprint.rs, config.rs, i18n.rs, users.rs, power.rs) - `src/` — Rust-Quellcode (main.rs, lockscreen.rs, auth.rs, fingerprint.rs, config.rs, i18n.rs, users.rs, power.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) - `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — PAM-Konfiguration und Beispiel-Config - `config/` — PAM-Konfiguration und Beispiel-Config
## Kommandos ## Kommandos
@@ -37,23 +37,28 @@ LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock
## Architektur ## Architektur
- `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, msg_style-aware, Zeroizing<Vec<u8>>) - `auth.rs` — PAM-Authentifizierung via Raw FFI (unsafe extern "C" conv callback, msg_style-aware, 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, sync stop with 3s timeout, on_exhausted callback after MAX_FP_ATTEMPTS - `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 (mit failed_attempts-Reset und Signal-Handler-Cleanup)
- `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection - `users.rs` — Aktuellen User via nix getuid, Avatar-Loading mit Symlink-Rejection
- `power.rs` — Reboot/Shutdown via /usr/bin/systemctl - `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 - `i18n.rs` — Locale-Erkennung (OnceLock-cached) und String-Tabellen (DE/EN), faillock_warning mit konfigurierbarem max_attempts
- `config.rs` — TOML-Config (background_path, fingerprint_enabled als Option<bool>) + Wallpaper-Fallback - `config.rs` — TOML-Config (background_path, background_blur clamped [0,100], fingerprint_enabled als Option<bool>) + Wallpaper-Fallback + Symlink-Rejection via symlink_metadata + 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 - `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, Root-Check, ext-session-lock-v1 (Pflicht in Release), Multi-Monitor, systemd-Journal-Logging, async fprintd-Init nach window.present() - `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(), Wallpaper-Laden nach lock()
## Sicherheit ## Sicherheit
- ext-session-lock-v1 garantiert: Compositor sperrt alle Surfaces bei lock() - ext-session-lock-v1 garantiert: Compositor sperrt alle Surfaces bei lock()
- Release-Build: Ohne ext-session-lock-v1 wird `exit(1)` aufgerufen — kein Fenster-Fallback - Release-Build: Ohne ext-session-lock-v1 wird `exit(1)` aufgerufen — kein Fenster-Fallback
- Panic-Hook: Bei Crash wird geloggt, aber NIEMALS unlock() aufgerufen — Screen bleibt schwarz - 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 - PAM-Callback: msg_style-aware (Passwort nur bei PAM_PROMPT_ECHO_OFF), strdup-OOM-sicher, num_msg-Guard gegen negative Werte
- 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) - fprintd: D-Bus Signal-Sender wird gegen fprintd's unique bus name validiert (Anti-Spoofing)
- Passwort: Zeroizing<String> ab GTK-Entry-Extraktion, 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 (mit failed_attempts-Reset und Signal-Handler-Cleanup)
- Wallpaper wird nach lock() geladen — Disk-I/O verzögert nicht die Lock-Akquisition
- 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 - Root-Check: Exit mit Fehler wenn als root gestartet
- Faillock: UI-Warnung nach 3 Fehlversuchen, aber PAM entscheidet über Lockout (Entry bleibt aktiv) - 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 - Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche PAM-Auth oder Fingerprint
- Kein Peek-Icon am Passwortfeld (Shoulder-Surfing-Schutz)
- GResource-Bundle: CSS/Assets in der Binary kompiliert - GResource-Bundle: CSS/Assets in der Binary kompiliert
Generated
+2 -132
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@@ -26,18 +20,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.22.0" version = "0.22.0"
@@ -83,15 +65,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -114,15 +87,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -133,16 +97,6 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@@ -556,21 +510,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -634,28 +573,18 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "moonlock" name = "moonlock"
version = "0.5.0" version = "0.6.7"
dependencies = [ dependencies = [
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",
"gio", "gio",
"glib", "glib",
"glib-build-tools", "glib-build-tools",
"graphene-rs",
"gtk4", "gtk4",
"gtk4-session-lock", "gtk4-session-lock",
"image",
"libc", "libc",
"log", "log",
"nix", "nix",
@@ -666,16 +595,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -688,15 +607,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -739,19 +649,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -780,12 +677,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -890,12 +781,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1284,18 +1169,3 @@ name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+8 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "moonlock" name = "moonlock"
version = "0.5.0" version = "0.6.7"
edition = "2024" edition = "2024"
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support" description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
license = "MIT" license = "MIT"
@@ -14,10 +14,10 @@ gdk-pixbuf = "0.22"
gio = "0.22" gio = "0.22"
toml = "0.8" toml = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
nix = { version = "0.29", features = ["user"] } nix = { version = "0.29", features = ["user"] }
zeroize = { version = "1", features = ["derive"] } zeroize = { version = "1", features = ["derive", "std"] }
libc = "0.2" libc = "0.2"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
log = "0.4" log = "0.4"
systemd-journal-logger = "2.2" systemd-journal-logger = "2.2"
@@ -26,3 +26,8 @@ tempfile = "3"
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
+36 -1
View File
@@ -2,7 +2,42 @@
Architectural and design decisions for Moonlock, in reverse chronological order. Architectural and design decisions for Moonlock, in reverse chronological order.
## 2026-03-28 Optional background blur via `image` crate ## 2026-03-30 Third audit: blur offset, lock-before-IO, FP signal lifecycle, TOCTOU
- **Who**: Nyx, Dom
- **Why**: Third triple audit (quality, performance, security) found: blur padding offset rendering texture at (0,0) instead of (-pad,-pad) causing edge darkening on left/top (BUG), wallpaper disk I/O blocking before lock() extending the unsecured window (PERF/SEC), signal handler duplication on resume_async (SEC), failed_attempts not reset on FP resume (SEC), unknown VerifyStatus with done=false hanging FP listener (SEC), TOCTOU in is_file+is_symlink checks (SEC), dead code in faillock_warning (QUALITY), unbounded blur sigma (SEC).
- **Tradeoffs**: Wallpaper loads after lock() — screen briefly shows without wallpaper until texture is ready. Acceptable: security > aesthetics. Blur sigma clamped to [0.0, 100.0] — arbitrary upper bound but prevents GPU memory exhaustion.
- **How**: (1) Texture offset to (-pad, -pad) in render_blurred_texture. (2) lock.lock() before resolve_background_path. (3) begin_verification disconnects old signal_id before registering new. (4) resume_async resets failed_attempts. (5) Unknown VerifyStatus with done=true triggers restart. (6) symlink_metadata() for atomic file+symlink check. (7) faillock_warning dead code removed, saturating_sub. (8) background_blur clamped. (9) Redundant Zeroizing<Vec<u8>> removed. (10) Default impl for FingerprintListener. (11) on_verify_status restricted to pub(crate). (12) Warn logging for non-UTF-8 GECOS and avatar paths.
## 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
- **Why**: Wallpaper is installed by moonarch to /usr/share/moonarch/wallpaper.jpg. Embedding a 374K JPEG in the binary is redundant. GTK background color (Catppuccin Mocha base) is a clean fallback.
- **Tradeoffs**: Without moonarch installed AND without config, lockscreen shows plain dark background instead of wallpaper. Acceptable — that's the expected minimal state.
- **How**: Remove wallpaper.jpg from GResources, return None from resolve_background_path when no file found, skip background picture creation when no texture available.
## 2026-03-28 Audit-driven security and lifecycle fixes (v0.6.0)
- **Who**: Nyx, Dom
- **Why**: Triple audit (quality, performance, security) revealed a critical D-Bus signal spoofing vector, fingerprint lifecycle bugs, and multi-monitor performance issues.
- **Tradeoffs**: `cleanup_dbus()` extraction adds a method but clarifies the stop/match ownership; `running_flag: Rc<Cell<bool>>` adds a field but prevents race between async restart and stop; sender validation adds a check per signal but closes the only known auth bypass.
- **How**: (1) Validate D-Bus VerifyStatus sender against fprintd's unique bus name. (2) Extract `cleanup_dbus()` from `stop()`, call it on verify-match. (3) `Rc<Cell<bool>>` running flag checked after await in `restart_verify_async`. (4) Consistent 3s D-Bus timeouts. (5) Panic hook before logging. (6) Blur and avatar caches shared across monitors. (7) Peek icon disabled. (8) Symlink rejection for background_path. (9) TOML parse errors logged.
## 2026-03-28 GPU blur via GskBlurNode replaces CPU blur
- **Who**: Nyx, Dom
- **Why**: CPU-side Gaussian blur (`image` crate) blocked the GTK main thread for 500ms2s on 4K wallpapers at cold cache. Disk cache mitigated repeat starts but added ~100 lines of complexity.
- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper. Removes `image` and `dirs` dependencies entirely. No disk cache needed.
- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer, producing a concrete `gdk::Texture`. Zero startup latency.
## 2026-03-28 Optional background blur via `image` crate (superseded)
- **Who**: Nyx, Dom - **Who**: Nyx, Dom
- **Why**: Consistent with moonset/moongreet — blurred wallpaper as lockscreen background is a common UX pattern - **Why**: Consistent with moonset/moongreet — blurred wallpaper as lockscreen background is a common UX pattern
-1
View File
@@ -2,7 +2,6 @@
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moonlock"> <gresource prefix="/dev/moonarch/moonlock">
<file>style.css</file> <file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file> <file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+1 -1
View File
@@ -23,7 +23,7 @@ window.lockscreen.visible {
/* Round avatar image */ /* Round avatar image */
.avatar { .avatar {
border-radius: 50%; border-radius: 9999px;
min-width: 128px; min-width: 128px;
min-height: 128px; min-height: 128px;
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

+68 -7
View File
@@ -8,6 +8,7 @@ use zeroize::Zeroizing;
// PAM return codes // PAM return codes
const PAM_SUCCESS: i32 = 0; const PAM_SUCCESS: i32 = 0;
const PAM_BUF_ERR: i32 = 5; const PAM_BUF_ERR: i32 = 5;
const PAM_AUTH_ERR: i32 = 7;
// PAM message styles // PAM message styles
const PAM_PROMPT_ECHO_OFF: libc::c_int = 1; const PAM_PROMPT_ECHO_OFF: libc::c_int = 1;
@@ -70,10 +71,14 @@ unsafe extern "C" fn pam_conv_callback(
appdata_ptr: *mut libc::c_void, appdata_ptr: *mut libc::c_void,
) -> libc::c_int { ) -> libc::c_int {
unsafe { unsafe {
if num_msg <= 0 {
return PAM_AUTH_ERR;
}
// Safety: appdata_ptr was set to a valid *const CString in authenticate() // Safety: appdata_ptr was set to a valid *const CString in authenticate()
let password = appdata_ptr as *const CString; let password = appdata_ptr as *const CString;
if password.is_null() { if password.is_null() {
return 7; // PAM_AUTH_ERR return PAM_AUTH_ERR;
} }
// Safety: calloc returns zeroed memory for num_msg PamResponse structs. // Safety: calloc returns zeroed memory for num_msg PamResponse structs.
@@ -84,7 +89,7 @@ unsafe extern "C" fn pam_conv_callback(
) as *mut PamResponse; ) as *mut PamResponse;
if resp_array.is_null() { if resp_array.is_null() {
return 7; // PAM_AUTH_ERR return PAM_BUF_ERR;
} }
for i in 0..num_msg as isize { for i in 0..num_msg as isize {
@@ -144,10 +149,9 @@ unsafe extern "C" fn pam_conv_callback(
/// Returns true on success, false on authentication failure. /// Returns true on success, false on authentication failure.
/// The password is wiped from memory after use via zeroize. /// The password is wiped from memory after use via zeroize.
pub fn authenticate(username: &str, password: &str) -> bool { pub fn authenticate(username: &str, password: &str) -> bool {
// Use Zeroizing to ensure password bytes are wiped on drop // CString::new takes ownership of the Vec — Zeroizing<CString> wipes on drop
let password_bytes = Zeroizing::new(password.as_bytes().to_vec()); let password_cstr = match CString::new(password.as_bytes().to_vec()) {
let password_cstr = match CString::new(password_bytes.as_slice()) { Ok(c) => Zeroizing::new(c),
Ok(c) => c,
Err(_) => return false, // Password contains null byte Err(_) => return false, // Password contains null byte
}; };
@@ -163,7 +167,7 @@ pub fn authenticate(username: &str, password: &str) -> bool {
let conv = PamConv { let conv = PamConv {
conv: pam_conv_callback, 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(); let mut handle: *mut libc::c_void = ptr::null_mut();
@@ -202,6 +206,51 @@ pub fn authenticate(username: &str, password: &str) -> bool {
acct_ret == PAM_SUCCESS 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -236,4 +285,16 @@ mod tests {
let result = authenticate("", "password"); let result = authenticate("", "password");
assert!(!result); assert!(!result);
} }
#[test]
fn check_account_empty_username_fails() {
let result = check_account("");
assert!(!result);
}
#[test]
fn check_account_null_byte_username_fails() {
let result = check_account("user\0name");
assert!(!result);
}
} }
+39 -11
View File
@@ -6,7 +6,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg"; const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock";
fn default_config_paths() -> Vec<PathBuf> { fn default_config_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/etc/moonlock/moonlock.toml")]; let mut paths = vec![PathBuf::from("/etc/moonlock/moonlock.toml")];
@@ -49,27 +48,36 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default(); let mut merged = Config::default();
for path in paths { for path in paths {
if let Ok(content) = fs::read_to_string(path) { if let Ok(content) = fs::read_to_string(path) {
if let Ok(parsed) = toml::from_str::<RawConfig>(&content) { match toml::from_str::<RawConfig>(&content) {
Ok(parsed) => {
if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } if parsed.background_path.is_some() { merged.background_path = parsed.background_path; }
if parsed.background_blur.is_some() { merged.background_blur = parsed.background_blur; } if let Some(blur) = parsed.background_blur {
merged.background_blur = Some(blur.clamp(0.0, 100.0));
}
if let Some(fp) = parsed.fingerprint_enabled { merged.fingerprint_enabled = fp; } if let Some(fp) = parsed.fingerprint_enabled { merged.fingerprint_enabled = fp; }
} }
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
}
}
} }
} }
merged merged
} }
pub fn resolve_background_path(config: &Config) -> PathBuf { pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
} }
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf { pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
if let Some(ref bg) = config.background_path { if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg); let path = PathBuf::from(bg);
if path.is_file() { return path; } if let Ok(meta) = path.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() { return Some(path); }
} }
if moonarch_wallpaper.is_file() { return moonarch_wallpaper.to_path_buf(); } }
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) if moonarch_wallpaper.is_file() { return Some(moonarch_wallpaper.to_path_buf()); }
None
} }
#[cfg(test)] #[cfg(test)]
@@ -104,7 +112,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let wp = dir.path().join("bg.jpg"); fs::write(&wp, "fake").unwrap(); let wp = dir.path().join("bg.jpg"); fs::write(&wp, "fake").unwrap();
let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), ..Config::default() }; let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), ..Config::default() };
assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), wp); assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), Some(wp));
} }
#[test] fn empty_user_config_preserves_system_fingerprint() { #[test] fn empty_user_config_preserves_system_fingerprint() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -115,9 +123,29 @@ mod tests {
let c = load_config(Some(&[sys_conf, usr_conf])); let c = load_config(Some(&[sys_conf, usr_conf]));
assert!(!c.fingerprint_enabled); assert!(!c.fingerprint_enabled);
} }
#[test] fn resolve_gresource_fallback() { #[test] fn resolve_no_wallpaper_returns_none() {
let c = Config::default(); let c = Config::default();
let r = resolve_background_path_with(&c, Path::new("/nonexistent")); let r = resolve_background_path_with(&c, Path::new("/nonexistent"));
assert!(r.to_str().unwrap().contains("moonlock")); assert!(r.is_none());
}
#[test] fn toml_parse_error_returns_default() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonlock.toml");
fs::write(&conf, "this is not valid toml {{{{").unwrap();
let c = load_config(Some(&[conf]));
assert!(c.fingerprint_enabled);
assert!(c.background_path.is_none());
}
#[cfg(unix)]
#[test] fn symlink_rejected_for_background() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("bg.jpg");
let link = dir.path().join("link.jpg");
fs::write(&real, "fake").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
let c = Config { background_path: Some(link.to_str().unwrap().to_string()), ..Config::default() };
// Symlink should be rejected — falls through to None
let r = resolve_background_path_with(&c, Path::new("/nonexistent"));
assert!(r.is_none());
} }
} }
+111 -46
View File
@@ -3,7 +3,7 @@
use gio::prelude::*; use gio::prelude::*;
use gtk4::gio; use gtk4::gio;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint"; const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint";
@@ -12,6 +12,8 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
const MAX_FP_ATTEMPTS: u32 = 10; const MAX_FP_ATTEMPTS: u32 = 10;
const DBUS_TIMEOUT_MS: i32 = 3000;
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
/// Retry-able statuses — finger not read properly, try again. /// Retry-able statuses — finger not read properly, try again.
const RETRY_STATUSES: &[&str] = &[ const RETRY_STATUSES: &[&str] = &[
@@ -26,26 +28,35 @@ pub struct FingerprintListener {
device_proxy: Option<gio::DBusProxy>, device_proxy: Option<gio::DBusProxy>,
signal_id: Option<glib::SignalHandlerId>, signal_id: Option<glib::SignalHandlerId>,
running: bool, running: bool,
/// Shared flag for async tasks to detect stop() between awaits.
running_flag: Rc<Cell<bool>>,
failed_attempts: u32, failed_attempts: u32,
on_success: Option<Box<dyn Fn() + 'static>>, on_success: Option<Box<dyn Fn() + 'static>>,
on_failure: Option<Box<dyn Fn() + 'static>>, on_failure: Option<Box<dyn Fn() + 'static>>,
on_exhausted: Option<Box<dyn Fn() + 'static>>, on_exhausted: Option<Box<dyn Fn() + 'static>>,
} }
impl FingerprintListener { impl Default for FingerprintListener {
/// Create a lightweight FingerprintListener without any D-Bus calls. fn default() -> Self {
/// Call `init_async().await` afterwards to connect to fprintd.
pub fn new() -> Self {
FingerprintListener { FingerprintListener {
device_proxy: None, device_proxy: None,
signal_id: None, signal_id: None,
running: false, running: false,
running_flag: Rc::new(Cell::new(false)),
failed_attempts: 0, failed_attempts: 0,
on_success: None, on_success: None,
on_failure: None, on_failure: None,
on_exhausted: None, on_exhausted: None,
} }
} }
}
impl FingerprintListener {
/// Create a lightweight FingerprintListener without any D-Bus calls.
/// Call `init_async().await` afterwards to connect to fprintd.
pub fn new() -> Self {
Self::default()
}
/// Connect to fprintd and get the default device asynchronously. /// Connect to fprintd and get the default device asynchronously.
pub async fn init_async(&mut self) { pub async fn init_async(&mut self) {
@@ -68,7 +79,7 @@ impl FingerprintListener {
// Call GetDefaultDevice // Call GetDefaultDevice
let result = match manager let result = match manager
.call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, -1) .call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await .await
{ {
Ok(r) => r, Ok(r) => r,
@@ -89,6 +100,10 @@ impl FingerprintListener {
if device_path.is_empty() { if device_path.is_empty() {
return; return;
} }
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
log::warn!("Unexpected fprintd device path: {device_path}");
return;
}
match gio::DBusProxy::for_bus_future( match gio::DBusProxy::for_bus_future(
gio::BusType::System, gio::BusType::System,
@@ -118,7 +133,7 @@ impl FingerprintListener {
let args = glib::Variant::from((&username,)); let args = glib::Variant::from((&username,));
match proxy match proxy
.call_future("ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, -1) .call_future("ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await .await
{ {
Ok(result) => { Ok(result) => {
@@ -150,14 +165,6 @@ impl FingerprintListener {
G: Fn() + 'static, G: Fn() + 'static,
H: 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(); let mut inner = listener.borrow_mut();
inner.on_success = Some(Box::new(on_success)); inner.on_success = Some(Box::new(on_success));
@@ -165,10 +172,43 @@ impl FingerprintListener {
inner.on_exhausted = Some(Box::new(on_exhausted)); 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,
) {
listener.borrow_mut().failed_attempts = 0;
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 mut inner = listener.borrow_mut();
let proxy = match inner.device_proxy.clone() {
Some(p) => p,
None => return,
};
// Disconnect any previous signal handler to prevent duplicates on resume
if let Some(old_id) = inner.signal_id.take() {
proxy.disconnect(old_id);
}
proxy
};
// Claim the device // Claim the device
let args = glib::Variant::from((&username,)); let args = glib::Variant::from((&username,));
if let Err(e) = proxy if let Err(e) = proxy
.call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, -1) .call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await .await
{ {
log::error!("Failed to claim fingerprint device: {e}"); log::error!("Failed to claim fingerprint device: {e}");
@@ -178,20 +218,34 @@ impl FingerprintListener {
// Start verification // Start verification
let start_args = glib::Variant::from((&"any",)); let start_args = glib::Variant::from((&"any",));
if let Err(e) = proxy if let Err(e) = proxy
.call_future("VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, -1) .call_future("VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await .await
{ {
log::error!("Failed to start fingerprint verification: {e}"); log::error!("Failed to start fingerprint verification: {e}");
let _ = proxy let _ = proxy
.call_future("Release", None, gio::DBusCallFlags::NONE, -1) .call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await; .await;
return; 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 // Connect the g-signal handler on the proxy to dispatch VerifyStatus
let listener_weak = Rc::downgrade(listener); let listener_weak = Rc::downgrade(listener);
let signal_id = proxy.connect_local("g-signal", false, move |values| { let signal_id = proxy.connect_local("g-signal", false, move |values| {
// g-signal arguments: (proxy, sender_name, signal_name, parameters) // 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() { let signal_name: String = match values[2].get() {
Ok(v) => v, Ok(v) => v,
Err(_) => return None, Err(_) => return None,
@@ -224,16 +278,17 @@ impl FingerprintListener {
let mut inner = listener.borrow_mut(); let mut inner = listener.borrow_mut();
inner.signal_id = Some(signal_id); inner.signal_id = Some(signal_id);
inner.running = true; inner.running = true;
inner.running_flag.set(true);
} }
/// Process a VerifyStatus signal from fprintd. /// Process a VerifyStatus signal from fprintd.
pub fn on_verify_status(&mut self, status: &str, done: bool) { pub(crate) fn on_verify_status(&mut self, status: &str, done: bool) {
if !self.running { if !self.running {
return; return;
} }
if status == "verify-match" { if status == "verify-match" {
self.running = false; self.cleanup_dbus();
if let Some(ref cb) = self.on_success { if let Some(ref cb) = self.on_success {
cb(); cb();
} }
@@ -267,20 +322,28 @@ impl FingerprintListener {
} }
log::debug!("Unhandled fprintd status: {status}"); log::debug!("Unhandled fprintd status: {status}");
if done {
self.restart_verify_async();
}
} }
/// Restart fingerprint verification asynchronously after a completed attempt. /// 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) { fn restart_verify_async(&self) {
if let Some(ref proxy) = self.device_proxy { if let Some(ref proxy) = self.device_proxy {
let proxy = proxy.clone(); let proxy = proxy.clone();
let running = self.running_flag.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
// VerifyStop before VerifyStart to avoid D-Bus errors // VerifyStop before VerifyStart to avoid D-Bus errors
let _ = proxy let _ = proxy
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, -1) .call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await; .await;
if !running.get() {
return;
}
let args = glib::Variant::from((&"any",)); let args = glib::Variant::from((&"any",));
if let Err(e) = proxy if let Err(e) = proxy
.call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, -1) .call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await .await
{ {
log::error!("Failed to restart fingerprint verification: {e}"); log::error!("Failed to restart fingerprint verification: {e}");
@@ -289,35 +352,37 @@ impl FingerprintListener {
} }
} }
/// Stop listening and release the device. /// Disconnect the signal handler and send VerifyStop + Release to fprintd.
/// Uses a short timeout (3s) to avoid blocking the UI indefinitely. /// Signal disconnect is synchronous to prevent further callbacks.
pub fn stop(&mut self) { /// D-Bus cleanup is fire-and-forget to avoid blocking the UI.
if !self.running { fn cleanup_dbus(&mut self) {
return;
}
self.running = false; self.running = false;
self.running_flag.set(false);
if let Some(ref proxy) = self.device_proxy { if let Some(ref proxy) = self.device_proxy {
if let Some(id) = self.signal_id.take() { if let Some(id) = self.signal_id.take() {
proxy.disconnect(id); proxy.disconnect(id);
} }
let _ = proxy.call_sync( let proxy = proxy.clone();
"VerifyStop", glib::spawn_future_local(async move {
None, let _ = proxy
gio::DBusCallFlags::NONE, .call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
3000, .await;
gio::Cancellable::NONE, let _ = proxy
); .call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
let _ = proxy.call_sync( .await;
"Release", });
None,
gio::DBusCallFlags::NONE,
3000,
gio::Cancellable::NONE,
);
} }
} }
/// 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)] #[cfg(test)]
@@ -339,36 +404,37 @@ mod tests {
#[test] #[test]
fn verify_match_sets_running_false_and_calls_success() { fn verify_match_sets_running_false_and_calls_success() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false)); let called = Rc::new(Cell::new(false));
let called_clone = called.clone(); let called_clone = called.clone();
let mut listener = FingerprintListener::new(); let mut listener = FingerprintListener::new();
listener.running = true; listener.running = true;
listener.running_flag.set(true);
listener.on_success = Some(Box::new(move || { called_clone.set(true); })); listener.on_success = Some(Box::new(move || { called_clone.set(true); }));
listener.on_verify_status("verify-match", false); listener.on_verify_status("verify-match", false);
assert!(called.get()); assert!(called.get());
assert!(!listener.running); assert!(!listener.running);
assert!(!listener.running_flag.get());
} }
#[test] #[test]
fn verify_no_match_calls_failure_and_stays_running() { fn verify_no_match_calls_failure_and_stays_running() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false)); let called = Rc::new(Cell::new(false));
let called_clone = called.clone(); let called_clone = called.clone();
let mut listener = FingerprintListener::new(); let mut listener = FingerprintListener::new();
listener.running = true; listener.running = true;
listener.running_flag.set(true);
listener.on_failure = Some(Box::new(move || { called_clone.set(true); })); listener.on_failure = Some(Box::new(move || { called_clone.set(true); }));
listener.on_verify_status("verify-no-match", false); listener.on_verify_status("verify-no-match", false);
assert!(called.get()); assert!(called.get());
assert!(listener.running); assert!(listener.running);
assert!(listener.running_flag.get());
assert_eq!(listener.failed_attempts, 1); assert_eq!(listener.failed_attempts, 1);
} }
#[test] #[test]
fn max_attempts_stops_listener_and_calls_exhausted() { fn max_attempts_stops_listener_and_calls_exhausted() {
use std::cell::Cell;
let exhausted = Rc::new(Cell::new(false)); let exhausted = Rc::new(Cell::new(false));
let exhausted_clone = exhausted.clone(); let exhausted_clone = exhausted.clone();
let mut listener = FingerprintListener::new(); let mut listener = FingerprintListener::new();
@@ -386,7 +452,6 @@ mod tests {
#[test] #[test]
fn not_running_ignores_signals() { fn not_running_ignores_signals() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false)); let called = Rc::new(Cell::new(false));
let called_clone = called.clone(); let called_clone = called.clone();
let mut listener = FingerprintListener::new(); let mut listener = FingerprintListener::new();
+39 -5
View File
@@ -28,6 +28,7 @@ pub struct Strings {
pub confirm_no: &'static str, pub confirm_no: &'static str,
pub faillock_attempts_remaining: &'static str, pub faillock_attempts_remaining: &'static str,
pub faillock_locked: &'static str, pub faillock_locked: &'static str,
pub auth_timeout: &'static str,
} }
const STRINGS_DE: Strings = Strings { const STRINGS_DE: Strings = Strings {
@@ -46,6 +47,7 @@ const STRINGS_DE: Strings = Strings {
confirm_no: "Abbrechen", confirm_no: "Abbrechen",
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!", faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked: "Konto ist möglicherweise gesperrt", faillock_locked: "Konto ist möglicherweise gesperrt",
auth_timeout: "Authentifizierung abgelaufen — bitte erneut versuchen",
}; };
const STRINGS_EN: Strings = Strings { const STRINGS_EN: Strings = Strings {
@@ -64,6 +66,7 @@ const STRINGS_EN: Strings = Strings {
confirm_no: "Cancel", confirm_no: "Cancel",
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!", faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
faillock_locked: "Account may be locked", faillock_locked: "Account may be locked",
auth_timeout: "Authentication timed out — please try again",
}; };
fn parse_lang_prefix(lang: &str) -> String { fn parse_lang_prefix(lang: &str) -> String {
@@ -97,10 +100,13 @@ pub fn load_strings(locale: Option<&str>) -> &'static Strings {
match locale { "de" => &STRINGS_DE, _ => &STRINGS_EN } match locale { "de" => &STRINGS_DE, _ => &STRINGS_EN }
} }
/// Returns a warning when the user is close to lockout (2 or fewer attempts remaining).
/// Caller is responsible for handling the locked state (count >= max_attempts).
pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option<String> { pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option<String> {
if attempt_count >= max_attempts { return Some(strings.faillock_locked.to_string()); } let remaining = max_attempts.saturating_sub(attempt_count);
let remaining = max_attempts - attempt_count; if remaining > 0 && remaining <= 2 {
if remaining == 1 { return Some(strings.faillock_attempts_remaining.replace("{n}", &remaining.to_string())); } return Some(strings.faillock_attempts_remaining.replace("{n}", &remaining.to_string()));
}
None None
} }
@@ -134,7 +140,35 @@ mod tests {
} }
#[test] fn faillock_zero() { assert!(faillock_warning(0, 3, load_strings(Some("en"))).is_none()); } #[test] fn faillock_zero() { assert!(faillock_warning(0, 3, load_strings(Some("en"))).is_none()); }
#[test] fn faillock_one() { assert!(faillock_warning(1, 3, load_strings(Some("en"))).is_none()); } #[test] fn faillock_one() { assert!(faillock_warning(1, 3, load_strings(Some("en"))).is_some()); }
#[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); } #[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); }
#[test] fn faillock_three() { assert_eq!(faillock_warning(3, 3, load_strings(Some("en"))).unwrap(), "Account may be locked"); } #[test] fn faillock_three() { assert!(faillock_warning(3, 3, load_strings(Some("en"))).is_none()); }
#[test]
fn faillock_caller_contract() {
// Mirrors the lockscreen.rs usage: caller handles count >= max separately,
// faillock_warning is only called when count < max.
let max = 3u32;
let strings = load_strings(Some("en"));
for count in 0..max {
let result = faillock_warning(count, max, strings);
let remaining = max - count;
if remaining <= 2 {
assert!(result.is_some(), "should warn at count={count} (remaining={remaining})");
} else {
assert!(result.is_none(), "should not warn at count={count}");
}
}
}
#[test]
fn faillock_warns_progressively_with_higher_max() {
let strings = load_strings(Some("en"));
// With max=5: warn at count 3 (rem=2) and count 4 (rem=1), not at 0-2
assert!(faillock_warning(0, 5, strings).is_none());
assert!(faillock_warning(2, 5, strings).is_none());
assert!(faillock_warning(3, 5, strings).is_some());
assert!(faillock_warning(4, 5, strings).is_some());
assert!(faillock_warning(5, 5, strings).is_none()); // at max, caller handles lockout
}
} }
+230 -59
View File
@@ -4,10 +4,10 @@
use gdk4 as gdk; use gdk4 as gdk;
use gdk_pixbuf::Pixbuf; use gdk_pixbuf::Pixbuf;
use glib::clone; use glib::clone;
use graphene_rs as graphene;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use image::imageops; use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::path::Path; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
@@ -32,6 +32,7 @@ pub struct LockscreenHandles {
const AVATAR_SIZE: i32 = 128; const AVATAR_SIZE: i32 = 128;
const FAILLOCK_MAX_ATTEMPTS: u32 = 3; const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
const PAM_TIMEOUT_SECS: u64 = 30;
/// Shared mutable state for the lockscreen. /// Shared mutable state for the lockscreen.
struct LockscreenState { struct LockscreenState {
@@ -41,11 +42,15 @@ struct LockscreenState {
/// Create a lockscreen window for a single monitor. /// Create a lockscreen window for a single monitor.
/// Fingerprint is not initialized here — use `wire_fingerprint()` after async init. /// Fingerprint is not initialized here — use `wire_fingerprint()` after async init.
/// The `blur_cache` and `avatar_cache` are shared across monitors for multi-monitor
/// setups, avoiding redundant GPU renders and SVG rasterizations.
pub fn create_lockscreen_window( pub fn create_lockscreen_window(
bg_texture: &gdk::Texture, bg_texture: Option<&gdk::Texture>,
_config: &Config, config: &Config,
app: &gtk::Application, app: &gtk::Application,
unlock_callback: Rc<dyn Fn()>, unlock_callback: Rc<dyn Fn()>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
avatar_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> LockscreenHandles { ) -> LockscreenHandles {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
.application(app) .application(app)
@@ -82,9 +87,11 @@ pub fn create_lockscreen_window(
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay)); window.set_child(Some(&overlay));
// Background wallpaper // Background wallpaper (if available — otherwise GTK background color shows through)
let background = create_background_picture(bg_texture); if let Some(texture) = bg_texture {
let background = create_background_picture(texture, config.background_blur, blur_cache);
overlay.set_child(Some(&background)); overlay.set_child(Some(&background));
}
// Centered vertical box // Centered vertical box
let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
@@ -109,12 +116,17 @@ pub fn create_lockscreen_window(
avatar_frame.append(&avatar_image); avatar_frame.append(&avatar_image);
login_box.append(&avatar_frame); login_box.append(&avatar_frame);
// Load avatar // Load avatar — use shared cache to avoid redundant loading on multi-monitor setups.
// The cache is populated by the first monitor and reused by subsequent ones.
if let Some(ref cached) = *avatar_cache.borrow() {
avatar_image.set_paintable(Some(cached));
} else {
let avatar_path = users::get_avatar_path(&user.home, &user.username); let avatar_path = users::get_avatar_path(&user.home, &user.username);
if let Some(path) = avatar_path { if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path); set_avatar_from_file(&avatar_image, &path, avatar_cache);
} else { } else {
set_default_avatar(&avatar_image, &window); set_default_avatar(&avatar_image, &window, avatar_cache);
}
} }
// Username label // Username label
@@ -125,7 +137,7 @@ pub fn create_lockscreen_window(
// Password entry // Password entry
let password_entry = gtk::PasswordEntry::builder() let password_entry = gtk::PasswordEntry::builder()
.placeholder_text(strings.password_placeholder) .placeholder_text(strings.password_placeholder)
.show_peek_icon(true) .show_peek_icon(false)
.hexpand(true) .hexpand(true)
.build(); .build();
password_entry.add_css_class("password-entry"); password_entry.add_css_class("password-entry");
@@ -210,13 +222,19 @@ pub fn create_lockscreen_window(
overlay.add_overlay(&power_box); 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 username = user.username.clone();
let auth_generation = Rc::new(Cell::new(0u32));
password_entry.connect_activate(clone!( password_entry.connect_activate(clone!(
#[strong] #[strong]
state, state,
#[strong] #[strong]
unlock_callback, unlock_callback,
#[strong]
auth_generation,
#[weak] #[weak]
error_label, error_label,
#[weak] #[weak]
@@ -231,6 +249,35 @@ pub fn create_lockscreen_window(
let username = username.clone(); let username = username.clone();
let unlock_cb = unlock_callback.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!( glib::spawn_future_local(clone!(
#[strong] #[strong]
state, state,
@@ -244,6 +291,20 @@ pub fn create_lockscreen_window(
auth::authenticate(&user, &password) auth::authenticate(&user, &password)
}).await; }).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 { match result {
Ok(true) => { Ok(true) => {
let s = state.borrow(); let s = state.borrow();
@@ -361,13 +422,53 @@ pub fn start_fingerprint(
let fp_label_fail = handles.fp_label.clone(); let fp_label_fail = handles.fp_label.clone();
let unlock_cb_fp = handles.unlock_callback.clone(); 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 on_success = move || {
let label = fp_label_success.clone(); let label = fp_label_success.clone();
let cb = unlock_cb_fp.clone(); let cb = unlock_cb_fp.clone();
let fp = fp_rc_success.clone();
let username = fp_username.clone();
glib::idle_add_local_once(move || { glib::idle_add_local_once(move || {
label.set_text(load_strings(None).fingerprint_success); let strings = load_strings(None);
label.set_text(strings.fingerprint_success);
label.add_css_class("success"); label.add_css_class("success");
cb(); // 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();
// 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;
});
},
);
}
}
});
}); });
}; };
@@ -415,73 +516,142 @@ pub fn start_fingerprint(
} }
/// Load the wallpaper as a texture once, for sharing across all windows. /// Load the wallpaper as a texture once, for sharing across all windows.
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied. /// Returns None if no wallpaper path is provided or the file cannot be loaded.
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture { /// Blur is applied at render time via GPU (GskBlurNode), not here.
let fallback = "/dev/moonarch/moonlock/wallpaper.jpg"; pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
let texture = if bg_path.starts_with("/dev/moonarch/moonlock") {
let resource_path = bg_path.to_str().unwrap_or(fallback);
gdk::Texture::from_resource(resource_path)
} else {
let file = gio::File::for_path(bg_path); let file = gio::File::for_path(bg_path);
gdk::Texture::from_file(&file).unwrap_or_else(|_| { match gdk::Texture::from_file(&file) {
gdk::Texture::from_resource(fallback) Ok(texture) => Some(texture),
}) Err(e) => {
}; log::warn!("Failed to load wallpaper {}: {e}", bg_path.display());
None
match blur_radius { }
Some(sigma) if sigma > 0.0 => apply_blur(&texture, sigma),
_ => texture,
} }
} }
/// Apply Gaussian blur to a texture and return a blurred texture. /// Create a Picture widget for the wallpaper background.
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture { /// When `blur_radius` is `Some(sigma)` with sigma > 0, blur is applied via GPU
let width = texture.width() as u32; /// (GskBlurNode). The blur is rendered to a concrete texture on `realize` (when
let height = texture.height() as u32; /// the GPU renderer is available), avoiding lazy-render artifacts.
let stride = width as usize * 4; /// The `blur_cache` is shared across monitors — the first to realize renders the
let mut pixel_data = vec![0u8; stride * height as usize]; /// blur, subsequent monitors reuse the cached texture.
texture.download(&mut pixel_data, stride); fn create_background_picture(
texture: &gdk::Texture,
let img = image::RgbaImage::from_raw(width, height, pixel_data) blur_radius: Option<f32>,
.expect("pixel buffer size matches texture dimensions"); blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma); ) -> gtk::Picture {
let bytes = glib::Bytes::from(blurred.as_raw());
let mem_texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
gdk::MemoryFormat::B8g8r8a8Premultiplied,
&bytes,
stride,
);
mem_texture.upcast()
}
/// Create a Picture widget for the wallpaper background from a shared texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture); let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover); background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true); background.set_hexpand(true);
background.set_vexpand(true); background.set_vexpand(true);
if let Some(sigma) = blur_radius {
if sigma > 0.0 {
let texture = texture.clone();
let cache = blur_cache.clone();
background.connect_realize(move |picture| {
if let Some(ref cached) = *cache.borrow() {
picture.set_paintable(Some(cached));
return;
}
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
picture.set_paintable(Some(&blurred));
*cache.borrow_mut() = Some(blurred);
}
});
}
}
background background
} }
/// Load an image file and set it as the avatar. /// Maximum texture dimension for blur input. Textures larger than this are
fn set_avatar_from_file(image: &gtk::Image, path: &Path) { /// downscaled before blurring — the blur destroys detail anyway, so there is
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { /// 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,
sigma: f32,
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
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 = (scaled_sigma * 3.0).ceil();
let snapshot = gtk::Snapshot::new();
// Clip output to scaled texture size
snapshot.push_clip(&graphene::Rect::new(pad, pad, w, h));
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(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
snapshot.pop(); // blur
snapshot.pop(); // clip
let node = snapshot.to_node()?;
let viewport = graphene::Rect::new(pad, pad, w, h);
Some(renderer.render_texture(&node, Some(&viewport)))
}
/// Load an image file and set it as the avatar. Stores the texture in the cache.
fn set_avatar_from_file(
image: &gtk::Image,
path: &Path,
cache: &Rc<RefCell<Option<gdk::Texture>>>,
) {
let path_str = match path.to_str() {
Some(s) => s,
None => {
log::warn!("Avatar path is not valid UTF-8: {:?}", path);
image.set_icon_name(Some("avatar-default-symbolic"));
return;
}
};
match Pixbuf::from_file_at_scale(path_str, AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => { Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf); let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture)); image.set_paintable(Some(&texture));
*cache.borrow_mut() = Some(texture);
} }
Err(_) => { Err(e) => {
log::warn!("Failed to load avatar from {:?}: {e}", path);
image.set_icon_name(Some("avatar-default-symbolic")); image.set_icon_name(Some("avatar-default-symbolic"));
} }
} }
} }
/// Load the default avatar SVG from GResources, tinted with the foreground color. /// Load the default avatar SVG from GResources, tinted with the foreground color.
fn set_default_avatar(image: &gtk::Image, window: &gtk::ApplicationWindow) { /// Stores the texture in the cache for reuse on additional monitors.
fn set_default_avatar(
image: &gtk::Image,
window: &gtk::ApplicationWindow,
cache: &Rc<RefCell<Option<gdk::Texture>>>,
) {
let resource_path = users::get_default_avatar_path(); let resource_path = users::get_default_avatar_path();
if let Ok(bytes) = if let Ok(bytes) =
gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE)
@@ -503,6 +673,7 @@ fn set_default_avatar(image: &gtk::Image, window: &gtk::ApplicationWindow) {
if let Some(pixbuf) = loader.pixbuf() { if let Some(pixbuf) = loader.pixbuf() {
let texture = gdk::Texture::for_pixbuf(&pixbuf); let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture)); image.set_paintable(Some(&texture));
*cache.borrow_mut() = Some(texture);
return; return;
} }
} }
+50 -19
View File
@@ -13,7 +13,7 @@ use gdk4 as gdk;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_session_lock; use gtk4_session_lock;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
use crate::fingerprint::FingerprintListener; use crate::fingerprint::FingerprintListener;
@@ -24,7 +24,7 @@ fn load_css(display: &gdk::Display) {
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
display, display,
&css_provider, &css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk::STYLE_PROVIDER_PRIORITY_USER,
); );
} }
@@ -40,16 +40,14 @@ fn activate(app: &gtk::Application) {
load_css(&display); load_css(&display);
let config = config::load_config(None); let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let bg_texture = lockscreen::load_background_texture(&bg_path, config.background_blur);
if gtk4_session_lock::is_supported() { if gtk4_session_lock::is_supported() {
activate_with_session_lock(app, &display, &bg_texture, &config); activate_with_session_lock(app, &display, &config);
} else { } else {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {
log::warn!("ext-session-lock-v1 not supported — running in development mode"); log::warn!("ext-session-lock-v1 not supported — running in development mode");
activate_without_lock(app, &bg_texture, &config); activate_without_lock(app, &config);
} }
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
{ {
@@ -62,22 +60,37 @@ fn activate(app: &gtk::Application) {
fn activate_with_session_lock( fn activate_with_session_lock(
app: &gtk::Application, app: &gtk::Application,
display: &gdk::Display, display: &gdk::Display,
bg_texture: &gdk::Texture,
config: &config::Config, config: &config::Config,
) { ) {
let lock = gtk4_session_lock::Instance::new(); let lock = gtk4_session_lock::Instance::new();
lock.lock(); lock.lock();
// Load wallpaper AFTER lock — disk I/O must not delay the lock acquisition
let bg_texture = config::resolve_background_path(config)
.and_then(|path| lockscreen::load_background_texture(&path));
let monitors = display.monitors(); let monitors = display.monitors();
// Shared unlock callback — unlocks session and quits // Shared unlock callback — unlocks session and quits.
// Guard prevents double-unlock if PAM and fingerprint succeed simultaneously.
let lock_clone = lock.clone(); let lock_clone = lock.clone();
let app_clone = app.clone(); let app_clone = app.clone();
let already_unlocked = Rc::new(Cell::new(false));
let au = already_unlocked.clone();
let unlock_callback: Rc<dyn Fn()> = Rc::new(move || { let unlock_callback: Rc<dyn Fn()> = Rc::new(move || {
if au.get() {
log::debug!("Unlock already triggered, ignoring duplicate");
return;
}
au.set(true);
lock_clone.unlock(); lock_clone.unlock();
app_clone.quit(); app_clone.quit();
}); });
// Shared caches for multi-monitor — first monitor renders, rest reuse
let blur_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
let avatar_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
// Create all monitor windows immediately — no D-Bus calls here // Create all monitor windows immediately — no D-Bus calls here
let mut all_handles = Vec::new(); let mut all_handles = Vec::new();
let mut created_any = false; let mut created_any = false;
@@ -87,10 +100,12 @@ fn activate_with_session_lock(
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) .and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{ {
let handles = lockscreen::create_lockscreen_window( let handles = lockscreen::create_lockscreen_window(
bg_texture, bg_texture.as_ref(),
config, config,
app, app,
unlock_callback.clone(), unlock_callback.clone(),
&blur_cache,
&avatar_cache,
); );
lock.assign_window_to_monitor(&handles.window, &monitor); lock.assign_window_to_monitor(&handles.window, &monitor);
handles.window.present(); handles.window.present();
@@ -101,7 +116,7 @@ fn activate_with_session_lock(
if !created_any { if !created_any {
log::error!("No lockscreen windows created — screen stays locked (compositor policy)"); log::error!("No lockscreen windows created — screen stays locked (compositor policy)");
return; std::process::exit(1);
} }
// Async fprintd initialization — runs after windows are visible // Async fprintd initialization — runs after windows are visible
@@ -144,19 +159,25 @@ fn init_fingerprint_async(all_handles: Vec<lockscreen::LockscreenHandles>) {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
fn activate_without_lock( fn activate_without_lock(
app: &gtk::Application, app: &gtk::Application,
bg_texture: &gdk::Texture,
config: &config::Config, config: &config::Config,
) { ) {
let bg_texture = config::resolve_background_path(config)
.and_then(|path| lockscreen::load_background_texture(&path));
let app_clone = app.clone(); let app_clone = app.clone();
let unlock_callback: Rc<dyn Fn()> = Rc::new(move || { let unlock_callback: Rc<dyn Fn()> = Rc::new(move || {
app_clone.quit(); app_clone.quit();
}); });
let blur_cache = Rc::new(RefCell::new(None));
let avatar_cache = Rc::new(RefCell::new(None));
let handles = lockscreen::create_lockscreen_window( let handles = lockscreen::create_lockscreen_window(
bg_texture, bg_texture.as_ref(),
config, config,
app, app,
unlock_callback, unlock_callback,
&blur_cache,
&avatar_cache,
); );
handles.window.set_default_size(800, 600); handles.window.set_default_size(800, 600);
handles.window.present(); handles.window.present();
@@ -168,11 +189,22 @@ fn activate_without_lock(
} }
fn setup_logging() { fn setup_logging() {
systemd_journal_logger::JournalLog::new() match systemd_journal_logger::JournalLog::new() {
.unwrap() Ok(logger) => {
.install() if let Err(e) = logger.install() {
.unwrap(); eprintln!("Failed to install journal logger: {e}");
log::set_max_level(log::LevelFilter::Info); }
}
Err(e) => {
eprintln!("Failed to create journal logger: {e}");
}
}
let level = if std::env::var("MOONLOCK_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_max_level(level);
} }
fn install_panic_hook() { fn install_panic_hook() {
@@ -187,6 +219,7 @@ fn install_panic_hook() {
} }
fn main() { fn main() {
install_panic_hook();
setup_logging(); setup_logging();
// Root check — moonlock should not run as root // Root check — moonlock should not run as root
@@ -194,8 +227,6 @@ fn main() {
log::error!("Moonlock should not run as root"); log::error!("Moonlock should not run as root");
std::process::exit(1); std::process::exit(1);
} }
install_panic_hook();
log::info!("Moonlock starting"); log::info!("Moonlock starting");
// Register compiled GResources // Register compiled GResources
-3
View File
@@ -7,14 +7,12 @@ use std::process::Command;
#[derive(Debug)] #[derive(Debug)]
pub enum PowerError { pub enum PowerError {
CommandFailed { action: &'static str, message: String }, CommandFailed { action: &'static str, message: String },
Timeout { action: &'static str },
} }
impl fmt::Display for PowerError { impl fmt::Display for PowerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
PowerError::CommandFailed { action, message } => write!(f, "{action} failed: {message}"), PowerError::CommandFailed { action, message } => write!(f, "{action} failed: {message}"),
PowerError::Timeout { action } => write!(f, "{action} timed out"),
} }
} }
} }
@@ -44,7 +42,6 @@ mod tests {
use super::*; use super::*;
#[test] fn power_error_display() { assert_eq!(PowerError::CommandFailed { action: "reboot", message: "fail".into() }.to_string(), "reboot failed: fail"); } #[test] fn power_error_display() { assert_eq!(PowerError::CommandFailed { action: "reboot", message: "fail".into() }.to_string(), "reboot failed: fail"); }
#[test] fn timeout_display() { assert_eq!(PowerError::Timeout { action: "shutdown" }.to_string(), "shutdown timed out"); }
#[test] fn missing_binary() { assert!(run_command("test", "nonexistent-xyz", &[]).is_err()); } #[test] fn missing_binary() { assert!(run_command("test", "nonexistent-xyz", &[]).is_err()); }
#[test] fn nonzero_exit() { assert!(run_command("test", "false", &[]).is_err()); } #[test] fn nonzero_exit() { assert!(run_command("test", "false", &[]).is_err()); }
#[test] fn success() { assert!(run_command("test", "true", &[]).is_ok()); } #[test] fn success() { assert!(run_command("test", "true", &[]).is_ok()); }
+14 -4
View File
@@ -18,7 +18,13 @@ pub struct User {
pub fn get_current_user() -> Option<User> { pub fn get_current_user() -> Option<User> {
let uid = getuid(); let uid = getuid();
let nix_user = NixUser::from_uid(uid).ok()??; let nix_user = NixUser::from_uid(uid).ok()??;
let gecos = nix_user.gecos.to_str().unwrap_or("").to_string(); let gecos = match nix_user.gecos.to_str() {
Ok(s) => s.to_string(),
Err(_) => {
log::warn!("GECOS field is not valid UTF-8, falling back to username");
String::new()
}
};
let display_name = if !gecos.is_empty() { let display_name = if !gecos.is_empty() {
let first = gecos.split(',').next().unwrap_or(""); let first = gecos.split(',').next().unwrap_or("");
if first.is_empty() { nix_user.name.clone() } else { first.to_string() } if first.is_empty() { nix_user.name.clone() } else { first.to_string() }
@@ -31,13 +37,17 @@ pub fn get_avatar_path(home: &Path, username: &str) -> Option<PathBuf> {
} }
pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option<PathBuf> { pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option<PathBuf> {
// ~/.face takes priority // ~/.face takes priority — single stat via symlink_metadata to avoid TOCTOU
let face = home.join(".face"); let face = home.join(".face");
if face.exists() && !face.is_symlink() { return Some(face); } if let Ok(meta) = face.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() { return Some(face); }
}
// AccountsService icon // AccountsService icon
if accountsservice_dir.exists() { if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username); let icon = accountsservice_dir.join(username);
if icon.exists() && !icon.is_symlink() { return Some(icon); } if let Ok(meta) = icon.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() { return Some(icon); }
}
} }
None None
} }