Compare commits

..

38 Commits
v0.2.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
de9a3e9e6a feat: add optional background blur, align to shared texture pattern
Gaussian blur applied at texture load time when `background_blur` is
set in moonlock.toml. Refactored wallpaper loading from per-window
Picture::for_filename() to shared gdk::Texture pattern (matching
moonset/moongreet), avoiding redundant JPEG decoding on multi-monitor.
2026-03-28 14:53:27 +01:00
09e0d47a38 fix: audit fixes — async restart_verify, locale caching, panic safety (v0.5.0)
- restart_verify() now async via spawn_future_local (was blocking main thread)
- stop() uses 3s timeout instead of unbounded
- load_strings() caches locale detection in OnceLock (was reading /etc/locale.conf on every call)
- child_get() replaced with child_value().get() for graceful D-Bus type mismatch handling
- Eliminate redundant password clone in auth path (direct move into spawn_blocking)
- Add on_exhausted callback: hides fp_label after MAX_FP_ATTEMPTS
- Set running=false before on_success callback (prevent double-unlock)
- Add 4 unit tests for on_verify_status state machine
- Document GLib-GString/CString zeroize limitation in CLAUDE.md
2026-03-28 10:16:06 +01:00
13b329cd98 perf: async fprintd initialization for instant window display
Move all fprintd D-Bus calls (init, availability check, claim, verify)
from synchronous to async using gio futures. Windows now appear
immediately without waiting for D-Bus — fingerprint label fades in
once fprintd is ready. Single shared FingerprintListener across all
monitors instead of one per monitor.
2026-03-28 09:57:56 +01:00
58c076198f feat: switch logging to systemd journal (v0.4.2)
Replace env_logger + /var/cache/moonlock file logging with
systemd-journal-logger. Logs are now reliably accessible via
journalctl --user -u moonlock, fixing invisible errors in the
systemd user service context.

- Cargo.toml: env_logger → systemd-journal-logger 2.2
- main.rs: setup_logging() uses JournalLog
- PKGBUILD: add systemd-libs dependency
- power.rs: include unstaged systemctl fixes (ABOUTME, --no-ask-password, output())
2026-03-28 01:11:48 +01:00
78bcf90492 fix: use systemctl instead of loginctl for reboot/poweroff
loginctl has no reboot/poweroff subcommands — these are systemctl
commands. The error was silently swallowed because stderr wasn't
captured and logs went to a non-existent directory.
2026-03-28 00:53:14 +01:00
17f8930ff7 fix: security and correctness audit fixes (v0.4.1)
PAM conv callback: check msg_style (password only for ECHO_OFF),
handle strdup OOM with proper cleanup, null-check PAM handle.

Fingerprint: self-wire D-Bus g-signal in start() via Rc<RefCell<>>
and connect_local — VerifyStatus signals are now actually dispatched.
VerifyStop before VerifyStart in restart_verify.

Lockscreen: password entry stays active after faillock threshold
(PAM decides lockout, not UI), use Zeroizing<String> from GTK entry.

Release builds exit(1) without ext-session-lock-v1 support.

Config: fingerprint_enabled as Option<bool> so empty user config
does not override system config.

Dead code: remove unused i18n strings and fingerprint accessors,
parameterize faillock_warning max_attempts.
2026-03-28 00:06:27 +01:00
64f032cd9a fix: PKGBUILD depends on gtk-session-lock (not gtk4-session-lock) 2026-03-27 23:28:11 +01:00
4968b7ee2d Merge rust-rewrite: moonlock v0.4.0 Rust rewrite 2026-03-27 23:18:03 +01:00
60e63a6857 Fix Rust 2024 unsafe block warnings in PAM callback
Wrap raw pointer operations in explicit unsafe blocks inside
the unsafe extern "C" conv callback, as required by Rust 2024
edition. Remove unused mut binding.
2026-03-27 23:13:23 +01:00
817a9547ad Rewrite moonlock from Python to Rust (v0.4.0)
Complete rewrite of the Wayland lockscreen from Python/PyGObject to
Rust/gtk4-rs for memory safety in security-critical PAM code and
consistency with the moonset/moongreet Rust ecosystem.

Modules: main, lockscreen, auth (PAM FFI), fingerprint (fprintd D-Bus),
config, i18n, users, power. 37 unit tests.

Security: PAM conversation callback with Zeroizing password, panic hook
that never unlocks, root check, ext-session-lock-v1 compositor policy,
absolute loginctl path, avatar symlink rejection.
2026-03-27 23:09:54 +01:00
7de3737a61 Revert "feat: fade-in animation for panel and wallpaper windows"
This reverts commit 4a2bbb3e98adfeded7fa88332e60e1ab3e3b14b0.
2026-03-27 15:12:26 +01:00
4a2bbb3e98 feat: fade-in animation for panel and wallpaper windows
CSS opacity transition (350ms ease-in) triggered via adding
a 'visible' class after the window is mapped.
2026-03-27 14:45:10 +01:00
fe6421c582 Move power confirmation below fingerprint label with own text
Confirmation prompt is now a self-contained box (label + buttons)
appended at the bottom of the login box instead of reusing the
error label above the fingerprint text.
2026-03-26 22:18:58 +01:00
fb11c551bd Security hardening based on triple audit (security, quality, performance)
- Remove SIGUSR1 unlock handler (unauthenticated unlock vector)
- Sanitize LD_PRELOAD (discard inherited environment)
- Refuse to run as root
- Validate D-Bus signal sender against fprintd proxy owner
- Pass bytearray (not str) to PAM conversation callback for wipeable password
- Resolve libc before returning CFUNCTYPE callback
- Bundle fingerprint success idle_add into single atomic callback
- Add running/device_proxy guards to VerifyStart retries with error handling
- Add fingerprint attempt counter (max 10 before disabling)
- Add power button confirmation dialog (inline yes/cancel)
- Move fingerprint D-Bus init before session lock to avoid mainloop blocking
- Resolve wallpaper path once, share across all monitor windows
- Document faillock as UI-only (pam_faillock handles real brute-force protection)
- Fix type hints (Callable), remove dead import (c_char), fix import order
2026-03-26 22:11:00 +01:00
5fda0dce0c Replace crash guard with SIGUSR1 unlock and crash logging
Remove sys.excepthook that unlocked on crash — this violated
ext-session-lock-v1 security model where the compositor must keep
the screen locked if the client dies (per protocol spec and hyprlock
reference). Now: crashes are logged but session stays locked.
SIGUSR1 handler added for external recovery (e.g. wrapper script).
2026-03-26 21:11:42 +01:00
3f31387305 Add crash guard, logging, and defensive error handling to prevent lockout
Global sys.excepthook unlocks the session on unhandled exceptions.
Structured logging to stderr and optional file at /var/cache/moonlock/.
Window creation, CSS loading, and fingerprint start wrapped in
try/except with automatic session unlock when all windows fail.
2026-03-26 17:56:45 +01:00
43 changed files with 3992 additions and 1839 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

8
.gitignore vendored
View File

@ -1,10 +1,4 @@
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
/target
# makepkg build artifacts
pkg/src/

View File

@ -1,53 +1,62 @@
# Moonlock
**Name**: Nyx (Göttin der Nacht — passend zum Lockscreen, der den Bildschirm verdunkelt)
## Projekt
Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Python + GTK4 + ext-session-lock-v1.
Teil des Moonarch-Ökosystems. Visuell und architektonisch inspiriert von Moongreet.
Moonlock ist ein sicherer Wayland-Lockscreen, gebaut mit Rust + gtk4-rs + ext-session-lock-v1.
Teil des Moonarch-Ökosystems.
## Tech-Stack
- Python 3.11+, PyGObject (GTK 4.0)
- Gtk4SessionLock (ext-session-lock-v1) für protokoll-garantiertes Screen-Locking
- PAM-Authentifizierung via ctypes-Wrapper (libpam.so.0)
- fprintd D-Bus Integration (Gio.DBusProxy) für Fingerabdruck-Unlock
- pytest für Tests
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-session-lock 0.4 für ext-session-lock-v1 Protokoll
- PAM-Authentifizierung via Raw FFI (libc, libpam.so)
- fprintd D-Bus Integration (gio::DBusProxy) für Fingerabdruck-Unlock
- zeroize für sicheres Passwort-Wiping
- `cargo test` für Unit-Tests
## Projektstruktur
- `src/moonlock/` — Quellcode
- `src/moonlock/data/` — Package-Assets (Default-Avatar, Icons)
- `tests/` — pytest Tests
- `config/` — Beispiel-Konfigurationsdateien
- `src/` — Rust-Quellcode (main.rs, lockscreen.rs, auth.rs, fingerprint.rs, config.rs, i18n.rs, users.rs, power.rs)
- `resources/` — GResource-Assets (style.css, default-avatar.svg)
- `config/` — PAM-Konfiguration und Beispiel-Config
## Kommandos
```bash
# Tests ausführen
uv run pytest tests/ -v
cargo test
# Typ-Checks
uv run pyright src/
# Release-Build
cargo build --release
# Lockscreen starten (zum Testen)
uv run moonlock
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock
```
## Architektur
- `auth.py` — PAM-Authentifizierung via ctypes (libpam.so.0)
- `fingerprint.py` — fprintd D-Bus Listener (Gio.DBusProxy, async im GLib-Mainloop)
- `users.py` — Aktuellen User ermitteln, Avatar laden
- `power.py` — Reboot/Shutdown via loginctl
- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN)
- `lockscreen.py` — GTK4 UI (Avatar, Passwort-Entry, Fingerprint-Indikator, Power-Buttons)
- `main.py` — Entry Point, GTK App, Session Lock Setup (ext-session-lock-v1)
- `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, 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
- `power.rs` — Reboot/Shutdown via /usr/bin/systemctl
- `i18n.rs` — Locale-Erkennung (OnceLock-cached) und String-Tabellen (DE/EN), faillock_warning mit konfigurierbarem max_attempts
- `config.rs` — TOML-Config (background_path, background_blur 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 mit 30s Timeout und Generation Counter, FP-Label/Start separat verdrahtet mit pam_acct_mgmt-Check und auto-resume, Zeroizing<String> für Passwort, Power-Confirm, GPU-Blur via GskBlurNode (Downscale auf max 1920px), Blur/Avatar-Cache für Multi-Monitor
- `main.rs` — Entry Point, Panic-Hook (vor Logging), Root-Check, ext-session-lock-v1 (Pflicht in Release), 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
- ext-session-lock-v1 garantiert: Compositor sperrt alle Surfaces bei lock()
- Bei Crash bleibt Screen schwarz (nicht offen)
- Passwort wird nach Verwendung im Speicher überschrieben
- Kein Schließen per Escape/Alt-F4 — nur durch erfolgreiche Auth
- 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. Hook wird vor Logging installiert.
- PAM-Callback: msg_style-aware (Passwort nur bei PAM_PROMPT_ECHO_OFF), strdup-OOM-sicher, num_msg-Guard gegen negative Werte
- fprintd: D-Bus Signal-Sender wird gegen fprintd's unique bus name validiert (Anti-Spoofing)
- Passwort: Zeroizing<String> ab GTK-Entry-Extraktion, Zeroizing<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
- 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
- Peek-Icon am Passwortfeld aktiv (UX-Entscheidung, konsistent mit moongreet)
- GResource-Bundle: CSS/Assets in der Binary kompiliert

1171
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
Cargo.toml Normal file
View File

@ -0,0 +1,33 @@
[package]
name = "moonlock"
version = "0.6.12"
edition = "2024"
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
license = "MIT"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_10"] }
gtk4-session-lock = { version = "0.4", features = ["v1_2"] }
glib = "0.22"
gdk4 = "0.11"
gdk-pixbuf = "0.22"
gio = "0.22"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
graphene-rs = { version = "0.22", package = "graphene-rs" }
nix = { version = "0.29", features = ["user"] }
zeroize = { version = "1", features = ["derive", "std"] }
libc = "0.2"
log = "0.4"
systemd-journal-logger = "2.2"
[dev-dependencies]
tempfile = "3"
[build-dependencies]
glib-build-tools = "0.22"
[profile.release]
lto = "fat"
codegen-units = 1
strip = true

87
DECISIONS.md Normal file
View File

@ -0,0 +1,87 @@
# Decisions
Architectural and design decisions for Moonlock, in reverse chronological order.
## 2026-04-24 Audit LOW fixes: docs, rustdoc, check_account scope, debug gating, lto fat (v0.6.12)
- **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
- **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.
## 2026-03-28 Shared wallpaper texture pattern (aligned with moonset/moongreet)
- **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.
- **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.

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# Moonlock
A secure Wayland lockscreen with GTK4, PAM authentication and fingerprint support.
Part of the Moonarch ecosystem.
## Features
- **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`) with 30s timeout and generation counter
- **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 + 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)
- **Faillock warning** — Progressive UI warning after failed attempts, PAM decides lockout
- **Panic safety** — Panic hook logs but never unlocks (installed before logging)
- **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
- GTK 4
- gtk4-session-lock (ext-session-lock-v1 support)
- PAM (`/etc/pam.d/moonlock`)
- Optional: fprintd for fingerprint support
## Building
```bash
cargo build --release
```
## Installation
```bash
# Install binary
sudo install -Dm755 target/release/moonlock /usr/bin/moonlock
# Install PAM config
sudo install -Dm644 config/moonlock-pam /etc/pam.d/moonlock
# Optional: Install example config
sudo install -Dm644 config/moonlock.toml.example /etc/moonlock/moonlock.toml.example
```
## Configuration
Create `/etc/moonlock/moonlock.toml` or `~/.config/moonlock/moonlock.toml`:
```toml
background_path = "/usr/share/wallpapers/moon.jpg"
background_blur = 40.0 # 0.0200.0, optional
fingerprint_enabled = true
```
## Usage
Typically launched via keybind in your Wayland compositor:
```
# Niri keybind example
binds {
Mod+L { spawn "moonlock"; }
}
```
## Development
```bash
cargo test
cargo build --release
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonlock
```
## License
MIT

13
build.rs Normal file
View File

@ -0,0 +1,13 @@
// ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css and default-avatar.svg into the binary.
fn main() {
glib_build_tools::compile_resources(
&["resources"],
"resources/resources.gresource.xml",
"moonlock.gresource",
);
// Link libpam for PAM authentication
println!("cargo:rustc-link-lib=pam");
}

View File

