Compare commits

..

22 Commits
v0.5.0 ... main

Author SHA1 Message Date
3e610bdb4b fix: audit LOW fixes — docs, rustdoc, scope, debug gate, lto fat (v0.6.12)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
- Update CLAUDE.md and README.md to reflect the blur range [0,200] that
  the code has clamped to since v0.6.8.
- Move the // SYNC: comment above the /// doc on MAX_BLUR_DIMENSION so
  rustdoc renders one coherent paragraph instead of a truncated sentence.
- Narrow check_account visibility to pub(crate) and document the caller
  precondition (username must come from users::get_current_user()).
- Gate MOONLOCK_DEBUG behind #[cfg(debug_assertions)]. Release builds
  always run at LevelFilter::Info so a session script cannot escalate
  journal verbosity to leak fprintd / D-Bus internals.
- Document why pam_setcred is deliberately not called in authenticate().
- Release profile: lto = "fat" instead of "thin" — doubles release build
  time for better cross-crate inlining on the auth + i18n hot paths.
2026-04-24 14:05:17 +02:00
9dfd1829e9 fix: audit MEDIUM fixes — D-Bus race, TOCTOU, FP reset, entry clear (v0.6.11)
- fingerprint: split cleanup_dbus into a sync take_cleanup_proxy() + async
  perform_dbus_cleanup(). resume_async now awaits VerifyStop+Release before
  re-claiming, so fprintd cannot reject the Claim on a slow bus. stop()
  still spawns the cleanup fire-and-forget.
- fingerprint: remove failed_attempts = 0 from resume_async. An attacker
  with sensor control could otherwise cycle verify-match → account-fail →
  resume and never trip the 10-attempt cap.
- lockscreen: open the wallpaper with O_NOFOLLOW and build the texture
  from bytes, closing the TOCTOU between the symlink check and Texture::
  from_file.
- lockscreen: clear password_entry immediately after extracting the
  Zeroizing<String>, shortening the window the GLib GString copy stays in
  libc-malloc'd memory.
2026-04-24 13:21:19 +02:00
39d9cbb624 fix: audit fixes — RefCell across await, async avatar decode (v0.6.10)
- init_fingerprint_async: hoist username before the await so a concurrent
  connect_monitor signal (hotplug / suspend-resume) cannot cause a RefCell
  panic. Re-borrow after the await for signal wiring.
- set_avatar_from_file: decode via gio::File::read_future +
  Pixbuf::from_stream_at_scale_future so the GTK main thread stays
  responsive during monitor hotplug. Default icon shown while loading.
2026-04-24 12:34:00 +02:00
3adc5e980d docs: drop Nyx persona, unify attribution on ClaudeCode
Remove the Nyx persona block from CLAUDE.md and rewrite prior
DECISIONS entries from Nyx and leftover Ragnar to ClaudeCode for
consistency with the rest of the ecosystem.
2026-04-21 09:03:23 +02:00
3f4448c641 style: replace hardcoded colors with GTK theme variables
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
Use @theme_bg_color, @theme_fg_color, @error_color and @success_color
instead of hardcoded hex values and 'white'. Makes moonlock respect the
active GTK theme instead of assuming Catppuccin Mocha colors.

Note: moongreet and moonset still use hardcoded colors and should be
updated to match.
2026-04-09 14:51:29 +02:00
b621b4e9fe fix: handle monitor hotplug to survive suspend/resume (v0.6.9)
moonlock crashed with segfault in libgtk-4.so after suspend/resume when
HDMI monitors disconnected and reconnected, invalidating GDK monitor
objects that statically created windows still referenced.

Replace manual monitor iteration with connect_monitor signal (v1_2) that
fires both at lock time and on hotplug. Windows are now created on demand
per monitor event and auto-unmap when their monitor disappears.
2026-04-09 14:48:06 +02:00
b89435b810 Remove unnecessary pacman git install from CI workflow
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
Git is already available in the runner image.
2026-04-02 08:28:08 +02:00
06dadc5cbf Revert CI workaround: remove pacman install step
Some checks failed
Update PKGBUILD version / update-pkgver (push) Failing after 0s
The act_runner now uses a custom Arch-based image with git
pre-installed, so per-workflow installs are no longer needed.
2026-04-01 16:17:45 +02:00
2a9cc52223 fix: audit fixes — peek icon, blur limit, GResource compression, sync markers (v0.6.8)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Enable peek icon on password entry (consistent with moongreet)
- Raise blur limit from 100 to 200 (consistent with moongreet/moonset)
- Add compressed="true" to GResource CSS/SVG entries
- Add SYNC comments to duplicated blur/background functions
2026-03-31 11:08:36 +02:00
102520d15f docs: update README features and fix build.rs comment
README was missing features added since v0.6.1 (GPU blur, journal
logging, lock-first architecture, PAM timeout, fprintd sender
validation, progressive faillock). build.rs comment still referenced
removed wallpaper.jpg.
2026-03-31 09:34:06 +02:00
59c509dcbb fix: audit fixes — fprintd path validation, progressive faillock warning (v0.6.7)
All checks were successful
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
af5b7c8912 ci: also update .SRCINFO in pkgver workflow
All checks were successful
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
1d8921abee fix: audit fixes — blur offset, lock-before-IO, FP signal lifecycle, TOCTOU (v0.6.6)
All checks were successful
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
65ea523b36 fix: audit fixes — CString zeroize, FP account check, PAM timeout, blur downscale (v0.6.5)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 1s
Address findings from second triple audit (quality, performance, security):

