#!/usr/bin/env python3 """Fingerprint login: the enrolled prints, and the one privileged switch. Two independent facts make a working fingerprint login, and conflating them is how the feature usually confuses people. fprintd must hold at least one enrolled print, and PAM must be told to ask the reader at all, which on Fedora is authselect's `with-fingerprint` feature. This helper reports both, enrolls and removes prints, and can flip the second. Both facts are reported unconditionally, including on a machine with no reader. The state that used to be invisible -- the feature switched on with nothing enrolled and no reader attached -- is exactly the state someone needs to see and turn off, and reporting `pamEnabled: false` because there was no reader to ask made it unreachable. Enrollment talks to fprintd over D-Bus (net.reactivated.Fprint) rather than handing the person to GNOME's Users panel: Claim, EnrollStart, then one `EnrollStatus` signal per touch until the device says it is done. Progress is printed as one JSON object per line, the same streaming shape panama-dictate's setup uses, so the page can count touches while they happen. That signal loop is why this is Python and no longer bash -- `status` and `set-unlock` still shell out to exactly the same tools, and answer in exactly the same shapes. panama-fingerprint status panama-fingerprint set-unlock on|off (prompts through panama-sudo/polkit) panama-fingerprint enroll FINGER (streams {stage,done,total,result}) panama-fingerprint remove FINGER panama-fingerprint remove-all authselect is baseline Fedora (it manages PAM for the whole install) and fprintd ships with Workstation; a machine with neither reports no reader and no feature. """ from __future__ import annotations import json import os import re import shutil import subprocess import sys import time FPRINT = "net.reactivated.Fprint" MANAGER_PATH = "/net/reactivated/Fprint/Manager" MANAGER_INTERFACE = "net.reactivated.Fprint.Manager" DEVICE_INTERFACE = "net.reactivated.Fprint.Device" # The authselect feature that decides whether PAM asks the reader at unlock. FEATURE = "with-fingerprint" # How long a reader may sit waiting for a finger before enrollment is given up # on. The page has a Cancel button; this is only for a session left open. IDLE_TIMEOUT_SECONDS = 90 # A canned fprintd, for contract runs. Points at a JSON file; see replay() for # the shape. Every call that would have gone to the bus is appended to # PANAMA_FINGERPRINT_LOG instead, so a test can pin the order of # Claim / EnrollStart / EnrollStop / Release without a reader in the room. FIXTURE_ENV = "PANAMA_FINGERPRINT_FIXTURE" LOG_ENV = "PANAMA_FINGERPRINT_LOG" PANAMA_PATH = os.environ.get("PANAMA_PATH") or os.path.expanduser("~/.local/share/Panama") # fprintd's own vocabulary, only far enough to reject nonsense before it becomes # a D-Bus error. What a finger is CALLED is presented by services/Fingerprint.qml, # which is the single place that decides how these read. FINGER = re.compile(r"^(left|right)-(thumb|(index|middle|ring|little)-finger)$") class BoundaryError(RuntimeError): """A user-visible failure: no reader, a refused claim, a bad finger name.""" # ── status ─────────────────────────────────────────────────────────────────── def unlock_feature_enabled() -> bool: """Whether PAM has been told to ask the reader. Asked unconditionally. This is a property of the PAM configuration and has nothing to do with whether a reader is plugged in, which is the whole point: the feature left on with no reader is a state someone has to be able to see. """ if not shutil.which("authselect"): return False try: current = subprocess.run(["authselect", "current"], capture_output=True, text=True, timeout=10, check=False) except (OSError, subprocess.SubprocessError): return False return FEATURE in (current.stdout or "") def status() -> dict: reader, name, enrolled, error = False, "", [], "" if shutil.which("fprintd-list"): # fprintd-list answers "is there a reader" (fprintd is bus-activated, so # this copes with the daemon not running yet) and names the enrolled # fingers in one call. # # LC_ALL=C: the "no devices" match below reads fprintd's message, and a # translated daemon would turn every readerless non-English machine into # a permanent error card. environment = dict(os.environ, LC_ALL="C") try: listing = subprocess.run(["fprintd-list", os.environ.get("USER", "")], capture_output=True, text=True, timeout=10, check=False, env=environment) output = (listing.stdout or "") + (listing.stderr or "") except (OSError, subprocess.SubprocessError) as failure: listing, output = None, str(failure) if listing is None or listing.returncode != 0: # "No devices available" is the normal no-reader machine; anything # else is a real problem worth surfacing. if "no devices" not in output.lower(): first = next((line for line in output.splitlines() if line.strip()), "") error = f"fprintd did not answer: {first}" else: reader = True # "Fingerprints for user gib on FocalTech ... (press):" carries the # reader product name; " - #0: right-index-finger" the enrollment. match = re.search(r"^Fingerprints for user \S+ on (.*) \(\w*\):$", output, re.MULTILINE) name = match.group(1) if match else "" enrolled = re.findall(r"^ *- #\d+: (.+)$", output, re.MULTILINE) feature = unlock_feature_enabled() return { "reader": reader, "readerName": name, "enrolled": enrolled, # Two names for one fact, on purpose: `pamEnabled` is what this helper # has always called it, `unlockFeatureEnabled` is what it is. "pamEnabled": feature, "unlockFeatureEnabled": feature, "error": error, } # ── the privileged switch ──────────────────────────────────────────────────── def set_unlock(state: str) -> int: if state == "on": verb = "enable-feature" reason = ("Turning on fingerprint login: telling PAM (via authselect) to " "ask the fingerprint reader when unlocking") elif state == "off": verb = "disable-feature" reason = ("Turning off fingerprint login: telling PAM (via authselect) to " "stop asking the fingerprint reader") else: print("panama-fingerprint set-unlock takes on|off", file=sys.stderr) return 1 escalate = os.path.join(PANAMA_PATH, "bin", "panama-sudo") prefix = ([escalate, "--reason", reason, "--"] if os.access(escalate, os.X_OK) else ["sudo"]) return subprocess.run(prefix + ["authselect", verb, FEATURE], check=False).returncode # ── talking to fprintd ─────────────────────────────────────────────────────── def emit(**fields) -> None: """One JSON object, one line, flushed. The page reads these as they arrive.""" print(json.dumps(fields, separators=(",", ":")), flush=True) def log_call(method: str, *arguments) -> None: """Record a call the fixture stood in for, so a contract can pin the order.""" path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log") if not path: return try: with open(path, "a", encoding="utf-8") as handle: handle.write(json.dumps({"method": method, "arguments": list(arguments)}, separators=(",", ":")) + "\n") except OSError: pass def fixture() -> dict | None: """The canned fprintd, or None when there is a real bus to talk to. {"enrollStages": 5, "results": ["enroll-stage-passed", "enroll-retry-scan-too-short", ...], "error": ""} <- non-empty stands in for a refused claim """ path = os.environ.get(FIXTURE_ENV) if not path: return None try: with open(path, encoding="utf-8") as handle: return json.load(handle) except (OSError, ValueError) as failure: raise BoundaryError(f"The fingerprint fixture could not be read: {failure}") def bus(): try: import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib return Gio, GLib, Gio.bus_get_sync(Gio.BusType.SYSTEM, None) except Exception as failure: # noqa: BLE001 - no bus is a legitimate state raise BoundaryError("The fingerprint service is not answering.") from failure def call(path: str, interface: str, method: str, parameters=None, reply=None): Gio, GLib, connection = bus() try: result = connection.call_sync( FPRINT, path, interface, method, parameters, GLib.VariantType(reply) if reply else None, Gio.DBusCallFlags.NONE, 30000, None) except Exception as failure: # noqa: BLE001 raise BoundaryError(_clean(str(failure))) from failure return result.unpack() if result is not None else None def _clean(message: str) -> str: """The useful sentence out of a D-Bus error, without the type prefix.""" trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", message).strip() if "no devices" in trimmed.lower(): return "No fingerprint reader is connected." if "permission denied" in trimmed.lower() or "not authorized" in trimmed.lower(): return "That was not authorized." if "already in use" in trimmed.lower() or "claimed" in trimmed.lower(): return "The fingerprint reader is busy with something else." return trimmed.splitlines()[0][:200] if trimmed else "The fingerprint reader failed." def default_device() -> str: return call(MANAGER_PATH, MANAGER_INTERFACE, "GetDefaultDevice", None, "(o)")[0] def enroll_stages(device: str) -> int: Gio, GLib, connection = bus() try: result = connection.call_sync( FPRINT, device, "org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", (DEVICE_INTERFACE, "num-enroll-stages")), GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 10000, None) return max(1, int(result.unpack()[0])) except Exception: # noqa: BLE001 - a device that will not say is not fatal # Readers overwhelmingly want five touches, and a counter that is wrong # is better than a page with no counter at all. return 5 # ── enroll ─────────────────────────────────────────────────────────────────── # fprintd says "enroll-stage-passed" for a touch that counted and # "enroll-completed" when there are no more to take. Everything else beginning # "enroll-retry" or naming a placement problem is a touch to do again, and the # terminal failures arrive with done=true. STAGE_PASSED = "enroll-stage-passed" COMPLETED = "enroll-completed" # The results that end an enrollment badly. The device says so itself over the # wire (the signal's `done` flag), so this list is only what the canned fprintd # has to recognize on its own. TERMINAL_FAILURES = ( "enroll-failed", "enroll-data-full", "enroll-disconnected", "enroll-duplicate", "enroll-unknown-error", ) def enroll(finger: str) -> int: if not FINGER.fullmatch(finger or ""): raise BoundaryError("That is not a finger fprintd knows.") canned = fixture() if canned is not None: return enroll_replay(finger, canned) device = default_device() total = enroll_stages(device) emit(ok=True, stage="claiming", done=0, total=total, result="", error="") Gio, GLib, connection = bus() call(device, DEVICE_INTERFACE, "Claim", GLib.Variant("(s)", (os.environ.get("USER", ""),))) loop = GLib.MainLoop() state = {"done": 0, "result": "", "error": "", "ok": False, "seen": time.monotonic()} def on_status(_connection, _sender, _path, _interface, _signal, parameters): result, finished = parameters.unpack() state["seen"] = time.monotonic() state["result"] = result if result == STAGE_PASSED: state["done"] = min(state["done"] + 1, total) if finished: state["ok"] = result == COMPLETED if not state["ok"]: state["error"] = describe_result(result) loop.quit() return emit(ok=True, stage="scanning", done=state["done"], total=total, result=result, error="") subscription = connection.signal_subscribe( None, DEVICE_INTERFACE, "EnrollStatus", device, None, Gio.DBusSignalFlags.NONE, on_status) # A cancelled enrollment is a terminated process -- the page drops the # Process and Quickshell sends a signal. The device must still be released, # or the next attempt finds the reader busy with a session that is gone. def cancelled(_data=None): state["error"] = "" state["result"] = "cancelled" loop.quit() return GLib.SOURCE_REMOVE GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, cancelled, None) GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, cancelled, None) def watchdog(_data=None): if time.monotonic() - state["seen"] > IDLE_TIMEOUT_SECONDS: state["error"] = "The reader stopped answering." loop.quit() return GLib.SOURCE_REMOVE return GLib.SOURCE_CONTINUE GLib.timeout_add_seconds(5, watchdog, None) try: call(device, DEVICE_INTERFACE, "EnrollStart", GLib.Variant("(s)", (finger,))) emit(ok=True, stage="scanning", done=0, total=total, result="", error="") loop.run() finally: connection.signal_unsubscribe(subscription) # Both are best-effort: the interesting failure already happened, and a # reader left claimed is worse than a second error nobody can act on. for method in ("EnrollStop", "Release"): try: call(device, DEVICE_INTERFACE, method) except BoundaryError: pass return finish(state, total) def finish(state: dict, total: int) -> int: if state["ok"]: emit(ok=True, stage="done", done=total, total=total, result=COMPLETED, error="") return 0 stage = "cancelled" if state["result"] == "cancelled" else "failed" emit(ok=False, stage=stage, done=state["done"], total=total, result=state["result"], error=state["error"]) return 1 def describe_result(result: str) -> str: """fprintd's terminal results, in words someone can act on.""" return { "enroll-failed": "That finger could not be read. Try enrolling it again.", "enroll-data-full": "The reader has no room for another fingerprint.", "enroll-disconnected": "The fingerprint reader was disconnected.", "enroll-duplicate": "That finger is already enrolled.", "enroll-unknown-error": "The fingerprint reader failed.", }.get(result, "Enrolling that finger did not finish.") def enroll_replay(finger: str, canned: dict) -> int: """The same stream, from a file. No bus, no reader, no waiting.""" total = max(1, int(canned.get("enrollStages", 5))) emit(ok=True, stage="claiming", done=0, total=total, result="", error="") log_call("Claim", os.environ.get("USER", "")) refusal = str(canned.get("error") or "") if refusal: emit(ok=False, stage="failed", done=0, total=total, result="", error=refusal) return 1 log_call("EnrollStart", finger) emit(ok=True, stage="scanning", done=0, total=total, result="", error="") state = {"done": 0, "result": "", "error": "", "ok": False} for result in canned.get("results", []): state["result"] = result if result == STAGE_PASSED: state["done"] = min(state["done"] + 1, total) if result == COMPLETED or result in TERMINAL_FAILURES: state["ok"] = result == COMPLETED if not state["ok"]: state["error"] = describe_result(result) break emit(ok=True, stage="scanning", done=state["done"], total=total, result=result, error="") log_call("EnrollStop") log_call("Release") return finish(state, total) # ── remove ─────────────────────────────────────────────────────────────────── def remove(finger: str | None) -> dict: """Delete one enrolled finger, or all of them, and report the fresh state.""" if finger is not None and not FINGER.fullmatch(finger): raise BoundaryError("That is not a finger fprintd knows.") canned = fixture() if canned is not None: log_call("Claim", os.environ.get("USER", "")) if finger is None: log_call("DeleteEnrolledFingers2") else: log_call("DeleteEnrolledFinger", finger) log_call("Release") return {**status(), "error": str(canned.get("error") or "")} from gi.repository import GLib device = default_device() call(device, DEVICE_INTERFACE, "Claim", GLib.Variant("(s)", (os.environ.get("USER", ""),))) try: if finger is None: # DeleteEnrolledFingers2 works on the claimed user; its predecessor # took a name and is deprecated for exactly the confusion that # invited -- deleting someone else's prints by typo. call(device, DEVICE_INTERFACE, "DeleteEnrolledFingers2") else: call(device, DEVICE_INTERFACE, "DeleteEnrolledFinger", GLib.Variant("(s)", (finger,))) finally: try: call(device, DEVICE_INTERFACE, "Release") except BoundaryError: pass return status() # ── entry ──────────────────────────────────────────────────────────────────── def main(arguments: list[str]) -> int: verb = arguments[0] if arguments else "" try: if verb == "status" and len(arguments) == 1: print(json.dumps(status(), separators=(",", ":"))) return 0 if verb == "set-unlock" and len(arguments) == 2: return set_unlock(arguments[1]) if verb == "enroll" and len(arguments) == 2: return enroll(arguments[1]) if verb == "remove" and len(arguments) == 2: print(json.dumps(remove(arguments[1]), separators=(",", ":"))) return 0 if verb == "remove-all" and len(arguments) == 1: print(json.dumps(remove(None), separators=(",", ":"))) return 0 except BoundaryError as failure: # Enrollment streams, so its failure has to arrive in the same shape as # its progress; the others answer with the state plus the message, so a # page never has to ask twice to find out what happened. if verb == "enroll": emit(ok=False, stage="failed", done=0, total=0, result="", error=str(failure)) else: print(json.dumps({**status(), "error": str(failure)}, separators=(",", ":"))) return 1 print("usage: panama-fingerprint status | set-unlock on|off | enroll FINGER | " "remove FINGER | remove-all", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))