#!/usr/bin/env bash # Fingerprint login, which is two systems that must be kept honest with each # other: fprintd holds the enrolled prints, authselect decides whether PAM # asks the reader. A print enrolled while with-fingerprint is off does # nothing, and that silence -- "I enrolled a finger and nothing happened" -- # is the failure this card exists to name. # # Two things changed, and each inverts a pin this file used to hold. # # Enrollment is Panama's now. It used to open GNOME's Users panel, which was # honest while fprintd's guided capture was the only thing there; the helper # drives EnrollStart itself and streams a touch counter, so the handoff is the # thing that would now be wrong. The needle is inverted rather than deleted: # the page must NOT open that panel. # # And the card is no longer hidden by the absence of a reader. `pamEnabled` # used to be reported as false whenever fprintd had nothing to say, so the one # state a person genuinely has to act on -- the PAM feature switched on, no # reader attached, every unlock now waiting on a device that is not there -- # rendered as no card at all. The two facts are read independently, and the # card appears when either is true. # # SAFETY. Enrollment claims a real device and authselect rewrites PAM, so # nothing here may reach either. `env -i` with a stub directory first on PATH, # both bus addresses pointed at sockets that do not exist, PANAMA_PATH pointed # at a recording panama-sudo that runs nothing, and the enrollment stream # replayed from PANAMA_FINGERPRINT_FIXTURE -- a canned fprintd whose calls land # in a log file instead of on the bus. The PATH claim is asserted before the # helper is run at all. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-fingerprint" service="$repo_dir/config/dot/quickshell/services/Fingerprint.qml" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" page="$settings_dir/UsersPage.qml" fail() { printf 'fingerprint contract: %s\n' "$1" >&2 exit 1 } for path in "$helper" "$service" "$page"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-fingerprint is not executable' file_calling() { grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 } # ── Enrollment stays here ──────────────────────────────────────────────────── # # The inverted needle. gnome-handoff-contract lost its UsersPage exception at # the same time; this is the other half, said from the page's side. grep -rn --include='*.qml' -E 'openGnomePanel\("(system", *"users|users")' "$settings_dir" \ && fail 'a settings page still sends fingerprint enrollment to GNOME Users, which Panama owns' enroll_page="$(file_calling 'Fingerprint.startEnroll(')" [[ -n "$enroll_page" ]] || fail 'nothing starts an enrollment' grep -q 'Fingerprint.cancelEnroll()' "$enroll_page" \ || fail 'an enrollment in progress cannot be cancelled, so the reader stays claimed' grep -qE 'Fingerprint\.(removeFinger|removeAll)\(' "$enroll_page" \ || fail 'an enrolled finger cannot be removed' # ── The card is visible in the state that needs acting on ──────────────────── # # `readerPresent || unlockFeatureEnabled`. Either alone hides the stuck state: # the feature on with no reader is invisible under the first, and a machine # with a reader and nothing configured is invisible under the second. python3 - "$page" "$service" <<'PY' || fail 'the fingerprint card is not visible in the state that needs acting on' import re import sys page = open(sys.argv[1], encoding="utf-8").read() service = open(sys.argv[2], encoding="utf-8").read() DISJUNCTION = re.compile( r"readerPresent\s*\|\|\s*(root\.)?unlockFeatureEnabled" r"|unlockFeatureEnabled\s*\|\|\s*(root\.)?readerPresent") visible = [line for line in page.splitlines() if "visible:" in line and "Fingerprint." in line] if not visible: print("nothing decides whether the fingerprint card is shown", file=sys.stderr) raise SystemExit(1) card = visible[0] # Either the page spells it out, or it binds to a service property that does. # The second is better -- one expression, so the card and the state it # describes cannot disagree -- so the indirection is followed rather than # forbidden. if DISJUNCTION.search(card): raise SystemExit(0) named = re.search(r"visible:\s*Fingerprint\.(\w+)", card) if named: definition = re.search(rf"property bool {named.group(1)}:([^\n]*)", service) if definition and DISJUNCTION.search(definition.group(1)): raise SystemExit(0) print(f"visible: Fingerprint.{named.group(1)}, which is " f"{definition.group(1).strip() if definition else 'not defined'}", file=sys.stderr) raise SystemExit(1) print(card.strip(), file=sys.stderr) raise SystemExit(1) PY # And it says why it is there. A card that appears on a machine with no reader # and offers nothing but a switch reads as a bug in the card. stuck_page="$(file_calling 'Fingerprint.unlockFeatureEnabled')" [[ -n "$stuck_page" ]] || fail 'no page reads the unlock feature independently of the reader' grep -qiE 'no reader' "$stuck_page" \ || fail 'the stuck state does not say there is no reader' grep -qE 'Turn off' "$stuck_page" \ || fail 'the stuck state offers no way out of it' grep -Fq 'onToggled: value => Fingerprint.setUnlockEnabled(value)' "$page" \ || fail 'the unlock switch does not drive authselect' grep -Fq 'Fingerprint.refresh()' "$page" \ || fail 'the page never reads the fingerprint state' # ── A finger is named in exactly one place ─────────────────────────────────── # # fprintd's vocabulary is "right-index-finger". What that READS as is a # presentation decision, and two copies of it disagree the first time one is # edited -- so the service presents, the helper only validates, and no page # carries a finger name of its own. grep -q 'function fingerLabel' "$service" \ || fail 'finger names have no single place to be presented from' python3 - "$service" "$settings_dir" "$repo_dir/config/dot/quickshell/services" <<'PY' \ || fail 'fprintd vocabulary is spelled out in more than one place' import pathlib import re import sys FINGER = re.compile(r'"(?:left|right)-(?:thumb|(?:index|middle|ring|little)-finger)"') owner = pathlib.Path(sys.argv[1]).resolve() # A single id elsewhere is a default choice -- "start on the right index # finger" -- and that is allowed. A second one is a copy of the list, and the # copy is what falls behind: the one in Fingerprint.qml grows a finger, the # other does not, and half the interface offers nine. for directory in sys.argv[2:]: for path in sorted(pathlib.Path(directory).rglob("*.qml")): if path.resolve() == owner: continue found = set(FINGER.findall(path.read_text(encoding="utf-8"))) if len(found) > 1: print(f"{path}: {sorted(found)}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # The service carries the enrollment state the panel counts with. for property in unlockFeatureEnabled enrolling enrollStage enrollTotal; do grep -qE "property (bool|int|var|string) $property" "$service" \ || fail "the service does not expose $property, so the page cannot show the enrollment" done # ── The privileged change still says why ───────────────────────────────────── grep -Fq 'authselect' "$helper" && grep -Fq 'with-fingerprint' "$helper" \ || fail 'the helper does not manage the authselect feature' grep -Fq -- '--reason' "$helper" \ || fail 'the privileged change carries no stated reason' # ── The helper cannot walk past the stubs ──────────────────────────────────── absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" [[ -z "$absolute" ]] \ || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" command -v jq >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no jq)\n'; exit 0; } command -v python3 >/dev/null 2>&1 || { printf 'fingerprint contract: SKIP (no python3)\n'; exit 0; } # ── The fake machine ───────────────────────────────────────────────────────── work="$(mktemp -d /tmp/panama-fingerprint-contract.XXXXXX)" stub_dir="$work/bin" state_dir="$work/state" home_dir="$work/home" run_dir="$work/run" panama_dir="$work/panama/bin" mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$run_dir" "$panama_dir" trap 'rm -rf "$work"' EXIT cat >"$stub_dir/fprintd-list" <>"$state_dir/argv" if [[ -e "$state_dir/no-reader" ]]; then echo 'Impossible to enumerate devices: No devices available' >&2 exit 1 fi cat <<'OUT' found 1 devices Device at /net/reactivated/Fprint/Device/0 Using device /net/reactivated/Fprint/Device/0 Fingerprints for user gib on Goodix MOC Fingerprint Sensor (press): - #0: right-index-finger - #1: left-thumb OUT STUB cat >"$stub_dir/authselect" <>"$state_dir/argv" if [[ "\$1" == "current" ]]; then echo 'Profile ID: local' [[ -e "$state_dir/feature-on" ]] && echo '- with-fingerprint' exit 0 fi printf 'fingerprint contract: authselect was asked to change PAM\n' >&2 exit 1 STUB # Records the escalation and runs NOTHING. The reason is the point: this page's # prompt has to say what it is about to do, and the only way to see that is to # catch the arguments before they become an authselect invocation. cat >"$panama_dir/panama-sudo" <>"$state_dir/argv" exit 0 STUB # Anything else that could reach the machine is closed rather than left open. for blocked in sudo pkexec gdbus busctl dbus-send fprintd-enroll fprintd-delete; do cat >"$stub_dir/$blocked" <>"$state_dir/argv" printf 'fingerprint contract: the helper reached for $blocked\n' >&2 exit 1 STUB done chmod +x "$stub_dir"/* "$panama_dir/panama-sudo" runh() { env -i \ PATH="$stub_dir:/usr/bin:/bin" \ HOME="$home_dir" \ USER=gib \ XDG_RUNTIME_DIR="$run_dir" \ PANAMA_PATH="$work/panama" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ LANG=C LC_ALL=C \ "$@" } run() { runh "$helper" "$@"; } # Enrollment and removal replay a canned fprintd; nothing they do reaches a bus. fixture="$work/fprintd.json" log="$work/fprintd.log" runf() { runh env PANAMA_FINGERPRINT_FIXTURE="$fixture" PANAMA_FINGERPRINT_LOG="$log" \ "$helper" "$@" } # The safety claim, verified rather than assumed. for binary in fprintd-list authselect sudo pkexec; do resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)" [[ "$resolved" == "$stub_dir/$binary" ]] \ || fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one" done # ── status: two facts, read independently ──────────────────────────────────── status="$(run status)" jq -e '.reader and .readerName == "Goodix MOC Fingerprint Sensor"' <<<"$status" >/dev/null \ || fail "the reader name did not parse: $status" jq -e '.enrolled == ["right-index-finger", "left-thumb"]' <<<"$status" >/dev/null \ || fail "enrolled fingers did not parse: $status" jq -e '.unlockFeatureEnabled == false and .error == ""' <<<"$status" >/dev/null \ || fail "authselect state misread as enabled: $status" # The old name is still answered, because the service and this file both read # it and a silent rename would report "off" forever. jq -e '.pamEnabled == .unlockFeatureEnabled' <<<"$status" >/dev/null \ || fail "the two names for the PAM feature disagree: $status" touch "$state_dir/feature-on" jq -e '.unlockFeatureEnabled == true' <<<"$(run status)" >/dev/null \ || fail 'with-fingerprint enabled was not detected' # THE regression. No reader, feature on: the state that used to render as no # card at all, because the feature was only read when fprintd answered. touch "$state_dir/no-reader" stuck="$(run status)" jq -e '.reader == false and .error == ""' <<<"$stuck" >/dev/null \ || fail "a readerless machine was reported as a problem: $stuck" jq -e '.unlockFeatureEnabled == true' <<<"$stuck" >/dev/null \ || fail "the PAM feature was not read on a machine with no reader, which is the one state somebody has to be able to turn off: $stuck" jq -e '.enrolled == []' <<<"$stuck" >/dev/null \ || fail "a machine with no reader reported enrolled fingers: $stuck" rm -f "$state_dir/no-reader" "$state_dir/feature-on" # ── The privileged change goes through, with the right feature and a reason ── : >"$state_dir/argv" run set-unlock on >/dev/null grep -Fq 'authselect enable-feature with-fingerprint' "$state_dir/argv" \ || fail "set-unlock on did not enable the authselect feature: $(cat "$state_dir/argv")" grep -qE 'panama-sudo --reason .*fingerprint' "$state_dir/argv" \ || fail 'the prompt does not say what it is about to do' grep -Eq '^(sudo|pkexec) ' "$state_dir/argv" \ && fail 'the privileged change escalated without a stated reason' : >"$state_dir/argv" run set-unlock off >/dev/null grep -Fq 'authselect disable-feature with-fingerprint' "$state_dir/argv" \ || fail 'set-unlock off did not disable the authselect feature' run set-unlock sideways >/dev/null 2>&1 \ && fail 'set-unlock accepted something that is neither on nor off' # ── enroll: a stream, one line per touch ───────────────────────────────────── # # The whole point of doing this natively: the page counts touches while they # happen. So the stream is read as a stream -- every line valid JSON on its own, # the counter monotonic and bounded, and a terminal line that says which way it # ended. printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-stage-passed","enroll-retry-scan-too-short","enroll-stage-passed","enroll-stage-passed","enroll-stage-passed","enroll-completed"],"error":""}\n' \ >"$fixture" : >"$log" runf enroll right-index-finger >"$work/stream.jsonl" \ || fail "a completed enrollment exited nonzero: $(cat "$work/stream.jsonl")" python3 - "$work/stream.jsonl" <<'PY' || fail 'the enrollment stream is not a usable progress stream' import json import sys lines = [line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() if line.strip()] if len(lines) < 3: print(f"only {len(lines)} line(s) of progress", file=sys.stderr) raise SystemExit(1) events = [] for line in lines: try: events.append(json.loads(line)) except ValueError: print(f"not a JSON object on its own line: {line}", file=sys.stderr) raise SystemExit(1) for event in events: for field in ("ok", "stage", "done", "total", "result"): if field not in event: print(f"a progress line is missing {field}: {event}", file=sys.stderr) raise SystemExit(1) if event["total"] != 5: print(f"the touch count changed mid-enrollment: {event}", file=sys.stderr) raise SystemExit(1) if not 0 <= event["done"] <= event["total"]: print(f"the counter left its own range: {event}", file=sys.stderr) raise SystemExit(1) counts = [event["done"] for event in events] if counts != sorted(counts): print(f"the counter went backwards: {counts}", file=sys.stderr) raise SystemExit(1) # A retry is a touch that did NOT count. If it advanced the counter, the panel # would promise a finish that never arrives. retries = [event for event in events if "retry" in str(event["result"])] if not retries: print("the failed touch produced no line at all, so the panel says nothing", file=sys.stderr) raise SystemExit(1) if retries[0]["done"] != 2: print(f"a retried touch was counted as progress: {retries[0]}", file=sys.stderr) raise SystemExit(1) last = events[-1] if not last["ok"] or last["done"] != last["total"]: print(f"a completed enrollment did not finish full: {last}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # The device is claimed and released around it, in that order. A reader left # claimed by a crashed enrollment refuses the next one, and the message a # person gets is "busy with something else" forever. python3 - "$log" <<'PY' || fail 'the reader is not claimed and released around an enrollment' import json import sys calls = [json.loads(line)["method"] for line in open(sys.argv[1], encoding="utf-8") if line.strip()] if not calls: print("nothing was called at all", file=sys.stderr) raise SystemExit(1) if calls[0] != "Claim": print(f"the device was used before it was claimed: {calls}", file=sys.stderr) raise SystemExit(1) if calls[-1] != "Release": print(f"the device was left claimed: {calls}", file=sys.stderr) raise SystemExit(1) if "EnrollStart" not in calls: print(f"nothing started an enrollment: {calls}", file=sys.stderr) raise SystemExit(1) if calls.index("EnrollStart") < calls.index("Claim"): print(f"enrollment started before the claim: {calls}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # A finger already on the reader ends the stream badly, and says so in words # rather than in fprintd's vocabulary. printf '{"enrollStages":5,"results":["enroll-stage-passed","enroll-duplicate"],"error":""}\n' \ >"$fixture" : >"$log" duplicate="$(runf enroll left-thumb)" && fail 'a failed enrollment exited zero' last="$(tail -1 <<<"$duplicate")" jq -e '.ok == false and .stage == "failed"' <<<"$last" >/dev/null \ || fail "a failed enrollment did not end in a failure line: $last" jq -e '(.error | length) > 0 and (.error | test("[a-z] [a-z]"))' <<<"$last" >/dev/null \ || fail "the failure is reported in fprintd's vocabulary rather than in words: $last" grep -Fq '"Release"' "$log" \ || fail 'a failed enrollment left the reader claimed' # A refused claim -- the reader busy with the lock screen, most often -- is one # line, not a hang. printf '{"enrollStages":5,"results":[],"error":"The fingerprint reader is busy with something else."}\n' \ >"$fixture" : >"$log" refused="$(runf enroll right-thumb)" && fail 'a refused claim exited zero' jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refused")" >/dev/null \ || fail "a refused claim did not report a failure: $refused" # ── Nonsense is refused before the reader is touched ───────────────────────── printf '{"enrollStages":5,"results":["enroll-completed"],"error":""}\n' >"$fixture" for bad in 'third-eye' 'right-index-finger; reboot' '' 'left-thumb-finger'; do : >"$log" refusal="$(runf enroll "$bad" 2>/dev/null)" \ && fail "enroll accepted ${bad@Q} as a finger" jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$refusal")" >/dev/null \ || fail "a bad finger name did not come back as a stream failure: $refusal" [[ ! -s "$log" ]] \ || fail "a bad finger name claimed the reader before being refused: ${bad@Q}" : >"$log" [[ -n "$(runf remove "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \ || fail "remove accepted ${bad@Q} as a finger" [[ ! -s "$log" ]] \ || fail "a bad finger name reached the reader through remove: ${bad@Q}" done # ── remove: one finger, or all of them, and never somebody else's ──────────── : >"$log" removed="$(runf remove right-index-finger)" || fail 'remove failed against the canned fprintd' jq -e 'has("reader") and has("enrolled") and has("unlockFeatureEnabled")' <<<"$removed" >/dev/null \ || fail "remove does not answer with the fresh state: $removed" python3 - "$log" right-index-finger <<'PY' || fail 'removing one finger did not delete that one finger' import json import sys calls = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] methods = [call["method"] for call in calls] if methods[0] != "Claim" or methods[-1] != "Release": print(f"the device was not claimed and released around it: {methods}", file=sys.stderr) raise SystemExit(1) delete = next((call for call in calls if call["method"].startswith("DeleteEnrolledFinger")), None) if delete is None: print(f"nothing was deleted: {methods}", file=sys.stderr) raise SystemExit(1) if delete["method"] == "DeleteEnrolledFingers2": print("removing one finger deleted all of them", file=sys.stderr) raise SystemExit(1) if sys.argv[2] not in delete["arguments"]: print(f"a different finger was deleted: {delete}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY : >"$log" runf remove-all >/dev/null || fail 'remove-all failed against the canned fprintd' # DeleteEnrolledFingers2 works on the claimed user. Its predecessor took a user # name, which is how a typo deletes somebody else's prints. grep -Fq '"DeleteEnrolledFingers2"' "$log" \ || fail 'remove-all does not use the method that works on the claimed user' python3 - "$log" <<'PY' || fail 'remove-all names a user, which is how a typo deletes somebody else' import json import sys for line in open(sys.argv[1], encoding="utf-8"): if not line.strip(): continue call = json.loads(line) if call["method"] == "DeleteEnrolledFingers2" and call["arguments"]: print(call, file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── With no fixture and no bus, it still answers in its own shape ──────────── # # The page reads this stream line by line. A traceback on stdout, or nothing at # all, leaves an enrollment panel open forever with no way to know it failed. absent="$(run enroll right-index-finger 2>/dev/null)" [[ -n "$absent" ]] || fail 'enrollment against no fprintd printed nothing at all' jq -e '.ok == false and (.error | length) > 0' <<<"$(tail -1 <<<"$absent")" >/dev/null \ || fail "enrollment against no fprintd did not answer in the stream's own shape: $absent" printf 'fingerprint contract: PASS (status, stuck state, enroll stream, remove, reason)\n'