- Wrap PAM CString password in Zeroizing<CString> to wipe on drop (S-H1)
- Add check_account() for pam_acct_mgmt after fingerprint unlock,
  with resume_async() to restart FP on transient failure (S-M1)
- 30s PAM timeout with generation counter to prevent stale result
  interference from parallel auth attempts (S-M3)
- Downscale wallpaper to max 1920px before GPU blur, reducing work
  by ~4x on 4K wallpapers (P-M1)
- exit(1) instead of return on no-monitor after lock.lock() (Q-2.1)
2026-03-30 00:24:43 +02:00
465a19811a ci: add workflow to auto-update pkgver in moonarch-pkgbuilds
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 1s
2026-03-29 23:05:09 +02:00
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
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
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
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
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
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
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
18 changed files with 903 additions and 381 deletions

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

View File

@ -1,7 +1,5 @@
# Moonlock # Moonlock
**Name**: Nyx (Göttin der Nacht — passend zum Lockscreen, der den Bildschirm verdunkelt)
## Projekt ## Projekt
Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Rust + gtk4-rs + ext-session-lock-v1. Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Rust + gtk4-rs + ext-session-lock-v1.
@ -19,7 +17,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 +35,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,200], 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), Monitor-Hotplug via `connect_monitor`-Signal (v1_2), shared Blur/Avatar-Caches in Rc, systemd-Journal-Logging, Debug-Level per `MOONLOCK_DEBUG` Env-Var, async fprintd-Init nach window.present()
## 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 vor lock() geladen — connect_monitor feuert während lock() und braucht die Textur; lokales JPEG-Laden ist schnell genug
- 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
- Peek-Icon am Passwortfeld aktiv (UX-Entscheidung, konsistent mit moongreet)
- GResource-Bundle: CSS/Assets in der Binary kompiliert - GResource-Bundle: CSS/Assets in der Binary kompiliert

134
Cargo.lock generated
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.12"
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",
]

View File

@ -1,23 +1,23 @@
[package] [package]
name = "moonlock" name = "moonlock"
version = "0.5.0" version = "0.6.12"
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"
[dependencies] [dependencies]
gtk4 = { version = "0.11", features = ["v4_10"] } gtk4 = { version = "0.11", features = ["v4_10"] }
gtk4-session-lock = { version = "0.4", features = ["v1_1"] } gtk4-session-lock = { version = "0.4", features = ["v1_2"] }
glib = "0.22" glib = "0.22"
gdk4 = "0.11" gdk4 = "0.11"
gdk-pixbuf = "0.22" 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 = "fat"
codegen-units = 1
strip = true

View File

