16 Commits

Author SHA1 Message Date
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 64f032cd9a fix: PKGBUILD depends on gtk-session-lock (not gtk4-session-lock) 2026-03-27 23:28:11 +01:00
nevaforget 4968b7ee2d Merge rust-rewrite: moonlock v0.4.0 Rust rewrite 2026-03-27 23:18:03 +01:00
nevaforget 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
nevaforget 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
nevaforget 7de3737a61 Revert "feat: fade-in animation for panel and wallpaper windows"
This reverts commit 4a2bbb3e98.
2026-03-27 15:12:26 +01:00
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 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
nevaforget 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
41 changed files with 3392 additions and 1761 deletions
+1 -7
View File
@@ -1,10 +1,4 @@
__pycache__/
*.pyc
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
/target
# makepkg build artifacts
pkg/src/
+31 -25
View File
@@ -4,50 +4,56 @@
## 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, wallpaper.jpg, 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<Vec<u8>>)
- `fingerprint.rs` — fprintd D-Bus Listener, async init/claim/verify via gio futures, sync stop with 3s timeout, on_exhausted callback after MAX_FP_ATTEMPTS
- `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, fingerprint_enabled als Option<bool>) + Wallpaper-Fallback
- `lockscreen.rs` — GTK4 UI via LockscreenHandles, PAM-Auth via gio::spawn_blocking, FP-Label/Start separat verdrahtet, Zeroizing<String> für Passwort, Power-Confirm
- `main.rs` — Entry Point, Panic-Hook, Root-Check, ext-session-lock-v1 (Pflicht in Release), Multi-Monitor, systemd-Journal-Logging, async fprintd-Init nach window.present()
## 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
- PAM-Callback: msg_style-aware (Passwort nur bei PAM_PROMPT_ECHO_OFF), strdup-OOM-sicher
- Passwort: Zeroizing<String> ab GTK-Entry-Extraktion, Zeroizing<Vec<u8>> im PAM-FFI-Layer (bekannte Einschränkung: GLib-GString und CString werden nicht gezeroized — inhärente GTK/libc-Limitierung)
- 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
- GResource-Bundle: CSS/Assets in der Binary kompiliert
Generated
+1301
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "moonlock"
version = "0.5.0"
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_1"] }
glib = "0.22"
gdk4 = "0.11"
gdk-pixbuf = "0.22"
gio = "0.22"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["user"] }
zeroize = { version = "1", features = ["derive"] }
libc = "0.2"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
log = "0.4"
systemd-journal-logger = "2.2"
[dev-dependencies]
tempfile = "3"
[build-dependencies]
glib-build-tools = "0.22"
+17
View File
@@ -0,0 +1,17 @@
# Decisions
Architectural and design decisions for Moonlock, in reverse chronological order.
## 2026-03-28 Optional background blur via `image` crate
- **Who**: Nyx, 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**: Nyx, 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.
+73
View File
@@ -0,0 +1,73 @@
# 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)
- **PAM authentication** — Uses system PAM stack (`/etc/pam.d/moonlock`)
- **Fingerprint unlock** — fprintd D-Bus integration, async init (optional, window appears instantly)
- **Multi-monitor** — Lockscreen on every monitor, single shared fingerprint listener
- **i18n** — German and English (auto-detected)
- **Faillock warning** — UI counter + system pam_faillock
- **Panic safety** — Panic hook logs but never unlocks
- **Password wiping** — Zeroize on drop
## 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"
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
View File
@@ -0,0 +1,13 @@
// ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css, wallpaper.jpg, 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");
}
+7 -10
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"
-30
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"

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moonlock">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>
@@ -6,6 +6,12 @@ window.lockscreen {
background-color: #1a1a2e;
background-size: cover;
background-position: center;
opacity: 0;
transition: opacity 350ms ease-in;
}
window.lockscreen.visible {
opacity: 1;
}
/* Central login area */
@@ -59,6 +65,38 @@ window.lockscreen {
color: #ff6b6b;
}
/* Confirmation prompt */
.confirm-label {
font-size: 16px;
color: white;
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;

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

+239
View File
@@ -0,0 +1,239 @@
// 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;
// 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 {
// Safety: appdata_ptr was set to a valid *const CString in authenticate()
let password = appdata_ptr as *const CString;
if password.is_null() {
return 7; // 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 7; // PAM_AUTH_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 {
// Use Zeroizing to ensure password bytes are wiped on drop
let password_bytes = Zeroizing::new(password.as_bytes().to_vec());
let password_cstr = match CString::new(password_bytes.as_slice()) {
Ok(c) => 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: &password_cstr as *const CString 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
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
}
#[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);
}
}
+123
View File
@@ -0,0 +1,123 @@
// 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";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonlock";
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) {
if let Ok(parsed) = toml::from_str::<RawConfig>(&content) {
if parsed.background_path.is_some() { merged.background_path = parsed.background_path; }
if parsed.background_blur.is_some() { merged.background_blur = parsed.background_blur; }
if let Some(fp) = parsed.fingerprint_enabled { merged.fingerprint_enabled = fp; }
}
}
}
merged
}
pub fn resolve_background_path(config: &Config) -> PathBuf {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if path.is_file() { return path; }
}
if moonarch_wallpaper.is_file() { return moonarch_wallpaper.to_path_buf(); }
PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg"))
}
#[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")), 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_gresource_fallback() {
let c = Config::default();
let r = resolve_background_path_with(&c, Path::new("/nonexistent"));
assert!(r.to_str().unwrap().contains("moonlock"));
}
}
+399
View File
@@ -0,0 +1,399 @@
// 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::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;
/// 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,
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 FingerprintListener {
/// Create a lightweight FingerprintListener without any D-Bus calls.
/// Call `init_async().await` afterwards to connect to fprintd.
pub fn new() -> Self {
FingerprintListener {
device_proxy: None,
signal_id: None,
running: false,
failed_attempts: 0,
on_success: None,
on_failure: None,
on_exhausted: None,
}
}
/// 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, -1)
.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;
}
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, -1)
.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 proxy = {
let inner = listener.borrow();
match inner.device_proxy.clone() {
Some(p) => p,
None => return,
}
};
{
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));
}
// Claim the device
let args = glib::Variant::from((&username,));
if let Err(e) = proxy
.call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, -1)
.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, -1)
.await
{
log::error!("Failed to start fingerprint verification: {e}");
let _ = proxy
.call_future("Release", None, gio::DBusCallFlags::NONE, -1)
.await;
return;
}
// 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 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;
}
/// Process a VerifyStatus signal from fprintd.
pub fn on_verify_status(&mut self, status: &str, done: bool) {
if !self.running {
return;
}
if status == "verify-match" {
self.running = false;
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}");
}
/// Restart fingerprint verification asynchronously after a completed attempt.
fn restart_verify_async(&self) {
if let Some(ref proxy) = self.device_proxy {
let proxy = proxy.clone();
glib::spawn_future_local(async move {
// VerifyStop before VerifyStart to avoid D-Bus errors
let _ = proxy
.call_future("VerifyStop", None, gio::DBusCallFlags::NONE, -1)
.await;
let args = glib::Variant::from((&"any",));
if let Err(e) = proxy
.call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, -1)
.await
{
log::error!("Failed to restart fingerprint verification: {e}");
}
});
}
}
/// Stop listening and release the device.
/// Uses a short timeout (3s) to avoid blocking the UI indefinitely.
pub fn stop(&mut self) {
if !self.running {
return;
}
self.running = false;
if let Some(ref proxy) = self.device_proxy {
if let Some(id) = self.signal_id.take() {
proxy.disconnect(id);
}
let _ = proxy.call_sync(
"VerifyStop",
None,
gio::DBusCallFlags::NONE,
3000,
gio::Cancellable::NONE,
);
let _ = proxy.call_sync(
"Release",
None,
gio::DBusCallFlags::NONE,
3000,
gio::Cancellable::NONE,
);
}
}
}
#[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() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false));
let called_clone = called.clone();
let mut listener = FingerprintListener::new();
listener.running = 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);
}
#[test]
fn verify_no_match_calls_failure_and_stays_running() {
use std::cell::Cell;
let called = Rc::new(Cell::new(false));
let called_clone = called.clone();
let mut listener = FingerprintListener::new();
listener.running = 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_eq!(listener.failed_attempts, 1);
}
#[test]
fn max_attempts_stops_listener_and_calls_exhausted() {
use std::cell::Cell;
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() {
use std::cell::Cell;
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());
}
}
+140
View File
@@ -0,0 +1,140 @@
// 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,
}
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",
};
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",
};
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 }
}
pub fn faillock_warning(attempt_count: u32, max_attempts: u32, strings: &Strings) -> Option<String> {
if attempt_count >= max_attempts { return Some(strings.faillock_locked.to_string()); }
let remaining = max_attempts - attempt_count;
if remaining == 1 { 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_none()); }
#[test] fn faillock_two() { assert!(faillock_warning(2, 3, load_strings(Some("en"))).is_some()); }
#[test] fn faillock_three() { assert_eq!(faillock_warning(3, 3, load_strings(Some("en"))).unwrap(), "Account may be locked"); }
}
+620
View File
@@ -0,0 +1,620 @@
// 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 gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use image::imageops;
use std::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;
/// 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.
pub fn create_lockscreen_window(
bg_texture: &gdk::Texture,
_config: &Config,
app: &gtk::Application,
unlock_callback: Rc<dyn Fn()>,
) -> 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
let background = create_background_picture(bg_texture);
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
let avatar_path = users::get_avatar_path(&user.home, &user.username);
if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path);
} else {
set_default_avatar(&avatar_image, &window);
}
// 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
let username = user.username.clone();
password_entry.connect_activate(clone!(
#[strong]
state,
#[strong]
unlock_callback,
#[weak]
error_label,
#[weak]
password_entry,
move |entry| {
let password = Zeroizing::new(entry.text().to_string());
if password.is_empty() {
return;
}
entry.set_sensitive(false);
let username = username.clone();
let unlock_cb = unlock_callback.clone();
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;
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 on_success = move || {
let label = fp_label_success.clone();
let cb = unlock_cb_fp.clone();
glib::idle_add_local_once(move || {
label.set_text(load_strings(None).fingerprint_success);
label.add_css_class("success");
cb();
});
};
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.
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture {
let fallback = "/dev/moonarch/moonlock/wallpaper.jpg";
let texture = if bg_path.starts_with("/dev/moonarch/moonlock") {
let resource_path = bg_path.to_str().unwrap_or(fallback);
gdk::Texture::from_resource(resource_path)
} else {
let file = gio::File::for_path(bg_path);
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
gdk::Texture::from_resource(fallback)
})
};
match blur_radius {
Some(sigma) if sigma > 0.0 => apply_blur(&texture, sigma),
_ => texture,
}
}
/// Apply Gaussian blur to a texture and return a blurred texture.
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let width = texture.width() as u32;
let height = texture.height() as u32;
let stride = width as usize * 4;
let mut pixel_data = vec![0u8; stride * height as usize];
texture.download(&mut pixel_data, stride);
let img = image::RgbaImage::from_raw(width, height, pixel_data)
.expect("pixel buffer size matches texture dimensions");
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
let bytes = glib::Bytes::from(blurred.as_raw());
let mem_texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
gdk::MemoryFormat::B8g8r8a8Premultiplied,
&bytes,
stride,
);
mem_texture.upcast()
}
/// Create a Picture widget for the wallpaper background from a shared texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
background.set_vexpand(true);
background
}
/// Load an image file and set it as the avatar.
fn set_avatar_from_file(image: &gtk::Image, path: &Path) {
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
}
Err(_) => {
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
fn set_default_avatar(image: &gtk::Image, window: &gtk::ApplicationWindow) {
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));
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);
}
}
+210
View File
@@ -0,0 +1,210 @@
// 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::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_APPLICATION,
);
}
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);
let bg_path = config::resolve_background_path(&config);
let bg_texture = lockscreen::load_background_texture(&bg_path, config.background_blur);
if gtk4_session_lock::is_supported() {
activate_with_session_lock(app, &display, &bg_texture, &config);
} else {
#[cfg(debug_assertions)]
{
log::warn!("ext-session-lock-v1 not supported — running in development mode");
activate_without_lock(app, &bg_texture, &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,
bg_texture: &gdk::Texture,
config: &config::Config,
) {
let lock = gtk4_session_lock::Instance::new();
lock.lock();
let monitors = display.monitors();
// Shared unlock callback — unlocks session and quits
let lock_clone = lock.clone();
let app_clone = app.clone();
let unlock_callback: Rc<dyn Fn()> = Rc::new(move || {
lock_clone.unlock();
app_clone.quit();
});
// Create all monitor windows immediately — no D-Bus calls here
let mut all_handles = Vec::new();
let mut created_any = false;
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let handles = lockscreen::create_lockscreen_window(
bg_texture,
config,
app,
unlock_callback.clone(),
);
lock.assign_window_to_monitor(&handles.window, &monitor);
handles.window.present();
all_handles.push(handles);
created_any = true;
}
}
if !created_any {
log::error!("No lockscreen windows created — screen stays locked (compositor policy)");
return;
}
// Async fprintd initialization — runs after windows are visible
if config.fingerprint_enabled {
init_fingerprint_async(all_handles);
}
}
/// Initialize fprintd asynchronously after windows are visible.
/// Uses a single FingerprintListener shared across all monitors —
/// only the first monitor's handles get the fingerprint UI wired up.
fn init_fingerprint_async(all_handles: Vec<lockscreen::LockscreenHandles>) {
glib::spawn_future_local(async move {
let mut listener = FingerprintListener::new();
listener.init_async().await;
// Use the first monitor's username to check enrollment
let username = &all_handles[0].username;
if username.is_empty() {
return;
}
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));
// Show fingerprint label on all monitors
for handles in &all_handles {
lockscreen::show_fingerprint_label(handles, &fp_rc);
}
// Start verification listener on the first monitor only
lockscreen::start_fingerprint(&all_handles[0], &fp_rc);
});
}
#[cfg(debug_assertions)]
fn activate_without_lock(
app: &gtk::Application,
bg_texture: &gdk::Texture,
config: &config::Config,
) {
let app_clone = app.clone();
let unlock_callback: Rc<dyn Fn()> = Rc::new(move || {
app_clone.quit();
});
let handles = lockscreen::create_lockscreen_window(
bg_texture,
config,
app,
unlock_callback,
);
handles.window.set_default_size(800, 600);
handles.window.present();
// Async fprintd initialization for development mode
if config.fingerprint_enabled {
init_fingerprint_async(vec![handles]);
}
}
fn setup_logging() {
systemd_journal_logger::JournalLog::new()
.unwrap()
.install()
.unwrap();
log::set_max_level(log::LevelFilter::Info);
}
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() {
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);
}
install_panic_hook();
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();
}
-2
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.
-154
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)
-62
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
-157
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
-97
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)
-296
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)
-110
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()
-14
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)
-65
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"))
+51
View File
@@ -0,0 +1,51 @@
// 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 },
Timeout { action: &'static str },
}
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}"),
PowerError::Timeout { action } => write!(f, "{action} timed out"),
}
}
}
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 timeout_display() { assert_eq!(PowerError::Timeout { action: "shutdown" }.to_string(), "shutdown timed out"); }
#[test] fn missing_binary() { assert!(run_command("test", "nonexistent-xyz", &[]).is_err()); }
#[test] fn nonzero_exit() { assert!(run_command("test", "false", &[]).is_err()); }
#[test] fn success() { assert!(run_command("test", "true", &[]).is_ok()); }
}
+93
View File
@@ -0,0 +1,93 @@
// 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 = nix_user.gecos.to_str().unwrap_or("").to_string();
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
let face = home.join(".face");
if face.exists() && !face.is_symlink() { return Some(face); }
// AccountsService icon
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(username);
if icon.exists() && !icon.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
-65
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"
-42
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
-152
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()
-67
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
-164
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"
-24
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)
-24
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
-96
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"
-53
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
Generated
-45
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" }