@ -4,24 +4,22 @@
# Maintainer: Dominik Kressler
pkgname=moonlock-git
pkgver=0.1.1.r0.g22f725e
pkgver=0.4.1.r1.g78bcf90
pkgrel=1
pkgdesc="A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
arch=('any')
arch=('x86_64')
url="https://gitea.moonarch.de/nevaforget/moonlock"
license=('MIT')
depends=(
'python'
'python-gobject'
'gtk4'
'gtk4-layer-shell'
'gtk-session-lock'
'pam'
'systemd-libs'
)
makedepends=(
'git'
'python-build'
'python-installer'
'python-hatchling'
'cargo'
)
optdepends=(
'fprintd: fingerprint authentication support'
@ -38,13 +36,12 @@ pkgver() {
build() {
cd "$srcdir/moonlock"
rm -rf dist/
python -m build --wheel --no-isolation
cargo build --release --locked
}
package() {
cd "$srcdir/moonlock"
python -m installer --destdir="$pkgdir" dist/*.whl
install -Dm755 target/release/moonlock "$pkgdir/usr/bin/moonlock"
# PAM configuration
install -Dm644 config/moonlock-pam "$pkgdir/etc/pam.d/moonlock"

View File

@ -1,30 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "moonlock"
version = "0.2.0"
description = "A secure Wayland lockscreen with GTK4, PAM and fingerprint support"
requires-python = ">=3.11"
license = "MIT"
dependencies = [
"PyGObject>=3.46",
]
[project.scripts]
moonlock = "moonlock.main:main"
[tool.hatch.build.targets.wheel]
packages = ["src/moonlock"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.pyright]
pythonVersion = "3.11"
pythonPlatform = "Linux"
venvPath = "."
venv = ".venv"
typeCheckingMode = "standard"

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

114
resources/style.css Normal file
View File

@ -0,0 +1,114 @@
/* ABOUTME: GTK4 CSS stylesheet for the Moonlock lockscreen. */
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
/* Main window background */
window.lockscreen {
background-color: @theme_bg_color;
background-size: cover;
background-position: center;
opacity: 0;
transition: opacity 350ms ease-in;
}
window.lockscreen.visible {
opacity: 1;
}
/* Central login area */
.login-box {
padding: 40px;
border-radius: 12px;
background-color: transparent;
}
/* Round avatar image */
.avatar {
border-radius: 9999px;
min-width: 128px;
min-height: 128px;
background-color: @theme_selected_bg_color;
border: 3px solid alpha(@theme_fg_color, 0.3);
}
/* Username label */
.username-label {
font-size: 24px;
font-weight: bold;
color: @theme_fg_color;
margin-top: 12px;
margin-bottom: 40px;
}
/* Password entry field */
.password-entry {
min-width: 280px;
}
/* Error message label */
.error-label {
color: @error_color;
font-size: 14px;
}
/* Fingerprint status indicator */
.fingerprint-label {
color: alpha(@theme_fg_color, 0.6);
font-size: 13px;
margin-top: 8px;
}
.fingerprint-label.success {
color: @success_color;
}
.fingerprint-label.failed {
color: @error_color;
}
/* Confirmation prompt */
.confirm-label {
font-size: 16px;
color: @theme_fg_color;
margin-bottom: 4px;
}
.confirm-yes {
padding: 8px 24px;
border-radius: 8px;
background-color: @error_color;
color: @theme_bg_color;
border: none;
font-weight: bold;
}
.confirm-yes:hover {
background-color: lighter(@error_color);
}
.confirm-no {
padding: 8px 24px;
border-radius: 8px;
background-color: @theme_unfocused_bg_color;
color: @theme_fg_color;
border: none;
}
.confirm-no:hover {
background-color: @theme_selected_bg_color;
}
/* Power buttons on the bottom right */
.power-button {
min-width: 48px;
min-height: 48px;
padding: 0px;
border-radius: 24px;
background-color: alpha(@theme_fg_color, 0.1);
color: @theme_fg_color;
border: none;
margin: 4px;
}
.power-button:hover {
background-color: alpha(@theme_fg_color, 0.25);
}

310
src/auth.rs Normal file
View File

@ -0,0 +1,310 @@
// ABOUTME: PAM authentication via raw FFI wrapper around libpam.so.
// ABOUTME: Provides authenticate(username, password) for the lockscreen with secure password wiping.
use std::ffi::CString;
use std::ptr;
use zeroize::Zeroizing;
// PAM return codes
const PAM_SUCCESS: i32 = 0;
const PAM_BUF_ERR: i32 = 5;
const PAM_AUTH_ERR: i32 = 7;
// PAM message styles
const PAM_PROMPT_ECHO_OFF: libc::c_int = 1;
const PAM_PROMPT_ECHO_ON: libc::c_int = 2;
/// PAM message structure (pam_message).
#[repr(C)]
struct PamMessage {
msg_style: libc::c_int,
msg: *const libc::c_char,
}
/// PAM response structure (pam_response).
#[repr(C)]
struct PamResponse {
resp: *mut libc::c_char,
resp_retcode: libc::c_int,
}
/// PAM conversation callback type.
type PamConvFn = unsafe extern "C" fn(
num_msg: libc::c_int,
msg: *mut *const PamMessage,
resp: *mut *mut PamResponse,
appdata_ptr: *mut libc::c_void,
) -> libc::c_int;
/// PAM conversation structure (pam_conv).
#[repr(C)]
struct PamConv {
conv: PamConvFn,
appdata_ptr: *mut libc::c_void,
}
// libpam function declarations
unsafe extern "C" {
fn pam_start(
service_name: *const libc::c_char,
user: *const libc::c_char,
pam_conversation: *const PamConv,
pamh: *mut *mut libc::c_void,
) -> libc::c_int;
fn pam_authenticate(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int;
fn pam_acct_mgmt(pamh: *mut libc::c_void, flags: libc::c_int) -> libc::c_int;
fn pam_end(pamh: *mut libc::c_void, pam_status: libc::c_int) -> libc::c_int;
}
/// PAM conversation callback — provides the password to PAM.
///
/// # Safety
/// Called by libpam during authentication. Allocates response memory with calloc/strdup
/// which PAM will free. The appdata_ptr must point to a valid CString (the password).
unsafe extern "C" fn pam_conv_callback(
num_msg: libc::c_int,
msg: *mut *const PamMessage,
resp: *mut *mut PamResponse,
appdata_ptr: *mut libc::c_void,
) -> libc::c_int {
unsafe {
if num_msg <= 0 {
return PAM_AUTH_ERR;
}
// Safety: appdata_ptr was set to a valid *const CString in authenticate()
let password = appdata_ptr as *const CString;
if password.is_null() {
return PAM_AUTH_ERR;
}
// Safety: calloc returns zeroed memory for num_msg PamResponse structs.
// PAM owns this memory and will free() it.
let resp_array = libc::calloc(
num_msg as libc::size_t,
std::mem::size_of::<PamResponse>() as libc::size_t,
) as *mut PamResponse;
if resp_array.is_null() {
return PAM_BUF_ERR;
}
for i in 0..num_msg as isize {
let resp_ptr = resp_array.offset(i);
// Safety: msg is an array of pointers provided by PAM
let pam_msg = *msg.offset(i);
let msg_style = (*pam_msg).msg_style;
match msg_style {
PAM_PROMPT_ECHO_OFF => {
// Password prompt — provide the password via strdup
let dup = libc::strdup((*password).as_ptr());
if dup.is_null() {
// strdup failed (OOM) — free all previously allocated strings
for j in 0..i {
let prev = resp_array.offset(j);
if !(*prev).resp.is_null() {
libc::free((*prev).resp as *mut libc::c_void);
}
}
libc::free(resp_array as *mut libc::c_void);
return PAM_BUF_ERR;
}
(*resp_ptr).resp = dup;
}
PAM_PROMPT_ECHO_ON => {
// Visible prompt — provide empty string, never the password
let empty = libc::strdup(b"\0".as_ptr() as *const libc::c_char);
if empty.is_null() {
for j in 0..i {
let prev = resp_array.offset(j);
if !(*prev).resp.is_null() {
libc::free((*prev).resp as *mut libc::c_void);
}
}
libc::free(resp_array as *mut libc::c_void);
return PAM_BUF_ERR;
}
(*resp_ptr).resp = empty;
}
_ => {
// PAM_ERROR_MSG, PAM_TEXT_INFO, or unknown — no response expected
(*resp_ptr).resp = ptr::null_mut();
}
}
(*resp_ptr).resp_retcode = 0;
}
// Safety: resp is a valid pointer provided by PAM
*resp = resp_array;
PAM_SUCCESS
}
}
/// Authenticate a user via PAM.
///
/// Returns true on success, false on authentication failure.
/// The password is wiped from memory after use via zeroize.
pub fn authenticate(username: &str, password: &str) -> bool {
// CString::new takes ownership of the Vec — Zeroizing<CString> wipes on drop
let password_cstr = match CString::new(password.as_bytes().to_vec()) {
Ok(c) => Zeroizing::new(c),
Err(_) => return false, // Password contains null byte
};
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,
};
let conv = PamConv {
conv: pam_conv_callback,
appdata_ptr: std::ptr::from_ref::<CString>(&password_cstr) as *mut libc::c_void,
};
let mut handle: *mut libc::c_void = ptr::null_mut();
// Safety: All pointers are valid CStrings that outlive the pam_start call.
// handle is an output parameter that PAM will set.
let ret = unsafe {
pam_start(
service.as_ptr(),
username_cstr.as_ptr(),
&conv,
&mut handle,
)
};
if ret != PAM_SUCCESS {
return false;
}
if handle.is_null() {
return false;
}
// 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 acct_ret = if auth_ret == PAM_SUCCESS {
// Safety: handle is valid, check account restrictions
unsafe { pam_acct_mgmt(handle, 0) }
} else {
auth_ret
};
// Safety: handle is valid, pam_end cleans up the PAM session
unsafe { pam_end(handle, acct_ret) };
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)]
mod tests {
use super::*;
#[test]
fn pam_service_name_is_valid_cstring() {
let service = CString::new("moonlock").unwrap();
assert_eq!(service.as_bytes(), b"moonlock");
}
#[test]
fn password_with_null_byte_fails() {
// authenticate should return false for passwords with embedded nulls
let result = authenticate("testuser", "pass\0word");
assert!(!result);
}
#[test]
fn zeroizing_wipes_password() {
let password = Zeroizing::new(vec![0x41u8, 0x42, 0x43]);
let ptr = password.as_ptr();
drop(password);
// After drop, the memory should be zeroed (though we can't reliably
// test this since the allocator may reuse the memory). This test
// verifies the Zeroizing wrapper compiles and drops correctly.
assert!(!ptr.is_null());
}
#[test]
fn empty_username_fails() {
// Empty username should not crash, just fail auth
let result = authenticate("", "password");
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);
}
}

151
src/config.rs Normal file
View File

@ -0,0 +1,151 @@
// ABOUTME: Configuration loading for the lockscreen.
// ABOUTME: Reads moonlock.toml for wallpaper and fingerprint settings with fallback hierarchy.
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
fn default_config_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/etc/moonlock/moonlock.toml")];
if let Some(home) = std::env::var_os("HOME") {
paths.push(PathBuf::from(home).join(".config/moonlock/moonlock.toml"));
}
paths
}
/// Raw deserialization struct — fingerprint_enabled is optional so that
/// an empty user config does not override the system config's value.
#[derive(Debug, Clone, Default, Deserialize)]
struct RawConfig {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
pub fingerprint_enabled: Option<bool>,
}
/// Resolved configuration with concrete values.
#[derive(Debug, Clone)]
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
pub fingerprint_enabled: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
background_path: None,
background_blur: None,
fingerprint_enabled: true,
}
}
}
pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let default_paths = default_config_paths();
let paths = config_paths.unwrap_or(&default_paths);
let mut merged = Config::default();
for path in paths {
if let Ok(content) = fs::read_to_string(path) {
match toml::from_str::<RawConfig>(&content) {
Ok(parsed) => {
if parsed.background_path.is_some() { merged.background_path = parsed.background_path; }
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; }
}
Err(e) => {
log::warn!("Failed to parse {}: {e}", path.display());
}
}
}
}
merged
}
pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
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 Some(moonarch_wallpaper.to_path_buf()); }
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn default_config() { let c = Config::default(); assert!(c.background_path.is_none()); assert!(c.background_blur.is_none()); assert!(c.fingerprint_enabled); }
#[test] fn load_default_fingerprint_true() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonlock.toml");
fs::write(&conf, "").unwrap();
let c = load_config(Some(&[conf]));
assert!(c.fingerprint_enabled);
}
#[test] fn load_background() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonlock.toml");
fs::write(&conf, "background_path = \"/custom/bg.jpg\"\nbackground_blur = 15.0\nfingerprint_enabled = false\n").unwrap();
let c = load_config(Some(&[conf]));
assert_eq!(c.background_path.as_deref(), Some("/custom/bg.jpg"));
assert_eq!(c.background_blur, Some(15.0));
assert!(!c.fingerprint_enabled);
}
#[test] fn load_blur_optional() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonlock.toml");
fs::write(&conf, "background_path = \"/bg.jpg\"\n").unwrap();
let c = load_config(Some(&[conf]));
assert!(c.background_blur.is_none());
}
#[test] fn resolve_config_path() {
let dir = tempfile::tempdir().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() };
assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), Some(wp));
}
#[test] fn empty_user_config_preserves_system_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let sys_conf = dir.path().join("system.toml");
let usr_conf = dir.path().join("user.toml");
fs::write(&sys_conf, "fingerprint_enabled = false\n").unwrap();
fs::write(&usr_conf, "").unwrap();
let c = load_config(Some(&[sys_conf, usr_conf]));
assert!(!c.fingerprint_enabled);
}
#[test] fn resolve_no_wallpaper_returns_none() {
let c = Config::default();
let r = resolve_background_path_with(&c, Path::new("/nonexistent"));
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());
}
}

487
src/fingerprint.rs Normal file
View File