@ -2,16 +2,86 @@
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-04-24 Audit LOW fixes: docs, rustdoc, check_account scope, debug gating, lto fat (v0.6.12)
- **Who**: Nyx, Dom - **Who**: ClaudeCode, Dom
- **Why**: Six LOW findings cleared in a single pass. (1) Docs referenced the old `[0,100]` blur range; code clamps `[0,200]` since v0.6.8. (2) The `MAX_BLUR_DIMENSION` doc comment was split by a `// SYNC:` block, producing a truncated sentence in rustdoc. (3) `check_account` was `pub` and relied on callers only ever passing `getuid()`-derived usernames; the contract was not enforced by the type system. (4) `MOONLOCK_DEBUG` env var flipped log verbosity to Debug in release builds, letting a compromised session script escalate journal noise about fprintd / D-Bus. (5) `pam_setcred` absence was undocumented. (6) `[profile.release]` used `lto = "thin"` — fine for most crates, but for a latency-critical auth binary compiled once per release, fat LTO's extra cross-crate inlining is worth the ~1 min build hit.
- **Tradeoffs**: `lto = "fat"` roughly doubles release build time (~30 s → ~60 s) for slightly better inlining across PAM FFI wrappers and the i18n/status paths. `#[cfg(debug_assertions)]` on the debug-level selector means you have to run a debug build to raise log level — inconvenient for live troubleshooting, but aligned with the security-first posture.
- **How**: (1) `CLAUDE.md` + `README.md` updated to `[0,200]`. (2) `// SYNC:` block moved above the `///` doc so rustdoc renders one coherent paragraph. (3) `check_account` visibility narrowed to `pub(crate)` with a `Precondition` paragraph explaining the username contract. (4) Debug-level selection wrapped in `#[cfg(debug_assertions)]`; release builds always run at `LevelFilter::Info`. (5) Added a comment block in `authenticate()` documenting why `pam_setcred` is deliberately absent and where it would hook in if needed. (6) `lto = "fat"` in `Cargo.toml`.
## 2026-04-24 Audit MEDIUM fixes: D-Bus cleanup race, TOCTOU open, FP reset, GTK entry clear (v0.6.11)
- **Who**: ClaudeCode, Dom
- **Why**: Second round after the HIGH fixes, addressing the four MEDIUM findings. (1) `cleanup_dbus` spawned VerifyStop + Release as fire-and-forget, then `resume_async` called Claim after only a 2 s timeout — shorter than the 3 s D-Bus timeout, so on a slow bus the Claim could race the Release and fprintd would reject it, leaving the FP listener permanently dead. (2) `load_background_texture` relied on the caller's `symlink_metadata` check, re-opening the path via `gdk::Texture::from_file` — a classic TOCTOU window. (3) `resume_async` unconditionally reset `failed_attempts`, allowing an attacker with sensor control to evade the 10-attempt cap by cycling verify-match → `check_account` fail → resume. (4) The GTK `PasswordEntry` buffer was only cleared on timeout or auth failure, leaving the password in GLib malloc'd memory longer than necessary.
- **Tradeoffs**: The D-Bus cleanup is now split into a synchronous helper (`take_cleanup_proxy` — signal disconnect + flag clear) and an async helper (`perform_dbus_cleanup` — VerifyStop + Release), so `resume_async` can await the release while `stop()` stays fire-and-forget. Dropping the `failed_attempts` reset means a flaky sensor could reach 10 failures faster, but the correct remedy is a new lock session (construction) rather than a reset that also helps attackers.
- **How**: (1) Split `cleanup_dbus` into `take_cleanup_proxy()` (sync) + `perform_dbus_cleanup(proxy)` (async). `resume_async` now awaits `perform_dbus_cleanup` before `begin_verification`. `stop()` still spawns the cleanup fire-and-forget. (2) `load_background_texture` opens with `O_NOFOLLOW` via `std::fs::OpenOptions::custom_flags`, reads to bytes, and builds the texture via `gdk::Texture::from_bytes`. (3) Removed `listener.borrow_mut().failed_attempts = 0` from `resume_async`. (4) `password_entry.set_text("")` now fires right after the `Zeroizing::new(entry.text().to_string())` extraction, shortening the GTK-side window.
## 2026-04-24 Audit fixes: RefCell borrow across await, async avatar decode
- **Who**: ClaudeCode, Dom
- **Why**: Triple audit found two HIGH issues. (1) `init_fingerprint_async` held a `RefCell` immutable borrow across `is_available_async().await` — a concurrent `connect_monitor` signal (hotplug / suspend-resume) invoking `borrow_mut()` during the await would panic. (2) `set_avatar_from_file` decoded avatars synchronously via `Pixbuf::from_file_at_scale`, blocking the GTK main thread inside the `connect_monitor` handler. With `MAX_AVATAR_FILE_SIZE` at 10 MB the worst-case stall was 200500 ms on monitor hotplug.
- **Tradeoffs**: Avatar is shown as the symbolic default icon for a brief window while decoding completes. Wallpaper stays synchronous because `connect_monitor` fires during `lock()` and needs the texture already present (see 2026-04-09).
- **How**: (1) Extract `username` into a local `String` in `init_fingerprint_async`, drop the borrow before the await, re-borrow in a new scope after — no awaits inside the second borrow, so hotplug during signal setup is safe. (2) `set_avatar_from_file` now uses `gio::File::read_future` + `Pixbuf::from_stream_at_scale_future` for async I/O and decode. The default icon is shown immediately; the decoded texture replaces it when ready. `Pixbuf` itself is `!Send`, so `gio::spawn_blocking` does not apply — the GIO async stream loader keeps the `Pixbuf` on the main thread while the kernel does the I/O asynchronously.
## 2026-04-09 Monitor hotplug via connect_monitor signal
- **Who**: ClaudeCode, Dom
- **Why**: moonlock crashed with segfault in libgtk-4.so after suspend/resume — HDMI monitor disconnect/reconnect invalidated GDK monitor objects, and the statically created windows referenced destroyed surfaces. Crash at consistent GTK4 offset (0x278 NULL dereference), 3x reproduced.
- **Tradeoffs**: Wallpaper texture now loaded before `lock()` instead of after (connect_monitor fires during lock() and needs the texture). Local JPEG loading is fast enough that the delay is negligible. Shared state moved to Rc's for the signal closure — slightly more indirection but necessary for dynamic window creation.
- **How**: (1) Bump gtk4-session-lock feature from `v1_1` to `v1_2` to enable `Instance::connect_monitor`. (2) Replace manual monitor iteration with `lock.connect_monitor()` signal handler that creates windows on demand. (3) Signal fires once per existing monitor at `lock()` and again on hotplug. (4) Windows auto-unmap when their monitor disappears (ext-session-lock-v1 guarantee). (5) Fingerprint listener published to shared Rc so hotplugged monitors get FP labels.
## 2026-03-31 Fourth audit: peek icon, blur limit, GResource compression, sync markers
- **Who**: ClaudeCode, Dom
- **Why**: Fourth triple audit found blur limit inconsistency (moonlock 0100 vs moongreet/moonset 0200), missing GResource compression, peek icon inconsistency, and duplicated code without sync markers.
- **Tradeoffs**: Peek icon enabled in lockscreen — user decision favoring UX consistency over shoulder-surfing protection. Acceptable for single-user desktop. Blur limit raised to 200 for ecosystem consistency.
- **How**: (1) `show_peek_icon(true)` in lockscreen password entry. (2) `clamp(0.0, 200.0)` for blur in config.rs. (3) `compressed="true"` on CSS/SVG GResource entries. (4) SYNC comments on duplicated blur/background functions pointing to moongreet and moonset.
## 2026-03-30 Third audit: blur offset, lock-before-IO, FP signal lifecycle, TOCTOU
- **Who**: ClaudeCode, 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**: ClaudeCode, 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**: ClaudeCode, 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**: ClaudeCode, 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**: ClaudeCode, 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**: ClaudeCode, 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
- **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors. - **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors.
- **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns `gdk::Texture`. Config option `background_blur: Option<f32>` in TOML. - **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns `gdk::Texture`. Config option `background_blur: Option<f32>` in TOML.
## 2026-03-28 Shared wallpaper texture pattern (aligned with moonset/moongreet) ## 2026-03-28 Shared wallpaper texture pattern (aligned with moonset/moongreet)
- **Who**: Nyx, Dom - **Who**: ClaudeCode, Dom
- **Why**: Previously loaded wallpaper per-window via `Picture::for_filename()`. Multi-monitor setups decoded the JPEG redundantly. Blur feature requires texture pixel access anyway. - **Why**: Previously loaded wallpaper per-window via `Picture::for_filename()`. Multi-monitor setups decoded the JPEG redundantly. Blur feature requires texture pixel access anyway.
- **Tradeoffs**: Slightly more code in main.rs (texture loaded before window creation), but avoids redundant decoding and enables the blur feature. - **Tradeoffs**: Slightly more code in main.rs (texture loaded before window creation), but avoids redundant decoding and enables the blur feature.
- **How**: `load_background_texture()` in lockscreen.rs decodes once, `create_background_picture()` wraps shared `gdk::Texture` in `gtk::Picture`. Same pattern as moonset/moongreet. - **How**: `load_background_texture()` in lockscreen.rs decodes once, `create_background_picture()` wraps shared `gdk::Texture` in `gtk::Picture`. Same pattern as moonset/moongreet.

