From 879d6583dacea9a6fe421319fe463d6cae73831b Mon Sep 17 00:00:00 2001 From: Geert-Johan Riemer Date: Sun, 4 Oct 2026 18:49:44 +0200 Subject: [PATCH] Fix fingerprint enrollment and lock-screen recovery (#7158) * Restart fprintd after resume to clear a claim wedged by suspend A fingerprint verify still open when the machine suspends leaves fprintd unable to hand the reader back: the verify dies with "Cannot run while suspended" and the follow-up ReleaseDevice fails on the still-busy device. The wedged claim then rejects every lock-screen attempt after resume until fprintd exits on its own 30-second idle timer -- and the retry loop keeps it from ever reaching that timer, so the reader stays dead until the user gives up and types a password. Install a system-sleep hook that restarts fprintd on resume, dropping the claim so the reader answers on the first touch. It is installed by omarchy-setup-security-fingerprint and removed by its teardown, so it is present exactly when a fingerprint reader is configured. try-restart is a no-op when fprintd is not running, so a healthy resume pays nothing. Approach suggested in #7229 and measured by @paracycle: 45 stray PAM sessions after resume down to 2. * Pace fingerprint retries and show when the reader is unavailable The lock screen retried fingerprint auth on a flat 250ms timer with no sign to the user, so a reader it could not reach -- a claim wedged across suspend, one held by another client, or a sensor gone from the bus -- spun PAM sessions at four per second behind an icon still inviting touches that could never unlock. Pace and report on one signal: whether an attempt reached the reader at all. pam_fprintd relays a finger prompt only once the claim lands, so an attempt that ends without prompting never reached the device. Those advance a streak that backs the retry off exponentially (to a ceiling above fprintd's 30s idle exit) and, past a few in a row, crosses out the icon and shows a "Fingerprint reader unavailable" notice. An attempt that did prompt proves the reader works -- a finger that merely did not match still reaches it -- so it clears the streak and the loop stays responsive. User presence (a keypress or touch) collapses a backed-off wait to a prompt retry, rate-limited so a moving cursor cannot respin the storm. An attempt that never reaches the reader within a few seconds is aborted and settled as unreached, so a claim orphaned by the resume restart surfaces the notice and retries a fresh daemon rather than hanging silently. The pacing, streak, nudge, and reach-timeout logic live in FingerprintModel.js with Node coverage; the new Text elements declare textFormat; lock status reports fingerprintUnavailable. The attempt state machine tracks the open attempt with fingerprintAuthenticating alone; the first settle closes it, and one PAM attempt raising both onError and onCompleted still folds into the streak exactly once. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Install the fprintd resume hook root-owned and keep it with the PAM file cp -p carried the checkout's owner and mode into /usr/lib/systemd/system-sleep/, so under dev-link the root-executed hook was user-owned, and a tree whose exec bit had been stripped installed a hook that systemd-sleep silently never ran. Use install -Dm755 -o root -g root, as the migration that installs the same file already does. The hook also belongs exactly where the fingerprint PAM file does: omarchy-apply-lock creates and removes /etc/pam.d/omarchy-lock-fingerprint on its own, and any apply-lock run after enrollment left PAM without the hook while its removal branch left a hook behind without PAM. Have apply-lock install and remove the hook together with the PAM file, and teach apply-lock-test.sh to redirect the hook into its scratch tree and assert the hardened run lands it beside the PAM fixtures. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Only treat fingerprint as configured when a print is enrolled The lock screen and omarchy-apply-lock decided fingerprint was set up with fprintd-list | grep -qi finger, which also matches "has no fingers enrolled" and "ListEnrolledFingers failed". A second account on a machine where one user enrolled, or anyone who ran fprintd-delete, was therefore handed the fingerprint loop: every attempt bailed before the claim, and with the new pacing that showed up as a crossed icon and "Fingerprint reader unavailable" for a reader the account simply has no print on. Match the per-print " - #N:" lines instead. apply-lock-test.sh follows: its fprintd-list stubs answer with a real enrolled-print row and its helper patcher matches the new probe line. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Exercise the migration's default hook source in its test Every case overrode OMARCHY_FPRINTD_RESUME_SRC, so the path the migration really reads from was never checked, while its -f guard turns a missing source into a clean exit and a permanent per-user marker. Add a case that runs against the shipped hook under the repo, and adopt set -euo pipefail like the sibling tests. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Take the fprintd restart off the thaw and bound its stop timeout The resume hook ran systemctl try-restart synchronously while user sessions were still frozen, so its cost landed on the wake path: half a second when fprintd answers SIGTERM, but a wedged fprintd on a stale device handle (the reader re-enumerated across the sleep) does not, and then the desktop stayed frozen for the whole stop timeout -- precisely in the case the hook exists for. Enqueue the restart with --no-block instead, as the unmount-fuse hook already does for the same reason, and ship a drop-in capping fprintd's TimeoutStopSec at 3s so the restart lands within seconds either way. The drop-in is numbered 10-stop-timeout.conf, as the other Omarchy system drop-ins are, so an administrator's override.conf sorts after it and wins. It is installed and removed wherever the hook is (setup, teardown, apply-lock, migration), and apply-lock-test.sh redirects it into its scratch tree alongside the hook. The hook's comments now say what actually happens on a locked resume -- Omarchy locks before every suspend and the lock screen opens a verify at once, so the restart is real, not a no-op -- and name the upstream defects this works around, fprintd#173 and fprintd#216, so the hook and the drop-in can be retired when upstream fixes them. Measured by MaxMad75 on an X390 Yoga (S3): 2 of 10 fprintd stops rode out the timeout to SIGKILL; the 3s cap verified with systemctl show. Co-authored-by: Omabot Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Close the status-check and start-failure exits through settle Two paths left the fingerprint loop stuck or misreporting. A mid-lock status check that found fingerprint unconfigured aborted the PAM context directly; abort() delivers no signal, so fingerprintAuthenticating stayed true and every later attempt and nudge returned on it until the password unlock. And a fingerprintPam.start() that fails synchronously means the PAM file is gone -- a configuration problem, not a reader miss -- yet it fed the reader streak and reported "Fingerprint reader unavailable". Route the abort through settleFingerprintAttempt like the reach timeout does, drop the pending retry with it, and on a start failure re-check the configuration so the icon disappears instead; a pending retry owns the next attempt when a status check comes back configured. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Pace fingerprint nudges by the pending tier and the cap's idle stretch The nudge cooldown was a flat 2s, shorter than every backoff step, so a user moving the mouse at a wedged reader collapsed each wait to 2s -- thirty claims a minute against the cap's 1.5 -- and each claim re-armed fprintd's 30s idle timer, so the hook-less recovery the cap exists for never happened while anyone was present. Grow the cooldown with the pending wait, so presence collapses each backed-off wait once and repeat nudges are paced by the tier. At the cap the wait itself is the cure -- it is what lets fprintd idle out and drop a wedged claim -- so there the idle stretch is measured from the last settle, not the last nudge: a nudged attempt that hung until the reach timeout would otherwise eat most of the window, and under continuous input fprintd would never be left alone long enough to exit. Wall-clock steps are handled in both directions: a clock stepped back past the last nudge does not hold a fresh nudge back, and one stepped back past the last settle counts as no idle time at the cap rather than as enough. The retry test drives continuous input against attempts that hang to the reach bound and checks the gap fprintd is left. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Give a slow fingerprint claim time to land before aborting it The reach bound aborted any attempt that had not prompted within 5s by SIGKILLing the PAM child mid-Claim. A reader whose device open takes longer than that (out-of-tree drivers, and any reader right after the resume hook forces a re-open) could then never prompt: each kill left fprintd tearing the claim down until the open finished, the 1s retry hit "already claimed", and three misses later the reader was reported unavailable for good. Raise the bound to 20s, under GDBus's 25s Claim timeout and pam_fprintd's 30s verify timeout (whose "Verification timed out" is a non-error message that would read as reached), and name the hazard the bound actually covers: a daemon restarted under the verify fails the attempt promptly, a stuck device open does not. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Detect resume and hold the streak through the restart window Monotonic timers pause across suspend, so a backed-off wait armed before the sleep picked up mid-count afterwards: with the streak at the cap the "Fingerprint reader unavailable" notice stayed up for the remaining wait after the resume hook had already freed the reader, and misses collected around the suspend edge carried across it, so a healthy reader could cross the notice threshold in the first seconds after waking. With the restart enqueued off the thaw, the loop's first attempts after a wake can also land on the old daemon while it is being stopped -- up to ~3s when it ignores SIGTERM -- and three of those would show the notice for a reader that was merely being restarted underneath. Notice a resume from any of three signals -- a sleep watch ticking the wall clock for the whole lock, a retry that fired late, or an unreached attempt whose settle finds the watch's last tick far in the past (so a suspend shorter than the reach bound is caught before the tick itself gets a chance to) -- and open a grace window: the stale streak is dropped, a pending wait retries the fresh daemon at once, and misses inside the window hold the streak at the first tier without ever counting toward the notice. Detection is idempotent within the window, since more than one timer can notice the same resume. Pinned by MaxMad75's reading: the window is armed by the resume, not by the first miss. Verified on his X390 (S3, frozen sessions): six lid-close cycles, fingerprint-resume at +15ms, streak held, notice never fired. Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Only let a definitive probe change whether fingerprint is configured The status probe collapsed every fprintd-list result into yes or no, so an unreachable fprintd -- restarting under the resume hook, or failing a D-Bus activation mid-resume -- read as "not configured": the icon vanished, the retry loop and the sleep watch stopped, and nothing asked again for the rest of the lock. One transient miss killed fingerprint until the next lock, with the password as the only clue. MaxMad75 hit it on hardware in run 6 of the X390 series; osborng filed the stock repro as #9453 (mask fprintd, lock, unmask -- fingerprint never returns). Classify the probe's output instead: an enrolled-print row is yes, fprintd's explicit no-prints answer (or a missing PAM file or binary) is no, and anything else is unknown -- the probe could not tell, so nothing changes and it is retried on the attempt-retry pacing. The unavailable notice, backoff, and resume detection all sit downstream of this flag; now only an answer that actually means something can clear it. Fixes #9453. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Log the fingerprint loop's misses, notice, and recovery as lock events The reach timeout, an unreached settle, the streak crossing into the notice, a resume restart, and the recovery all changed lock state without touching logEvent, so a report of "Fingerprint reader unavailable" left no omarchy lock line to line up with suspend and resume timestamps in omarchy-debug-idle output. Log those transitions; reached attempts are the steady state and stay quiet. A match that unlocks after a run of misses is the recovery too -- the unlock resets the streak without settling, so it logs fingerprint-recovered there as well. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Abort an attempt stranded in flight when a resume is detected A verify that survived into the suspend still prompted comes back to a daemon the resume hook has already replaced, and the loop's resume handling deliberately left it alone: the reach timer stopped at the prompt, so nothing bounded it but pam_fprintd's own ~25s timeout, and until that ran out the icon invited touches that could not work. Most visible where user sessions are not frozen across sleep and the lock races the hook. Abort the stranded session when the resume is detected and route it through settle: it lands inside the grace window, so the kill never counts toward the notice, and the settle arms the fast retry against the fresh daemon itself. Suggested by sliekens in review. Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt * Simplify fingerprint recovery and consolidate enrollment checks Use the enrolled-entry matcher from #9551 while retaining the lock's tri-state probe recovery and the privileged /usr/bin/fprintd-list call. Unknown enrollment probes must preserve existing PAM and resume recovery rather than deleting the machinery needed to recover. Preserve administrator-owned unnumbered timeout files during migration. Remove presence-driven retry overrides and their cooldown, clock, and idle-window state: resume has its own fast recovery path, while other errors can follow the bounded automatic backoff. Let the existing sleep watcher detect resume instead of also tracking the age of each retry. Setup now uses apply-lock so PAM and recovery installation have one implementation. Keep the restart, stop bound, unreachable-attempt pacing, unavailable feedback, reach watchdog, and probe rechecks because each handles a distinct failure. Co-Authored-By: Karl Ahlin Co-Authored-By: Codex Medium * Preserve failed-enrollment coverage in the setup fixture The successful-enrollment fixture accepts PAM commands, so failure checks must explicitly reject those commands instead of relying on an unexpected-command error. Log both sed and tee and stub apply-lock for every case so premature authentication setup is detected without reaching live PAM files. Co-Authored-By: Codex Medium * Complete fingerprint recovery and setup reporting Back off immediate device errors after the verification prompt as well as failed claims, while retaining fast retries for mismatches and normal scan timeouts. Measure from the prompt so a slow claim cannot hide a fast failure. Paced user activity retries preserve the daemon idle window required to clear a wedged claim. Initial probe outages remain visible without inventing enrollment, and setup cannot claim lock-screen success when the PAM configuration was not installed. Exercise the real QML service rather than a copy of its state machine. Co-Authored-By: GPT-6 Co-Authored-By: Claude Opus 5.5 Medium * Avoid competing fingerprint probes and partial setup Known enrollment is recovered by the PAM retry loop, so failed status probes must not raise a false unavailable notice or interrupt its daemon idle window. Initial unknown enrollment still gets paced probes. Install recovery files before enabling fingerprint PAM so a missing source cannot leave a new partial configuration. Co-Authored-By: GPT-6 Co-Authored-By: Claude Opus 5.5 Medium * Keep the fingerprint error clock at the first prompt pam_fprintd also sends Verification timed out as an informational message. Updating the prompt timestamp on that message made a normal full scan window look like an immediate device error and caused unnecessary backoff. Record the first prompt of each PAM attempt so later status messages cannot move the error window. Co-Authored-By: GPT-6 --------- Co-authored-by: Omabot Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Codex XHigh Co-authored-by: David Heinemeier Hansson Co-authored-by: Karl Ahlin Co-authored-by: Omarchy Bot --- bin/omarchy-apply-lock | 31 ++- bin/omarchy-remove-security-fingerprint | 14 ++ bin/omarchy-setup-security-fingerprint | 16 +- default/systemd/system-sleep/fprintd-resume | 9 + .../fprintd.service.d/10-stop-timeout.conf | 4 + migrations/1791125650.sh | 22 ++ shell/plugins/lock/FingerprintModel.js | 91 +++++++ shell/plugins/lock/LockView.qml | 26 +- shell/plugins/lock/Service.qml | 223 ++++++++++++++++-- test/shell.d/apply-lock-test.sh | 97 +++++++- test/shell.d/fingerprint-package-test.sh | 45 +++- .../lock-fingerprint-indicator/shell.qml | 21 ++ .../lock-fingerprint-service/shell.qml | 178 ++++++++++++++ test/shell.d/fprintd-resume-hook-test.sh | 61 +++++ test/shell.d/fprintd-resume-migration-test.sh | 112 +++++++++ test/shell.d/lock-fingerprint-retry-test.sh | 182 ++++++++++++++ test/shell.d/lock-fingerprint-service-test.sh | 73 ++++++ 17 files changed, 1156 insertions(+), 49 deletions(-) create mode 100755 default/systemd/system-sleep/fprintd-resume create mode 100644 default/systemd/system/fprintd.service.d/10-stop-timeout.conf create mode 100644 migrations/1791125650.sh create mode 100644 shell/plugins/lock/FingerprintModel.js create mode 100644 test/shell.d/fixtures/lock-fingerprint-service/shell.qml create mode 100755 test/shell.d/fprintd-resume-hook-test.sh create mode 100755 test/shell.d/fprintd-resume-migration-test.sh create mode 100755 test/shell.d/lock-fingerprint-retry-test.sh create mode 100644 test/shell.d/lock-fingerprint-service-test.sh diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 5bb261cb..1472e04a 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -40,16 +40,41 @@ auth required pam_faillock.so authsucc account include system-local-login EOF -if [[ -x /usr/bin/fprintd-list ]] && - /usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then +# Install resume recovery alongside fingerprint PAM, so setup and upgrades +# cannot leave one configured without the other. +resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume" +resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume +stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" +stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf + +# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment. +if [[ -x /usr/bin/fprintd-list ]]; then + fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true) +else + fingerprint_status="no" +fi + +if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then echo "Configuring lock screen fingerprint authentication..." + as_root install -Dm755 -o root -g root "$resume_hook_src" "$resume_hook_dst" + as_root install -Dm644 -o root -g root "$stop_timeout_src" "$stop_timeout_dst" + as_root systemctl daemon-reload as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 auth required pam_fprintd.so account include system-local-login EOF +elif [[ $fingerprint_status == "no" ]] || grep -qi 'has no fingers enrolled' <<< "$fingerprint_status"; then + as_root rm -f /etc/pam.d/omarchy-lock-fingerprint "$resume_hook_dst" + # Only reload when something was there: this branch also runs in the + # install chroot, where there is no systemd to reload. + if [[ -e $stop_timeout_dst ]]; then + as_root rm -f "$stop_timeout_dst" + as_root rmdir --ignore-fail-on-non-empty "$(dirname "$stop_timeout_dst")" + as_root systemctl daemon-reload + fi else - as_root rm -f /etc/pam.d/omarchy-lock-fingerprint + echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2 fi # omarchy-shell can't reach a running shell during chroot install. The echo diff --git a/bin/omarchy-remove-security-fingerprint b/bin/omarchy-remove-security-fingerprint index fc53c109..7c2254be 100755 --- a/bin/omarchy-remove-security-fingerprint +++ b/bin/omarchy-remove-security-fingerprint @@ -43,11 +43,25 @@ remove_lock_fingerprint_pam() { fi } +remove_resume_hook() { + if [[ -f /usr/lib/systemd/system-sleep/fprintd-resume ]]; then + echo "Removing fingerprint resume hook..." + sudo rm -f /usr/lib/systemd/system-sleep/fprintd-resume + fi + if [[ -f /etc/systemd/system/fprintd.service.d/10-stop-timeout.conf ]]; then + echo "Removing fprintd stop timeout bound..." + sudo rm -f /etc/systemd/system/fprintd.service.d/10-stop-timeout.conf + sudo rmdir --ignore-fail-on-non-empty /etc/systemd/system/fprintd.service.d + sudo systemctl daemon-reload + fi +} + echo -e "\e[32mRemoving fingerprint scanner from authentication.\n\e[0m" remove_pam_config remove_lock_fingerprint_pam +remove_resume_hook sudo rm -rf -- "/var/lib/fprint/$fingerprint_user" diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index 4b3c436a..0367cefa 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -56,16 +56,6 @@ EOF fi } -setup_lock_fingerprint_pam() { - echo "Configuring lock screen for fingerprint authentication..." - sudo tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' -#%PAM-1.0 -auth required pam_fprintd.so -account include system-local-login -EOF -} - - echo -e "\e[32mSetting up fingerprint scanner for authentication.\n\e[0m" # Bail before installing anything if there's no reader to talk to. @@ -99,7 +89,11 @@ if sudo fprintd-enroll "$USER"; then # Editing the stacks up front would leave those machines pointing at # pam_fprintd with nothing to match. setup_pam_config - setup_lock_fingerprint_pam + "$OMARCHY_PATH/bin/omarchy-apply-lock" + if [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]]; then + echo "Fingerprint enrollment succeeded, but lock-screen configuration could not be confirmed. Run omarchy-apply-lock and try again." >&2 + exit 1 + fi echo -e "\e[32m\nPerfect! Fingerprint authentication is now configured.\e[0m" echo "You can use your fingerprint for sudo, polkit, and lock screen (Super + Ctrl + L)." else diff --git a/default/systemd/system-sleep/fprintd-resume b/default/systemd/system-sleep/fprintd-resume new file mode 100755 index 00000000..6bdb778e --- /dev/null +++ b/default/systemd/system-sleep/fprintd-resume @@ -0,0 +1,9 @@ +#!/bin/bash + +# Suspend can wedge fprintd's claim; restart it after resume to clear it. +# Upstream: https://gitlab.freedesktop.org/libfprint/fprintd/-/issues/173 + +if [[ $1 == "post" ]]; then + # user.slice stays frozen until this hook returns; enqueue rather than wait. + systemctl --no-block try-restart fprintd.service 2>/dev/null || true +fi diff --git a/default/systemd/system/fprintd.service.d/10-stop-timeout.conf b/default/systemd/system/fprintd.service.d/10-stop-timeout.conf new file mode 100644 index 00000000..f67e9aea --- /dev/null +++ b/default/systemd/system/fprintd.service.d/10-stop-timeout.conf @@ -0,0 +1,4 @@ +# A stale device handle can make fprintd ignore SIGTERM after resume. +# The numeric prefix lets an administrator's override.conf take precedence. +[Service] +TimeoutStopSec=3s diff --git a/migrations/1791125650.sh b/migrations/1791125650.sh new file mode 100644 index 00000000..52301af7 --- /dev/null +++ b/migrations/1791125650.sh @@ -0,0 +1,22 @@ +echo "Install the fingerprint resume hook on existing fingerprint setups" + +# Existing enrolled machines never rerun setup. Install only missing files +# so administrator changes survive an upgrade. + +hook_src="${OMARCHY_FPRINTD_RESUME_SRC:-$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume}" +hook_dst="${OMARCHY_FPRINTD_RESUME_DST:-/usr/lib/systemd/system-sleep/fprintd-resume}" +stop_timeout_src="${OMARCHY_FPRINTD_STOP_TIMEOUT_SRC:-$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf}" +stop_timeout_dst="${OMARCHY_FPRINTD_STOP_TIMEOUT_DST:-/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf}" +lock_pam="${OMARCHY_LOCK_FINGERPRINT_PAM:-/etc/pam.d/omarchy-lock-fingerprint}" + +[[ -f $lock_pam ]] || exit 0 + +if [[ -f $hook_src && ! -e $hook_dst ]]; then + echo "Installing the fprintd resume hook" + sudo install -Dm755 "$hook_src" "$hook_dst" +fi + +if [[ -f $stop_timeout_src && ! -e $stop_timeout_dst ]]; then + sudo install -Dm644 "$stop_timeout_src" "$stop_timeout_dst" + sudo systemctl daemon-reload +fi diff --git a/shell/plugins/lock/FingerprintModel.js b/shell/plugins/lock/FingerprintModel.js new file mode 100644 index 00000000..b489c7ec --- /dev/null +++ b/shell/plugins/lock/FingerprintModel.js @@ -0,0 +1,91 @@ +// Back off missing prompts and fast device errors; ordinary mismatches +// and a full scan window without a finger keep the normal swipe interval. +var MATCH_RETRY_MS = 250 +var FAST_ERROR_MS = 2000 +var NUDGE_COOLDOWN_MS = 2000 +var IDLE_CLEAR_MS = 32000 +var ERROR_RETRY_BASE_MS = 1000 +// Leave enough idle time for fprintd to exit and clear a wedged claim. +var FPRINTD_IDLE_EXIT_MS = 30000 +var ERROR_RETRY_CAP_MS = 40000 +var UNAVAILABLE_AFTER = 3 +// Abort before the D-Bus claim timeout, while allowing slow device opens. +var REACH_TIMEOUT_MS = 20000 +// Monotonic timers pause across suspend; a wall-clock gap detects resume. +var SLEEP_GAP_MS = 2000 +// Ignore transient misses while the asynchronous resume restart lands. +var RESUME_GRACE_MS = 5000 + +function retryDelayMs(streak) { + if (streak <= 0) return MATCH_RETRY_MS + var delay = ERROR_RETRY_BASE_MS * Math.pow(2, streak - 1) + return Math.min(delay, ERROR_RETRY_CAP_MS) +} + +// Preserve the daemon idle window even under continuous user activity. +function shouldNudge(nowMs, lastNudgeMs, lastSettleMs, currentIntervalMs) { + if (currentIntervalMs <= MATCH_RETRY_MS) return false + var sinceNudge = nowMs - lastNudgeMs + var sinceSettle = nowMs - lastSettleMs + // A backward clock step cannot count as a completed idle window. + if (sinceNudge >= 0 && sinceNudge < Math.max(NUDGE_COOLDOWN_MS, currentIntervalMs)) return false + if (sinceSettle < 0) sinceSettle = 0 + if (currentIntervalMs >= ERROR_RETRY_CAP_MS && sinceSettle < IDLE_CLEAR_MS) return false + return true +} + + +// A failed probe is unknown, so it cannot disable authentication for the lock. +function classifyProbe(text) { + var s = String(text || "").trim() + if (/^[ \t]*-[ \t]*#[0-9]+:/m.test(s)) return "yes" + if (s === "no") return "no" + if (/has no fingers enrolled/i.test(s)) return "no" + return "unknown" +} + +// Resume-time misses stay at the first tier while fprintd restarts. +function nextStreak(streak, usableAttempt, inResumeGrace) { + if (usableAttempt) return 0 + if (inResumeGrace) return 1 + return streak + 1 +} + +// Event-loop stalls may also open the grace window; retries remain paced. +function spannedSleep(elapsedMs, expectedMs) { + return elapsedMs > expectedMs + SLEEP_GAP_MS +} + +function inResumeGrace(nowMs, resumedAtMs) { + if (resumedAtMs <= 0) return false + var elapsed = nowMs - resumedAtMs + return elapsed >= 0 && elapsed < RESUME_GRACE_MS +} + +// A few consecutive misses avoid reporting a single claim conflict. +function isUnavailable(streak) { + return streak >= UNAVAILABLE_AFTER +} + +if (typeof module !== "undefined") { + module.exports = { + MATCH_RETRY_MS: MATCH_RETRY_MS, + FAST_ERROR_MS: FAST_ERROR_MS, + NUDGE_COOLDOWN_MS: NUDGE_COOLDOWN_MS, + IDLE_CLEAR_MS: IDLE_CLEAR_MS, + shouldNudge: shouldNudge, + ERROR_RETRY_BASE_MS: ERROR_RETRY_BASE_MS, + ERROR_RETRY_CAP_MS: ERROR_RETRY_CAP_MS, + FPRINTD_IDLE_EXIT_MS: FPRINTD_IDLE_EXIT_MS, + UNAVAILABLE_AFTER: UNAVAILABLE_AFTER, + REACH_TIMEOUT_MS: REACH_TIMEOUT_MS, + SLEEP_GAP_MS: SLEEP_GAP_MS, + RESUME_GRACE_MS: RESUME_GRACE_MS, + spannedSleep: spannedSleep, + classifyProbe: classifyProbe, + inResumeGrace: inResumeGrace, + retryDelayMs: retryDelayMs, + nextStreak: nextStreak, + isUnavailable: isUnavailable + } +} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index dea08319..e00ef6e9 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -10,6 +10,7 @@ Item { property string videoPosterPath: "" property int backgroundVersion: 0 property bool fingerprintConfigured: false + property bool fingerprintUnavailable: false property bool authenticatingPassword: false property string failureMessage: "" property int failedAttempts: 0 @@ -235,20 +236,41 @@ Item { // Fingerprint hint pinned inside the field's right edge when a sensor is // enrolled, so the user knows they can touch to unlock instead of typing. // Matches hyprlock, which draws its fingerprint icon in the same spot. + // A reader the shell cannot reach crosses out rather than disappears, so + // it stops inviting touches that can never unlock. Text { id: fingerprintIcon objectName: "fingerprintIndicator" + textFormat: Text.PlainText anchors.right: parent.right anchors.rightMargin: inputField.borderRight + 18 anchors.verticalCenter: parent.verticalCenter visible: root.fingerprintConfigured - text: "󰈷" - color: Color.lock.placeholder + text: root.fingerprintUnavailable ? "󰺱" : "󰈷" + color: root.fingerprintUnavailable ? Color.lock.textError : Color.lock.placeholder font.family: Style.font.family font.pixelSize: Math.round(root.fieldFontSize * 1.1) horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } + + // The crossed-out icon has no meaning to a user who has never seen it — it + // is not intuitive that it signals a broken reader — so the words carry the + // explanation and the icon only reinforces it. + Text { + objectName: "fingerprintUnavailableNotice" + textFormat: Text.PlainText + anchors.top: inputField.bottom + anchors.topMargin: 18 + anchors.horizontalCenter: inputField.horizontalCenter + visible: root.fingerprintConfigured && root.fingerprintUnavailable + text: "Fingerprint reader unavailable" + color: Color.lock.textError + font.family: Style.font.family + font.pixelSize: Style.font.heading + font.italic: true + horizontalAlignment: Text.AlignHCenter + } } } diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 845e1d1e..41336479 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Pam import Quickshell.Wayland import qs.Commons +import "FingerprintModel.js" as FingerprintModel Item { id: root @@ -22,6 +23,14 @@ Item { property bool fingerprintAuthenticating: false property bool passwordPamConfigured: false property bool fingerprintConfigured: false + property int fingerprintUnreachedStreak: 0 + property bool fingerprintAttemptReachedDevice: false + property bool fingerprintAttemptFastError: false + property double fingerprintAttemptPromptedAtMs: 0 + property double fingerprintLastNudgeMs: 0 + property double fingerprintLastSettleMs: 0 + property double fingerprintResumedAtMs: 0 + property int fingerprintProbeStreak: 0 property bool previewVisible: false property string enteredPassword: "" property string pendingPassword: "" @@ -50,6 +59,8 @@ Item { readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false + // A prompt clears the unavailable notice before the attempt finishes. + readonly property bool fingerprintUnavailable: FingerprintModel.isUnavailable(fingerprintProbeStreak) || (fingerprintConfigured && (!fingerprintAttemptReachedDevice || fingerprintAttemptFastError) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak)) function realScreenCount() { var screens = Quickshell.screens || [] @@ -131,6 +142,33 @@ Item { if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true } + // Only definitive enrollment results may disable authentication. + function applyFingerprintProbe(text) { + var status = FingerprintModel.classifyProbe(text) + if (status === "unknown") { + if (fingerprintConfigured) return + fingerprintProbeStreak += 1 + if (lockRequested) { + fingerprintRecheckTimer.interval = FingerprintModel.retryDelayMs(fingerprintProbeStreak) + fingerprintRecheckTimer.restart() + } + return + } + fingerprintProbeStreak = 0 + fingerprintRecheckTimer.stop() + fingerprintConfigured = status === "yes" + if (lockRequested && fingerprintConfigured) { + // A pending retry already owns the next attempt. + if (!fingerprintRetryTimer.running) startFingerprint() + } else if (!fingerprintConfigured) { + // abort() delivers no completion signal, so close the attempt here + // too; settle returns before arming a retry while unconfigured. + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + fingerprintRetryTimer.stop() + } + } + function logEvent(event) { lastEvent = event lastEventAt = new Date().toISOString() @@ -144,7 +182,16 @@ Item { failedAttempts = 0 authenticatingPassword = false fingerprintAuthenticating = false + fingerprintUnreachedStreak = 0 + fingerprintAttemptFastError = false + fingerprintAttemptPromptedAtMs = 0 + fingerprintLastNudgeMs = 0 + fingerprintLastSettleMs = 0 + fingerprintResumedAtMs = 0 + fingerprintProbeStreak = 0 + fingerprintRecheckTimer.stop() fingerprintRetryTimer.stop() + fingerprintReachTimer.stop() if (passwordPam.active) passwordPam.abort() if (fingerprintPam.active) fingerprintPam.abort() } @@ -193,6 +240,55 @@ Item { root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() + nudgeFingerprint() + } + + // User activity advances retries without recreating a busy retry loop. + function nudgeFingerprint() { + if (!lockRequested) return + if (!fingerprintConfigured && fingerprintRecheckTimer.running) { + var now = Date.now() + if (FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, now - FingerprintModel.IDLE_CLEAR_MS, fingerprintRecheckTimer.interval)) { + fingerprintLastNudgeMs = now + fingerprintRecheckTimer.interval = FingerprintModel.MATCH_RETRY_MS + fingerprintRecheckTimer.restart() + } + return + } + if (!fingerprintConfigured) return + if (fingerprintPam.active || fingerprintAuthenticating) return + if (!fingerprintRetryTimer.running) return + var now = Date.now() + if (!FingerprintModel.shouldNudge(now, fingerprintLastNudgeMs, fingerprintLastSettleMs, fingerprintRetryTimer.interval)) return + fingerprintLastNudgeMs = now + armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS) + } + + + function armFingerprintRetry(delayMs) { + fingerprintRetryTimer.interval = delayMs + fingerprintRetryTimer.restart() + } + + // Reset pre-sleep failures while the resume hook restarts fprintd. + function noteFingerprintResumed() { + var now = Date.now() + if (FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs)) return + logEvent("fingerprint-resume: streak=" + fingerprintUnreachedStreak) + fingerprintResumedAtMs = now + fingerprintUnreachedStreak = 0 + } + + // A suspended PAM conversation may be orphaned by the daemon restart. + function restartFingerprintAfterSleep() { + noteFingerprintResumed() + if (fingerprintAuthenticating || fingerprintPam.active) { + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + return + } + if (!fingerprintRetryTimer.running) return + armFingerprintRetry(FingerprintModel.MATCH_RETRY_MS) } function runBlank() { @@ -263,19 +359,75 @@ Item { if (fingerprintPam.active || fingerprintAuthenticating) return fingerprintAuthenticating = true + fingerprintAttemptReachedDevice = false + fingerprintAttemptFastError = false + fingerprintAttemptPromptedAtMs = 0 if (!fingerprintPam.start()) { - fingerprintAuthenticating = false + // Pace a failed start while checking whether its PAM configuration was + // removed; a definitive "no" stops retries and hides the indicator. + settleFingerprintAttempt() + refreshFingerprintStatus() + return } + // Bound claims that never prompt; a normal verify waits for a finger + // under pam_fprintd's own timeout after reaching the reader. + fingerprintReachTimer.restart() + } + + // A prompt proves the claim landed, so stop waiting for reachability. + function noteFingerprintReachedDevice() { + if (fingerprintAttemptReachedDevice) return + fingerprintAttemptReachedDevice = true + fingerprintAttemptPromptedAtMs = Date.now() + fingerprintReachTimer.stop() + } + + // abort() gives no completion signal; settle the attempt here. + function timeoutFingerprintReach() { + logEvent("fingerprint-reach-timeout") + if (fingerprintPam.active) fingerprintPam.abort() + settleFingerprintAttempt() + } + + // onError and onCompleted can both fire; settle each attempt once. + function settleFingerprintAttempt(deviceError) { + if (!fingerprintAuthenticating) return + fingerprintAuthenticating = false + fingerprintReachTimer.stop() + if (!lockRequested || !fingerprintConfigured) return + + // An error can arrive before the sleep watcher notices the wall-clock gap. + var now = Date.now() + fingerprintAttemptFastError = !!deviceError && fingerprintAttemptReachedDevice && now - fingerprintAttemptPromptedAtMs < FingerprintModel.FAST_ERROR_MS + var usableAttempt = fingerprintAttemptReachedDevice && !fingerprintAttemptFastError + if (!usableAttempt && fingerprintSleepWatch.running + && FingerprintModel.spannedSleep(now - fingerprintSleepWatch.lastTickMs, fingerprintSleepWatch.interval)) { + noteFingerprintResumed() + } + + // Reached attempts are the steady state (one per swipe window), so only + // the misses and the recovery from them leave a trace. + var previousStreak = fingerprintUnreachedStreak + var inGrace = FingerprintModel.inResumeGrace(now, fingerprintResumedAtMs) + fingerprintUnreachedStreak = FingerprintModel.nextStreak(previousStreak, usableAttempt, inGrace) + if (!usableAttempt) { + var crossed = !FingerprintModel.isUnavailable(previousStreak) && FingerprintModel.isUnavailable(fingerprintUnreachedStreak) + logEvent((crossed ? "fingerprint-unavailable" : "fingerprint-unreached") + ": streak=" + fingerprintUnreachedStreak) + } else if (previousStreak > 0) { + logEvent("fingerprint-recovered: streak=" + previousStreak) + } + fingerprintLastSettleMs = now + armFingerprintRetry(FingerprintModel.retryDelayMs(fingerprintUnreachedStreak)) } function handleFingerprintFinished(result) { - fingerprintAuthenticating = false - - if (!lockRequested) return - if (result === PamResult.Success) { + if (result === PamResult.Success && lockRequested) { + // A match after a run of misses is the recovery too; the unlock resets + // the streak without settling, so log it here or it leaves no trace. + if (fingerprintUnreachedStreak > 0) logEvent("fingerprint-recovered: streak=" + fingerprintUnreachedStreak) finishUnlock() - } else if (fingerprintConfigured) { - fingerprintRetryTimer.restart() + } else { + settleFingerprintAttempt(result === PamResult.Error) } } @@ -323,7 +475,8 @@ Item { backgroundPath: root.backgroundPath videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion - fingerprintConfigured: root.fingerprintConfigured + fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable + fingerprintUnavailable: root.fingerprintUnavailable authenticatingPassword: root.authenticatingPassword failureMessage: root.failureMessage failedAttempts: root.failedAttempts @@ -356,7 +509,8 @@ Item { backgroundPath: root.backgroundPath videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion - fingerprintConfigured: root.fingerprintConfigured + fingerprintConfigured: root.fingerprintConfigured || root.fingerprintUnavailable + fingerprintUnavailable: root.fingerprintUnavailable authenticatingPassword: false failureMessage: "" failedAttempts: 0 @@ -400,13 +554,16 @@ Item { config: "omarchy-lock-fingerprint" user: root.userName + onPamMessage: { + if (!messageIsError) root.noteFingerprintReachedDevice() + } + onCompleted: function(result) { root.handleFingerprintFinished(result) } onError: function(error) { - root.fingerprintAuthenticating = false - if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart() + root.settleFingerprintAttempt(true) } } @@ -438,11 +595,34 @@ Item { Timer { id: fingerprintRetryTimer - interval: 250 + interval: FingerprintModel.MATCH_RETRY_MS repeat: false onTriggered: root.startFingerprint() } + // Detect resume both during an active attempt and during backoff. + Timer { + id: fingerprintSleepWatch + interval: 1000 + repeat: true + running: root.lockRequested && root.fingerprintConfigured + property double lastTickMs: 0 + onRunningChanged: lastTickMs = Date.now() + onTriggered: { + var now = Date.now() + var slept = FingerprintModel.spannedSleep(now - lastTickMs, interval) + lastTickMs = now + if (slept) root.restartFingerprintAfterSleep() + } + } + + Timer { + id: fingerprintReachTimer + interval: FingerprintModel.REACH_TIMEOUT_MS + repeat: false + onTriggered: root.timeoutFingerprintReach() + } + Process { id: readlinkProc command: ["bash", "-c", "path=$(readlink -f -- \"$1\") && printf '%s\\n%s\\n' \"$path\" \"$(stat -Lc %Y:%s -- \"$path\" 2>/dev/null)\"", "_", root.currentBackgroundLink] @@ -480,15 +660,21 @@ Item { } } + // Keep fprintd errors distinguishable from an explicit empty enrollment. Process { id: fingerprintCheckProc - command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"] + command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1; then LC_ALL=C fprintd-list \"$USER\" 2>&1; else echo no; fi"] stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true } - onExited: { - root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes" - if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint() - else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort() - } + onExited: root.applyFingerprintProbe(fingerprintCheckStdout.text) + } + + // Retries a probe that could not reach fprintd, paced like the attempt + // retries so a daemon that stays unreachable is asked about ever less often. + Timer { + id: fingerprintRecheckTimer + interval: FingerprintModel.ERROR_RETRY_BASE_MS + repeat: false + onTriggered: root.refreshFingerprintStatus() } Process { @@ -664,6 +850,7 @@ Item { realScreens: root.realScreenCount(), passwordPam: root.passwordPamConfigured, fingerprint: root.fingerprintConfigured, + fingerprintUnavailable: root.fingerprintUnavailable, authenticating: root.authenticating, lastEvent: root.lastEvent, lastEventAt: root.lastEventAt diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh index 490e7c8c..b3cb03ee 100644 --- a/test/shell.d/apply-lock-test.sh +++ b/test/shell.d/apply-lock-test.sh @@ -66,12 +66,16 @@ patched_helper="$test_tmp/omarchy-apply-lock-patched" absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +hook_source="$test_tmp/fprintd-resume-source" +cp "$ROOT/default/systemd/system-sleep/fprintd-resume" "$hook_source" +timeout_source="$test_tmp/fprintd-stop-timeout-source" +cp "$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" "$timeout_source" target_user=omarchy-regression-user mkdir -p "$poison_bin" "$trusted_root_bin" # The runtime copy pins to this isolated root path. It contains every bare # command the exercised helper needs, but deliberately no fprintd-list. -for helper in grep rm tee; do +for helper in grep rm tee install rmdir dirname; do ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" done @@ -85,7 +89,8 @@ cat >"$trusted_fprintd" <<'EOF' printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" -echo "Fingerprints are enrolled" +printf '%s\n' "${TEST_FPRINTD_OUTPUT:- - #0: right-index-finger}" +exit "${TEST_FPRINTD_STATUS:-0}" EOF cat >"$poison_bin/fprintd-list" <<'EOF' @@ -93,7 +98,8 @@ cat >"$poison_bin/fprintd-list" <<'EOF' printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" -echo "Fingerprints are enrolled" +printf '%s\n' "${TEST_FPRINTD_OUTPUT:- - #0: right-index-finger}" +exit "${TEST_FPRINTD_STATUS:-0}" EOF chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" @@ -107,7 +113,11 @@ prepare_helper() { -v trusted_root_bin="$trusted_root_bin" \ -v trusted_fprintd="$trusted_fprintd" \ -v keep_root_path="$keep_root_path" \ - -v use_absolute_fprintd="$use_absolute_fprintd" ' + -v use_absolute_fprintd="$use_absolute_fprintd" \ + -v hook_src="$hook_source" \ + -v hook_dst="$test_tmp/system-sleep/fprintd-resume" \ + -v timeout_src="$timeout_source" \ + -v timeout_dst="$test_tmp/fprintd.service.d/10-stop-timeout.conf" ' { line = $0 gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) @@ -121,22 +131,42 @@ prepare_helper() { print " export PATH=\"" trusted_root_bin "\"" next } - if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (line == "if [[ -x /usr/bin/fprintd-list ]]; then") { if (use_absolute_fprintd == 1) { - print "if [[ -x \"" trusted_fprintd "\" ]] &&" + print "if [[ -x \"" trusted_fprintd "\" ]]; then" } else { - print "if command -v fprintd-list >/dev/null 2>&1 &&" + print "if command -v fprintd-list >/dev/null 2>&1; then" } next } - if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (line == " fingerprint_status=$(/usr/bin/fprintd-list \"$target_user\" 2>&1 || true)") { if (use_absolute_fprintd == 1) { - print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + print " fingerprint_status=$(\"" trusted_fprintd "\" \"$target_user\" 2>&1 || true)" } else { - print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + print " fingerprint_status=$(fprintd-list \"$target_user\" 2>&1 || true)" } next } + if (line == "resume_hook_src=\"$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume\"") { + print "resume_hook_src=\"" hook_src "\"" + next + } + if (line == "resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume") { + print "resume_hook_dst=\"" hook_dst "\"" + next + } + if (line == "stop_timeout_src=\"$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf\"") { + print "stop_timeout_src=\"" timeout_src "\"" + next + } + if (line == "stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf") { + print "stop_timeout_dst=\"" timeout_dst "\"" + next + } + if (line == " as_root systemctl daemon-reload" || line == " as_root systemctl daemon-reload") { + print " :" + next + } if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { print "if false; then" next @@ -156,6 +186,9 @@ prepare_helper "$unprotected_helper" 0 0 for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do if grep -F '/etc/pam.d/' "$helper" >/dev/null || grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F '/usr/lib/systemd/system-sleep' "$helper" >/dev/null || + grep -F '/etc/systemd/system/' "$helper" >/dev/null || + grep -F 'systemctl daemon-reload' "$helper" >/dev/null || grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then fail "the isolated root fixture redirects every live-system lock-helper target" fi @@ -181,6 +214,10 @@ grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe r grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" [[ -s $password_pam && -s $fingerprint_pam ]] || fail "the isolated root lock-helper run writes both scratch PAM fixtures" +[[ -x $test_tmp/system-sleep/fprintd-resume ]] || + fail "the lock helper installs the resume hook beside the fingerprint PAM file" +[[ -f $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "the lock helper installs the stop-timeout drop-in beside the resume hook" pass "the hardened root lock helper uses the trusted fingerprint probe" reset_runtime_files @@ -204,3 +241,43 @@ grep -Fx "$target_user" "$attack_args" >/dev/null || fail "the planted fprintd-list receives the target user" [[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" pass "the root lock-helper matrix rejects the vulnerable PATH lookup" + +# A failed probe must retain the recovery machinery needed by a later resume. +run_as_root "$patched_helper" "restore the enrolled fingerprint fixture" +cp "$fingerprint_pam" "$test_tmp/saved-pam" +cp "$test_tmp/system-sleep/fprintd-resume" "$test_tmp/saved-hook" +cp "$test_tmp/fprintd.service.d/10-stop-timeout.conf" "$test_tmp/saved-timeout" +TEST_FPRINTD_OUTPUT="Impossible to get devices: Could not activate remote peer" TEST_FPRINTD_STATUS=1 \ + run_as_root "$patched_helper" "an unavailable daemon preserves fingerprint authentication" +for pair in "$fingerprint_pam:saved-pam" "$test_tmp/system-sleep/fprintd-resume:saved-hook" "$test_tmp/fprintd.service.d/10-stop-timeout.conf:saved-timeout"; do + cmp -s "${pair%:*}" "$test_tmp/${pair##*:}" || fail "an unknown enrollment probe preserves existing recovery files" +done +pass "an unknown enrollment probe preserves existing recovery files" + +# The device name and empty-enrollment prose both contain the word "finger". +TEST_FPRINTD_OUTPUT="User alice has no fingers enrolled for Goodix MOC Fingerprint Sensor." \ + run_as_root "$patched_helper" "an empty enrollment clears fingerprint authentication" +[[ -s $password_pam ]] || fail "password authentication remains configured without fingerprints" +[[ ! -e $fingerprint_pam && ! -e $test_tmp/system-sleep/fprintd-resume && ! -e $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "empty enrollment removes fingerprint PAM and resume recovery files" +pass "empty enrollment removes fingerprint authentication and its resume recovery" + +# Never enable fingerprint PAM before both recovery files are installed. +for source in "$hook_source" "$timeout_source"; do + reset_runtime_files + mv "$source" "$source.saved" + if PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$patched_helper" >"$test_tmp/failed-install" 2>&1; then + fail "missing recovery source must fail installation" + fi + [[ ! -e $fingerprint_pam ]] || fail "failed recovery installation cannot create fingerprint PAM" + mv "$source.saved" "$source" +done +pass "failed recovery installation cannot create fingerprint PAM" + +run_as_root "$patched_helper" "restore enrollment before removing fprintd-list" +rm -f "$trusted_fprintd" +run_as_root "$patched_helper" "a missing fingerprint package clears fingerprint authentication" +[[ ! -e $fingerprint_pam && ! -e $test_tmp/system-sleep/fprintd-resume && ! -e $test_tmp/fprintd.service.d/10-stop-timeout.conf ]] || + fail "a missing fprintd-list removes fingerprint PAM and resume recovery files" +pass "a missing fprintd-list removes fingerprint authentication and its resume recovery" diff --git a/test/shell.d/fingerprint-package-test.sh b/test/shell.d/fingerprint-package-test.sh index efc14fcc..cf67e347 100755 --- a/test/shell.d/fingerprint-package-test.sh +++ b/test/shell.d/fingerprint-package-test.sh @@ -23,6 +23,8 @@ cat > "$scratch/bin/sudo" <<'STUB' #!/bin/bash case "$1" in pacman | fprintd-enroll) exec "$@" ;; + sed) printf 'pam %s\n' "$*" >> "$CALL_LOG" ;; + tee) printf 'pam %s\n' "$*" >> "$CALL_LOG"; cat >/dev/null ;; *) echo "Unexpected privileged call: $*" >> "$CALL_LOG"; exit 99 ;; esac STUB @@ -49,22 +51,36 @@ cat > "$scratch/bin/fprintd-enroll" <<'STUB' #!/bin/bash # Stop before verification/PAM; no host authentication files may be changed. echo enroll >> "$CALL_LOG" -exit 1 +exit "${ENROLL_STATUS:-1}" STUB cat > "$scratch/bin/fprintd-verify" <<'STUB' #!/bin/bash echo verify >> "$CALL_LOG" -exit 1 +exit "${VERIFY_STATUS:-1}" STUB chmod +x "$scratch/bin/"* +cat > "$scratch/bin/omarchy-apply-lock" <<'STUB' +#!/bin/bash +echo apply-lock >> "$CALL_LOG" +if [[ ${LOCK_SETUP_UNKNOWN:-0} != "1" ]]; then + touch "$TEST_LOCK_PAM" +fi +STUB +chmod +x "$scratch/bin/omarchy-apply-lock" + +export TEST_LOCK_PAM="$scratch/omarchy-lock-fingerprint" +setup_script="$scratch/omarchy-setup-security-fingerprint" +sed "s|/etc/pam.d/omarchy-lock-fingerprint|$TEST_LOCK_PAM|g" "$ROOT/bin/omarchy-setup-security-fingerprint" > "$setup_script" +chmod +x "$setup_script" + run_setup() { : > "$CALL_LOG" - if "$ROOT/bin/omarchy-setup-security-fingerprint" > "$scratch/output" 2>&1; then + if OMARCHY_PATH="$scratch" "$setup_script" > "$scratch/output" 2>&1; then fail "setup stops on the simulated enrollment or installation failure" fi - if grep -q 'Unexpected privileged call' "$CALL_LOG"; then - fail "setup does not change PAM after failed enrollment" + if grep -Eq '^(pam |apply-lock$|Unexpected privileged call)' "$CALL_LOG"; then + fail "setup does not change PAM or lock recovery after failed enrollment" fi } @@ -98,3 +114,22 @@ pass "a failed installation stops before enrollment" HARDWARE_STATUS=1 run_setup [[ ! -s $CALL_LOG ]] || fail "missing hardware stops before package operations" pass "missing hardware performs no package operations" + +# Successful setup must reuse the same lock/recovery installer as updates. +: > "$CALL_LOG" +OMARCHY_PATH="$scratch" ENROLL_STATUS=0 VERIFY_STATUS=0 \ + "$setup_script" > "$scratch/output" 2>&1 || fail "successful enrollment configures authentication" +[[ $(grep -E '^(enroll|verify|apply-lock)$' "$CALL_LOG") == $'enroll\nverify\napply-lock' ]] || + fail "setup configures lock recovery once, after enrollment and verification" +pass "setup reuses apply-lock after enrollment and verification" + +rm -f "$TEST_LOCK_PAM" +if OMARCHY_PATH="$scratch" ENROLL_STATUS=0 VERIFY_STATUS=0 LOCK_SETUP_UNKNOWN=1 \ + "$setup_script" > "$scratch/output" 2>&1; then + fail "an inconclusive lock installer cannot report successful lock setup" +fi +grep -q 'lock-screen configuration could not be confirmed' "$scratch/output" || fail "inconclusive setup explains how to retry" +if grep -q 'Perfect!\|You can use your fingerprint' "$scratch/output"; then + fail "inconclusive setup does not promise fingerprint unlock" +fi +pass "an inconclusive lock installer cannot report successful lock setup" diff --git a/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml index 1a5f92fe..dc016133 100644 --- a/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml +++ b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml @@ -84,6 +84,27 @@ ShellRoot { view.fingerprintConfigured = false root.assertTrue(view.fingerprintReserve === 0, "no space is reserved when no sensor is configured") + + // An unreachable reader must announce itself instead of silently + // inviting touches that can never unlock. + view.fingerprintConfigured = true + var notice = findByObjectName(view, "fingerprintUnavailableNotice") + root.assertTrue(notice !== null, "unavailable notice exists in the lock view") + + if (notice) { + var workingGlyph = indicator.text + root.assertTrue(!notice.visible, "notice stays hidden while the reader works") + + view.fingerprintUnavailable = true + root.assertTrue(notice.visible, "notice appears when the reader is unavailable") + root.assertTrue(indicator.visible, "icon stays visible when the reader is unavailable") + root.assertTrue(indicator.text !== workingGlyph, "icon crosses out when the reader is unavailable") + root.assertTrue(view.fingerprintReserve > 0, "space stays reserved for the crossed-out icon") + + view.fingerprintUnavailable = false + root.assertTrue(!notice.visible, "notice clears when the reader recovers") + root.assertTrue(indicator.text === workingGlyph, "icon restores when the reader recovers") + } } view.destroy() diff --git a/test/shell.d/fixtures/lock-fingerprint-service/shell.qml b/test/shell.d/fixtures/lock-fingerprint-service/shell.qml new file mode 100644 index 00000000..298cf040 --- /dev/null +++ b/test/shell.d/fixtures/lock-fingerprint-service/shell.qml @@ -0,0 +1,178 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pam +import qs.Commons + +ShellRoot { + id: root + + readonly property string rootPath: Quickshell.env("OMARCHY_PATH") + readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property var failures: [] + property int checks: 0 + + Item { id: host } + + function check(condition, message) { + checks += 1 + if (!condition) failures.push(message) + } + + function timer(service, interval, repeat) { + var objects = service.data || [] + for (var i = 0; i < objects.length; i++) { + if (objects[i].interval === interval && objects[i].repeat === repeat) return objects[i] + } + throw new Error("Missing timer: " + interval + ", repeat=" + repeat) + } + + function run() { + var component = Qt.createComponent("file://" + rootPath + "/shell/plugins/lock/Service.qml", Component.PreferSynchronous) + if (component.status !== Component.Ready) throw new Error(component.errorString()) + var service = component.createObject(host, { omarchyPath: rootPath }) + if (!service) throw new Error(component.errorString()) + try { + var retry = timer(service, 250, false) + var reach = timer(service, 20000, false) + var recheck = timer(service, 1000, false) + service.lockRequested = true + + service.applyFingerprintProbe("Could not activate remote peer") + check(!service.fingerprintConfigured, "unknown cannot invent an enrollment") + check(recheck.running && recheck.interval === 1000, "unknown schedules a paced probe") + + service.applyFingerprintProbe("Could not activate remote peer") + service.applyFingerprintProbe("No devices available") + check(!service.fingerprintConfigured && service.fingerprintUnavailable, "an initial outage shows unavailable without inventing enrollment") + service.nudgeFingerprint() + check(recheck.interval === 250, "input promptly retries an initial unknown probe") + + service.applyFingerprintProbe("Fingerprints for user test on Goodix:\n - #0: right-index-finger") + check(service.fingerprintConfigured, "a real enrollment row enables fingerprint") + check(!service.fingerprintUnavailable && !recheck.running, "a definitive enrollment clears probe notice and recheck") + service.applyFingerprintProbe("ListEnrolledFingers failed: Timeout was reached") + check(service.fingerprintConfigured, "unknown preserves a known enrollment") + service.applyFingerprintProbe("No devices available") + service.applyFingerprintProbe("ListEnrolledFingers failed: Timeout was reached") + check(!service.fingerprintUnavailable && !recheck.running, "known enrollment uses PAM recovery without competing probe retries") + + service.fingerprintAuthenticating = true + retry.start() + reach.start() + service.applyFingerprintProbe("User test has no fingers enrolled for Goodix MOC Fingerprint Sensor.") + check(!service.fingerprintConfigured, "empty enrollment is not a fingerprint") + check(!retry.running, "empty enrollment stops authentication retries") + check(!service.fingerprintAuthenticating && !reach.running, "empty enrollment closes the attempt and reach timer") + + service.fingerprintConfigured = true + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + reach.start() + service.settleFingerprintAttempt() + check(service.fingerprintUnreachedStreak === 1, "an unreached attempt advances the streak") + check(retry.running && retry.interval === 1000, "an unreached attempt retries with backoff") + check(!reach.running, "settle cancels the reach timeout") + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 1, "error and completion settle an attempt only once") + check(service.lockRequested, "a PAM error never unlocks") + + service.fingerprintUnreachedStreak = 3 + service.fingerprintLastNudgeMs = 0 + retry.interval = 8000 + service.nudgeFingerprint() + check(retry.interval === 250, "input advances a backed-off PAM retry") + retry.interval = 8000 + service.nudgeFingerprint() + check(retry.interval === 8000, "continuous input cannot collapse every retry") + check(service.fingerprintUnavailable, "repeated unreached attempts report unavailable") + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() - 5000 + service.noteFingerprintReachedDevice() + check(Date.now() - service.fingerprintAttemptPromptedAtMs < 2000, "the prompt resets the fast-error clock after a slow claim") + check(!service.fingerprintUnavailable, "a prompt immediately clears the unavailable notice") + service.settleFingerprintAttempt() + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a reached attempt clears backoff") + + for (var i = 0; i < 3; i++) { + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = true + service.fingerprintAttemptFastError = false + service.fingerprintAttemptPromptedAtMs = Date.now() + service.settleFingerprintAttempt(true) + service.handleFingerprintFinished(PamResult.Error) + } + check(service.fingerprintUnreachedStreak === 3 && retry.interval === 4000, "fast errors after prompting back off and settle once") + check(service.fingerprintUnavailable && service.lockRequested, "a prompted but failing reader is unavailable without unlocking") + + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() - 31000 + service.noteFingerprintReachedDevice() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a timeout message cannot restart the fast-error clock") + + service.fingerprintUnreachedStreak = 3 + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() + service.handleFingerprintFinished(PamResult.Failed) + check(service.fingerprintUnreachedStreak === 0 && retry.interval === 250, "a quick mismatch is not a device error") + + service.fingerprintAuthenticating = true + service.fingerprintAttemptPromptedAtMs = Date.now() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 1, "error completion backs off even without a preceding error signal") + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + service.fingerprintAttemptPromptedAtMs = Date.now() - 5000 + service.noteFingerprintReachedDevice() + service.handleFingerprintFinished(PamResult.Error) + check(service.fingerprintUnreachedStreak === 2, "a slow claim followed by a fast verification error still backs off") + + for (var i = 0; i < 8; i++) { + service.fingerprintAuthenticating = true + service.fingerprintAttemptReachedDevice = false + service.settleFingerprintAttempt() + } + check(retry.interval === 40000 && service.fingerprintUnavailable, "persistent misses reach the capped wait") + service.nudgeFingerprint() + check(retry.interval === 40000, "input at the cap preserves the daemon's idle window") + + service.fingerprintAuthenticating = true + service.restartFingerprintAfterSleep() + check(!service.fingerprintAuthenticating, "resume settles an in-flight attempt") + check(service.fingerprintUnreachedStreak === 1 && !service.fingerprintUnavailable, "resume grace suppresses an unavailable notice") + check(retry.running && retry.interval === 1000, "resume retries without retaining the capped wait") + check(service.lockRequested, "resume never unlocks") + + service.fingerprintAuthenticating = true + reach.start() + service.timeoutFingerprintReach() + check(!service.fingerprintAuthenticating && !reach.running, "the reach bound closes a stuck attempt") + check(service.lockRequested, "a timeout never unlocks") + + service.lockRequested = false + service.resetAuthenticationState() + check(!retry.running && !reach.running && !recheck.running, "unlock reset stops every fingerprint timer") + service.applyFingerprintProbe("Daemon is restarting") + check(!recheck.running, "an unknown probe outside the lock cannot restart it") + } finally { + service.lockRequested = false + service.resetAuthenticationState() + service.destroy() + } + } + + Timer { + interval: 1 + running: true + onTriggered: { + try { + root.run() + } catch (error) { + root.failures.push(String(error)) + } + var payload = JSON.stringify({ ok: root.failures.length === 0, checks: root.checks, failures: root.failures }) + var quoted = "'" + payload.replace(/'/g, "'\\''") + "'" + Quickshell.execDetached(["bash", "-c", "printf '%s' " + quoted + " > \"$1\"", "_", root.resultPath]) + } + } +} diff --git a/test/shell.d/fprintd-resume-hook-test.sh b/test/shell.d/fprintd-resume-hook-test.sh new file mode 100755 index 00000000..6a9c6a87 --- /dev/null +++ b/test/shell.d/fprintd-resume-hook-test.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +hook="$ROOT/default/systemd/system-sleep/fprintd-resume" + +# systemd execs the files in system-sleep/ directly, so a hook without the +# executable bit silently never runs. The installers set the mode explicitly, +# but the checkout should not ship a hook that cannot run as-is either. +[[ -x $hook ]] || + fail "the resume hook is executable" "mode: $(stat -c '%A' "$hook")" +pass "the resume hook is executable" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +mock_bin="$tmpdir/bin" +call_log="$tmpdir/systemctl-calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/systemctl" <>"$call_log" +SH +chmod +x "$mock_bin/systemctl" + +run_hook() { + : >"$call_log" + PATH="$mock_bin:$PATH" "$hook" "$@" +} + +# Resume ("post") is the only edge that clears a claim wedged across suspend. +# The restart is enqueued, not awaited: user sessions stay frozen until the +# hook returns, and a wedged fprintd can ride out its whole stop timeout. +run_hook post suspend +[[ $(<"$call_log") == "--no-block try-restart fprintd.service" ]] || + fail "resume enqueues an fprintd restart to clear a wedged claim" "calls: $(<"$call_log")" +pass "resume enqueues an fprintd restart to clear a wedged claim" + +# Every resume path lands on "post" regardless of how the machine slept. +run_hook post hibernate +[[ $(<"$call_log") == "--no-block try-restart fprintd.service" ]] || + fail "resume from hibernate also restarts fprintd" "calls: $(<"$call_log")" +pass "resume from hibernate also restarts fprintd" + +# The drop-in installed beside the hook is what keeps a SIGTERM-ignoring +# fprintd from turning that restart into a multi-second dead reader. +dropin="$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" +grep -Eq '^TimeoutStopSec=[0-9]+s?$' "$dropin" || + fail "the stop-timeout drop-in bounds TimeoutStopSec" "content: $(<"$dropin")" +pass "the stop-timeout drop-in bounds TimeoutStopSec" + +# The claim only wedges once the verify dies under suspend, so there is +# nothing to clear before sleep; a restart there would just take the reader +# away from the verify the lock screen still has open. "pre" must do nothing. +run_hook pre suspend +[[ -z $(<"$call_log") ]] || + fail "pre-suspend leaves fprintd alone" "calls: $(<"$call_log")" +pass "pre-suspend leaves fprintd alone" diff --git a/test/shell.d/fprintd-resume-migration-test.sh b/test/shell.d/fprintd-resume-migration-test.sh new file mode 100755 index 00000000..cbcfb469 --- /dev/null +++ b/test/shell.d/fprintd-resume-migration-test.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration=$(grep -rl 'Install the fingerprint resume hook on existing fingerprint setups' "$ROOT/migrations" | head -n 1 || true) +[[ -n $migration ]] || fail "fprintd resume hook migration exists" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The migration installs to a system path via sudo; stub it so the test writes +# into a temp tree instead of /usr. +stub_bin="$TMPDIR/bin" +mkdir -p "$stub_bin" +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$stub_bin/sudo" +reload_log="$TMPDIR/systemctl-calls" +cat >"$stub_bin/systemctl" <>"$reload_log" +STUB +chmod +x "$stub_bin/systemctl" + +src="$TMPDIR/fprintd-resume" +printf '#!/bin/bash\n' >"$src" +chmod +x "$src" +dst="$TMPDIR/system-sleep/fprintd-resume" +dropin_src="$TMPDIR/10-stop-timeout.conf" +printf '[Service]\nTimeoutStopSec=3s\n' >"$dropin_src" +dropin_dst="$TMPDIR/fprintd.service.d/10-stop-timeout.conf" +lock_pam="$TMPDIR/omarchy-lock-fingerprint" + +# omarchy-migrate runs each migration with `bash -euo pipefail`; match it. +run_migration() { + PATH="$stub_bin:$PATH" \ + OMARCHY_FPRINTD_RESUME_SRC="$src" \ + OMARCHY_FPRINTD_RESUME_DST="$dst" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_SRC="$dropin_src" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_DST="$dropin_dst" \ + OMARCHY_LOCK_FINGERPRINT_PAM="$lock_pam" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean" +} + +# The migration exits clean when its source is missing, so a hook moved +# without updating it would silently install nothing and mark itself done. +# Run once against the real default source under the repo to pin that path. +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +: >"$lock_pam" +PATH="$stub_bin:$PATH" \ + OMARCHY_PATH="$ROOT" \ + OMARCHY_FPRINTD_RESUME_DST="$dst" \ + OMARCHY_FPRINTD_STOP_TIMEOUT_DST="$dropin_dst" \ + OMARCHY_LOCK_FINGERPRINT_PAM="$lock_pam" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean from its default sources" +[[ -x $dst ]] || fail "migration finds the hook at its default source path" "dst: $(stat -c '%A' "$dst" 2>/dev/null || echo missing)" +cmp -s "$dst" "$ROOT/default/systemd/system-sleep/fprintd-resume" || fail "migration installs the shipped hook from its default source" +cmp -s "$dropin_dst" "$ROOT/default/systemd/system/fprintd.service.d/10-stop-timeout.conf" || fail "migration installs the shipped drop-in from its default source" +pass "migration installs the shipped hook and drop-in from their default sources" + +# A machine with fingerprint configured but no hook yet gets it, executable, +# plus the stop-timeout drop-in, and systemd is told about the drop-in. +: >"$lock_pam" +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +: >"$reload_log" +run_migration +[[ -x $dst ]] || fail "migration installs the hook, executable" "dst: $(stat -c '%A' "$dst" 2>/dev/null || echo missing)" +pass "migration installs the hook, executable" +[[ $(stat -c '%a' "$dropin_dst" 2>/dev/null) == "644" ]] || fail "migration installs the stop-timeout drop-in" "dst: $(stat -c '%A' "$dropin_dst" 2>/dev/null || echo missing)" +grep -qx "daemon-reload" "$reload_log" || fail "migration reloads systemd after installing the drop-in" "calls: $(<"$reload_log")" +pass "migration installs the stop-timeout drop-in and reloads systemd" + +# Running twice must not fail (both now exist), must not touch them, and has +# nothing to reload. +printf 'sentinel\n' >>"$dst" +printf '# sentinel\n' >>"$dropin_dst" +: >"$reload_log" +run_migration +grep -q sentinel "$dst" || fail "migration leaves an existing hook alone" +grep -q sentinel "$dropin_dst" || fail "migration leaves an existing drop-in alone" +[[ ! -s $reload_log ]] || fail "migration does not reload systemd when nothing changed" "calls: $(<"$reload_log")" +pass "migration leaves existing files alone" + +# An unnumbered drop-in may belong to the administrator; never replace it. +legacy="$TMPDIR/fprintd.service.d/stop-timeout.conf" +rm -f "$dropin_dst"; printf '[Service]\nTimeoutStopSec=15s\n' >"$legacy" +cp "$legacy" "$TMPDIR/saved-admin-timeout" +: >"$reload_log" +run_migration +cmp -s "$legacy" "$TMPDIR/saved-admin-timeout" || fail "migration preserves the administrator's unnumbered drop-in" +[[ -f $dropin_dst ]] || fail "migration installs the numbered drop-in alongside the administrator's file" +grep -qx "daemon-reload" "$reload_log" || fail "migration reloads systemd after installing the numbered drop-in" +pass "migration preserves the administrator's unnumbered drop-in" + +# The drop-in is installed on its own where only the hook is already present. +rm -rf "$TMPDIR/fprintd.service.d" +run_migration +[[ -f $dropin_dst ]] || fail "migration adds the drop-in beside an existing hook" +pass "migration adds the drop-in beside an existing hook" + +# No fingerprint configured -> nothing to fix, so nothing is installed. +rm -f "$lock_pam" +rm -rf "$TMPDIR/system-sleep" "$TMPDIR/fprintd.service.d" +run_migration +[[ ! -e $dst && ! -e $dropin_dst ]] || fail "migration skips machines without fingerprint configured" +pass "migration skips machines without fingerprint configured" diff --git a/test/shell.d/lock-fingerprint-retry-test.sh b/test/shell.d/lock-fingerprint-retry-test.sh new file mode 100755 index 00000000..eb662b4f --- /dev/null +++ b/test/shell.d/lock-fingerprint-retry-test.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const model = requireFromRoot('shell/plugins/lock/FingerprintModel.js') + +// Pacing: a reached attempt (streak 0) retries fast; unreached attempts back +// off exponentially to a ceiling above fprintd's 30-second idle exit. +assertEqual(model.retryDelayMs(0), model.MATCH_RETRY_MS, 'a reached attempt retries at the fast interval') +assertEqual(model.retryDelayMs(1), model.ERROR_RETRY_BASE_MS, 'the first unreached attempt backs off') +assertEqual(model.retryDelayMs(2), model.ERROR_RETRY_BASE_MS * 2, 'consecutive unreached attempts double the delay') +assertEqual(model.retryDelayMs(3), model.ERROR_RETRY_BASE_MS * 4, 'the backoff keeps doubling') +assertEqual(model.retryDelayMs(50), model.ERROR_RETRY_CAP_MS, 'the backoff is capped') +assertEqual(model.FPRINTD_IDLE_EXIT_MS, 30000, "the model carries fprintd's 30-second idle exit") +assert(model.ERROR_RETRY_CAP_MS > model.FPRINTD_IDLE_EXIT_MS, "the cap exceeds fprintd's idle exit so a wedged claim can clear") +assert(model.IDLE_CLEAR_MS > model.FPRINTD_IDLE_EXIT_MS && model.IDLE_CLEAR_MS <= model.ERROR_RETRY_CAP_MS, + 'the idle stretch a nudge must leave at the cap covers the exit and fits inside the cap') + +let previous = 0 +let monotonic = true +for (let streak = 1; streak <= 20; streak++) { + const delay = model.retryDelayMs(streak) + if (delay < previous) monotonic = false + previous = delay +} +assert(monotonic, 'the backoff never shrinks while attempts keep failing to reach the reader') + +// The signal: reaching the device (a prompt) clears the streak; not reaching it +// advances it. This is what keeps a healthy-but-untouched reader off the notice +// while a wedged or held one climbs toward it. +assertEqual(model.nextStreak(0, true), 0, 'reaching the reader keeps the streak at zero') +assertEqual(model.nextStreak(5, true), 0, 'reaching the reader clears an accumulated streak') +assertEqual(model.nextStreak(0, false), 1, 'failing to reach the reader starts the streak') +assertEqual(model.nextStreak(2, false), 3, 'failing to reach the reader advances the streak') + +// Availability: reported unavailable only past a few consecutive misses, so a +// single transient claim conflict does not flash the notice. +assert(!model.isUnavailable(0), 'a working reader is not reported unavailable') +assert(!model.isUnavailable(model.UNAVAILABLE_AFTER - 1), 'a couple of misses do not report unavailable') +assert(model.isUnavailable(model.UNAVAILABLE_AFTER), 'enough consecutive misses report the reader unavailable') + +// End to end: repeatedly failing to reach the reader crosses the threshold, and +// the first attempt that reaches it clears both the streak and the notice. +let streak = 0 +for (let i = 0; i < model.UNAVAILABLE_AFTER; i++) streak = model.nextStreak(streak, false) +assert(model.isUnavailable(streak), 'a run of unreached attempts ends up unavailable') +streak = model.nextStreak(streak, true) +assert(!model.isUnavailable(streak), 'one reached attempt clears the unavailable state') + +// Presence nudge: it only collapses a backed-off wait, and only once per +// cooldown, so a moving cursor cannot respin the loop. +const backedOff = model.retryDelayMs(2) +assert(!model.shouldNudge(10000, 0, 0, model.MATCH_RETRY_MS), 'no nudge when the wait is already the fast interval') +assert(model.shouldNudge(10000, 0, 0, backedOff), 'a keypress collapses a backed-off wait') +assert(!model.shouldNudge(10000, 9000, 0, backedOff), 'a second nudge inside the cooldown is refused') +assert(model.shouldNudge(10000, 10000 - model.NUDGE_COOLDOWN_MS, 0, backedOff), 'a nudge one cooldown later is allowed') +assert(model.shouldNudge(10000, 11500, 0, backedOff), 'a clock stepped backwards does not refuse the nudge') +assert(model.shouldNudge(10000, 0, 11500, backedOff), 'a clock stepped backwards since the settle does not refuse it either') +assert(model.shouldNudge(10000, 0, 9990, backedOff), 'below the cap a fresh settle does not hold the nudge') + +// A cursor moving at 125 Hz raises a wake every 8 ms; over 10 s the floor caps +// the collapses at one per cooldown instead of one per event. +let last = -model.NUDGE_COOLDOWN_MS +let collapses = 0 +for (let t = 0; t <= 10000; t += 8) { + if (model.shouldNudge(t, last, 0, backedOff)) { collapses++; last = t } +} +assert(collapses <= 10000 / model.NUDGE_COOLDOWN_MS + 1, + 'a moving cursor is capped at one collapse per cooldown, got ' + collapses) + +// Once the wait has backed off past the cooldown, the tier paces the nudges: +// presence collapses each wait once, but typing at a reader that keeps failing +// cannot pull the loop under the tier's rate. +const capped = model.retryDelayMs(50) +const longAgo = -capped +assert(model.shouldNudge(capped, 0, longAgo, capped), 'a nudge one tier later is allowed') +assert(!model.shouldNudge(capped - 1, 0, longAgo, capped), 'a nudge inside the tier is refused') +last = -capped +collapses = 0 +for (let t = 0; t <= 4 * capped; t += 8) { + if (model.shouldNudge(t, last, longAgo, capped)) { collapses++; last = t } +} +assertEqual(collapses, 5, 'continuous input at the cap collapses once per tier') + +// At the cap a fresh settle holds the nudge until fprintd has had its idle +// stretch, then lets it through -- before the cap's own timer would fire. +assert(!model.shouldNudge(model.IDLE_CLEAR_MS - 1, longAgo, 0, capped), 'a nudge before the idle stretch is refused at the cap') +assert(model.shouldNudge(model.IDLE_CLEAR_MS, longAgo, 0, capped), 'a nudge after the idle stretch is allowed at the cap') +// A clock stepped back past the settle says nothing about how long fprintd +// has been idle, so at the cap it must not stand in for the idle stretch. +assert(!model.shouldNudge(10000, longAgo, 15000, capped), 'a clock stepped back past the settle does not bypass the idle guard at the cap') +assert(model.shouldNudge(10000, 15000, longAgo, capped), 'a clock stepped back past the nudge still allows the nudge at the cap once idle') + +// At the cap the idle stretch is measured from the settle: a nudged attempt +// that hangs until the reach timeout must not eat into fprintd's idle exit. +// Simulate continuous input at 125 Hz against a reader whose every attempt +// hangs for the full reach bound, and check the gap fprintd is left between +// one attempt ending and the next claiming. +let settle = 0 +let nudge = 0 +let attemptStart = model.MATCH_RETRY_MS +let minIdle = Infinity +let cycles = 0 +for (let t = attemptStart; cycles < 5; t += 8) { + const attemptEnd = attemptStart + model.REACH_TIMEOUT_MS + if (t < attemptEnd) continue + if (t === attemptEnd || settle < attemptStart) settle = attemptEnd + const timerFires = t >= settle + capped + if (timerFires || model.shouldNudge(t, nudge, settle, capped)) { + if (!timerFires) nudge = t + const claimAt = t + model.MATCH_RETRY_MS + minIdle = Math.min(minIdle, claimAt - settle) + attemptStart = claimAt + cycles++ + } +} +assert(minIdle >= model.FPRINTD_IDLE_EXIT_MS, + 'continuous input against attempts that hang to the reach bound still leaves fprintd its idle exit, got ' + minIdle + 'ms') +assert(minIdle < capped, + 'the nudge still shortens the wait at the cap rather than being dead there, got ' + minIdle + 'ms') + +// Resume grace: right after a wake the hook is restarting fprintd under the +// loop, so a miss there holds the streak at the first tier instead of +// climbing toward the notice; a reached attempt still clears it. +assertEqual(model.nextStreak(0, false, true), 1, 'a miss inside the resume grace holds the streak at the first tier') +assertEqual(model.nextStreak(5, false, true), 1, 'the grace pins any accumulated streak to the first tier') +assertEqual(model.nextStreak(5, true, true), 0, 'a reached attempt inside the grace clears the streak') +assertEqual(model.nextStreak(1, false, false), 2, 'outside the grace misses accumulate') +assert(model.RESUME_GRACE_MS >= 3000 + 1000, "the grace outlasts fprintd's 3s stop cap plus its start") +assert(model.RESUME_GRACE_MS < model.ERROR_RETRY_CAP_MS, 'the grace is shorter than the cap, so it cannot mask a reader that is really gone') +assert(!model.inResumeGrace(5000, 0), 'no resume noted means no grace') +assert(model.inResumeGrace(1000 + model.RESUME_GRACE_MS - 1, 1000), 'an attempt settling inside the window is in grace') +assert(!model.inResumeGrace(1000 + model.RESUME_GRACE_MS, 1000), 'an attempt settling at the window edge is not') +assert(!model.inResumeGrace(500, 1000), 'a clock stepped back past the resume is not grace') + +// Sleep detection: an unreached attempt cannot outlive the reach bound on the +// monotonic clock, so one that did on the wall clock spanned a suspend. +assert(!model.spannedSleep(model.REACH_TIMEOUT_MS + model.SLEEP_GAP_MS, model.REACH_TIMEOUT_MS), 'the bound plus slack is not a sleep') +assert(model.spannedSleep(model.REACH_TIMEOUT_MS + model.SLEEP_GAP_MS + 1, model.REACH_TIMEOUT_MS), 'longer than the bound plus slack is a sleep') +assert(model.spannedSleep(1000 + 8 * 3600 * 1000, 1000), 'an eight-hour wait on a one-second timer is a sleep') + +// End to end: the async resume hook lands a fresh daemon ~3.3s after wake in +// the worst case (SIGTERM-ignoring fprintd, 3s stop cap). Every attempt until +// then misses; none may show the notice, and the loop must still be retrying +// at the first tier when the daemon comes back. +const resumedAt = 1000 +let graceStreak = 0 +let attemptAt = resumedAt + model.MATCH_RETRY_MS +let attempts = 0 +while (attemptAt < resumedAt + 3300) { + graceStreak = model.nextStreak(graceStreak, false, model.inResumeGrace(attemptAt, resumedAt)) + assert(!model.isUnavailable(graceStreak), 'a miss ' + (attemptAt - resumedAt) + 'ms after resume must not show the notice') + attemptAt += model.retryDelayMs(graceStreak) + attempts++ +} +assert(attempts >= 3, 'the loop keeps retrying through the restart window, got ' + attempts) +assert(attemptAt - resumedAt < 3300 + model.ERROR_RETRY_BASE_MS + model.MATCH_RETRY_MS, + 'the first attempt after the daemon is back comes within a tier, at ' + (attemptAt - resumedAt) + 'ms') + +// Probe classification (#9453): only a definitive answer may change state. +assertEqual(model.classifyProbe('found 1 devices\nFingerprints for user g on X:\n - #0: right-index-finger'), 'yes', 'an enrolled print row reads yes') +assertEqual(model.classifyProbe('User bob has no fingers enrolled for Goodix MOC Fingerprint Sensor.'), 'no', 'a fingerprint-named reader does not turn empty enrollment into yes') +assertEqual(model.classifyProbe('Header mentions - #0: but lists nothing'), 'unknown', 'an enrollment row must start a line') +assertEqual(model.classifyProbe(' - #oops: right-index-finger'), 'unknown', 'an enrollment row requires a numeric index') +assertEqual(model.classifyProbe('\t- #12: left-index-finger'), 'yes', 'indented enrollment rows and multi-digit indexes are supported') +assertEqual(model.classifyProbe('No devices available'), 'unknown', 'a temporarily missing reader is unknown, not empty enrollment') +assertEqual(model.classifyProbe('no'), 'no', "the probe script's own no reads no") +assertEqual(model.classifyProbe('found 1 devices\nUser bob has no fingers enrolled.'), 'no', "fprintd's explicit no-prints answer reads no") +assertEqual(model.classifyProbe('Impossible to get devices: GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not activate remote peer'), 'unknown', 'an activation failure reads unknown, not unconfigured') +assertEqual(model.classifyProbe('ListEnrolledFingers failed: Timeout was reached'), 'unknown', 'a D-Bus timeout reads unknown') +assertEqual(model.classifyProbe(''), 'unknown', 'empty output reads unknown') + +assert(model.REACH_TIMEOUT_MS < model.ERROR_RETRY_CAP_MS, + 'the reach bound is shorter than the backoff cap, so a stuck attempt is caught well before the cap') +assert(model.REACH_TIMEOUT_MS < 25000, + "the reach bound lands before GDBus fails a stuck Claim at 25s and pam_fprintd's 30s verify timeout reports as a non-error message") +assert(model.REACH_TIMEOUT_MS >= 10000, + 'the reach bound outlasts a slow device open, so a reader that takes seconds to claim is not killed mid-Claim on every attempt') +JS diff --git a/test/shell.d/lock-fingerprint-service-test.sh b/test/shell.d/lock-fingerprint-service-test.sh new file mode 100644 index 00000000..0283c7f0 --- /dev/null +++ b/test/shell.d/lock-fingerprint-service-test.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + if [[ -n $TMPDIR && -d $TMPDIR ]]; then + rm -rf "$TMPDIR" + fi +} +trap cleanup EXIT + +require_compositor "lock fingerprint service test" + +if ! command -v quickshell >/dev/null 2>&1; then + skip "quickshell not installed; skipping lock fingerprint service test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/lock-fingerprint-service" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/lock-fingerprint-service/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/Ui" "$config_dir/Ui" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +XDG_CONFIG_HOME="$TMPDIR/home/.config" \ +XDG_CACHE_HOME="$TMPDIR/home/.cache" \ +XDG_STATE_HOME="$TMPDIR/home/.local/state" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint service quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint service test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'Lock fingerprint service result:\n' >&2 + jq . "$result" >&2 + printf 'Lock fingerprint service log:\n' >&2 + sed -n '1,220p' "$log" >&2 + fail "fingerprint service handles enrollment, failures and recovery" +fi + +pass "fingerprint service handles enrollment, failures and recovery"