@ -0,0 +1,487 @@
// ABOUTME: fprintd D-Bus integration for fingerprint authentication.
// ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy.
use gio::prelude::*;
use gtk4::gio;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint";
const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager";
const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
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.
const RETRY_STATUSES: &[&str] = &[
"verify-swipe-too-short",
"verify-finger-not-centered",
"verify-remove-and-retry",
"verify-retry-scan",
];
/// Fingerprint listener state.
pub struct FingerprintListener {
device_proxy: Option<gio::DBusProxy>,
signal_id: Option<glib::SignalHandlerId>,
running: bool,
/// Shared flag for async tasks to detect stop() between awaits.
running_flag: Rc<Cell<bool>>,
failed_attempts: u32,
on_success: Option<Box<dyn Fn() + 'static>>,
on_failure: Option<Box<dyn Fn() + 'static>>,
on_exhausted: Option<Box<dyn Fn() + 'static>>,
}
impl Default for FingerprintListener {
fn default() -> Self {
FingerprintListener {
device_proxy: None,
signal_id: None,
running: false,
running_flag: Rc::new(Cell::new(false)),
failed_attempts: 0,
on_success: None,
on_failure: 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.
pub async fn init_async(&mut self) {
let manager = match gio::DBusProxy::for_bus_future(
gio::BusType::System,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
FPRINTD_MANAGER_PATH,
FPRINTD_MANAGER_IFACE,
)
.await
{
Ok(m) => m,
Err(e) => {
log::debug!("fprintd manager not available: {e}");
return;
}
};
// Call GetDefaultDevice
let result = match manager
.call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
Ok(r) => r,
Err(e) => {
log::debug!("fprintd GetDefaultDevice failed: {e}");
return;
}
};
// Extract device path from variant tuple
let device_path = match result.child_value(0).get::<String>() {
Some(p) => p,
None => {
log::debug!("fprintd: unexpected GetDefaultDevice response type");
return;
}
};
if device_path.is_empty() {
return;
}
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
log::warn!("Unexpected fprintd device path: {device_path}");
return;
}
match gio::DBusProxy::for_bus_future(
gio::BusType::System,
gio::DBusProxyFlags::NONE,
None,
FPRINTD_BUS_NAME,
&device_path,
FPRINTD_DEVICE_IFACE,
)
.await
{
Ok(proxy) => {
self.device_proxy = Some(proxy);
}
Err(e) => {
log::debug!("fprintd device proxy failed: {e}");
}
}
}
/// Check if fprintd is available and the user has enrolled fingerprints (async).
pub async fn is_available_async(&self, username: &str) -> bool {
let proxy = match &self.device_proxy {
Some(p) => p,
None => return false,
};
let args = glib::Variant::from((&username,));
match proxy
.call_future("ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
Ok(result) => {
// Result is a tuple of (array of strings)
match result.child_value(0).get::<Vec<String>>() {
Some(fingers) => !fingers.is_empty(),
None => {
log::debug!("fprintd: unexpected ListEnrolledFingers response type");
false
}
}
}
Err(_) => false,
}
}
/// Start listening for fingerprint verification.
/// Claims the device and starts verification using async D-Bus calls.
/// Connects the D-Bus g-signal handler internally. The `listener` parameter
/// must be the same `Rc<RefCell<FingerprintListener>>` that owns `self`.
pub async fn start_async<F, G, H>(
listener: &Rc<RefCell<FingerprintListener>>,
username: &str,
on_success: F,
on_failure: G,
on_exhausted: H,
) where
F: Fn() + 'static,
G: Fn() + 'static,
H: Fn() + 'static,
{
{
let mut inner = listener.borrow_mut();
inner.on_success = Some(Box::new(on_success));
inner.on_failure = Some(Box::new(on_failure));
inner.on_exhausted = Some(Box::new(on_exhausted));
}
Self::begin_verification(listener, username).await;
}
/// Resume fingerprint verification after a transient interruption (e.g. failed
/// PAM account check). Reuses previously stored callbacks. Re-claims the device
/// and restarts verification from scratch. 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
let args = glib::Variant::from((&username,));
if let Err(e) = proxy
.call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
log::error!("Failed to claim fingerprint device: {e}");
return;
}
// Start verification
let start_args = glib::Variant::from((&"any",));
if let Err(e) = proxy
.call_future("VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
log::error!("Failed to start fingerprint verification: {e}");
let _ = proxy
.call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await;
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
let listener_weak = Rc::downgrade(listener);
let signal_id = proxy.connect_local("g-signal", false, move |values| {
// 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() {
Ok(v) => v,
Err(_) => return None,
};
if signal_name.as_str() != "VerifyStatus" {
return None;
}
let params = match values[3].get::<glib::Variant>() {
Ok(v) => v,
Err(_) => return None,
};
let status = params
.child_value(0)
.get::<String>()
.unwrap_or_default();
let done = params
.child_value(1)
.get::<bool>()
.unwrap_or(false);
if let Some(listener_rc) = listener_weak.upgrade() {
listener_rc.borrow_mut().on_verify_status(&status, done);
}
None
});
let mut inner = listener.borrow_mut();
inner.signal_id = Some(signal_id);
inner.running = true;
inner.running_flag.set(true);
}
/// Process a VerifyStatus signal from fprintd.
pub(crate) fn on_verify_status(&mut self, status: &str, done: bool) {
if !self.running {
return;
}
if status == "verify-match" {
self.cleanup_dbus();
if let Some(ref cb) = self.on_success {
cb();
}
return;
}
if RETRY_STATUSES.contains(&status) {
if done {
self.restart_verify_async();
}
return;
}
if status == "verify-no-match" {
self.failed_attempts += 1;
if self.failed_attempts >= MAX_FP_ATTEMPTS {
log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping");
if let Some(ref cb) = self.on_exhausted {
cb();
}
self.stop();
return;
}
if let Some(ref cb) = self.on_failure {
cb();
}
if done {
self.restart_verify_async();
}
return;
}
log::debug!("Unhandled fprintd status: {status}");
if done {
self.restart_verify_async();
}
}
/// 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) {
if let Some(ref proxy) = self.device_proxy {
let proxy = proxy.clone();
let running = self.running_flag.clone();
glib::spawn_future_local(async move {
// VerifyStop before VerifyStart to avoid D-Bus errors
let _ = proxy
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await;
if !running.get() {
return;
}
let args = glib::Variant::from((&"any",));
if let Err(e) = proxy
.call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS)
.await
{
log::error!("Failed to restart fingerprint verification: {e}");
}
});
}
}
/// Disconnect the signal handler and clear running flags. Returns the proxy
/// 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) {
if !self.running {
return;
}
self.cleanup_dbus();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retry_statuses_are_defined() {
assert!(RETRY_STATUSES.contains(&"verify-swipe-too-short"));
assert!(RETRY_STATUSES.contains(&"verify-finger-not-centered"));
assert!(!RETRY_STATUSES.contains(&"verify-match"));
assert!(!RETRY_STATUSES.contains(&"verify-no-match"));
}
#[test]
fn max_attempts_constant() {
assert_eq!(MAX_FP_ATTEMPTS, 10);
}
#[test]
fn verify_match_sets_running_false_and_calls_success() {
let called = Rc::new(Cell::new(false));
let called_clone = called.clone();
let mut listener = FingerprintListener::new();
listener.running = true;
listener.running_flag.set(true);
listener.on_success = Some(Box::new(move || { called_clone.set(true); }));
listener.on_verify_status("verify-match", false);
assert!(called.get());
assert!(!listener.running);
assert!(!listener.running_flag.get());
}
#[test]
fn verify_no_match_calls_failure_and_stays_running() {
let called = Rc::new(Cell::new(false));
let called_clone = called.clone();
let mut listener = FingerprintListener::new();
listener.running = true;
listener.running_flag.set(true);
listener.on_failure = Some(Box::new(move || { called_clone.set(true); }));
listener.on_verify_status("verify-no-match", false);
assert!(called.get());
assert!(listener.running);
assert!(listener.running_flag.get());
assert_eq!(listener.failed_attempts, 1);
}
#[test]
fn max_attempts_stops_listener_and_calls_exhausted() {
let exhausted = Rc::new(Cell::new(false));
let exhausted_clone = exhausted.clone();
let mut listener = FingerprintListener::new();
listener.running = true;
listener.on_failure = Some(Box::new(|| {}));
listener.on_exhausted = Some(Box::new(move || { exhausted_clone.set(true); }));
for _ in 0..MAX_FP_ATTEMPTS {
listener.on_verify_status("verify-no-match", true);
}
assert!(!listener.running);
assert!(exhausted.get());
assert_eq!(listener.failed_attempts, MAX_FP_ATTEMPTS);
}
#[test]
fn not_running_ignores_signals() {
let called = Rc::new(Cell::new(false));
let called_clone = called.clone();
let mut listener = FingerprintListener::new();
listener.running = false;
listener.on_success = Some(Box::new(move || { called_clone.set(true); }));
listener.on_verify_status("verify-match", false);
assert!(!called.get());
}
}

174
src/i18n.rs Normal file
View File

@ -0,0 +1,174 @@
// ABOUTME: Locale detection and string lookup for the lockscreen UI.
// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
use std::env;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
/// Cached locale prefix — detected once, reused for all subsequent calls.
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct Strings {
pub password_placeholder: &'static str,
pub reboot_tooltip: &'static str,
pub shutdown_tooltip: &'static str,
pub fingerprint_prompt: &'static str,
pub fingerprint_success: &'static str,
pub fingerprint_failed: &'static str,
pub wrong_password: &'static str,
pub reboot_failed: &'static str,
pub shutdown_failed: &'static str,
pub reboot_confirm: &'static str,
pub shutdown_confirm: &'static str,
pub confirm_yes: &'static str,
pub confirm_no: &'static str,
pub faillock_attempts_remaining: &'static str,
pub faillock_locked: &'static str,
pub auth_timeout: &'static str,
}
const STRINGS_DE: Strings = Strings {
password_placeholder: "Passwort",
reboot_tooltip: "Neustart",
shutdown_tooltip: "Herunterfahren",
fingerprint_prompt: "Fingerabdruck auflegen zum Entsperren",
fingerprint_success: "Fingerabdruck erkannt",
fingerprint_failed: "Fingerabdruck nicht erkannt",
wrong_password: "Falsches Passwort",
reboot_failed: "Neustart fehlgeschlagen",
shutdown_failed: "Herunterfahren fehlgeschlagen",
reboot_confirm: "Wirklich neu starten?",
shutdown_confirm: "Wirklich herunterfahren?",
confirm_yes: "Ja",
confirm_no: "Abbrechen",
faillock_attempts_remaining: "Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked: "Konto ist möglicherweise gesperrt",
auth_timeout: "Authentifizierung abgelaufen — bitte erneut versuchen",
};
const STRINGS_EN: Strings = Strings {
password_placeholder: "Password",
reboot_tooltip: "Reboot",
shutdown_tooltip: "Shut down",
fingerprint_prompt: "Place finger on reader to unlock",
fingerprint_success: "Fingerprint recognized",
fingerprint_failed: "Fingerprint not recognized",
wrong_password: "Wrong password",
reboot_failed: "Reboot failed",
shutdown_failed: "Shutdown failed",
reboot_confirm: "Really reboot?",
shutdown_confirm: "Really shut down?",
confirm_yes: "Yes",
confirm_no: "Cancel",
faillock_attempts_remaining: "{n} attempt(s) remaining before lockout!",
faillock_locked: "Account may be locked",
auth_timeout: "Authentication timed out — please try again",
};
fn parse_lang_prefix(lang: &str) -> String {
if lang.is_empty() || lang == "C" || lang == "POSIX" { return "en".to_string(); }
let prefix = lang.split('_').next().unwrap_or(lang).split('.').next().unwrap_or(lang).to_lowercase();
if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() { prefix } else { "en".to_string() }
}
fn read_lang_from_conf(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = line.strip_prefix("LANG=") {
let value = value.trim();
if !value.is_empty() { return Some(value.to_string()); }
}
}
None
}
pub fn detect_locale() -> String {
let lang = env::var("LANG").ok().filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
match lang { Some(l) => parse_lang_prefix(&l), None => "en".to_string() }
}
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
let locale = match locale {
Some(l) => l,
None => CACHED_LOCALE.get_or_init(detect_locale),
};
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> {
let remaining = max_attempts.saturating_sub(attempt_count);
if remaining > 0 && remaining <= 2 {
return Some(strings.faillock_attempts_remaining.replace("{n}", &remaining.to_string()));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test] fn parse_german() { assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); }
#[test] fn parse_english() { assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); }
#[test] fn parse_c() { assert_eq!(parse_lang_prefix("C"), "en"); }
#[test] fn parse_empty() { assert_eq!(parse_lang_prefix(""), "en"); }
#[test] fn read_conf() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
}
#[test] fn strings_de() { let s = load_strings(Some("de")); assert_eq!(s.password_placeholder, "Passwort"); }
#[test] fn strings_en() { let s = load_strings(Some("en")); assert_eq!(s.password_placeholder, "Password"); }
#[test] fn strings_fallback() { let s = load_strings(Some("fr")); assert_eq!(s.password_placeholder, "Password"); }
#[test] fn fingerprint_strings() {
let s = load_strings(Some("de"));
assert!(!s.fingerprint_prompt.is_empty());
assert!(!s.fingerprint_success.is_empty());
assert!(!s.fingerprint_failed.is_empty());
}
#[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_some()); }
#[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); }
#[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
}
}

833
src/lockscreen.rs Normal file
View File