View File

@ -5,14 +5,16 @@ Part of the Moonarch ecosystem.
## Features ## Features
- **ext-session-lock-v1** — Protocol-guaranteed screen locking (compositor keeps screen locked on crash) - **ext-session-lock-v1** — Protocol-guaranteed screen locking (compositor keeps screen locked on crash, `exit(1)` in release if unsupported)
- **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`) - **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`) with 30s timeout and generation counter
- **Fingerprint unlock** — fprintd D-Bus integration, async init (optional, window appears instantly) - **Fingerprint unlock** — fprintd D-Bus integration with sender validation, async init (window appears instantly), `pam_acct_mgmt` check after verify, auto-resume on transient errors
- **Multi-monitor** — Lockscreen on every monitor, single shared fingerprint listener - **Multi-monitor + hotplug** — Lockscreen on every monitor with shared blur and avatar caches; monitors added after suspend/resume get windows automatically via `connect_monitor` signal
- **GPU blur** — Background blur via GskBlurNode (downscale to max 1920px, configurable 0200)
- **i18n** — German and English (auto-detected) - **i18n** — German and English (auto-detected)
- **Faillock warning** — UI counter + system pam_faillock - **Faillock warning** — Progressive UI warning after failed attempts, PAM decides lockout
- **Panic safety** — Panic hook logs but never unlocks - **Panic safety** — Panic hook logs but never unlocks (installed before logging)
- **Password wiping** — Zeroize on drop - **Password wiping**`Zeroize` on drop from GTK entry through PAM FFI layer
- **Journal logging**`journalctl -t moonlock`, debug level via `MOONLOCK_DEBUG` env var
## Requirements ## Requirements
@ -46,6 +48,7 @@ Create `/etc/moonlock/moonlock.toml` or `~/.config/moonlock/moonlock.toml`:
```toml ```toml
background_path = "/usr/share/wallpapers/moon.jpg" background_path = "/usr/share/wallpapers/moon.jpg"
background_blur = 40.0 # 0.0200.0, optional
fingerprint_enabled = true fingerprint_enabled = true
``` ```

