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 <omabot@omarchy.org>
Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
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 <kalle.ahlin@gmail.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

* 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 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

* 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 <noreply@openai.com>

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Karl Ahlin <kalle.ahlin@gmail.com>
Co-authored-by: Omarchy Bot <omarchybot@users.noreply.github.com>
This commit is contained in:
17 files changed
+1156 -49

No files matched your search

+28 -3
View File
@@ -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
+14
View File
@@ -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"
+5 -11
View File
@@ -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
+9
View File
@@ -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
@@ -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
+22
View File
@@ -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
+91
View File
@@ -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
}
}
+24 -2
View File
@@ -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
}
}
}
+205 -18
View File
@@ -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
+87 -10
View File
@@ -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"
+40 -5
View File
@@ -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"
@@ -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()
@@ -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])
}
}
}
+61
View File
@@ -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" <<SH
#!/bin/bash
printf '%s\n' "\$*" >>"$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"
+112
View File
@@ -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" <<STUB
#!/bin/bash
printf '%s\n' "\$*" >>"$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"
+182
View File
@@ -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
@@ -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"