@ -0,0 +1,833 @@
// ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons.
// ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow.
use gdk4 as gdk;
use gdk_pixbuf::Pixbuf;
use glib::clone;
use graphene_rs as graphene;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use std::cell::{Cell, RefCell};
use std::path::Path;
use std::rc::Rc;
use zeroize::Zeroizing;
use crate::auth;
use crate::config::Config;
use crate::fingerprint::FingerprintListener;
use crate::i18n::{faillock_warning, load_strings, Strings};
use crate::power::{self, PowerError};
use crate::users;
/// Handles returned from create_lockscreen_window for post-creation wiring.
pub struct LockscreenHandles {
pub window: gtk::ApplicationWindow,
pub fp_label: gtk::Label,
pub password_entry: gtk::PasswordEntry,
pub unlock_callback: Rc<dyn Fn()>,
pub username: String,
state: Rc<RefCell<LockscreenState>>,
}
const AVATAR_SIZE: i32 = 128;
const FAILLOCK_MAX_ATTEMPTS: u32 = 3;
const PAM_TIMEOUT_SECS: u64 = 30;
/// Shared mutable state for the lockscreen.
struct LockscreenState {
failed_attempts: u32,
fp_listener_rc: Option<Rc<RefCell<FingerprintListener>>>,
}
/// Create a lockscreen window for a single monitor.
/// 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(
bg_texture: Option<&gdk::Texture>,
config: &Config,
app: &gtk::Application,
unlock_callback: Rc<dyn Fn()>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
avatar_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> LockscreenHandles {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("lockscreen");
let strings = load_strings(None);
let user = match users::get_current_user() {
Some(u) => u,
None => {
log::error!("Failed to get current user");
let fp_label = gtk::Label::new(None);
fp_label.set_visible(false);
return LockscreenHandles {
window,
fp_label,
password_entry: gtk::PasswordEntry::new(),
unlock_callback,
username: String::new(),
state: Rc::new(RefCell::new(LockscreenState {
failed_attempts: 0,
fp_listener_rc: None,
})),
};
}
};
let state = Rc::new(RefCell::new(LockscreenState {
failed_attempts: 0,
fp_listener_rc: None,
}));
// Root overlay for background + centered content
let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay));
// Background wallpaper (if available — otherwise GTK background color shows through)
if let Some(texture) = bg_texture {
let background = create_background_picture(texture, config.background_blur, blur_cache);
overlay.set_child(Some(&background));
}
// Centered vertical box
let main_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
main_box.set_halign(gtk::Align::Center);
main_box.set_valign(gtk::Align::Center);
overlay.add_overlay(&main_box);
// Login box
let login_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
login_box.set_halign(gtk::Align::Center);
login_box.add_css_class("login-box");
main_box.append(&login_box);
// Avatar
let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0);
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE);
avatar_frame.set_halign(gtk::Align::Center);
avatar_frame.set_overflow(gtk::Overflow::Hidden);
avatar_frame.add_css_class("avatar");
let avatar_image = gtk::Image::new();
avatar_image.set_pixel_size(AVATAR_SIZE);
avatar_frame.append(&avatar_image);
login_box.append(&avatar_frame);
// 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);
if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path, avatar_cache);
} else {
set_default_avatar(&avatar_image, &window, avatar_cache);
}
}
// Username label
let username_label = gtk::Label::new(Some(&user.display_name));
username_label.add_css_class("username-label");
login_box.append(&username_label);
// Password entry
let password_entry = gtk::PasswordEntry::builder()
.placeholder_text(strings.password_placeholder)
.show_peek_icon(true)
.hexpand(true)
.build();
password_entry.add_css_class("password-entry");
login_box.append(&password_entry);
// Error label
let error_label = gtk::Label::new(None);
error_label.add_css_class("error-label");
error_label.set_visible(false);
login_box.append(&error_label);
// Fingerprint label — hidden until async fprintd init completes
let fp_label = gtk::Label::new(None);
fp_label.add_css_class("fingerprint-label");
fp_label.set_visible(false);
login_box.append(&fp_label);
// Confirm box area (for power confirm)
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
confirm_area.set_halign(gtk::Align::Center);
login_box.append(&confirm_area);
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
// Power buttons (bottom right)
let power_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
power_box.set_halign(gtk::Align::End);
power_box.set_valign(gtk::Align::End);
power_box.set_hexpand(true);
power_box.set_vexpand(true);
power_box.set_margin_end(16);
power_box.set_margin_bottom(16);
let reboot_btn = gtk::Button::new();
reboot_btn.set_icon_name("system-reboot-symbolic");
reboot_btn.add_css_class("power-button");
reboot_btn.set_tooltip_text(Some(strings.reboot_tooltip));
reboot_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
show_power_confirm(
strings.reboot_confirm,
power::reboot,
strings.reboot_failed,
strings,
&confirm_area,
&confirm_box,
&error_label,
);
}
));
power_box.append(&reboot_btn);
let shutdown_btn = gtk::Button::new();
shutdown_btn.set_icon_name("system-shutdown-symbolic");
shutdown_btn.add_css_class("power-button");
shutdown_btn.set_tooltip_text(Some(strings.shutdown_tooltip));
shutdown_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
show_power_confirm(
strings.shutdown_confirm,
power::shutdown,
strings.shutdown_failed,
strings,
&confirm_area,
&confirm_box,
&error_label,
);
}
));
power_box.append(&shutdown_btn);
overlay.add_overlay(&power_box);
// Password entry "activate" handler.
// A generation counter tracks which auth attempt is current. When the user
// submits a new password, the generation increments — stale PAM results from
// prior attempts are ignored (except success: a correct password always unlocks).
let username = user.username.clone();
let auth_generation = Rc::new(Cell::new(0u32));
password_entry.connect_activate(clone!(
#[strong]
state,
#[strong]
unlock_callback,
#[strong]
auth_generation,
#[weak]
error_label,
#[weak]
password_entry,
move |entry| {
let password = Zeroizing::new(entry.text().to_string());
if password.is_empty() {
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);
let username = username.clone();
let unlock_cb = unlock_callback.clone();
// Invalidate stale timeouts/results from prior attempts
let auth_gen = auth_generation.get().wrapping_add(1);
auth_generation.set(auth_gen);
let gen_timeout = auth_generation.clone();
let gen_result = auth_generation.clone();
// If PAM hangs (e.g. broken LDAP module), the timeout re-enables the UI
glib::timeout_add_local_once(
std::time::Duration::from_secs(PAM_TIMEOUT_SECS),
clone!(
#[weak]
error_label,
#[weak]
password_entry,
move || {
if gen_timeout.get() != auth_gen {
return;
}
log::error!("PAM authentication timed out after {PAM_TIMEOUT_SECS}s");
let strings = load_strings(None);
password_entry.set_text("");
password_entry.set_sensitive(true);
password_entry.grab_focus();
error_label.set_text(strings.auth_timeout);
error_label.set_visible(true);
}
),
);
glib::spawn_future_local(clone!(
#[strong]
state,
#[weak]
error_label,
#[weak]
password_entry,
async move {
let user = username.clone();
let result = gio::spawn_blocking(move || {
auth::authenticate(&user, &password)
}).await;
// Stale result from a superseded attempt — only unlock on success
// (a correct password should always unlock, regardless of timing)
if gen_result.get() != auth_gen {
if matches!(result, Ok(true)) {
let s = state.borrow();
if let Some(ref fp_rc) = s.fp_listener_rc {
fp_rc.borrow_mut().stop();
}
drop(s);
unlock_cb();
}
return;
}
match result {
Ok(true) => {
let s = state.borrow();
if let Some(ref fp_rc) = s.fp_listener_rc {
fp_rc.borrow_mut().stop();
}
drop(s);
unlock_cb();
}
_ => {
let mut s = state.borrow_mut();
s.failed_attempts += 1;
let count = s.failed_attempts;
let strings = load_strings(None);
password_entry.set_text("");
if count >= FAILLOCK_MAX_ATTEMPTS {
// Show warning but keep entry active — PAM decides lockout
error_label.set_text(strings.faillock_locked);
error_label.set_visible(true);
password_entry.set_sensitive(true);
password_entry.grab_focus();
} else {
password_entry.set_sensitive(true);
password_entry.grab_focus();
if let Some(warning) = faillock_warning(count, FAILLOCK_MAX_ATTEMPTS, strings) {
error_label.set_text(&warning);
} else {
error_label.set_text(strings.wrong_password);
}
error_label.set_visible(true);
}
}
}
}
));
}
));
// Keyboard handling — Escape clears password and error
let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(clone!(
#[weak]
password_entry,
#[weak]
error_label,
#[upgrade_or]
glib::Propagation::Proceed,
move |_, keyval, _, _| {
if keyval == gdk::Key::Escape {
password_entry.set_text("");
error_label.set_visible(false);
password_entry.grab_focus();
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
}
));
window.add_controller(key_controller);
// Fade-in on map
window.connect_map(|w| {
glib::idle_add_local_once(clone!(
#[weak]
w,
move || {
w.add_css_class("visible");
}
));
});
// Focus password entry on realize
window.connect_realize(clone!(
#[weak]
password_entry,
move |_| {
glib::idle_add_local_once(move || {
password_entry.grab_focus();
});
}
));
LockscreenHandles {
window,
fp_label,
password_entry: password_entry.clone(),
unlock_callback,
username: user.username,
state: state.clone(),
}
}
/// Show the fingerprint label and store the listener reference for stop-on-unlock.
/// Does NOT start verification — call `start_fingerprint()` on one monitor for that.
pub fn show_fingerprint_label(
handles: &LockscreenHandles,
fp_rc: &Rc<RefCell<FingerprintListener>>,
) {
let strings = load_strings(None);
handles.fp_label.set_text(strings.fingerprint_prompt);
handles.fp_label.set_visible(true);
// Store the Rc reference for stop() on unlock
handles.state.borrow_mut().fp_listener_rc = Some(fp_rc.clone());
}
/// Start fingerprint verification on a single monitor's handles.
/// Wires up on_success/on_failure callbacks and calls start_async.
pub fn start_fingerprint(
handles: &LockscreenHandles,
fp_rc: &Rc<RefCell<FingerprintListener>>,
) {
let fp_label_success = handles.fp_label.clone();
let fp_label_fail = handles.fp_label.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 label = fp_label_success.clone();
let cb = unlock_cb_fp.clone();
let fp = fp_rc_success.clone();
let username = fp_username.clone();
glib::idle_add_local_once(move || {
let strings = load_strings(None);
label.set_text(strings.fingerprint_success);
label.add_css_class("success");
// 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;
});
},
);
}
}
});
});
};
let on_failure = move || {
let label = fp_label_fail.clone();
glib::idle_add_local_once(clone!(
#[weak]
label,
move || {
let strings = load_strings(None);
label.set_text(strings.fingerprint_failed);
label.add_css_class("failed");
// Reset after 2 seconds
glib::timeout_add_local_once(
std::time::Duration::from_secs(2),
clone!(
#[weak]
label,
move || {
label.set_text(load_strings(None).fingerprint_prompt);
label.remove_css_class("success");
label.remove_css_class("failed");
}
),
);
}
));
};
let fp_label_exhausted = handles.fp_label.clone();
let on_exhausted = move || {
let label = fp_label_exhausted.clone();
glib::idle_add_local_once(move || {
label.set_visible(false);
});
};
let username = handles.username.clone();
let fp_rc_clone = fp_rc.clone();
glib::spawn_future_local(async move {
FingerprintListener::start_async(
&fp_rc_clone, &username, on_success, on_failure, on_exhausted,
).await;
});
}
/// Load the wallpaper as a texture once, for sharing across all windows.
/// Returns None if no wallpaper path is provided or the file cannot be loaded.
/// Blur is applied at render time via GPU (GskBlurNode), not here.
///
/// 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 mut file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(bg_path)
{
Ok(f) => f,
Err(e) => {
log::warn!("Failed to open wallpaper {}: {e}", bg_path.display());
return None;
}
};
let mut bytes = Vec::new();
if let Err(e) = file.read_to_end(&mut bytes) {
log::warn!("Failed to read wallpaper {}: {e}", bg_path.display());
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
}
}
}
/// Create a Picture widget for the wallpaper background.
/// When `blur_radius` is `Some(sigma)` with sigma > 0, blur is applied via GPU
/// (GskBlurNode). The blur is rendered to a concrete texture on `realize` (when
/// the GPU renderer is available), avoiding lazy-render artifacts.
/// The `blur_cache` is shared across monitors — the first to realize renders the
/// blur, subsequent monitors reuse the cached texture.
fn create_background_picture(
texture: &gdk::Texture,
blur_radius: Option<f32>,
blur_cache: &Rc<RefCell<Option<gdk::Texture>>>,
) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(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
}
// SYNC: MAX_BLUR_DIMENSION, render_blurred_texture, and create_background_picture
// are duplicated in moongreet/src/greeter.rs and moonset/src/panel.rs.
// 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) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image_clone.set_paintable(Some(&texture));
*cache_clone.borrow_mut() = Some(texture);
}
Err(e) => {
log::warn!("Failed to decode avatar from {}: {e}", display_path.display());
}
}
});
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
/// 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();
if let Ok(bytes) =
gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE)
{
let svg_text = String::from_utf8_lossy(&bytes);
let rgba = window.color();
let fg_color = format!(
"#{:02x}{:02x}{:02x}",
(rgba.red() * 255.0) as u8,
(rgba.green() * 255.0) as u8,
(rgba.blue() * 255.0) as u8,
);
let tinted = svg_text.replace("#PLACEHOLDER", &fg_color);
let svg_bytes = tinted.as_bytes();
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
if loader.write(svg_bytes).is_ok() {
let _ = loader.close();
if let Some(pixbuf) = loader.pixbuf() {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
*cache.borrow_mut() = Some(texture);
return;
}
}
}
}
image.set_icon_name(Some("avatar-default-symbolic"));
}
/// Show inline power confirmation.
fn show_power_confirm(
message: &'static str,
action_fn: fn() -> Result<(), PowerError>,
error_message: &'static str,
strings: &'static Strings,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
dismiss_power_confirm(confirm_area, confirm_box);
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center);
new_box.set_margin_top(16);
let confirm_label = gtk::Label::new(Some(message));
confirm_label.add_css_class("confirm-label");
new_box.append(&confirm_label);
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
button_row.set_halign(gtk::Align::Center);
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
yes_btn.add_css_class("confirm-yes");
yes_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
dismiss_power_confirm(&confirm_area, &confirm_box);
execute_power_action(action_fn, error_message, &error_label);
}
));
button_row.append(&yes_btn);
let no_btn = gtk::Button::with_label(strings.confirm_no);
no_btn.add_css_class("confirm-no");
no_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
move |_| {
dismiss_power_confirm(&confirm_area, &confirm_box);
}
));
button_row.append(&no_btn);
new_box.append(&button_row);
confirm_area.append(&new_box);
*confirm_box.borrow_mut() = Some(new_box);
no_btn.grab_focus();
}
/// Remove the power confirmation prompt.
fn dismiss_power_confirm(confirm_area: &gtk::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
if let Some(box_widget) = confirm_box.borrow_mut().take() {
confirm_area.remove(&box_widget);
}
}
/// Execute a power action in a background thread.
fn execute_power_action(
action_fn: fn() -> Result<(), PowerError>,
error_message: &'static str,
error_label: &gtk::Label,
) {
glib::spawn_future_local(clone!(
#[weak]
error_label,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
log::error!("Power action failed: {e}");
error_label.set_text(error_message);
error_label.set_visible(true);
}
Err(_) => {
log::error!("Power action panicked");
error_label.set_text(error_message);
error_label.set_visible(true);
}
}
}
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn faillock_threshold() {
assert_eq!(FAILLOCK_MAX_ATTEMPTS, 3);
}
#[test]
fn avatar_size_matches_css() {
assert_eq!(AVATAR_SIZE, 128);
}
}

298
src/main.rs Normal file
View File