View File

@ -1,5 +1,5 @@
// ABOUTME: Build script for compiling GResource bundle. // ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary. // ABOUTME: Bundles style.css and default-avatar.svg into the binary.
fn main() { fn main() {
glib_build_tools::compile_resources( glib_build_tools::compile_resources(

View File

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moonlock"> <gresource prefix="/dev/moonarch/moonlock">
<file>style.css</file> <file compressed="true">style.css</file>
<file>wallpaper.jpg</file> <file compressed="true">default-avatar.svg</file>
<file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -1,9 +1,9 @@
/* ABOUTME: GTK4 CSS stylesheet for the Moonlock lockscreen. */ /* ABOUTME: GTK4 CSS stylesheet for the Moonlock lockscreen. */
/* ABOUTME: Dark theme styling matching the Moonarch ecosystem. */ /* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
/* Main window background */ /* Main window background */
window.lockscreen { window.lockscreen {
background-color: #1a1a2e; background-color: @theme_bg_color;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
opacity: 0; opacity: 0;
@ -23,18 +23,18 @@ 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;
border: 3px solid alpha(white, 0.3); border: 3px solid alpha(@theme_fg_color, 0.3);
} }
/* Username label */ /* Username label */
.username-label { .username-label {
font-size: 24px; font-size: 24px;
font-weight: bold; font-weight: bold;
color: white; color: @theme_fg_color;
margin-top: 12px; margin-top: 12px;
margin-bottom: 40px; margin-bottom: 40px;
} }
@ -46,29 +46,29 @@ window.lockscreen.visible {
/* Error message label */ /* Error message label */
.error-label { .error-label {
color: #ff6b6b; color: @error_color;
font-size: 14px; font-size: 14px;
} }
/* Fingerprint status indicator */ /* Fingerprint status indicator */
.fingerprint-label { .fingerprint-label {
color: alpha(white, 0.6); color: alpha(@theme_fg_color, 0.6);
font-size: 13px; font-size: 13px;
margin-top: 8px; margin-top: 8px;
} }
.fingerprint-label.success { .fingerprint-label.success {
color: #51cf66; color: @success_color;
} }
.fingerprint-label.failed { .fingerprint-label.failed {
color: #ff6b6b; color: @error_color;
} }
/* Confirmation prompt */ /* Confirmation prompt */
.confirm-label { .confirm-label {
font-size: 16px; font-size: 16px;
color: white; color: @theme_fg_color;
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -103,12 +103,12 @@ window.lockscreen.visible {
min-height: 48px; min-height: 48px;
padding: 0px; padding: 0px;
border-radius: 24px; border-radius: 24px;
background-color: alpha(white, 0.1); background-color: alpha(@theme_fg_color, 0.1);
color: white; color: @theme_fg_color;
border: none; border: none;
margin: 4px; margin: 4px;
} }
.power-button:hover { .power-button:hover {
background-color: alpha(white, 0.25); background-color: alpha(@theme_fg_color, 0.25);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

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();
@ -187,7 +191,12 @@ pub fn authenticate(username: &str, password: &str) -> bool {
return false; return false;
} }
// Safety: handle is valid and non-null after successful pam_start // Safety: handle is valid and non-null after successful pam_start.
// Note: pam_setcred is intentionally NOT called here. A lockscreen unlocks
// an existing session whose credentials were already established at login;
// refreshing them would duplicate work done by the session's login manager.
// If per-unlock credential refresh (Kerberos tickets, pam_gnome_keyring)
// is ever desired, hook it here with PAM_ESTABLISH_CRED.
let auth_ret = unsafe { pam_authenticate(handle, 0) }; let auth_ret = unsafe { pam_authenticate(handle, 0) };
let acct_ret = if auth_ret == PAM_SUCCESS { let acct_ret = if auth_ret == PAM_SUCCESS {
// Safety: handle is valid, check account restrictions // Safety: handle is valid, check account restrictions
@ -202,6 +211,56 @@ 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.
///
/// **Precondition**: `username` must be the authenticated system user, derived
/// via `users::get_current_user()` (which reads `getuid()`). Calling this with
/// an attacker-controlled username is unsafe — `pam_acct_mgmt` returns SUCCESS
/// for any valid unlocked account, giving a trivial unlock bypass.
pub(crate) 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 +295,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);
}
} }

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, 200.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());
} }
} }

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,55 @@ 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. Awaits any in-flight VerifyStop +
/// Release before re-claiming the device so fprintd does not reject the Claim
/// while the previous session is still being torn down.
pub async fn resume_async(
listener: &Rc<RefCell<FingerprintListener>>,
username: &str,
) {
// Drain in-flight cleanup so the device is actually released before Claim.
// Without this, a fast resume after on_verify_status's fire-and-forget
// cleanup races the Release call and fprintd returns "already claimed".
let proxy = listener.borrow_mut().take_cleanup_proxy();
if let Some(proxy) = proxy {
Self::perform_dbus_cleanup(proxy).await;
}
// Deliberately do NOT reset failed_attempts here. An attacker with sensor
// control could otherwise cycle verify-match → check_account fail → resume,
// and the 10-attempt cap would never trigger. The counter decays only via
// a fresh lock session (listener construction).
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 +230,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 +290,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 +334,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,33 +364,46 @@ impl FingerprintListener {
} }
} }
/// Stop listening and release the device. /// Disconnect the signal handler and clear running flags. Returns the proxy
/// Uses a short timeout (3s) to avoid blocking the UI indefinitely. /// the caller should use for the async D-Bus cleanup (VerifyStop + Release).
///
/// Split into a sync part (signal disconnect, flags) and an async part
/// (`perform_dbus_cleanup`) so callers can either spawn the async work
/// fire-and-forget (via `cleanup_dbus`) or await it to serialize with a
/// subsequent Claim (via `resume_async`).
fn take_cleanup_proxy(&mut self) -> Option<gio::DBusProxy> {
self.running = false;
self.running_flag.set(false);
let proxy = self.device_proxy.clone()?;
if let Some(id) = self.signal_id.take() {
proxy.disconnect(id);
}
Some(proxy)
}
async fn perform_dbus_cleanup(proxy: gio::DBusProxy) {
let _ = proxy
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await;
let _ = proxy
.call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await;
}
/// Fire-and-forget cleanup for code paths that cannot await (e.g. drop, stop).
fn cleanup_dbus(&mut self) {
if let Some(proxy) = self.take_cleanup_proxy() {
glib::spawn_future_local(Self::perform_dbus_cleanup(proxy));
}
}
/// Stop listening and release the device. Idempotent — safe to call multiple times.
pub fn stop(&mut self) { pub fn stop(&mut self) {
if !self.running { if !self.running {
return; return;
} }
self.running = false; self.cleanup_dbus();
if let Some(ref proxy) = self.device_proxy {
if let Some(id) = self.signal_id.take() {
proxy.disconnect(id);
}
let _ = proxy.call_sync(
"VerifyStop",
None,
gio::DBusCallFlags::NONE,
3000,
gio::Cancellable::NONE,
);
let _ = proxy.call_sync(
"Release",
None,
gio::DBusCallFlags::NONE,
3000,
gio::Cancellable::NONE,
);
}
} }
} }
@ -339,36 +427,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 +475,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();

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
}
} }

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
@ -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]
@ -226,11 +244,44 @@ pub fn create_lockscreen_window(
if password.is_empty() { if password.is_empty() {
return; return;
} }
// Clear the GTK entry's internal buffer as early as possible. GTK allocates
// the backing GString via libc malloc, which zeroize cannot reach — the
// best we can do is shorten the window during which it resides in memory.
entry.set_text("");
entry.set_sensitive(false); entry.set_sensitive(false);
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 +295,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 +426,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 +520,180 @@ 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"; ///
/// Opens the file with O_NOFOLLOW to close the TOCTOU window between the
/// symlink check in `resolve_background_path_with` and this read. If the path
/// was swapped for a symlink after the check, `open` fails with ELOOP.
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let texture = if bg_path.starts_with("/dev/moonarch/moonlock") { let mut file = match std::fs::OpenOptions::new()
let resource_path = bg_path.to_str().unwrap_or(fallback); .read(true)
gdk::Texture::from_resource(resource_path) .custom_flags(libc::O_NOFOLLOW)
} else { .open(bg_path)
let file = gio::File::for_path(bg_path); {
gdk::Texture::from_file(&file).unwrap_or_else(|_| { Ok(f) => f,
gdk::Texture::from_resource(fallback) Err(e) => {
}) log::warn!("Failed to open wallpaper {}: {e}", bg_path.display());
return None;
}
}; };
let mut bytes = Vec::new();
match blur_radius { if let Err(e) = file.read_to_end(&mut bytes) {
Some(sigma) if sigma > 0.0 => apply_blur(&texture, sigma), log::warn!("Failed to read wallpaper {}: {e}", bg_path.display());
_ => texture, return None;
}
let glib_bytes = glib::Bytes::from_owned(bytes);
match gdk::Texture::from_bytes(&glib_bytes) {
Ok(texture) => Some(texture),
Err(e) => {
log::warn!("Failed to decode wallpaper {}: {e}", bg_path.display());
None
}
} }
} }
/// 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. // SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
fn set_avatar_from_file(image: &gtk::Image, path: &Path) { // are duplicated in moongreet/src/greeter.rs and moonset/src/panel.rs.
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { // Changes here must be mirrored to the other two projects.
/// Maximum texture dimension for blur input. Textures larger than this are
/// downscaled before blurring — the blur destroys detail anyway, so there is
/// no visible quality loss, but GPU work is reduced significantly.
const MAX_BLUR_DIMENSION: f32 = 1920.0;
/// Render a blurred texture using the widget's GPU renderer.
/// Returns None if the renderer is not available.
///
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
/// is applied to the padded area, then cropped back to the original size.
///
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
/// reduce GPU work. The sigma is scaled proportionally.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
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.
/// Decoding runs via GIO async I/O + async pixbuf stream loader so the GTK main
/// loop stays responsive — avatars may be loaded inside the `connect_monitor`
/// signal handler at hotplug time, which must not block. The fallback icon is
/// shown immediately; the decoded texture replaces it when ready.
fn set_avatar_from_file(
image: &gtk::Image,
path: &Path,
cache: &Rc<RefCell<Option<gdk::Texture>>>,
) {
image.set_icon_name(Some("avatar-default-symbolic"));
let display_path = path.to_path_buf();
let file = gio::File::for_path(path);
let image_clone = image.clone();
let cache_clone = cache.clone();
glib::spawn_future_local(async move {
let stream = match file.read_future(glib::Priority::default()).await {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to open avatar {}: {e}", display_path.display());
return;
}
};
match Pixbuf::from_stream_at_scale_future(&stream, AVATAR_SIZE, AVATAR_SIZE, true).await {
Ok(pixbuf) => { Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf); let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture)); image_clone.set_paintable(Some(&texture));
*cache_clone.borrow_mut() = Some(texture);
} }
Err(_) => { Err(e) => {
image.set_icon_name(Some("avatar-default-symbolic")); log::warn!("Failed to decode avatar from {}: {e}", display_path.display());
} }
} }
});
} }
/// 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 +715,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;
} }
} }

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))]
{ {
@ -61,118 +59,209 @@ 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();
let monitors = display.monitors(); // Load wallpaper before lock — connect_monitor fires during lock() and needs the
// texture. This means disk I/O happens before locking, but loading a local JPEG
// is fast enough that the delay is negligible.
let bg_texture: Rc<Option<gdk::Texture>> = Rc::new(
config::resolve_background_path(config)
.and_then(|path| lockscreen::load_background_texture(&path)),
);
// 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();
}); });
// Create all monitor windows immediately — no D-Bus calls here // Shared caches for multi-monitor — first monitor renders, rest reuse
let mut all_handles = Vec::new(); let blur_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
let mut created_any = false; let avatar_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors // Shared config for use in the monitor signal handler
.item(i) let config = Rc::new(config.clone());
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{ // Shared handles list — populated by connect_monitor, read by fingerprint init
let handles = lockscreen::create_lockscreen_window( let all_handles: Rc<RefCell<Vec<lockscreen::LockscreenHandles>>> =
bg_texture, Rc::new(RefCell::new(Vec::new()));
config,
// Shared fingerprint listener — None until async init completes.
// The monitor handler checks this to wire up FP labels on hotplugged monitors.
let shared_fp: Rc<RefCell<Option<Rc<RefCell<FingerprintListener>>>>> =
Rc::new(RefCell::new(None));
// The ::monitor signal fires once per existing monitor at lock(), and again
// whenever a monitor is hotplugged (e.g. after suspend/resume). This replaces
// the old manual monitor iteration and handles hotplug automatically.
let lock_for_signal = lock.clone();
lock.connect_monitor(glib::clone!(
#[strong]
app, app,
#[strong]
config,
#[strong]
bg_texture,
#[strong]
unlock_callback,
#[strong]
blur_cache,
#[strong]
avatar_cache,
#[strong]
all_handles,
#[strong]
shared_fp,
move |_instance, monitor| {
log::debug!("Monitor signal: creating lockscreen window");
let handles = lockscreen::create_lockscreen_window(
bg_texture.as_ref().as_ref(),
&config,
&app,
unlock_callback.clone(), unlock_callback.clone(),
&blur_cache,
&avatar_cache,
); );
lock.assign_window_to_monitor(&handles.window, &monitor); lock_for_signal.assign_window_to_monitor(&handles.window, monitor);
handles.window.present(); handles.window.present();
all_handles.push(handles);
created_any = true; // If fingerprint is already initialized, wire up the label
} if let Some(ref fp_rc) = *shared_fp.borrow() {
lockscreen::show_fingerprint_label(&handles, fp_rc);
} }
if !created_any { all_handles.borrow_mut().push(handles);
log::error!("No lockscreen windows created — screen stays locked (compositor policy)");
return;
} }
));
lock.lock();
// Async fprintd initialization — runs after windows are visible // Async fprintd initialization — runs after windows are visible
if config.fingerprint_enabled { if config.fingerprint_enabled {
init_fingerprint_async(all_handles); init_fingerprint_async(all_handles, shared_fp);
} }
} }
/// Initialize fprintd asynchronously after windows are visible. /// Initialize fprintd asynchronously after windows are visible.
/// Uses a single FingerprintListener shared across all monitors — /// Uses a single FingerprintListener shared across all monitors —
/// only the first monitor's handles get the fingerprint UI wired up. /// only the first monitor's handles get the fingerprint verification wired up.
fn init_fingerprint_async(all_handles: Vec<lockscreen::LockscreenHandles>) { /// The `shared_fp` is set after init so that the connect_monitor handler can
/// wire up FP labels on monitors that appear after initialization.
fn init_fingerprint_async(
all_handles: Rc<RefCell<Vec<lockscreen::LockscreenHandles>>>,
shared_fp: Rc<RefCell<Option<Rc<RefCell<FingerprintListener>>>>>,
) {
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let mut listener = FingerprintListener::new(); let mut listener = FingerprintListener::new();
listener.init_async().await; listener.init_async().await;
// Use the first monitor's username to check enrollment // Extract username without holding a borrow across the await below —
let username = &all_handles[0].username; // otherwise a concurrent connect_monitor signal (hotplug / suspend-resume)
if username.is_empty() { // that tries to borrow_mut() panics at runtime.
let username = {
let handles = all_handles.borrow();
if handles.is_empty() {
return; return;
} }
let u = handles[0].username.clone();
if u.is_empty() {
return;
}
u
};
if !listener.is_available_async(username).await { if !listener.is_available_async(&username).await {
log::debug!("fprintd not available or no enrolled fingers"); log::debug!("fprintd not available or no enrolled fingers");
return; return;
} }
let fp_rc = Rc::new(RefCell::new(listener)); let fp_rc = Rc::new(RefCell::new(listener));
// Show fingerprint label on all monitors // Re-borrow after the await — no further awaits in this scope, so it is
for handles in &all_handles { // safe to hold the borrow briefly while wiring up the labels.
lockscreen::show_fingerprint_label(handles, &fp_rc); {
let handles = all_handles.borrow();
for h in handles.iter() {
lockscreen::show_fingerprint_label(h, &fp_rc);
}
lockscreen::start_fingerprint(&handles[0], &fp_rc);
} }
// Start verification listener on the first monitor only // Publish the listener so hotplugged monitors get FP labels too
lockscreen::start_fingerprint(&all_handles[0], &fp_rc); *shared_fp.borrow_mut() = Some(fp_rc);
}); });
} }
#[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();
// Async fprintd initialization for development mode // Async fprintd initialization for development mode
if config.fingerprint_enabled { if config.fingerprint_enabled {
init_fingerprint_async(vec![handles]); let all_handles = Rc::new(RefCell::new(vec![handles]));
let shared_fp = Rc::new(RefCell::new(None));
init_fingerprint_async(all_handles, shared_fp);
} }
} }
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}");
}
}
// Debug level is only selectable in debug builds. Release binaries ignore
// MOONLOCK_DEBUG so a session script cannot escalate log verbosity to leak
// fprintd / D-Bus internals into the journal.
#[cfg(debug_assertions)]
let level = if std::env::var("MOONLOCK_DEBUG").is_ok() {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
#[cfg(not(debug_assertions))]
let level = log::LevelFilter::Info;
log::set_max_level(level);
} }
fn install_panic_hook() { fn install_panic_hook() {
@ -187,6 +276,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 +284,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

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()); }

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
} }