@ -0,0 +1,298 @@
// ABOUTME: Entry point for Moonlock — secure Wayland lockscreen.
// ABOUTME: Sets up GTK Application, ext-session-lock-v1, Panic-Hook, and multi-monitor windows.
mod auth;
mod config;
mod fingerprint;
mod i18n;
mod lockscreen;
mod power;
mod users;
use gdk4 as gdk;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_session_lock;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::fingerprint::FingerprintListener;
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moonlock/style.css");
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_USER,
);
}
fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() {
Some(d) => d,
None => {
log::error!("No display available — cannot start lockscreen UI");
return;
}
};
load_css(&display);
let config = config::load_config(None);
if gtk4_session_lock::is_supported() {
activate_with_session_lock(app, &display, &config);
} else {
#[cfg(debug_assertions)]
{
log::warn!("ext-session-lock-v1 not supported — running in development mode");
activate_without_lock(app, &config);
}
#[cfg(not(debug_assertions))]
{
log::error!("ext-session-lock-v1 not supported — refusing to run without session lock");
std::process::exit(1);
}
}
}
fn activate_with_session_lock(
app: &gtk::Application,
_display: &gdk::Display,
config: &config::Config,
) {
let lock = gtk4_session_lock::Instance::new();
// 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.
// Guard prevents double-unlock if PAM and fingerprint succeed simultaneously.
let lock_clone = lock.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 || {
if au.get() {
log::debug!("Unlock already triggered, ignoring duplicate");
return;
}
au.set(true);
lock_clone.unlock();
app_clone.quit();
});
// Shared caches for multi-monitor — first monitor renders, rest reuse
let blur_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
let avatar_cache: Rc<RefCell<Option<gdk::Texture>>> = Rc::new(RefCell::new(None));
// Shared config for use in the monitor signal handler
let config = Rc::new(config.clone());
// Shared handles list — populated by connect_monitor, read by fingerprint init
let all_handles: Rc<RefCell<Vec<lockscreen::LockscreenHandles>>> =
Rc::new(RefCell::new(Vec::new()));
// 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,
#[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(),
&blur_cache,
&avatar_cache,
);
lock_for_signal.assign_window_to_monitor(&handles.window, monitor);
handles.window.present();
// 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);
}
all_handles.borrow_mut().push(handles);
}
));
lock.lock();
// Async fprintd initialization — runs after windows are visible
if config.fingerprint_enabled {
init_fingerprint_async(all_handles, shared_fp);
}
}
/// Initialize fprintd asynchronously after windows are visible.
/// Uses a single FingerprintListener shared across all monitors —
/// only the first monitor's handles get the fingerprint verification wired up.
/// 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 {
let mut listener = FingerprintListener::new();
listener.init_async().await;
// Extract username without holding a borrow across the await below —
// otherwise a concurrent connect_monitor signal (hotplug / suspend-resume)
// that tries to borrow_mut() panics at runtime.
let username = {
let handles = all_handles.borrow();
if handles.is_empty() {
return;
}
let u = handles[0].username.clone();
if u.is_empty() {
return;
}
u
};
if !listener.is_available_async(&username).await {
log::debug!("fprintd not available or no enrolled fingers");
return;
}
let fp_rc = Rc::new(RefCell::new(listener));
// Re-borrow after the await — no further awaits in this scope, so it is
// safe to hold the borrow briefly while wiring up the labels.
{
let handles = all_handles.borrow();
for h in handles.iter() {
lockscreen::show_fingerprint_label(h, &fp_rc);
}
lockscreen::start_fingerprint(&handles[0], &fp_rc);
}
// Publish the listener so hotplugged monitors get FP labels too
*shared_fp.borrow_mut() = Some(fp_rc);
});
}
#[cfg(debug_assertions)]
fn activate_without_lock(
app: &gtk::Application,
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 unlock_callback: Rc<dyn Fn()> = Rc::new(move || {
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(
bg_texture.as_ref(),
config,
app,
unlock_callback,
&blur_cache,
&avatar_cache,
);
handles.window.set_default_size(800, 600);
handles.window.present();
// Async fprintd initialization for development mode
if config.fingerprint_enabled {
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() {
match systemd_journal_logger::JournalLog::new() {
Ok(logger) => {
if let Err(e) = logger.install() {
eprintln!("Failed to install journal logger: {e}");
}
}
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() {
// Install a panic hook BEFORE starting the app.
// On panic, we log but NEVER unlock. The compositor's ext-session-lock-v1
// policy keeps the screen locked when the client crashes.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("PANIC — screen stays locked (compositor policy): {info}");
default_hook(info);
}));
}
fn main() {
install_panic_hook();
setup_logging();
// Root check — moonlock should not run as root
if nix::unistd::getuid().is_root() {
log::error!("Moonlock should not run as root");
std::process::exit(1);
}
log::info!("Moonlock starting");
// Register compiled GResources
gio::resources_register_include!("moonlock.gresource").expect("Failed to register resources");
let app = gtk::Application::builder()
.application_id("dev.moonarch.moonlock")
.build();
app.connect_activate(activate);
app.run();
}

View File

@ -1,2 +0,0 @@
# ABOUTME: Package init for moonlock — a secure Wayland lockscreen.
# ABOUTME: Uses ext-session-lock-v1, PAM auth and fprintd fingerprint support.

View File

@ -1,154 +0,0 @@
# ABOUTME: PAM authentication via ctypes wrapper around libpam.so.
# ABOUTME: Provides authenticate(username, password) for the lockscreen.
import ctypes
import ctypes.util
from ctypes import POINTER, Structure, c_char_p, c_int, c_void_p, CFUNCTYPE, pointer, c_char
# PAM return codes
PAM_SUCCESS = 0
PAM_AUTH_ERR = 7
PAM_PROMPT_ECHO_OFF = 1
class PamMessage(Structure):
"""PAM message structure (pam_message)."""
_fields_ = [
("msg_style", c_int),
("msg", c_char_p),
]
class PamResponse(Structure):
"""PAM response structure (pam_response)."""
# resp is c_void_p (not c_char_p) to preserve raw malloc'd pointers —
# ctypes auto-converts c_char_p returns to Python bytes, losing the pointer.
_fields_ = [
("resp", c_void_p),
("resp_retcode", c_int),
]
# PAM conversation callback type
PamConvFunc = CFUNCTYPE(
c_int,
c_int,
POINTER(POINTER(PamMessage)),
POINTER(POINTER(PamResponse)),
c_void_p,
)
class PamConv(Structure):
"""PAM conversation structure (pam_conv)."""
_fields_ = [
("conv", PamConvFunc),
("appdata_ptr", c_void_p),
]
_cached_libpam: ctypes.CDLL | None = None
_cached_libc: ctypes.CDLL | None = None
def _get_libpam() -> ctypes.CDLL:
"""Load and return the libpam shared library (cached after first call)."""
global _cached_libpam
if _cached_libpam is None:
pam_path = ctypes.util.find_library("pam")
if not pam_path:
raise OSError("libpam not found")
_cached_libpam = ctypes.CDLL(pam_path)
return _cached_libpam
def _get_libc() -> ctypes.CDLL:
"""Load and return the libc shared library (cached after first call)."""
global _cached_libc
if _cached_libc is None:
libc_path = ctypes.util.find_library("c")
if not libc_path:
raise OSError("libc not found")
libc = ctypes.CDLL(libc_path)
libc.calloc.restype = c_void_p
libc.calloc.argtypes = [ctypes.c_size_t, ctypes.c_size_t]
libc.strdup.restype = c_void_p
libc.strdup.argtypes = [c_char_p]
_cached_libc = libc
return _cached_libc
def _make_conv_func(password: str) -> PamConvFunc:
"""Create a PAM conversation callback that provides the password."""
def _conv(
num_msg: int,
msg: POINTER(POINTER(PamMessage)),
resp: POINTER(POINTER(PamResponse)),
appdata_ptr: c_void_p,
) -> int:
# PAM expects malloc'd memory — it will free() the responses and resp strings
libc = _get_libc()
resp_array = libc.calloc(num_msg, ctypes.sizeof(PamResponse))
if not resp_array:
return PAM_AUTH_ERR
resp_ptr = ctypes.cast(resp_array, POINTER(PamResponse))
for i in range(num_msg):
# strdup allocates with malloc, which PAM can safely free()
resp_ptr[i].resp = libc.strdup(password.encode("utf-8"))
resp_ptr[i].resp_retcode = 0
resp[0] = resp_ptr
return PAM_SUCCESS
return PamConvFunc(_conv)
def _wipe_bytes(data: bytes | bytearray) -> None:
"""Overwrite sensitive bytes in memory with zeros."""
if isinstance(data, bytearray):
for i in range(len(data)):
data[i] = 0
def authenticate(username: str, password: str) -> bool:
"""Authenticate a user via PAM. Returns True on success, False otherwise."""
libpam = _get_libpam()
# Use a mutable bytearray so we can wipe the password after use
password_bytes = bytearray(password.encode("utf-8"))
# Set up conversation
conv_func = _make_conv_func(password)
conv = PamConv(conv=conv_func, appdata_ptr=None)
# PAM handle
handle = c_void_p()
# Start PAM session
ret = libpam.pam_start(
b"moonlock",
username.encode("utf-8"),
pointer(conv),
pointer(handle),
)
if ret != PAM_SUCCESS:
_wipe_bytes(password_bytes)
return False
try:
# Authenticate
ret = libpam.pam_authenticate(handle, 0)
if ret != PAM_SUCCESS:
return False
# Check account validity
ret = libpam.pam_acct_mgmt(handle, 0)
return ret == PAM_SUCCESS
finally:
libpam.pam_end(handle, ret)
_wipe_bytes(password_bytes)

View File

@ -1,62 +0,0 @@
# ABOUTME: Configuration loading for the lockscreen.
# ABOUTME: Reads moonlock.toml for wallpaper and feature settings.
import tomllib
from dataclasses import dataclass, field
from importlib.resources import files
from pathlib import Path
MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg")
PACKAGE_WALLPAPER = Path(str(files("moonlock") / "data" / "wallpaper.jpg"))
DEFAULT_CONFIG_PATHS = [
Path("/etc/moonlock/moonlock.toml"),
Path.home() / ".config" / "moonlock" / "moonlock.toml",
]
@dataclass(frozen=True)
class Config:
"""Lockscreen configuration."""
background_path: str | None = None
fingerprint_enabled: bool = True
def load_config(
config_paths: list[Path] | None = None,
) -> Config:
"""Load config from TOML file. Later paths override earlier ones."""
if config_paths is None:
config_paths = DEFAULT_CONFIG_PATHS
merged: dict = {}
for path in config_paths:
if path.exists():
with open(path, "rb") as f:
data = tomllib.load(f)
merged.update(data)
return Config(
background_path=merged.get("background_path"),
fingerprint_enabled=merged.get("fingerprint_enabled", True),
)
def resolve_background_path(config: Config) -> Path:
"""Resolve the wallpaper path using the fallback hierarchy.
Priority: config background_path > Moonarch system default > package fallback.
"""
# User-configured path
if config.background_path:
path = Path(config.background_path)
if path.is_file():
return path
# Moonarch ecosystem default
if MOONARCH_WALLPAPER.is_file():
return MOONARCH_WALLPAPER
# Package fallback (always present)
return PACKAGE_WALLPAPER

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

View File

@ -1,157 +0,0 @@
# ABOUTME: fprintd D-Bus integration for fingerprint authentication.
# ABOUTME: Provides FingerprintListener that runs async in the GLib mainloop.
from typing import Callable
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
FPRINTD_BUS_NAME = "net.reactivated.Fprint"
FPRINTD_MANAGER_PATH = "/net/reactivated/Fprint/Manager"
FPRINTD_MANAGER_IFACE = "net.reactivated.Fprint.Manager"
FPRINTD_DEVICE_IFACE = "net.reactivated.Fprint.Device"
# Retry-able statuses (finger not read properly, try again)
_RETRY_STATUSES = {
"verify-swipe-too-short",
"verify-finger-not-centered",
"verify-remove-and-retry",
"verify-retry-scan",
}
class FingerprintListener:
"""Listens for fingerprint verification events via fprintd D-Bus."""
def __init__(self) -> None:
self._device_proxy: Gio.DBusProxy | None = None
self._device_path: str | None = None
self._signal_id: int | None = None
self._running: bool = False
self._on_success: Callable[[], None] | None = None
self._on_failure: Callable[[], None] | None = None
self._init_device()
def _init_device(self) -> None:
"""Connect to fprintd and get the default device."""
try:
manager = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SYSTEM,
Gio.DBusProxyFlags.NONE,
None,
FPRINTD_BUS_NAME,
FPRINTD_MANAGER_PATH,
FPRINTD_MANAGER_IFACE,
None,
)
result = manager.GetDefaultDevice()
if result:
self._device_path = result
self._device_proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SYSTEM,
Gio.DBusProxyFlags.NONE,
None,
FPRINTD_BUS_NAME,
self._device_path,
FPRINTD_DEVICE_IFACE,
None,
)
except GLib.Error:
self._device_proxy = None
self._device_path = None
def is_available(self, username: str) -> bool:
"""Check if fprintd is running and the user has enrolled fingerprints."""
if not self._device_proxy:
return False
try:
result = self._device_proxy.ListEnrolledFingers("(s)", username)
return bool(result)
except GLib.Error:
return False
def start(
self,
username: str,
on_success: Callable[[], None],
on_failure: Callable[[], None],
) -> None:
"""Start listening for fingerprint verification."""
if not self._device_proxy:
return
self._on_success = on_success
self._on_failure = on_failure
self._running = True
self._device_proxy.Claim("(s)", username)
# Connect to the VerifyStatus signal
self._signal_id = self._device_proxy.connect(
"g-signal", self._on_signal
)
self._device_proxy.VerifyStart("(s)", "any")
def stop(self) -> None:
"""Stop listening and release the device."""
if not self._running:
return
self._running = False
if self._device_proxy:
if self._signal_id is not None:
self._device_proxy.disconnect(self._signal_id)
self._signal_id = None
try:
self._device_proxy.VerifyStop()
except GLib.Error:
pass
try:
self._device_proxy.Release()
except GLib.Error:
pass
def _on_signal(
self,
proxy: Gio.DBusProxy,
sender_name: str | None,
signal_name: str,
parameters: GLib.Variant,
) -> None:
"""Handle D-Bus signals from the fprintd device."""
if signal_name != "VerifyStatus":
return
status = parameters[0]
done = parameters[1]
self._on_verify_status(status, done)
def _on_verify_status(self, status: str, done: bool) -> None:
"""Process a VerifyStatus signal from fprintd."""
if not self._running:
return
if status == "verify-match":
if self._on_success:
self._on_success()
return
if status in _RETRY_STATUSES:
# Retry — finger wasn't read properly
if done:
self._device_proxy.VerifyStart("(s)", "any")
return
if status == "verify-no-match":
if self._on_failure:
self._on_failure()
# Restart verification for another attempt
if done:
self._device_proxy.VerifyStart("(s)", "any")
return

View File

@ -1,97 +0,0 @@
# ABOUTME: Locale detection and string lookup for the lockscreen UI.
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
@dataclass(frozen=True)
class Strings:
"""All user-visible strings for the lockscreen UI."""
# UI labels
password_placeholder: str
unlock_button: str
reboot_tooltip: str
shutdown_tooltip: str
# Fingerprint
fingerprint_prompt: str
fingerprint_success: str
fingerprint_failed: str
# Error messages
auth_failed: str
wrong_password: str
reboot_failed: str
shutdown_failed: str
# Templates (use .format())
faillock_attempts_remaining: str
faillock_locked: str
_STRINGS_DE = Strings(
password_placeholder="Passwort",
unlock_button="Entsperren",
reboot_tooltip="Neustart",
shutdown_tooltip="Herunterfahren",
fingerprint_prompt="Fingerabdruck auflegen zum Entsperren",
fingerprint_success="Fingerabdruck erkannt",
fingerprint_failed="Fingerabdruck nicht erkannt",
auth_failed="Authentifizierung fehlgeschlagen",
wrong_password="Falsches Passwort",
reboot_failed="Neustart fehlgeschlagen",
shutdown_failed="Herunterfahren fehlgeschlagen",
faillock_attempts_remaining="Noch {n} Versuch(e) vor Kontosperrung!",
faillock_locked="Konto ist möglicherweise gesperrt",
)
_STRINGS_EN = Strings(
password_placeholder="Password",
unlock_button="Unlock",
reboot_tooltip="Reboot",
shutdown_tooltip="Shut down",
fingerprint_prompt="Place finger on reader to unlock",
fingerprint_success="Fingerprint recognized",
fingerprint_failed="Fingerprint not recognized",
auth_failed="Authentication failed",
wrong_password="Wrong password",
reboot_failed="Reboot failed",
shutdown_failed="Shutdown failed",
faillock_attempts_remaining="{n} attempt(s) remaining before lockout!",
faillock_locked="Account may be locked",
)
_LOCALE_MAP: dict[str, Strings] = {
"de": _STRINGS_DE,
"en": _STRINGS_EN,
}
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
"""Determine the system language from LANG env var or /etc/locale.conf."""
lang = os.environ.get("LANG")
if not lang and locale_conf_path.exists():
for line in locale_conf_path.read_text().splitlines():
if line.startswith("LANG="):
lang = line.split("=", 1)[1].strip()
break
if not lang or lang in ("C", "POSIX"):
return "en"
# Extract language prefix: "de_DE.UTF-8" → "de"
lang = lang.split("_")[0].split(".")[0]
return lang
def load_strings(locale: str | None = None) -> Strings:
"""Return the string table for the given locale, defaulting to English."""
if locale is None:
locale = detect_locale()
return _LOCALE_MAP.get(locale, _STRINGS_EN)

View File

@ -1,296 +0,0 @@
# ABOUTME: GTK4 lockscreen UI — avatar, password entry, fingerprint indicator, power buttons.
# ABOUTME: Connects PAM auth and fprintd listener, handles unlock flow.
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
from pathlib import Path
from moonlock.auth import authenticate
from moonlock.config import Config, load_config, resolve_background_path
from moonlock.fingerprint import FingerprintListener
from moonlock.i18n import Strings, load_strings
from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User
from moonlock import power
FAILLOCK_MAX_ATTEMPTS = 3
AVATAR_SIZE = 128
class LockscreenWindow(Gtk.ApplicationWindow):
"""Fullscreen lockscreen window with password and fingerprint auth."""
def __init__(self, application: Gtk.Application, unlock_callback: callable | None = None,
config: Config | None = None,
fingerprint_listener: FingerprintListener | None = None) -> None:
super().__init__(application=application)
self.add_css_class("lockscreen")
self._config = config or load_config()
self._strings = load_strings()
self._user = get_current_user()
self._failed_attempts = 0
self._unlock_callback = unlock_callback
# Fingerprint listener (shared across windows to avoid multiple device claims)
self._fp_listener = fingerprint_listener or FingerprintListener()
self._fp_available = (
self._config.fingerprint_enabled
and self._fp_listener.is_available(self._user.username)
)
self._build_ui()
self._setup_keyboard()
self._password_entry.grab_focus()
# Start fingerprint listener if available (only once across shared instances)
if self._fp_available and not self._fp_listener._running:
self._fp_listener.start(
self._user.username,
on_success=self._on_fingerprint_success,
on_failure=self._on_fingerprint_failure,
)
def _build_ui(self) -> None:
"""Build the lockscreen layout."""
# Main overlay for background + centered content
overlay = Gtk.Overlay()
self.set_child(overlay)
# Background wallpaper
wallpaper_path = resolve_background_path(self._config)
background = Gtk.Picture.new_for_filename(str(wallpaper_path))
background.set_content_fit(Gtk.ContentFit.COVER)
background.set_hexpand(True)
background.set_vexpand(True)
overlay.set_child(background)
# Centered vertical box
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
main_box.set_halign(Gtk.Align.CENTER)
main_box.set_valign(Gtk.Align.CENTER)
overlay.add_overlay(main_box)
# Login box (centered card)
self._login_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._login_box.set_halign(Gtk.Align.CENTER)
self._login_box.add_css_class("login-box")
main_box.append(self._login_box)
# Avatar — wrapped in a clipping frame for round shape
avatar_frame = Gtk.Box()
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE)
avatar_frame.set_halign(Gtk.Align.CENTER)
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
avatar_frame.add_css_class("avatar")
self._avatar_image = Gtk.Image()
self._avatar_image.set_pixel_size(AVATAR_SIZE)
avatar_frame.append(self._avatar_image)
self._login_box.append(avatar_frame)
avatar_path = get_avatar_path(self._user.home, self._user.username)
if avatar_path:
self._set_avatar_from_file(avatar_path)
else:
self._set_default_avatar()
# Username label
username_label = Gtk.Label(label=self._user.display_name)
username_label.add_css_class("username-label")
self._login_box.append(username_label)
# Password entry
self._password_entry = Gtk.PasswordEntry()
self._password_entry.set_property("placeholder-text", self._strings.password_placeholder)
self._password_entry.set_property("show-peek-icon", True)
self._password_entry.add_css_class("password-entry")
self._password_entry.connect("activate", self._on_password_submit)
self._login_box.append(self._password_entry)
# Error label
self._error_label = Gtk.Label()
self._error_label.add_css_class("error-label")
self._error_label.set_visible(False)
self._login_box.append(self._error_label)
# Fingerprint status label
self._fp_label = Gtk.Label()
self._fp_label.add_css_class("fingerprint-label")
if self._fp_available:
self._fp_label.set_text(self._strings.fingerprint_prompt)
self._fp_label.set_visible(True)
else:
self._fp_label.set_visible(False)
self._login_box.append(self._fp_label)
# Power buttons (bottom right)
power_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
power_box.set_halign(Gtk.Align.END)
power_box.set_valign(Gtk.Align.END)
power_box.set_hexpand(True)
power_box.set_vexpand(True)
power_box.set_margin_end(16)
power_box.set_margin_bottom(16)
overlay.add_overlay(power_box)
reboot_btn = Gtk.Button(icon_name="system-reboot-symbolic")
reboot_btn.add_css_class("power-button")
reboot_btn.set_tooltip_text(self._strings.reboot_tooltip)
reboot_btn.connect("clicked", lambda _: self._on_power_action(power.reboot))
power_box.append(reboot_btn)
shutdown_btn = Gtk.Button(icon_name="system-shutdown-symbolic")
shutdown_btn.add_css_class("power-button")
shutdown_btn.set_tooltip_text(self._strings.shutdown_tooltip)
shutdown_btn.connect("clicked", lambda _: self._on_power_action(power.shutdown))
power_box.append(shutdown_btn)
def _setup_keyboard(self) -> None:
"""Set up keyboard event handling."""
controller = Gtk.EventControllerKey()
controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(controller)
def _on_key_pressed(self, controller: Gtk.EventControllerKey, keyval: int,
keycode: int, state: Gdk.ModifierType) -> bool:
"""Handle key presses — Escape clears the password field."""
if keyval == Gdk.KEY_Escape:
self._password_entry.set_text("")
self._error_label.set_visible(False)
self._password_entry.grab_focus()
return True
return False
def _on_password_submit(self, entry: Gtk.PasswordEntry) -> None:
"""Handle password submission via Enter key."""
password = entry.get_text()
if not password:
return
# Run PAM auth in a thread to avoid blocking the UI
entry.set_sensitive(False)
def _do_auth() -> bool:
return authenticate(self._user.username, password)
def _on_auth_done(result: bool) -> None:
if result:
self._unlock()
return
self._failed_attempts += 1
entry.set_text("")
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS:
# Permanently disable entry after max failed attempts
self._show_error(self._strings.faillock_locked)
entry.set_sensitive(False)
else:
entry.set_sensitive(True)
entry.grab_focus()
if self._failed_attempts >= FAILLOCK_MAX_ATTEMPTS - 1:
remaining = FAILLOCK_MAX_ATTEMPTS - self._failed_attempts
self._show_error(
self._strings.faillock_attempts_remaining.format(n=remaining)
)
else:
self._show_error(self._strings.wrong_password)
# Use GLib thread pool to avoid blocking GTK mainloop
def _auth_thread() -> bool:
try:
result = _do_auth()
except Exception:
result = False
GLib.idle_add(_on_auth_done, result)
return GLib.SOURCE_REMOVE
GLib.Thread.new("pam-auth", _auth_thread)
def _on_fingerprint_success(self) -> None:
"""Called when fingerprint verification succeeds."""
GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_success)
GLib.idle_add(self._fp_label.add_css_class, "success")
GLib.idle_add(self._unlock)
def _on_fingerprint_failure(self) -> None:
"""Called when fingerprint verification fails (no match)."""
GLib.idle_add(self._fp_label.set_text, self._strings.fingerprint_failed)
GLib.idle_add(self._fp_label.add_css_class, "failed")
# Reset label after 2 seconds
GLib.timeout_add(
2000,
self._reset_fp_label,
)
def _reset_fp_label(self) -> bool:
"""Reset fingerprint label to prompt state."""
self._fp_label.set_text(self._strings.fingerprint_prompt)
self._fp_label.remove_css_class("success")
self._fp_label.remove_css_class("failed")
return GLib.SOURCE_REMOVE
def _get_foreground_color(self) -> str:
"""Get the current GTK theme foreground color as a hex string."""
rgba = self.get_color()
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
return f"#{r:02x}{g:02x}{b:02x}"
def _set_default_avatar(self) -> None:
"""Load the default avatar SVG, tinted with the GTK foreground color."""
try:
default_path = get_default_avatar_path()
svg_text = default_path.read_text()
fg_color = self._get_foreground_color()
svg_text = svg_text.replace("#PLACEHOLDER", fg_color)
svg_bytes = svg_text.encode("utf-8")
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
loader.set_size(AVATAR_SIZE, AVATAR_SIZE)
loader.write(svg_bytes)
loader.close()
pixbuf = loader.get_pixbuf()
if pixbuf:
self._avatar_image.set_from_pixbuf(pixbuf)
return
except (GLib.Error, OSError):
pass
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
def _set_avatar_from_file(self, path: Path) -> None:
"""Load an image file and set it as the avatar, scaled to AVATAR_SIZE."""
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), AVATAR_SIZE, AVATAR_SIZE, True
)
self._avatar_image.set_from_pixbuf(pixbuf)
except GLib.Error:
self._set_default_avatar()
def _show_error(self, message: str) -> None:
"""Display an error message."""
self._error_label.set_text(message)
self._error_label.set_visible(True)
def _unlock(self) -> None:
"""Unlock the screen after successful authentication."""
# Stop fingerprint listener
self._fp_listener.stop()
if self._unlock_callback:
self._unlock_callback()
def _on_power_action(self, action: callable) -> None:
"""Execute a power action (reboot/shutdown)."""
try:
action()
except Exception:
error_msg = (
self._strings.reboot_failed
if action == power.reboot
else self._strings.shutdown_failed
)
self._show_error(error_msg)

View File

@ -1,110 +0,0 @@
# ABOUTME: Entry point for Moonlock — sets up GTK Application and ext-session-lock-v1.
# ABOUTME: Handles CLI invocation, session locking, and multi-monitor support.
import os
import sys
from importlib.resources import files
# gtk4-layer-shell must be loaded before libwayland-client
_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so"
_existing_preload = os.environ.get("LD_PRELOAD", "")
if _LAYER_SHELL_LIB not in _existing_preload and os.path.exists(_LAYER_SHELL_LIB):
os.environ["LD_PRELOAD"] = f"{_existing_preload}:{_LAYER_SHELL_LIB}".lstrip(":")
os.execvp(sys.executable, [sys.executable, "-m", "moonlock.main"] + sys.argv[1:])
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk
from moonlock.config import load_config
from moonlock.fingerprint import FingerprintListener
from moonlock.lockscreen import LockscreenWindow
# ext-session-lock-v1 via gtk4-layer-shell
try:
gi.require_version("Gtk4SessionLock", "1.0")
from gi.repository import Gtk4SessionLock
HAS_SESSION_LOCK = True
except (ValueError, ImportError):
HAS_SESSION_LOCK = False
class MoonlockApp(Gtk.Application):
"""GTK Application for the Moonlock lockscreen."""
def __init__(self) -> None:
super().__init__(application_id="dev.moonarch.moonlock")
self._lock_instance = None
self._windows: list[Gtk.Window] = []
self._config = load_config()
def do_activate(self) -> None:
"""Create the lockscreen and lock the session."""
self._load_css()
if HAS_SESSION_LOCK and Gtk4SessionLock.is_supported():
self._activate_with_session_lock()
else:
# Fallback for development/testing without Wayland
self._activate_without_lock()
def _activate_with_session_lock(self) -> None:
"""Lock the session using ext-session-lock-v1 protocol."""
self._lock_instance = Gtk4SessionLock.Instance.new()
self._lock_instance.lock()
display = Gdk.Display.get_default()
monitors = display.get_monitors()
# Shared fingerprint listener across all windows (only one can claim the device)
fp_listener = FingerprintListener()
for i in range(monitors.get_n_items()):
monitor = monitors.get_item(i)
window = LockscreenWindow(
application=self,
unlock_callback=self._unlock,
config=self._config,
fingerprint_listener=fp_listener,
)
self._lock_instance.assign_window_to_monitor(window, monitor)
window.present()
self._windows.append(window)
def _activate_without_lock(self) -> None:
"""Fallback for development — no session lock, just a window."""
window = LockscreenWindow(
application=self,
unlock_callback=self._unlock,
)
window.set_default_size(800, 600)
window.present()
self._windows.append(window)
def _unlock(self) -> None:
"""Unlock the session and exit."""
if self._lock_instance:
self._lock_instance.unlock()
self.quit()
def _load_css(self) -> None:
"""Load the CSS stylesheet for the lockscreen."""
css_provider = Gtk.CssProvider()
css_path = files("moonlock") / "style.css"
css_provider.load_from_path(str(css_path))
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def main() -> None:
"""Run the Moonlock application."""
app = MoonlockApp()
app.run(sys.argv)
if __name__ == "__main__":
main()

View File

@ -1,14 +0,0 @@
# ABOUTME: Power actions — reboot and shutdown via loginctl.
# ABOUTME: Simple wrappers around system commands for the lockscreen UI.
import subprocess
def reboot() -> None:
"""Reboot the system via loginctl."""
subprocess.run(["loginctl", "reboot"], check=True)
def shutdown() -> None:
"""Shut down the system via loginctl."""
subprocess.run(["loginctl", "poweroff"], check=True)

View File

@ -1,76 +0,0 @@
/* ABOUTME: GTK4 CSS stylesheet for the Moonlock lockscreen. */
/* ABOUTME: Dark theme styling matching the Moonarch ecosystem. */
/* Main window background */
window.lockscreen {
background-color: #1a1a2e;
background-size: cover;
background-position: center;
}
/* Central login area */
.login-box {
padding: 40px;
border-radius: 12px;
background-color: transparent;
}
/* Round avatar image */
.avatar {
border-radius: 50%;
min-width: 128px;
min-height: 128px;
background-color: @theme_selected_bg_color;
border: 3px solid alpha(white, 0.3);
}
/* Username label */
.username-label {
font-size: 24px;
font-weight: bold;
color: white;
margin-top: 12px;
margin-bottom: 40px;
}
/* Password entry field */
.password-entry {
min-width: 280px;
}
/* Error message label */
.error-label {
color: #ff6b6b;
font-size: 14px;
}
/* Fingerprint status indicator */
.fingerprint-label {
color: alpha(white, 0.6);
font-size: 13px;
margin-top: 8px;
}
.fingerprint-label.success {
color: #51cf66;
}
.fingerprint-label.failed {
color: #ff6b6b;
}
/* Power buttons on the bottom right */
.power-button {
min-width: 48px;
min-height: 48px;
padding: 0px;
border-radius: 24px;
background-color: alpha(white, 0.1);
color: white;
border: none;
margin: 4px;
}
.power-button:hover {
background-color: alpha(white, 0.25);
}

View File

@ -1,65 +0,0 @@
# ABOUTME: Current user detection and avatar loading for the lockscreen.
# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
import os
import pwd
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
@dataclass(frozen=True)
class User:
"""Represents the current user for the lockscreen."""
username: str
display_name: str
home: Path
uid: int
def get_current_user() -> User:
"""Get the currently logged-in user's info from the system."""
# Use getuid() instead of getlogin() — getlogin() fails without a controlling
# terminal (systemd units, display-manager-started sessions).
pw = pwd.getpwuid(os.getuid())
gecos = pw.pw_gecos
# GECOS field may contain comma-separated values; first field is the full name
display_name = gecos.split(",")[0] if gecos else pw.pw_name
if not display_name:
display_name = pw.pw_name
return User(
username=pw.pw_name,
display_name=display_name,
home=Path(pw.pw_dir),
uid=pw.pw_uid,
)
def get_avatar_path(
home: Path,
username: str | None = None,
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
) -> Path | None:
"""Find the user's avatar image, checking ~/.face then AccountsService."""
# ~/.face takes priority
face = home / ".face"
if face.exists():
return face
# AccountsService icon
if username and accountsservice_dir.exists():
icon = accountsservice_dir / username
if icon.exists():
return icon
return None
def get_default_avatar_path() -> Path:
"""Return the path to the package default avatar SVG."""
return Path(str(files("moonlock") / "data" / "default-avatar.svg"))

48
src/power.rs Normal file
View File

@ -0,0 +1,48 @@
// ABOUTME: Power actions — reboot and shutdown via systemctl.
// ABOUTME: Wrappers around system commands for the lockscreen UI.
use std::fmt;
use std::process::Command;
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
}
impl fmt::Display for PowerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PowerError::CommandFailed { action, message } => write!(f, "{action} failed: {message}"),
}
}
}
impl std::error::Error for PowerError {}
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
let output = Command::new(program)
.args(args)
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| PowerError::CommandFailed { action, message: e.to_string() })?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
action, message: format!("exit code {}: {}", output.status, stderr.trim()),
});
}
Ok(())
}
pub fn reboot() -> Result<(), PowerError> { run_command("reboot", "/usr/bin/systemctl", &["--no-ask-password", "reboot"]) }
pub fn shutdown() -> Result<(), PowerError> { run_command("shutdown", "/usr/bin/systemctl", &["--no-ask-password", "poweroff"]) }
#[cfg(test)]
mod tests {
use super::*;
#[test] fn power_error_display() { assert_eq!(PowerError::CommandFailed { action: "reboot", message: "fail".into() }.to_string(), "reboot failed: fail"); }
#[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 success() { assert!(run_command("test", "true", &[]).is_ok()); }
}

103
src/users.rs Normal file
View File

@ -0,0 +1,103 @@
// ABOUTME: Current user detection and avatar loading for the lockscreen.
// ABOUTME: Retrieves user info from the system (nix getuid, AccountsService, ~/.face).
use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock";
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
pub fn get_current_user() -> Option<User> {
let uid = getuid();
let nix_user = NixUser::from_uid(uid).ok()??;
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 first = gecos.split(',').next().unwrap_or("");
if first.is_empty() { nix_user.name.clone() } else { first.to_string() }
} else { nix_user.name.clone() };
Some(User { username: nix_user.name, display_name, home: nix_user.dir, uid: uid.as_raw() })
}
pub fn get_avatar_path(home: &Path, username: &str) -> Option<PathBuf> {
get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
}
pub fn get_avatar_path_with(home: &Path, username: &str, accountsservice_dir: &Path) -> Option<PathBuf> {
// ~/.face takes priority — single stat via symlink_metadata to avoid TOCTOU
let face = home.join(".face");
if let Ok(meta) = face.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() { return Some(face); }
}
// AccountsService icon
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username);
if let Ok(meta) = icon.symlink_metadata() {
if meta.is_file() && !meta.file_type().is_symlink() { return Some(icon); }
}
}
None
}
pub fn get_default_avatar_path() -> String {
format!("{GRESOURCE_PREFIX}/default-avatar.svg")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test] fn current_user_exists() {
let u = get_current_user();
assert!(u.is_some());
let u = u.unwrap();
assert!(!u.username.is_empty());
}
#[test] fn face_file_priority() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face"); fs::write(&face, "img").unwrap();
let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap();
let icon = icons.join("test"); fs::write(&icon, "img").unwrap();
assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(face));
}
#[test] fn accountsservice_fallback() {
let dir = tempfile::tempdir().unwrap();
let icons = dir.path().join("icons"); fs::create_dir(&icons).unwrap();
let icon = icons.join("test"); fs::write(&icon, "img").unwrap();
assert_eq!(get_avatar_path_with(dir.path(), "test", &icons), Some(icon));
}
#[test] fn no_avatar() {
let dir = tempfile::tempdir().unwrap();
assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none());
}
#[test] fn rejects_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real"); fs::write(&real, "x").unwrap();
std::os::unix::fs::symlink(&real, dir.path().join(".face")).unwrap();
assert!(get_avatar_path_with(dir.path(), "test", Path::new("/nonexistent")).is_none());
}
#[test] fn default_avatar_gresource() {
let p = get_default_avatar_path();
assert!(p.contains("moonlock"));
assert!(p.contains("default-avatar.svg"));
}
}

View File

View File

@ -1,65 +0,0 @@
# ABOUTME: Tests for PAM authentication via ctypes wrapper.
# ABOUTME: Verifies authenticate() calls libpam correctly and handles success/failure.
from unittest.mock import patch, MagicMock, ANY
import ctypes
from moonlock.auth import authenticate, PAM_SUCCESS, PAM_AUTH_ERR
class TestAuthenticate:
"""Tests for PAM authentication."""
@patch("moonlock.auth._get_libpam")
def test_returns_true_on_successful_auth(self, mock_get_libpam):
libpam = MagicMock()
mock_get_libpam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_SUCCESS
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
libpam.pam_end.return_value = PAM_SUCCESS
assert authenticate("testuser", "correctpassword") is True
@patch("moonlock.auth._get_libpam")
def test_returns_false_on_wrong_password(self, mock_get_libpam):
libpam = MagicMock()
mock_get_libpam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_AUTH_ERR
libpam.pam_end.return_value = PAM_SUCCESS
assert authenticate("testuser", "wrongpassword") is False
@patch("moonlock.auth._get_libpam")
def test_pam_end_always_called(self, mock_get_libpam):
libpam = MagicMock()
mock_get_libpam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_AUTH_ERR
libpam.pam_end.return_value = PAM_SUCCESS
authenticate("testuser", "wrongpassword")
libpam.pam_end.assert_called_once()
@patch("moonlock.auth._get_libpam")
def test_returns_false_when_pam_start_fails(self, mock_get_libpam):
libpam = MagicMock()
mock_get_libpam.return_value = libpam
libpam.pam_start.return_value = PAM_AUTH_ERR
assert authenticate("testuser", "password") is False
@patch("moonlock.auth._get_libpam")
def test_uses_moonlock_as_service_name(self, mock_get_libpam):
libpam = MagicMock()
mock_get_libpam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_SUCCESS
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
libpam.pam_end.return_value = PAM_SUCCESS
authenticate("testuser", "password")
args = libpam.pam_start.call_args
# First positional arg should be the service name
assert args[0][0] == b"moonlock"

View File

@ -1,42 +0,0 @@
# ABOUTME: Tests for configuration loading.
# ABOUTME: Verifies TOML parsing, defaults, and path override behavior.
from pathlib import Path
from moonlock.config import Config, load_config
class TestLoadConfig:
"""Tests for config loading."""
def test_defaults_when_no_config_file(self, tmp_path: Path):
nonexistent = tmp_path / "nonexistent.toml"
config = load_config(config_paths=[nonexistent])
assert config.background_path is None
assert config.fingerprint_enabled is True
def test_reads_background_path(self, tmp_path: Path):
config_file = tmp_path / "moonlock.toml"
config_file.write_text('background_path = "/usr/share/wallpapers/moon.jpg"\n')
config = load_config(config_paths=[config_file])
assert config.background_path == "/usr/share/wallpapers/moon.jpg"
def test_reads_fingerprint_disabled(self, tmp_path: Path):
config_file = tmp_path / "moonlock.toml"
config_file.write_text("fingerprint_enabled = false\n")
config = load_config(config_paths=[config_file])
assert config.fingerprint_enabled is False
def test_later_paths_override_earlier(self, tmp_path: Path):
system_conf = tmp_path / "system.toml"
system_conf.write_text('background_path = "/system/wallpaper.jpg"\n')
user_conf = tmp_path / "user.toml"
user_conf.write_text('background_path = "/home/user/wallpaper.jpg"\n')
config = load_config(config_paths=[system_conf, user_conf])
assert config.background_path == "/home/user/wallpaper.jpg"
def test_partial_config_uses_defaults(self, tmp_path: Path):
config_file = tmp_path / "moonlock.toml"
config_file.write_text('background_path = "/some/path.jpg"\n')
config = load_config(config_paths=[config_file])
assert config.fingerprint_enabled is True

View File

@ -1,152 +0,0 @@
# ABOUTME: Tests for fprintd D-Bus integration.
# ABOUTME: Verifies fingerprint listener lifecycle and signal handling with mocked D-Bus.
from unittest.mock import patch, MagicMock, call
from moonlock.fingerprint import FingerprintListener
class TestFingerprintListenerAvailability:
"""Tests for checking fprintd availability."""
@patch("moonlock.fingerprint.Gio.DBusProxy.new_for_bus_sync")
def test_is_available_when_fprintd_running_and_enrolled(self, mock_proxy_cls):
manager = MagicMock()
mock_proxy_cls.return_value = manager
manager.GetDefaultDevice.return_value = ("(o)", "/dev/0")
device = MagicMock()
mock_proxy_cls.return_value = device
device.ListEnrolledFingers.return_value = ("(as)", ["right-index-finger"])
listener = FingerprintListener.__new__(FingerprintListener)
listener._manager_proxy = manager
listener._device_proxy = device
listener._device_path = "/dev/0"
assert listener.is_available("testuser") is True
def test_is_available_returns_false_when_no_device(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = None
listener._device_path = None
assert listener.is_available("testuser") is False
class TestFingerprintListenerLifecycle:
"""Tests for start/stop lifecycle."""
def test_start_calls_verify_start(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._device_path = "/dev/0"
listener._signal_id = None
listener._running = False
on_success = MagicMock()
on_failure = MagicMock()
listener.start("testuser", on_success=on_success, on_failure=on_failure)
listener._device_proxy.Claim.assert_called_once_with("(s)", "testuser")
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
def test_stop_calls_verify_stop_and_release(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
listener._signal_id = 42
listener.stop()
listener._device_proxy.VerifyStop.assert_called_once()
listener._device_proxy.Release.assert_called_once()
assert listener._running is False
def test_stop_is_noop_when_not_running(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = False
listener.stop()
listener._device_proxy.VerifyStop.assert_not_called()
class TestFingerprintSignalHandling:
"""Tests for VerifyStatus signal processing."""
def test_verify_match_calls_on_success(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
on_success = MagicMock()
on_failure = MagicMock()
listener._on_success = on_success
listener._on_failure = on_failure
listener._on_verify_status("verify-match", False)
on_success.assert_called_once()
def test_verify_no_match_calls_on_failure_and_retries_when_done(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
on_success = MagicMock()
on_failure = MagicMock()
listener._on_success = on_success
listener._on_failure = on_failure
listener._on_verify_status("verify-no-match", True)
on_failure.assert_called_once()
# Should restart verification when done=True
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
def test_verify_no_match_calls_on_failure_without_restart_when_not_done(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
on_success = MagicMock()
on_failure = MagicMock()
listener._on_success = on_success
listener._on_failure = on_failure
listener._on_verify_status("verify-no-match", False)
on_failure.assert_called_once()
# Should NOT restart verification when done=False (still in progress)
listener._device_proxy.VerifyStart.assert_not_called()
def test_verify_swipe_too_short_retries_when_done(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
on_success = MagicMock()
on_failure = MagicMock()
listener._on_success = on_success
listener._on_failure = on_failure
listener._on_verify_status("verify-swipe-too-short", True)
on_success.assert_not_called()
on_failure.assert_not_called()
listener._device_proxy.VerifyStart.assert_called_once_with("(s)", "any")
def test_retry_status_does_not_restart_when_not_done(self):
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
on_success = MagicMock()
on_failure = MagicMock()
listener._on_success = on_success
listener._on_failure = on_failure
listener._on_verify_status("verify-swipe-too-short", False)
on_success.assert_not_called()
on_failure.assert_not_called()
# Should NOT restart — verification still in progress
listener._device_proxy.VerifyStart.assert_not_called()

View File

@ -1,67 +0,0 @@
# ABOUTME: Tests for locale detection and string lookup.
# ABOUTME: Verifies correct language detection from env vars and /etc/locale.conf.
import os
from pathlib import Path
from unittest.mock import patch
from moonlock.i18n import Strings, detect_locale, load_strings
class TestDetectLocale:
"""Tests for locale detection."""
@patch.dict(os.environ, {"LANG": "de_DE.UTF-8"})
def test_detects_german_from_env(self):
assert detect_locale() == "de"
@patch.dict(os.environ, {"LANG": "en_US.UTF-8"})
def test_detects_english_from_env(self):
assert detect_locale() == "en"
@patch.dict(os.environ, {"LANG": ""}, clear=False)
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path):
locale_conf = tmp_path / "locale.conf"
locale_conf.write_text("LANG=de_DE.UTF-8\n")
assert detect_locale(locale_conf_path=locale_conf) == "de"
@patch.dict(os.environ, {"LANG": "C"})
def test_c_locale_defaults_to_english(self):
assert detect_locale() == "en"
@patch.dict(os.environ, {"LANG": "POSIX"})
def test_posix_locale_defaults_to_english(self):
assert detect_locale() == "en"
@patch.dict(os.environ, {}, clear=True)
def test_missing_env_and_no_file_defaults_to_english(self, tmp_path: Path):
nonexistent = tmp_path / "nonexistent"
assert detect_locale(locale_conf_path=nonexistent) == "en"
class TestLoadStrings:
"""Tests for string table loading."""
def test_german_strings(self):
strings = load_strings("de")
assert isinstance(strings, Strings)
assert strings.password_placeholder == "Passwort"
def test_english_strings(self):
strings = load_strings("en")
assert strings.password_placeholder == "Password"
def test_unknown_locale_falls_back_to_english(self):
strings = load_strings("fr")
assert strings.password_placeholder == "Password"
def test_lockscreen_specific_strings_exist(self):
strings = load_strings("de")
assert strings.unlock_button is not None
assert strings.fingerprint_prompt is not None
assert strings.fingerprint_success is not None
def test_faillock_template_strings(self):
strings = load_strings("de")
msg = strings.faillock_attempts_remaining.format(n=2)
assert "2" in msg

View File

@ -1,164 +0,0 @@
# ABOUTME: Integration tests for the complete auth flow.
# ABOUTME: Tests password and fingerprint unlock paths end-to-end (mocked PAM/fprintd).
from unittest.mock import patch, MagicMock, PropertyMock
from moonlock.lockscreen import LockscreenWindow, FAILLOCK_MAX_ATTEMPTS
class TestPasswordAuthFlow:
"""Integration tests for password authentication flow."""
@patch("moonlock.lockscreen.FingerprintListener")
@patch("moonlock.lockscreen.get_avatar_path", return_value=None)
@patch("moonlock.lockscreen.get_current_user")
@patch("moonlock.lockscreen.authenticate")
def test_successful_password_unlock(self, mock_auth, mock_user, mock_avatar, mock_fp):
"""Successful password auth should trigger unlock callback."""
mock_user.return_value = MagicMock(
username="testuser", display_name="Test", home="/tmp", uid=1000
)
mock_fp_instance = MagicMock()
mock_fp_instance.is_available.return_value = False
mock_fp.return_value = mock_fp_instance
mock_auth.return_value = True
unlock_called = []
# We can't create a real GTK window without a display, so test the auth logic directly
from moonlock.auth import authenticate
result = authenticate.__wrapped__("testuser", "correct") if hasattr(authenticate, '__wrapped__') else mock_auth("testuser", "correct")
assert result is True
@patch("moonlock.lockscreen.authenticate", return_value=False)
def test_failed_password_increments_counter(self, mock_auth):
"""Failed password should increment failed attempts."""
# Test the counter logic directly
failed_attempts = 0
result = mock_auth("testuser", "wrong")
if not result:
failed_attempts += 1
assert failed_attempts == 1
assert result is False
def test_faillock_warning_after_threshold(self):
"""Faillock warning should appear near max attempts."""
from moonlock.i18n import load_strings
strings = load_strings("de")
failed = FAILLOCK_MAX_ATTEMPTS - 1
remaining = FAILLOCK_MAX_ATTEMPTS - failed
msg = strings.faillock_attempts_remaining.format(n=remaining)
assert "1" in msg
def test_faillock_locked_at_max_attempts(self):
"""Account locked message at max failed attempts."""
from moonlock.i18n import load_strings
strings = load_strings("de")
assert strings.faillock_locked
class TestFingerprintAuthFlow:
"""Integration tests for fingerprint authentication flow."""
def test_fingerprint_success_triggers_unlock(self):
"""verify-match signal should lead to unlock."""
from moonlock.fingerprint import FingerprintListener
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
unlock_called = []
listener._on_success = lambda: unlock_called.append(True)
listener._on_failure = MagicMock()
listener._on_verify_status("verify-match", False)
assert len(unlock_called) == 1
def test_fingerprint_no_match_retries_when_done(self):
"""verify-no-match with done=True should call on_failure and restart verification."""
from moonlock.fingerprint import FingerprintListener
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
listener._on_success = MagicMock()
listener._on_failure = MagicMock()
listener._on_verify_status("verify-no-match", True)
listener._on_failure.assert_called_once()
listener._device_proxy.VerifyStart.assert_called_once()
def test_fingerprint_no_match_no_restart_when_not_done(self):
"""verify-no-match with done=False should call on_failure but not restart."""
from moonlock.fingerprint import FingerprintListener
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
listener._on_success = MagicMock()
listener._on_failure = MagicMock()
listener._on_verify_status("verify-no-match", False)
listener._on_failure.assert_called_once()
listener._device_proxy.VerifyStart.assert_not_called()
def test_fingerprint_and_password_independent(self):
"""Both auth methods should work independently."""
from moonlock.fingerprint import FingerprintListener
from moonlock.auth import authenticate
# Fingerprint path
listener = FingerprintListener.__new__(FingerprintListener)
listener._device_proxy = MagicMock()
listener._running = True
fp_unlock = []
listener._on_success = lambda: fp_unlock.append(True)
listener._on_failure = MagicMock()
listener._on_verify_status("verify-match", False)
assert len(fp_unlock) == 1
# Password path (mocked)
with patch("moonlock.auth._get_libpam") as mock_pam:
from moonlock.auth import PAM_SUCCESS
libpam = MagicMock()
mock_pam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_SUCCESS
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
libpam.pam_end.return_value = PAM_SUCCESS
assert authenticate("testuser", "correct") is True
class TestSecurityConstraints:
"""Tests for security-related behavior."""
def test_escape_does_not_unlock(self):
"""Escape key should only clear the field, not unlock."""
# Escape should clear password, not trigger any auth
from gi.repository import Gdk
assert Gdk.KEY_Escape != Gdk.KEY_Return
def test_empty_password_not_submitted(self):
"""Empty password should not trigger PAM auth."""
with patch("moonlock.auth._get_libpam") as mock_pam:
# The lockscreen checks for empty password before calling authenticate
password = ""
assert not password # falsy, so auth should not be called
mock_pam.assert_not_called()
def test_pam_service_name_is_moonlock(self):
"""PAM should use 'moonlock' as service name, not 'login' or 'sudo'."""
with patch("moonlock.auth._get_libpam") as mock_pam:
from moonlock.auth import PAM_SUCCESS
libpam = MagicMock()
mock_pam.return_value = libpam
libpam.pam_start.return_value = PAM_SUCCESS
libpam.pam_authenticate.return_value = PAM_SUCCESS
libpam.pam_acct_mgmt.return_value = PAM_SUCCESS
libpam.pam_end.return_value = PAM_SUCCESS
from moonlock.auth import authenticate
authenticate("user", "pass")
assert libpam.pam_start.call_args[0][0] == b"moonlock"

View File

@ -1,24 +0,0 @@
# ABOUTME: Tests for power actions (reboot, shutdown).
# ABOUTME: Verifies loginctl commands are called correctly.
from unittest.mock import patch, call
from moonlock.power import reboot, shutdown
class TestReboot:
"""Tests for the reboot function."""
@patch("moonlock.power.subprocess.run")
def test_reboot_calls_loginctl(self, mock_run):
reboot()
mock_run.assert_called_once_with(["loginctl", "reboot"], check=True)
class TestShutdown:
"""Tests for the shutdown function."""
@patch("moonlock.power.subprocess.run")
def test_shutdown_calls_loginctl(self, mock_run):
shutdown()
mock_run.assert_called_once_with(["loginctl", "poweroff"], check=True)

View File

@ -1,24 +0,0 @@
# ABOUTME: Tests for security-related functionality.
# ABOUTME: Verifies password wiping, PAM cleanup, and lockscreen bypass prevention.
from moonlock.auth import _wipe_bytes
class TestPasswordWiping:
"""Tests for sensitive data cleanup."""
def test_wipe_bytes_zeroes_bytearray(self):
data = bytearray(b"secretpassword")
_wipe_bytes(data)
assert data == bytearray(len(b"secretpassword"))
assert all(b == 0 for b in data)
def test_wipe_bytes_handles_empty(self):
data = bytearray(b"")
_wipe_bytes(data)
assert data == bytearray(b"")
def test_wipe_bytes_handles_bytes_gracefully(self):
# Regular bytes are immutable, wipe should be a no-op
data = b"secret"
_wipe_bytes(data) # should not raise

View File

@ -1,96 +0,0 @@
# ABOUTME: Tests for current user detection and avatar loading.
# ABOUTME: Verifies user info retrieval from the system.
import os
from pathlib import Path
from unittest.mock import patch
from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User
class TestGetCurrentUser:
"""Tests for current user detection."""
@patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwuid")
def test_returns_user_with_correct_username(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User"
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.username == "testuser"
assert user.display_name == "Test User"
assert user.home == Path("/home/testuser")
mock_pwd.assert_called_once_with(1000)
@patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwuid")
def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = ""
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.display_name == "testuser"
@patch("moonlock.users.os.getuid", return_value=1000)
@patch("moonlock.users.pwd.getpwuid")
def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User,,,Room 42"
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.display_name == "Test User"
class TestGetAvatarPath:
"""Tests for avatar path resolution."""
def test_returns_face_file_if_exists(self, tmp_path: Path):
face = tmp_path / ".face"
face.write_text("fake image")
path = get_avatar_path(tmp_path)
assert path == face
def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path):
username = "testuser"
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
icon = icons_dir / username
icon.write_text("fake image")
path = get_avatar_path(
tmp_path, username=username, accountsservice_dir=icons_dir
)
assert path == icon
def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path):
face = tmp_path / ".face"
face.write_text("fake image")
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
icon = icons_dir / "testuser"
icon.write_text("fake image")
path = get_avatar_path(
tmp_path, username="testuser", accountsservice_dir=icons_dir
)
assert path == face
def test_returns_none_when_no_avatar(self, tmp_path: Path):
path = get_avatar_path(tmp_path)
assert path is None
class TestGetDefaultAvatarPath:
"""Tests for default avatar fallback."""
def test_default_avatar_exists(self):
"""The package default avatar must always be present."""
path = get_default_avatar_path()
assert path.is_file()
def test_default_avatar_is_svg(self):
"""The default avatar should be an SVG file."""
path = get_default_avatar_path()
assert path.suffix == ".svg"

View File

@ -1,53 +0,0 @@
# ABOUTME: Tests for wallpaper path resolution.
# ABOUTME: Verifies fallback hierarchy: config > Moonarch system default > package fallback.
from pathlib import Path
from unittest.mock import patch
from moonlock.config import Config, resolve_background_path, MOONARCH_WALLPAPER, PACKAGE_WALLPAPER
class TestResolveBackgroundPath:
"""Tests for the wallpaper fallback hierarchy."""
def test_config_path_used_when_file_exists(self, tmp_path: Path):
"""Config background_path takes priority if the file exists."""
wallpaper = tmp_path / "custom.jpg"
wallpaper.write_bytes(b"\xff\xd8")
config = Config(background_path=str(wallpaper))
result = resolve_background_path(config)
assert result == wallpaper
def test_config_path_skipped_when_file_missing(self, tmp_path: Path):
"""Config path should be skipped if the file does not exist."""
config = Config(background_path="/nonexistent/wallpaper.jpg")
with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"):
result = resolve_background_path(config)
assert result == PACKAGE_WALLPAPER
def test_moonarch_default_used_when_no_config(self, tmp_path: Path):
"""Moonarch system wallpaper is used when config has no background_path."""
moonarch_wp = tmp_path / "wallpaper.jpg"
moonarch_wp.write_bytes(b"\xff\xd8")
config = Config(background_path=None)
with patch("moonlock.config.MOONARCH_WALLPAPER", moonarch_wp):
result = resolve_background_path(config)
assert result == moonarch_wp
def test_moonarch_default_skipped_when_missing(self, tmp_path: Path):
"""Falls back to package wallpaper when Moonarch default is missing."""
config = Config(background_path=None)
with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"):
result = resolve_background_path(config)
assert result == PACKAGE_WALLPAPER
def test_package_fallback_always_exists(self):
"""The package fallback wallpaper must always be present."""
assert PACKAGE_WALLPAPER.is_file()
def test_full_fallback_chain(self, tmp_path: Path):
"""With no config and no Moonarch default, package fallback is returned."""
config = Config()
with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"):
result = resolve_background_path(config)
assert result == PACKAGE_WALLPAPER

45
uv.lock generated
View File

@ -1,45 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "moonlock"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pygobject" },
]
[package.metadata]
requires-dist = [{ name = "pygobject", specifier = ">=3.46" }]
[[package]]
name = "pycairo"
version = "1.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" },
{ url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" },
{ url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
{ url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
{ url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
{ url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
{ url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
{ url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
{ url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
{ url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
{ url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
{ url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
]
[[package]]
name = "pygobject"
version = "3.56.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycairo" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }