From b850fd174f767c3621a4ce09c9073db88efa0079 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Tue, 19 May 2026 08:45:13 +0100 Subject: [PATCH 01/27] Add support for sensor type 0xd51 (138a:00ab, 06cb:00b7) The 0xd51 chip does not emit the b[0]=2 "finger detected" interrupt during capture, so the wait-finger loop in Sensor.capture() hangs forever. Accept b[0]=3 as a substitute (gated on the real device type so existing chips are unaffected) and pass the interrupt through to the wait-capture-complete loop. Also wires the two known USB IDs through SupportedDevices, blobs, firmware_tables, the udev rule, and aliases sensor type 0xd51 to the 0x199 profile so SensorTypeInfo / SensorCaptureProg lookups succeed (no native profile exists yet). Closes #181 Closes #225 Closes #238 --- debian/python3-validity.udev | 2 ++ validitysensor/blobs.py | 4 ++++ validitysensor/firmware_tables.py | 7 ++++++- validitysensor/sensor.py | 25 +++++++++++++++++++++++-- validitysensor/usb.py | 2 ++ 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/debian/python3-validity.udev b/debian/python3-validity.udev index d9ca628..bb4a74a 100644 --- a/debian/python3-validity.udev +++ b/debian/python3-validity.udev @@ -4,6 +4,8 @@ ENV{DEVTYPE}!="usb_device", GOTO="python_validity_end" ATTRS{idVendor}=="138a", ATTRS{idProduct}=="0090", GOTO="python_validity_match" ATTRS{idVendor}=="138a", ATTRS{idProduct}=="0097", GOTO="python_validity_match" ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="009a", GOTO="python_validity_match" +ATTRS{idVendor}=="138a", ATTRS{idProduct}=="00ab", GOTO="python_validity_match" +ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="00b7", GOTO="python_validity_match" GOTO="python_validity_end" diff --git a/validitysensor/blobs.py b/validitysensor/blobs.py index a9f16ab..fb2a1a0 100644 --- a/validitysensor/blobs.py +++ b/validitysensor/blobs.py @@ -8,9 +8,13 @@ def __load_blob(blob: str) -> bytes: from . import blobs_97 as blobs elif usb.usb_dev().idProduct == 0x009d: from . import blobs_9d as blobs + elif usb.usb_dev().idProduct == 0x00ab: + from . import blobs_97 as blobs # HP EliteBook 840 G5; verified elif usb.usb_dev().idVendor == 0x06cb: if usb.usb_dev().idProduct == 0x009a: from . import blobs_9a as blobs + elif usb.usb_dev().idProduct == 0x00b7: + from . import blobs_9a as blobs # HP G6 series; same sensor type as 0x00ab globals()[blob] = getattr(blobs, blob) return globals()[blob] diff --git a/validitysensor/firmware_tables.py b/validitysensor/firmware_tables.py index bd0b867..dbbc6cd 100644 --- a/validitysensor/firmware_tables.py +++ b/validitysensor/firmware_tables.py @@ -29,5 +29,10 @@ SupportedDevices.DEV_90: '6_07f_Lenovo.xpfwext', SupportedDevices.DEV_97: '6_07f_lenovo_mis_qm.xpfwext', SupportedDevices.DEV_9a: '6_07f_lenovo_mis_qm.xpfwext', - SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext' + SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext', + # 0xd51-sensor variants ship with firmware pre-loaded; xpfwext upload is + # only needed for factory-reset / unprovisioned chips. The HP softpaq + # filename matches what extracted from HP's Windows driver (sp135736.exe). + SupportedDevices.DEV_AB: '6_07f_hp_cmit_mis_qm.xpfwext', # HP EliteBook 840 G5 + SupportedDevices.DEV_B7: '6_07f_hp_cmit_mis_qm.xpfwext', # HP G6 series (same chip family) } diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index dd019bb..dff7b1a 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -24,7 +24,8 @@ calib_data_path = PYTHON_VALIDITY_DATA_DIR + 'calib-data.bin' line_update_type1_devices = [ - 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199 + 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199, + 0xD51, # HP EliteBook 840 G5 (138a:00ab) / HP G6 series (06cb:00b7) ] @@ -224,6 +225,17 @@ class Sensor: def open(self): self.device_info = identify_sensor() + self.real_device_type = self.device_info.type + + # Sensor type 0xd51 (HP EliteBook 840 G5 138a:00ab, HP G6 series 06cb:00b7) + # has no native SensorTypeInfo / SensorCaptureProg entry. Empirically the + # 0x199 profile produces images that the on-chip matcher accepts after + # enrollment/verify; the 0xdb profile does not. Spoofing keeps the rest + # of this method (calibration switch, capture program lookup) on a code + # path that works. + if self.device_info.type == 0xd51: + logging.info('Sensor type 0xd51 — aliasing to 0x199 profile') + self.device_info.type = 0x199 logging.info('Opening sensor: %s' % self.device_info.name) self.type_info = SensorTypeInfo.get_by_type(self.device_info.type) @@ -702,14 +714,23 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: raise Exception('wait_start: Unexpected interrupt type %s' % hexlify(b).decode()) # wait for finger + # Sensor type 0xd51 (138a:00ab, 06cb:00b7) does not emit the + # b[0]=2 "finger detected" interrupt — it jumps directly from the + # start ack to b[0]=3 capture events. Accept b[0]=3 as a substitute + # and pass the interrupt through to the wait-capture-complete loop. + saved_b = None while True: b = usb.wait_int() if b[0] == 2: break + if b[0] == 3 and getattr(self, 'real_device_type', None) == 0xd51: + saved_b = b + break # wait capture complete while True: - b = usb.wait_int() + b = saved_b if saved_b is not None else usb.wait_int() + saved_b = None if b[0] != 3: raise Exception('Unexpected interrupt type %s' % hexlify(b).decode()) diff --git a/validitysensor/usb.py b/validitysensor/usb.py index 464b092..22bca4f 100644 --- a/validitysensor/usb.py +++ b/validitysensor/usb.py @@ -18,6 +18,8 @@ class SupportedDevices(Enum): DEV_97 = (0x138a, 0x0097) DEV_9d = (0x138a, 0x009d) DEV_9a = (0x06cb, 0x009a) + DEV_AB = (0x138a, 0x00ab) # HP EliteBook 840 G5 — sensor type 0xd51 + DEV_B7 = (0x06cb, 0x00b7) # HP G6 series — sensor type 0xd51 @classmethod def from_usbid(cls, vendorid, productid): From 20df336480f5170230e426a43fe75054e6da17be Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Wed, 20 May 2026 19:40:28 +0100 Subject: [PATCH 02/27] Add FIRMWARE_URIS entries for DEV_AB and DEV_B7 Without these entries `validity-sensors-firmware` crashes with a KeyError for users on 138a:00ab and 06cb:00b7 who follow the README's standard install flow. The 0xd51-family chips ship with firmware pre-loaded so the downloader is only needed for factory-reset chips, but the script should not crash. Both PIDs point at HP softpaq sp135736.exe (the same blob extracted to 6_07f_hp_cmit_mis_qm.xpfwext in FIRMWARE_NAMES). sha512 verified against the canonical ftp.hp.com URL. Reported by a 06cb:00b7 user on PR #256. --- validitysensor/firmware_tables.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/validitysensor/firmware_tables.py b/validitysensor/firmware_tables.py index dbbc6cd..5e3317c 100644 --- a/validitysensor/firmware_tables.py +++ b/validitysensor/firmware_tables.py @@ -22,6 +22,16 @@ 'driver': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'referral': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'sha512': 'a4a4e6058b1ea8ab721953d2cfd775a1e7bc589863d160e5ebbb90344858f147d695103677a8df0b2de0c95345df108bda97196245b067f45630038fb7c807cd' + }, + SupportedDevices.DEV_AB: { + 'driver': 'https://ftp.hp.com/pub/softpaq/sp135501-136000/sp135736.exe', + 'referral': 'https://support.hp.com/us-en/drivers', + 'sha512': 'f9a91e2796a5070f1f40099e2318aa9716e2e6a31b9ba6a93986c450eedbfb0b323dff55c5e4536466946da3e01985f367b1db27bbd7b65f4c333ce0cd47b78c' + }, + SupportedDevices.DEV_B7: { + 'driver': 'https://ftp.hp.com/pub/softpaq/sp135501-136000/sp135736.exe', + 'referral': 'https://support.hp.com/us-en/drivers', + 'sha512': 'f9a91e2796a5070f1f40099e2318aa9716e2e6a31b9ba6a93986c450eedbfb0b323dff55c5e4536466946da3e01985f367b1db27bbd7b65f4c333ce0cd47b78c' } } From 4ac8bbd3aa014ac2c24e2794dd3b1cb0a25e6c9a Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 13:04:21 +0100 Subject: [PATCH 03/27] Replace existing finger record on same-subtype re-enroll The chip's database rejects creating a second finger record with the same subtype for the same user, which previously caused fprintd-enroll to fail at the final stage with enroll-failed after all per-stage captures had passed. Detect and delete any pre-existing record with the same subtype right before db.new_finger. Placing the delete here (inside do_create_finger, after all captures have completed) rather than at EnrollStart matters: pre-deleting before the enrollment session starts left the chip in a state where every subsequent capture returned retry-scan indefinitely until the daemon was restarted. By the time do_create_finger runs the captures are done and the chip is ready to accept the save, so deletion at this point doesn't disrupt session state. --- validitysensor/sensor.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index dff7b1a..984e5ff 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -832,11 +832,20 @@ def enroll(self, identity: SidIdentity, subtype: int, def do_create_finger(final_template: bytes, tid: bytes): tinfo = self.make_finger_data(subtype, final_template, tid) - usr = db.lookup_user(identity) - if usr is None: + existing = db.lookup_user(identity) + if existing is None: usr = db.new_user(identity) else: - usr = usr.dbid + # Replace any existing enrollment for this finger slot. The chip + # rejects creating a second record with the same subtype for the + # same user. Deleting here (after all captures are done) keeps + # the chip's enroll session uninterrupted — earlier attempts to + # pre-delete before EnrollStart left the chip in a state where + # subsequent captures kept returning retry-scan indefinitely. + for f in existing.fingers: + if f['subtype'] == subtype: + db.del_record(f['dbid']) + usr = existing.dbid recid = db.new_finger(usr, tinfo) usb.wait_int() From 90d5d67ea195d029bb2808176116d284daa40206 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 13:06:09 +0100 Subject: [PATCH 04/27] validity-sensors-firmware: fall back to cabextract for non-Inno archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lenovo softpaqs (the previously supported devices) ship as Inno Setup installers, which is what innoextract handles. HP softpaqs (sp135736.exe used for the new DEV_AB and DEV_B7 entries) are CAB-wrapped self- extracting exes — innoextract rejects them with "Not a supported Inno Setup installer!" and the postinst surfaces a noisy Python traceback. Try innoextract first; on CalledProcessError or FileNotFoundError, fall back to cabextract. The standalone tool-availability check accepts either extractor so installations on either family work out of the box. Note: dropped the `-F ` filter from cabextract — its pattern matches the full path inside the cab (e.g. src/driver/INF/x64/6_07f_... xpfwext) not just the basename, so -F would silently skip the target. Extract everything; the existing `find` afterward locates the file regardless of the subdirectory it landed in. --- bin/validity-sensors-firmware | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/bin/validity-sensors-firmware b/bin/validity-sensors-firmware index c9d357b..124fd75 100755 --- a/bin/validity-sensors-firmware +++ b/bin/validity-sensors-firmware @@ -59,10 +59,23 @@ def download_and_extract_fw(dev_type, fwdir, fwuri=None): raise Exception('Hash mismatch for driver download! Expected {}, got {}'.format( expected_hash, actual_hash)) - subprocess.check_call([ - 'innoextract', '--output-dir', fwdir, '--include', fwname, '--collisions', 'overwrite', - fwarchive - ]) + # Lenovo softpaqs are Inno Setup installers; HP softpaqs are CAB-wrapped + # self-extracting exes. Try innoextract first, fall back to cabextract. + try: + subprocess.check_call([ + 'innoextract', '--output-dir', fwdir, '--include', fwname, + '--collisions', 'overwrite', fwarchive + ], stderr=subprocess.DEVNULL) + except (subprocess.CalledProcessError, FileNotFoundError): + try: + # No -F filter: HP softpaqs nest the target under e.g. src/driver/INF/x64/, + # and cabextract -F matches the full path. Extract everything; the find + # call below locates the target file regardless of subdirectory. + subprocess.check_call(['cabextract', '-q', '-d', fwdir, fwarchive]) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise Exception( + 'Failed to extract {} from {}: neither innoextract nor cabextract ' + 'could handle the archive ({}).'.format(fwname, fwarchive, e)) fwpath = subprocess.check_output(['find', fwdir, '-name', fwname]).decode('utf-8').strip() print('Found firmware at {}'.format(fwpath)) @@ -91,10 +104,16 @@ if __name__ == "__main__": if not dev_type: raise Exception('No supported validity device found') - try: - subprocess.check_call(['innoextract', '--version'], stdout=subprocess.DEVNULL) - except Exception as e: - print('Impossible to run innoextract: {}'.format(e)) + have_extractor = False + for tool in ('innoextract', 'cabextract'): + try: + subprocess.check_call([tool, '--version'], stdout=subprocess.DEVNULL) + have_extractor = True + break + except (subprocess.CalledProcessError, FileNotFoundError): + continue + if not have_extractor: + print('Need at least one of innoextract or cabextract installed.') sys.exit(1) with tempfile.TemporaryDirectory() as fwdir: From d1936a1c094af37e4a5f41b4937a48181afb516a Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 13:06:57 +0100 Subject: [PATCH 05/27] Debian packaging: PAM auto-enable, cabextract dep, version 0.16~hp3 debian/python3-validity.postinst: run pam-auth-update --package --enable fprintd so sudo / screen-unlock / GNOME Settings fingerprint flows work immediately after install, without the user having to know about pam-auth-update. debian/control: recommend libpam-fprintd (the PAM module our postinst enables) and cabextract (the fallback extractor used by the new validity-sensors-firmware path for HP softpaqs). Bumped changelog through 0.16~hp1/hp2/hp3, with hp3 documenting the same-finger re-enroll fix shipped this release. --- debian/changelog | 38 ++++++++++++++++++++++++++++++++ debian/control | 1 + debian/python3-validity.postinst | 3 +++ 3 files changed, 42 insertions(+) diff --git a/debian/changelog b/debian/changelog index 8d7c453..534b4c2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,41 @@ +python-validity (0.16~hp3) noble; urgency=medium + + * sensor.enroll: replace existing finger record when re-enrolling the same + subtype for the same user. Without this, the chip's db rejects creating + a duplicate, surfacing as enroll-failed at the final stage after all + captures pass. The delete is performed inside do_create_finger (right + before db.new_finger) so the chip's enroll session isn't disrupted — + pre-deleting before EnrollStart caused subsequent captures to return + retry-scan indefinitely until the daemon was restarted. + + -- SimpleX-T Thu, 21 May 2026 23:00:00 +0100 + +python-validity (0.16~hp2) noble; urgency=medium + + * validity-sensors-firmware: fall back to cabextract when innoextract + cannot handle the archive. HP softpaqs (e.g. sp135736.exe used for + 138a:00ab and 06cb:00b7) are CAB-wrapped self-extracting exes, not + Inno Setup installers, so innoextract rejected them and the postinst + surfaced a noisy Python traceback. With the fallback, HP softpaq + extraction now works, and either extractor satisfies the runtime + requirement. + * Recommends: cabextract (so the fallback path is available by default). + + -- SimpleX-T Thu, 21 May 2026 11:00:00 +0100 + +python-validity (0.16~hp1) noble; urgency=medium + + * Add support for sensor type 0xd51 (138a:00ab, 06cb:00b7) — HP EliteBook + 840 G5 and related models. Fixes a chip-specific interrupt protocol + that caused fprintd-verify to hang forever on these devices. + * Add FIRMWARE_URIS entries for DEV_AB and DEV_B7 so that + validity-sensors-firmware no longer crashes with KeyError on these PIDs. + * Enable libpam-fprintd via pam-auth-update in postinst so that sudo / + screen-unlock prompt for fingerprint immediately after install. + * Recommend libpam-fprintd. + + -- SimpleX-T Wed, 20 May 2026 22:25:34 +0100 + python-validity (0.15~ppa2) noble; urgency=medium * Change all write paths to /var/run/python-validity diff --git a/debian/control b/debian/control index 2b09da3..99c2ac9 100644 --- a/debian/control +++ b/debian/control @@ -19,6 +19,7 @@ Depends: ${python3:Depends}, dbus, open-fprintd (>= 0.6~), innoextract (>= 1.6~) +Recommends: libpam-fprintd, cabextract Description: Validity Fingerprint Sensor DBus Driver This package adds support to some Validity sensors. . diff --git a/debian/python3-validity.postinst b/debian/python3-validity.postinst index ca62874..7dfc806 100644 --- a/debian/python3-validity.postinst +++ b/debian/python3-validity.postinst @@ -8,5 +8,8 @@ if [ "$1" = "configure" ]; then systemctl daemon-reload || true udevadm control --reload-rules || true udevadm trigger || true + if [ -x /usr/sbin/pam-auth-update ]; then + pam-auth-update --package --enable fprintd || true + fi fi From bc5dd32c734ccd7e71c84dd1c1a0685600837277 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 13:18:37 +0100 Subject: [PATCH 06/27] Emit verify-retry-scan at most once per VerifyStart cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pam_fprintd re-prints "Place your finger on the reader" for every verify-retry-scan signal it receives. On the 0xd51 chip the capture loop is chatty — a single 10-second verify window can fire the signal 20+ times, flooding the terminal during sudo authentication. Suppress all but the first verify-retry-scan per VerifyStart so the user sees one initial prompt and one early "place again" hint, then silence until match or timeout. Enrollment behavior is unchanged because per-stage retry hints are useful there (each stage is a discrete user action where lift-and-retry feedback matters). --- dbus_service/dbus-service | 13 ++++++++++++- debian/changelog | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 1c63e8d..b30c24e 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -114,8 +114,19 @@ class Device(dbus.service.Object): self.VerifyFingerSelected('any') + # pam_fprintd re-prints "Place your finger on the reader" for every + # verify-retry-scan signal, which on this chip's chatty capture loop + # can fire 30+ times per verify cycle and floods the terminal during + # sudo. Emit it at most once per VerifyStart so the user gets one + # initial prompt + one early retry hint, then silence until match + # or timeout. Enroll keeps its per-stage signals — those are useful + # because each stage is a discrete user action. + retry_emitted = [False] + def update_cb(e): - self.VerifyStatus('verify-retry-scan', False) + if not retry_emitted[0]: + self.VerifyStatus('verify-retry-scan', False) + retry_emitted[0] = True def run(): try: diff --git a/debian/changelog b/debian/changelog index 534b4c2..ec18078 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +python-validity (0.16~hp4) noble; urgency=medium + + * VerifyStart: emit verify-retry-scan at most once per verify cycle so + pam_fprintd doesn't repeat "Place your finger on the reader" for every + one of the chip's chatty capture-quality retries. On the 0xd51 chip the + capture loop fires the signal 20+ times during a 10s timeout window, + which flooded the terminal during sudo. Enrollment is unchanged — + per-stage retry-scans there are useful because each stage is a discrete + user action. + + -- SimpleX-T Thu, 21 May 2026 23:30:00 +0100 + python-validity (0.16~hp3) noble; urgency=medium * sensor.enroll: replace existing finger record when re-enrolling the same From 122a147e7d196f008263760a22268a1f395b2464 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 19:30:31 +0100 Subject: [PATCH 07/27] Exclude local build artifacts from the source tarball debian/source/options: tar-ignore .pybuild, build, *.egg-info, __pycache__, *.pyc. 3.0 (native) source packages bundle the working tree verbatim, so any leftover pybuild cache from prior local debuild runs gets shipped to Launchpad. pybuild's cached state embeds the developer's absolute paths (e.g. /home//.../python-validity/.pybuild/...), which the build chroot can't write to. The first per-series build succeeded because pybuild created the cache fresh; subsequent series builds inherited the dirty cache and failed with Permission denied trying to write into the baked-in path. This is a packaging-only change; no source code differs. --- debian/changelog | 12 ++++++++++++ debian/source/options | 5 +++++ 2 files changed, 17 insertions(+) create mode 100644 debian/source/options diff --git a/debian/changelog b/debian/changelog index ec18078..c34f7c7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +python-validity (0.16~hp5) noble; urgency=medium + + * debian/source/options: tar-ignore .pybuild, build, *.egg-info, + __pycache__, *.pyc. Without this, dpkg-source for 3.0 (native) + packages bundles leftover local build artifacts (cached pybuild + state with absolute paths from the developer's machine) into the + source tarball, which caused per-series Launchpad builds after + the first one to fail with "Permission denied" trying to write + into a baked-in /home//... path. + + -- SimpleX-T Thu, 21 May 2026 13:30:00 +0100 + python-validity (0.16~hp4) noble; urgency=medium * VerifyStart: emit verify-retry-scan at most once per verify cycle so diff --git a/debian/source/options b/debian/source/options new file mode 100644 index 0000000..8dc4dda --- /dev/null +++ b/debian/source/options @@ -0,0 +1,5 @@ +tar-ignore = ".pybuild" +tar-ignore = "build" +tar-ignore = "*.egg-info" +tar-ignore = "__pycache__" +tar-ignore = "*.pyc" From baec76e43d3f1cd1cf753e72927f46adbf26b70a Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Thu, 21 May 2026 23:53:55 +0100 Subject: [PATCH 08/27] Add diagnostic logging for chip geometry and per-verify retry count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sensor.py: log resolved capture geometry once at chip open. Useful when investigating whether the 0x199 spoof produces appropriate dimensions for whatever chip the daemon is talking to. The log line includes real_type vs spoofed_type, lines_2d from the capture program, computed lines_per_frame, bytes_per_line, line_width, and lines_per_calibration_data — everything needed to recognize a profile mismatch without re-instrumenting. dbus-service: in VerifyStart, log every internal chip retry-scan to the journal independent of the D-Bus signal throttle. Lets users and support tickets quantify capture quality without manual instrumentation — `journalctl -u python3-validity.service | grep 'Chip capture retry-scan'` gives the raw count immediately. Both changes are INFO-level log lines, no functional impact. About 1 log per chip open + 0-3 lines per verify in normal use. --- dbus_service/dbus-service | 8 ++++++++ debian/changelog | 16 ++++++++++++++++ validitysensor/sensor.py | 15 +++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index b30c24e..98909e6 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -122,8 +122,16 @@ class Device(dbus.service.Object): # or timeout. Enroll keeps its per-stage signals — those are useful # because each stage is a discrete user action. retry_emitted = [False] + retry_count = [0] def update_cb(e): + # Always log every chip retry to journal — useful for the + # task #17 capture-quality benchmark. The D-Bus emit below + # is still throttled to once per cycle (avoids spamming + # pam_fprintd which re-prints the prompt on each signal). + retry_count[0] += 1 + logging.info('Chip capture retry-scan #%d (user=%s)', + retry_count[0], user) if not retry_emitted[0]: self.VerifyStatus('verify-retry-scan', False) retry_emitted[0] = True diff --git a/debian/changelog b/debian/changelog index c34f7c7..d8087e8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +python-validity (0.16~hp6) noble; urgency=medium + + * Diagnostic logging (no behavior change): + - sensor.py: log resolved capture geometry once at chip open + (real_type, spoofed_type, lines_per_frame, bytes_per_line, + line_width, calibration data lines). Makes future "is the + spoofed-to-0x199 profile right for this chip?" questions + answerable from journal alone. + - dbus-service: log every internal chip retry-scan during + VerifyStart, independent of the D-Bus signal throttle. + Lets users / support tickets quantify capture quality. + * Both changes are INFO-level log lines only. ~1 log per chip + open + ~0-3 lines per verify in normal use. + + -- SimpleX-T Thu, 21 May 2026 23:55:00 +0100 + python-validity (0.16~hp5) noble; urgency=medium * debian/source/options: tar-ignore .pybuild, build, *.egg-info, diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 984e5ff..7ed5e79 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -266,6 +266,21 @@ def open(self): self.lines_per_frame = lines_2d * self.type_info.repeat_multiplier self.bytes_per_line = self.type_info.bytes_per_line + # Diagnostic (task #17): log resolved capture geometry so we can + # tell whether the 0x199-spoofed profile matches what the 0xd51 + # chip actually expects. lines_2d is extracted from the capture + # program's 0x2f chunk; if the chip is producing a different + # frame size, this is where the mismatch first shows up. + logging.info( + 'Capture geometry: real_type=0x%x spoofed_type=0x%x ' + 'lines_2d=%d repeat_multiplier=%d lines_per_frame=%d ' + 'bytes_per_line=0x%x line_width=%d ' + 'lines_per_calibration_data=%d', + self.real_device_type, self.device_info.type, lines_2d, + self.type_info.repeat_multiplier, self.lines_per_frame, + self.bytes_per_line, self.type_info.line_width, + self.type_info.lines_per_calibration_data) + factory_bits = get_factory_bits(0x0e00) self.factory_calibration_values = factory_bits[3][4:] From cd0def0702b21c3d741960e813d59ae42faf11dc Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Sun, 24 May 2026 21:30:51 +0100 Subject: [PATCH 09/27] usb.py: defensive USB reset at open_dev() entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple users reported the 0xd51-family chips (138a:00ab, 06cb:00b7) getting stuck after cold boot, unclean exit, or suspend/resume — the chip accepts the bulk-OUT but never responds on bulk-IN, so the first cleartext cmd (cmd 3e get_flash_info) times out and the daemon restart-loops every 15 seconds. The workaround users were running manually is a USB-level reset: sudo systemctl stop python3-validity open-fprintd sudo udevadm trigger --attr-match=idVendor=138a --attr-match=idProduct=00ab sudo systemctl start python3-validity open-fprintd The reset call here is the in-driver equivalent. The chip's USB address can shift after reset, so we re-find by vid/pid. Reported by Killersparrow1 (issue #238, Fedora 44, sensor vanishes on reboot) and a separate Arch / ZBook G5 user (USBTimeoutError on cmd 3e). The patch matches what the project memory has flagged for the past two sessions as "kept local and not in PR" — turns out it was the actually-load-bearing piece. Locally confirmed: clean daemon restart, sudo matches in 1 retry, no traceback, no "USB reset failed" warning. --- debian/changelog | 21 +++++++++++++++++++++ validitysensor/usb.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/debian/changelog b/debian/changelog index d8087e8..c3becb3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,24 @@ +python-validity (0.16~hp7) noble; urgency=medium + + * usb.py: defensive USB reset at the start of open_dev(). The 0xd51- + family chips (138a:00ab, 06cb:00b7) can be left in a "stuck" + protocol state across cold boot, unclean exit of the daemon, or + sudden suspend/resume — in that state the chip accepts the + bulk-OUT but never replies on bulk-IN, so the first cleartext + command (get_flash_info, cmd 3e) times out and the daemon enters + a 15-second restart loop. The reset is the in-driver equivalent + of the manual `udevadm trigger --attr-match=idVendor=... + --attr-match=idProduct=...` workaround multiple users were + running to recover after every reboot. Reported by Killersparrow1 + (uunicorn/python-validity#238, Fedora 44, sensor vanishes on + reboot) and a separate Arch / ZBook G5 user (USBTimeoutError on + cmd 3e). Also observed intermittently on the maintainer's + machine. + * After reset, re-find the device by vid/pid because the USB + address can shift across the reset. + + -- SimpleX-T Sun, 25 May 2026 00:30:00 +0100 + python-validity (0.16~hp6) noble; urgency=medium * Diagnostic logging (no behavior change): diff --git a/validitysensor/usb.py b/validitysensor/usb.py index 22bca4f..0cd293e 100644 --- a/validitysensor/usb.py +++ b/validitysensor/usb.py @@ -1,5 +1,6 @@ import errno import logging +import time import typing from binascii import hexlify, unhexlify from enum import Enum @@ -63,6 +64,34 @@ def open_dev(self, dev: ucore.Device): if dev is None: raise Exception('No matching devices found') + # Defensive USB reset on init. + # + # The 0xd51-family chips (HP 138a:00ab / 06cb:00b7) can be left in + # a "stuck" protocol state across a previous unclean exit of this + # daemon, a cold boot, or a sudden suspend/resume. In that state + # the chip accepts the bulk-OUT but never replies on bulk-IN, so + # the very first cleartext command (typically `cmd 3e` + # get_flash_info) times out — the daemon then restart-loops at + # 15s intervals and the sensor is "vanished" until a manual USB + # reset. This block is the in-driver equivalent of the manual + # `udevadm trigger --attr-match=idVendor=... --attr-match=idProduct=...` + # workaround users have been running to recover. + # + # Reported by Killersparrow1 (#238, Fedora 44, vanishes on reboot) + # and Maarten (Arch, ZBook G5, USBTimeoutError on first 3e). Also + # observed locally on the maintainer's machine (sensor prompts but + # doesn't detect after a while). + try: + vid, pid = dev.idVendor, dev.idProduct + dev.reset() + time.sleep(0.5) + # USB address may shift after reset; re-find by vid/pid. + dev = ucore.find(idVendor=vid, idProduct=pid) + if dev is None: + raise Exception('Device disappeared after USB reset') + except USBError as e: + logging.warning('open_dev: USB reset failed (often non-fatal): %s', e) + self.dev = dev self.dev.default_timeout = 15000 dev.set_configuration() From 0c00f0a6d5ae9070c9da46b40af1296bf0b4f97c Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Mon, 6 Jul 2026 10:33:40 -0400 Subject: [PATCH 10/27] Add sensor type 0x969 (HP ZBook Studio x360 G5, 138a:00ab) The 138a:00ab USB ID is shared across HP machines but maps to different sensor silicon. The EliteBook 840 G5 reports sensor type 0xd51; the ZBook Studio x360 G5 reports 0x969. Both lack native SensorTypeInfo / SensorCaptureProg entries, and empirically both work when aliased to the 0x199 capture profile -- the on-chip matcher accepts the resulting images. Mirror the existing 0xd51 handling for 0x969: - add 0x969 to line_update_type1_devices - alias 0x969 -> 0x199 in open() - accept the b[0]==3 finger interrupt when real_device_type is 0x969 Verified on an HP ZBook Studio x360 G5: init + calibration + enroll (5 stages, enroll-completed) + verify (verify-match). --- validitysensor/sensor.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 7ed5e79..f5ff2ff 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -26,6 +26,7 @@ line_update_type1_devices = [ 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199, 0xD51, # HP EliteBook 840 G5 (138a:00ab) / HP G6 series (06cb:00b7) + 0x969, # HP ZBook Studio x360 G5 (138a:00ab -- same PID, different silicon) ] @@ -227,14 +228,15 @@ def open(self): self.device_info = identify_sensor() self.real_device_type = self.device_info.type - # Sensor type 0xd51 (HP EliteBook 840 G5 138a:00ab, HP G6 series 06cb:00b7) - # has no native SensorTypeInfo / SensorCaptureProg entry. Empirically the - # 0x199 profile produces images that the on-chip matcher accepts after - # enrollment/verify; the 0xdb profile does not. Spoofing keeps the rest - # of this method (calibration switch, capture program lookup) on a code - # path that works. - if self.device_info.type == 0xd51: - logging.info('Sensor type 0xd51 — aliasing to 0x199 profile') + # Sensor types 0xd51 (HP EliteBook 840 G5 138a:00ab, HP G6 series + # 06cb:00b7) and 0x969 (HP ZBook Studio x360 G5 138a:00ab -- same PID, + # different silicon) have no native SensorTypeInfo / SensorCaptureProg + # entry. Empirically the 0x199 profile produces images that the on-chip + # matcher accepts after enrollment/verify; the 0xdb profile does not. + # Spoofing keeps the rest of this method (calibration switch, capture + # program lookup) on a code path that works. + if self.device_info.type in (0xd51, 0x969): + logging.info('Sensor type 0x%x — aliasing to 0x199 profile' % self.device_info.type) self.device_info.type = 0x199 logging.info('Opening sensor: %s' % self.device_info.name) @@ -738,7 +740,7 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: b = usb.wait_int() if b[0] == 2: break - if b[0] == 3 and getattr(self, 'real_device_type', None) == 0xd51: + if b[0] == 3 and getattr(self, 'real_device_type', None) in (0xd51, 0x969): saved_b = b break From 3b231940082ffcad45bb5e24562d47a347c23807 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Tue, 7 Jul 2026 22:16:28 +0100 Subject: [PATCH 11/27] sensor.py: recover 0x969 chips reporting 0x199 post-resume After suspend/resume, 0x969 chips re-enumerate reporting sensor type 0x199 directly rather than 0x969. Without intervention Sensor.open() skips the alias-to-0x199 block and capture()'s b[0]==3 interrupt fix (which keys off real_device_type), so verify hangs indefinitely post-resume. Detect via the device *name*, which is stable across boot and resume: FM-3439-xxx and FM- 154-xxx are the two known 0x969 model families (HP ZBook 17 G6, ZBook Studio G5, ProBook G6). Genuine 0x199 chips (FM-3367-xxx, FM-3380-xxx, FM-155-xxx) don't match either pattern. Diagnosed and tested by @Karloss1234 on Kubuntu 26.04 with an HP ZBook 17 G6 (06cb:00b7). Reported at Point 4 of the review on uunicorn/python-validity#256. 0xd51 users: if verify hangs after resume, please report; we may need the same treatment for 'FM-154-xxx' (no space). --- validitysensor/sensor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index f5ff2ff..23d760a 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -238,6 +238,24 @@ def open(self): if self.device_info.type in (0xd51, 0x969): logging.info('Sensor type 0x%x — aliasing to 0x199 profile' % self.device_info.type) self.device_info.type = 0x199 + elif self.device_info.type == 0x199 and ( + 'FM-3439' in self.device_info.name or 'FM- 154' in self.device_info.name): + # After suspend/resume, 0x969 chips re-enumerate reporting sensor + # type 0x199 directly rather than 0x969. Without intervention the + # alias block above is skipped, real_device_type stays at 0x199, + # and capture()'s `b[0]==3` interrupt fix (which keys off + # real_device_type) is bypassed — so verify hangs indefinitely + # post-resume. The device *name* is stable across boot and resume, + # so we key off it: FM-3439-xxx and FM- 154-xxx are the two known + # 0x969 model families (HP ZBook 17 G6, ZBook Studio G5, ProBook + # G6). Genuine 0x199 chips (FM-3367-xxx, FM-3380-xxx, FM-155-xxx) + # don't match either pattern. + # + # 0xd51 users: if verify hangs after resume, please report — we + # may need the same treatment for 'FM-154-xxx' (no space). + logging.info('Sensor %s reporting 0x199 on resume — treating as 0x969' + % self.device_info.name.strip()) + self.real_device_type = 0x969 logging.info('Opening sensor: %s' % self.device_info.name) self.type_info = SensorTypeInfo.get_by_type(self.device_info.type) From 4ba618bda8a36c1e72464b5217c0b3eb3e257692 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Tue, 7 Jul 2026 22:17:47 +0100 Subject: [PATCH 12/27] Surface 0404 at reset_blob with an actionable error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both @bcoutts (fresh factory 0xd51 on ProBook 445R G6) and @ntoyiakhona06-creator (Windows-Hello-paired 0xd51 on EliteBook 840 G5) hit the same failure: usb.cmd(reset_blob) returns status 0404 — from opposite chip states (factory-fresh vs. previously paired). reset_blob is byte-identical across blobs_97 / blobs_9a / blobs_9d (shared "Prometheus" blob), and was originally extracted from Windows drivers for 0x199-class chips. The failure is not blob content or selection — it's that 0xd51 / 0x969 silicon does not accept this reset primitive at all. Until someone extracts a working reset_blob for the newer chip family, init_flash on a factory-fresh chip and factory_reset for unpair-from-Windows are both unsupported on this hardware. Catch 0404 at both call sites and raise a specific, actionable error (pointing at the tracking PR and telling users what to attach when reporting) instead of the bare "Failed: 0404" that assert_status produces. Also add a "known issue" section to the README. References: uunicorn/python-validity#256 (bcoutts, ntoyiakhona06-creator comments). --- validitysensor/init_flash.py | 20 +++++++++++++++++++- validitysensor/sensor.py | 21 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/validitysensor/init_flash.py b/validitysensor/init_flash.py index 61c918b..a4937d4 100644 --- a/validitysensor/init_flash.py +++ b/validitysensor/init_flash.py @@ -127,7 +127,25 @@ def init_flash(): else: logging.info('Flash was not initialized yet. Formatting...') - assert_status(usb.cmd(reset_blob)) + rsp = usb.cmd(reset_blob) + status, = unpack(' Date: Tue, 7 Jul 2026 22:18:01 +0100 Subject: [PATCH 13/27] db.py: fix del_record; raise a clear error when the DB partition is full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related flash-management issues found by @Karloss1234 while tracing 04b5 errors during enroll on an HP ZBook 17 G6 (Point 6 of the review on uunicorn/python-validity#256). 1. del_record was sending 0x48 without the db_write_enable prefix that new_record and every write in flash.py use. On 0x969 chips this returns error 04b6 (silent) and the record stays on flash — deletes never actually free space. Mirror new_record's shape: db_write_enable + try/finally: call_cleanups(). 2. Even with (1) fixed, the chip does not automatically compact the database partition. Windows only triggers compaction as part of its own enroll flow after a TPM reset, so a long-running Linux- only setup accumulates uncompacted dead records until enroll fails at new_finger with the chip's opaque 04b5. new_record was already fetching db_info() with a TODO to actually check the numbers — actually check them. If the payload plus a small header estimate doesn't fit in Info.free, raise a clear error that names the recovery path (erase_flash(4) + reboot), instead of letting the on-wire 04b5 bubble up. --- validitysensor/db.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/validitysensor/db.py b/validitysensor/db.py index accda03..fa20c80 100644 --- a/validitysensor/db.py +++ b/validitysensor/db.py @@ -196,7 +196,16 @@ def get_record_children(self, dbid: int): return rec def del_record(self, dbid: int): - assert_status(tls.cmd(pack(' Date: Tue, 7 Jul 2026 22:18:14 +0100 Subject: [PATCH 14/27] README: document 0xd51/0x969 caveats surfaced by PR #256 reviewers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions from the discussion on uunicorn/python-validity#256: - Template competition (Windows Hello vs. Linux fprintd sharing the on-chip database) with the "enroll different fingers per OS" and "erase partition 4" workarounds. From @Karloss1234's Point 2. - Kubuntu / KDE lock-screen PAM setup — three /etc/pam.d files and a pam_permit.so → pam_unix.so sddm-greeter fix. Not needed on GNOME. From @Karloss1234's Point 5. - Known issue: 0404 on reset_blob for 0xd51/0x969 silicon (factory init and factory_reset are unsupported on this chip family). Tells users what to attach when reporting. Code changes for the same items are in prior commits; this is docs. --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/README.md b/README.md index e327899..1cd7439 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,30 @@ $ sudo systemctl enable open-fprintd-resume open-fprintd-suspend For even more error procedures, check [this Arch comment thread](https://aur.archlinux.org/packages/python-validity/#comment-755904) or [this python-validity bug comment thread](https://github.com/uunicorn/python-validity/issues/3). +#### `factory_reset` / `init_flash` fails with `0404` + +On 0xd51 and 0x969 silicon (HP EliteBook 840 G5, HP G6 family, +HP ZBook Studio x360 G5, and likely other 138a:00ab / 06cb:00b7 variants) +the `reset_blob` we ship — extracted from Windows drivers for older +0x199-class Prometheus chips — is rejected by the chip with status `0404`. +This affects two scenarios: + +- **Factory-fresh chip** (e.g. after a UEFI BIOS reset). `init_flash` + cannot format the flash and the daemon crash-loops. +- **Windows-Hello-paired chip.** After hitting the "Signature verification + failed" error, users typically try `playground/factory-reset.py`; on + these chips it fails at the very first command with `0404`. + +There is currently **no known Linux-side workaround** — we do not have a +reset_blob known to work on 0xd51 / 0x969. If you hit this, please add +your hardware details (`dmidecode -t 1`, `lsusb -v`, and the failing +journal output) to +[uunicorn/python-validity#256](https://github.com/uunicorn/python-validity/pull/256) +so affected models can be tracked. Windows-paired users can, as a +workaround, boot Windows and reinstall the Synaptics driver (Device +Manager → uninstall with "delete driver software" → reboot → let Windows +reinstall) to re-pair the chip on the Windows side. + ## Enabling fingerprint for system authentication if it doesn't come automatically, you might need to make changes to files in `/etc/pam.d` to enable fingerprint login (depending on your distro). @@ -137,6 +161,49 @@ user_to_sid: ``` Note the indentation; each entry has to be preceded by at least one space. +### Template competition (0xd51 / 0x969 chips) + +The chip's on-chip matcher scores captured images against **every** enrolled +template — including any Windows Hello templates written by a previous +Windows session — and returns the highest-scoring match. On some HP models +(reported for the ZBook G6 family, but likely broader) Windows Hello writes +very high-quality templates that consistently outscore Linux `fprintd` +templates for the same finger, so `fprintd-verify` silently loses even when +enrollment succeeded. + +Two workarounds, in order of preference: + +1. **Enroll different fingers per OS.** Right-index in Linux, right-middle + in Windows (or whichever split you prefer). No competition, both OSes + keep fingerprint auth. +2. **Erase the on-chip database from Linux.** Wipes all templates on both + OSes; Windows Hello fingerprint login stops working until you re-enroll + in Windows. PIN / TPM state is unaffected. See + `playground/erase-flash.py` (partition `4`). + +Investigated and documented by @Karloss1234 on PR +[uunicorn/python-validity#256](https://github.com/uunicorn/python-validity/pull/256). + +### KDE / Kubuntu lock-screen PAM + +On Kubuntu the greeter/lock-screen PAM stacks aren't touched by +`pam-auth-update`. To wire the fingerprint reader into the KDE lock screen +you need three files under `/etc/pam.d` mirroring the same `sufficient` +line: + +``` +# /etc/pam.d/kde, /etc/pam.d/kde-fingerprint, /etc/pam.d/kde-smartcard +#%PAM-1.0 +auth sufficient pam_fprintd.so max_tries=3 timeout=10 +auth required pam_unix.so +``` + +Also check `/etc/pam.d/sddm-greeter` for a `pam_permit.so` fallback and +replace it with `pam_unix.so` — otherwise the lock screen can unlock +without authentication after fingerprint timeout. + +Contributed by @Karloss1234; not required on GNOME / Ubuntu proper. + ## Playground This package contains a set of scripts you can use to do a low-level debugging of the sensor protocol. From 7925b97ac4aeb0db1a5b80679703afb813251520 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Tue, 7 Jul 2026 23:26:25 +0100 Subject: [PATCH 15/27] dbus-service: watchdog on VerifyStart for wedged-chip state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0xd51 / 0x969 chips can enter a "wedged capture-quality gate" state after long uptime + heavy dev cycles: every captured frame is rejected by the on-chip quality gate and no capture-complete interrupt is ever emitted, so sensor.identify() loops in wait_int() indefinitely. Nothing the daemon can do from userspace clears this — even sensor.cancel() only unblocks wait_int; the wedge itself survives systemctl restart. Recovery is a real cold power cycle (shutdown, unplug charger, hold power ~15s, boot). Historically the daemon-side identify() thread would keep running past pam_fprintd's 10s timeout, and diagnosing the state took ~30 minutes of guessing because the journal just showed retry-scan repetitions with no summary. Add a retry-count watchdog to VerifyStart's update_cb: after 25 consecutive retries with no capture-complete (typically 12-15s), log a specific warning naming the wedge condition and the required recovery path, and call sensor.cancel() to unblock the identify() thread. The outer except in run() then emits verify-no-match cleanly. 25 is empirically past the tail of a healthy verify (~2 retries typical, p95 = 2 in the task #17 benchmark) with comfortable margin. Enrollment path is left alone — its retry-scans are per-stage user actions, not the same signature. Diagnosed on the maintainer's own 840 G5 (0xd51) after 4 days of uptime + a diagnostic init.open() that didn't clean up. --- dbus_service/dbus-service | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 98909e6..41aed4d 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -124,6 +124,19 @@ class Device(dbus.service.Object): retry_emitted = [False] retry_count = [0] + # Watchdog: 0xd51 / 0x969 chips can enter a "wedged capture-quality + # gate" state after long uptime + heavy dev cycles, where every + # captured frame is rejected and no capture-complete interrupt is + # ever emitted. sensor.identify() then loops in wait_int() until + # something external cancels it (pam_fprintd's 10s timeout). This + # threshold lets us abort daemon-side after ~25 rejected frames + # (typically 12-15s), emit a specific journal message pointing at + # the real recovery path (cold power cycle, not a systemctl + # restart), and unblock the identify() thread cleanly. See the + # discussion on PR uunicorn/python-validity#256 for the wedge + # signature and recovery notes. + CHIP_WEDGE_RETRY_THRESHOLD = 25 + def update_cb(e): # Always log every chip retry to journal — useful for the # task #17 capture-quality benchmark. The D-Bus emit below @@ -132,6 +145,18 @@ class Device(dbus.service.Object): retry_count[0] += 1 logging.info('Chip capture retry-scan #%d (user=%s)', retry_count[0], user) + if retry_count[0] == CHIP_WEDGE_RETRY_THRESHOLD: + logging.warning( + 'Chip appears wedged after %d consecutive retries with ' + 'no capture-complete interrupt. Aborting verify. This ' + 'usually means the on-chip capture-quality gate is ' + 'rejecting every frame, which the daemon cannot reset ' + 'from userspace. Recovery: cold power cycle (shutdown, ' + 'unplug charger, hold power ~15s, boot). A `systemctl ' + 'restart python3-validity` is unlikely to help; the ' + 'wedge is below the daemon.', + retry_count[0]) + sensor.cancel() if not retry_emitted[0]: self.VerifyStatus('verify-retry-scan', False) retry_emitted[0] = True From a825eaf96dd1f480a304e0570e686abae0400932 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Tue, 7 Jul 2026 23:28:26 +0100 Subject: [PATCH 16/27] debian/changelog: 0.16~hp8 for noble Rolls up: - 0x969 support (ggiesen SimpleX-T#2, Karloss1234) - post-resume 0x969 recovery (Karloss1234) - 0404 friendlier error at reset_blob (bcoutts, ntoyiakhona06) - del_record db_write_enable fix + flash-full pre-check (Karloss1234) - VerifyStart watchdog for wedged-chip state - README caveats for 0xd51/0x969 Full context in uunicorn/python-validity#256. --- debian/changelog | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/debian/changelog b/debian/changelog index c3becb3..70af3ae 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,64 @@ +python-validity (0.16~hp8) noble; urgency=medium + + * sensor.py: add support for sensor type 0x969 alongside 0xd51. HP + ZBook Studio x360 G5 uses the same 138a:00ab PID but a different + silicon variant reporting 0x969; HP G6 family on 06cb:00b7 also + covers this variant. Mirrors the existing 0xd51 handling across + line_update_type1_devices, the alias-to-0x199 block in + Sensor.open(), and the b[0]==3 interrupt fix in Sensor.capture(). + Contributed by @ggiesen (SimpleX-T/python-validity#2), verified + on HP ZBook Studio x360 G5. Also independently developed by + @Karloss1234 on HP ZBook 17 G6. + * sensor.py: recover 0x969 chips reporting 0x199 post-resume. On + suspend/resume, 0x969 chips re-enumerate reporting sensor type + 0x199 directly, causing Sensor.open() to skip the alias + + interrupt fix. Detect via the device name (FM-3439-xxx or + FM- 154-xxx — the two known 0x969 model families) and restore + real_device_type = 0x969. Diagnosed by @Karloss1234 on Kubuntu + 26.04. + * init_flash.py, sensor.py: catch status 0404 from usb.cmd(reset_ + blob) at init_flash() and factory_reset(), raise an actionable + error naming the chip-family limitation (0xd51 / 0x969 silicon + does not accept the reset_blob shipped by this driver, which + was extracted from Windows drivers for older 0x199-class chips). + No fix — until a working reset_blob for the newer chips is + obtained, init on factory-fresh chips and factory_reset for + Windows-Hello-paired chips remain unsupported on this family. + But the daemon no longer surfaces a bare "Failed: 0404" that + users can't act on. Reported by @bcoutts (fresh chip, HP ProBook + 445R G6) and @ntoyiakhona06-creator (Windows-paired 840 G5). + * db.py: fix del_record missing db_write_enable. On 0x969 chips + the delete cmd (0x48) returns 04b6 silently without the + write-enable prefix, so records marked deleted never actually + freed flash space. Mirror new_record's shape: db_write_enable + + try/finally: call_cleanups(). Diagnosed by @Karloss1234 while + tracing 04b5 flash-full errors on enroll. + * db.py: proactive flash-full check in new_record. The chip does + not automatically compact the database partition after deletes, + so long-running Linux-only installs accumulate uncompacted + dead records until enroll fails at new_finger with an opaque + 04b5. Use the db_info() call that was already there (with a + TODO to check the numbers) to actually check them; raise a + clear error naming the recovery path (erase_flash(4) + reboot) + before the on-wire failure. + * dbus-service: watchdog on VerifyStart. 0xd51 / 0x969 chips can + enter a "wedged capture-quality gate" state after long uptime + + heavy dev cycles where every captured frame is rejected and no + capture-complete interrupt is ever emitted — sensor.identify() + then loops in wait_int() indefinitely. Add a retry-count + threshold (25 consecutive retries, ~12-15s) that calls + sensor.cancel() and logs a specific "chip appears wedged; cold + power cycle required" warning to the journal. Recovery is a + real cold power cycle (shutdown, unplug charger, hold power + ~15s, boot) — the wedge sits below the daemon and no systemctl + restart can clear it. + * README: document Windows Hello template competition workarounds + (enroll different fingers per OS, or erase partition 4), the + Kubuntu / KDE lock-screen PAM stack recipe, and the known 0404 + reset_blob issue. + + -- SimpleX-T Tue, 07 Jul 2026 23:27:29 +0100 + python-validity (0.16~hp7) noble; urgency=medium * usb.py: defensive USB reset at the start of open_dev(). The 0xd51- From fddbd07011d3759a0282fa20cc95af1d80095e65 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Sun, 2 Aug 2026 17:34:29 +0100 Subject: [PATCH 17/27] hw_tables: return independent device identities --- tests/test_sensor_identity.py | 17 +++++++++++++++++ validitysensor/hw_tables.py | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/test_sensor_identity.py diff --git a/tests/test_sensor_identity.py b/tests/test_sensor_identity.py new file mode 100644 index 0000000..14275e0 --- /dev/null +++ b/tests/test_sensor_identity.py @@ -0,0 +1,17 @@ +import unittest + +from validitysensor.hw_tables import dev_info_lookup + + +class SensorIdentityTests(unittest.TestCase): + def test_lookup_does_not_expose_shared_table_entry(self): + first = dev_info_lookup(0x190, 0x70) + first.type = 0x199 + second = dev_info_lookup(0x190, 0x70) + + self.assertIsNot(first, second) + self.assertEqual(second.type, 0xd51) + + +if __name__ == '__main__': + unittest.main() diff --git a/validitysensor/hw_tables.py b/validitysensor/hw_tables.py index 99e7c46..503e30f 100644 --- a/validitysensor/hw_tables.py +++ b/validitysensor/hw_tables.py @@ -440,9 +440,12 @@ def dev_info_lookup(major: int, ver: int): if ver == 0 or masked_ver == 0: fuzzy_match = i elif ver == masked_ver: - return i + return DeviceInfo(i.major, i.type, i.version, i.version_mask, i.name) - return fuzzy_match + if fuzzy_match is None: + return None + return DeviceInfo(fuzzy_match.major, fuzzy_match.type, fuzzy_match.version, + fuzzy_match.version_mask, fuzzy_match.name) class FlashIcInfo: From 01500cbdc5a0c60a0f2484e8afcd7ba3bc7d6f21 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Sun, 2 Aug 2026 17:34:29 +0100 Subject: [PATCH 18/27] persist sensor calibration across reboots --- debian/changelog | 10 ++++++++++ debian/rules | 4 +++- tests/test_state_dir.py | 34 +++++++++++++++++++++++++++++++++ validitysensor/init_data_dir.py | 21 +++++++++++++++++--- validitysensor/sensor.py | 11 ++++++++--- 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 tests/test_state_dir.py diff --git a/debian/changelog b/debian/changelog index 70af3ae..de6366c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +python-validity (0.16~hp9) noble; urgency=medium + + * Keep hardware-table identities immutable across sensor reopen. Profile + aliasing previously mutated the shared DeviceInfo entry, causing d51/969 + capture handling to stop working after the first reopen. + * Persist calibration under /var/lib/python-validity instead of tmpfs-backed + /var/run so enrolled templates remain usable across reboot. + + -- Dev Tochukwu Sun, 02 Aug 2026 18:00:00 +0100 + python-validity (0.16~hp8) noble; urgency=medium * sensor.py: add support for sensor type 0x969 alongside 0xd51. HP diff --git a/debian/rules b/debian/rules index d44c5b9..157f7ca 100755 --- a/debian/rules +++ b/debian/rules @@ -5,6 +5,9 @@ %: dh $@ --with python3 --buildsystem=pybuild +override_dh_auto_test: + PYTHONPATH=$(CURDIR) python3 -m unittest discover -s tests -v + override_dh_installsystemd: dh_installsystemd --name=python3-validity @@ -13,4 +16,3 @@ override_dh_auto_install: override_dh_auto_clean: python3 ./setup.py clean - diff --git a/tests/test_state_dir.py b/tests/test_state_dir.py new file mode 100644 index 0000000..5869e07 --- /dev/null +++ b/tests/test_state_dir.py @@ -0,0 +1,34 @@ +import os +import tempfile +import unittest + +from validitysensor.init_data_dir import migrate_legacy_calibration + + +class CalibrationMigrationTests(unittest.TestCase): + def test_migrates_legacy_calibration_once_without_overwrite(self): + with tempfile.TemporaryDirectory() as root: + runtime_dir = os.path.join(root, 'run') + state_dir = os.path.join(root, 'lib') + os.mkdir(runtime_dir) + os.mkdir(state_dir) + legacy_path = os.path.join(runtime_dir, 'calib-data.bin') + state_path = os.path.join(state_dir, 'calib-data.bin') + + with open(legacy_path, 'wb') as calibration: + calibration.write(b'original') + migrate_legacy_calibration(runtime_dir, state_dir) + + with open(state_path, 'rb') as calibration: + self.assertEqual(calibration.read(), b'original') + self.assertEqual(os.stat(state_path).st_mode & 0o777, 0o600) + + with open(legacy_path, 'wb') as calibration: + calibration.write(b'new legacy value') + migrate_legacy_calibration(runtime_dir, state_dir) + with open(state_path, 'rb') as calibration: + self.assertEqual(calibration.read(), b'original') + + +if __name__ == '__main__': + unittest.main() diff --git a/validitysensor/init_data_dir.py b/validitysensor/init_data_dir.py index 2f38872..ec5d78b 100644 --- a/validitysensor/init_data_dir.py +++ b/validitysensor/init_data_dir.py @@ -1,8 +1,23 @@ import os +import shutil PYTHON_VALIDITY_DATA_DIR = '/var/run/python-validity/' +PYTHON_VALIDITY_STATE_DIR = '/var/lib/python-validity/' -def init_data_dir(): - if not os.path.isdir(PYTHON_VALIDITY_DATA_DIR): - os.mkdir(PYTHON_VALIDITY_DATA_DIR) +def migrate_legacy_calibration(runtime_dir=PYTHON_VALIDITY_DATA_DIR, + state_dir=PYTHON_VALIDITY_STATE_DIR): + legacy_path = os.path.join(runtime_dir, 'calib-data.bin') + state_path = os.path.join(state_dir, 'calib-data.bin') + if os.path.isfile(legacy_path) and not os.path.exists(state_path): + temporary_path = state_path + '.migrating' + shutil.copyfile(legacy_path, temporary_path) + os.chmod(temporary_path, 0o600) + os.replace(temporary_path, state_path) + + +def init_data_dir(): + for path in (PYTHON_VALIDITY_DATA_DIR, PYTHON_VALIDITY_STATE_DIR): + os.makedirs(path, mode=0o700, exist_ok=True) + os.chmod(path, 0o700) + migrate_legacy_calibration() diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 3fc88ea..19a8c46 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -14,14 +14,14 @@ from .db import db, SidIdentity from .flash import write_enable, call_cleanups, read_flash, erase_flash, write_flash_all, read_flash_all from .hw_tables import dev_info_lookup -from .init_data_dir import PYTHON_VALIDITY_DATA_DIR +from .init_data_dir import PYTHON_VALIDITY_STATE_DIR from .table_types import SensorTypeInfo, SensorCaptureProg from .tls import tls from .usb import usb, CancelledException from .util import assert_status, unhex # TODO: this should be specific to an individual device (system may have more than one sensor) -calib_data_path = PYTHON_VALIDITY_DATA_DIR + 'calib-data.bin' +calib_data_path = PYTHON_VALIDITY_STATE_DIR + 'calib-data.bin' line_update_type1_devices = [ 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199, @@ -329,8 +329,13 @@ def open(self): self.calibrate() def save(self): - with open(calib_data_path, 'wb') as f: + temporary_path = calib_data_path + '.new' + with open(temporary_path, 'wb') as f: f.write(self.calib_data) + f.flush() + os.fsync(f.fileno()) + os.chmod(temporary_path, 0o600) + os.replace(temporary_path, calib_data_path) # This is the exact logic from the DLL. # If it looks broken that was probably intended. From bfaeb4e37616118d52f62bd41cbe887a5ef74cb0 Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 8 Jul 2026 20:06:19 -0400 Subject: [PATCH 19/27] Add 06cb:00cb (HP Pavilion x360 14-dh, sensor type 0x969) Same 0x969 silicon as the ZBook 138a:00ab already supported on this branch, so it reuses the existing 0x199-profile handling and needs only its own device registration, reset_blob, and firmware. - usb.py: register DEV_CB = (0x06cb, 0x00cb). - blobs_00cb.py: init_hardcoded / init_hardcoded_clean_slate / db_write_enable are byte-identical to blobs_9a; only reset_blob is 06cb:00cb-specific (every existing set's reset_blob is rejected 0404/04be on this chip, so factory_reset couldn't wipe a foreign pairing). Recovered by static RE of HP's Windows SGX driver (synaWudfBioUsb116SGX.dll) and confirmed on hardware. - firmware_tables.py: fetch 6_07f_hp_mis_qm.xpfwext from HP SoftPaq sp138431 (validity-sensors-firmware auto-provisions it). Confirmed on hardware: factory_reset wipes the foreign pairing, then re-partition / pair / calibrate / enroll / verify all work. --- validitysensor/blobs.py | 2 + validitysensor/blobs_00cb.py | 230 ++++++++++++++++++++++++++++++ validitysensor/firmware_tables.py | 6 + validitysensor/usb.py | 1 + 4 files changed, 239 insertions(+) create mode 100644 validitysensor/blobs_00cb.py diff --git a/validitysensor/blobs.py b/validitysensor/blobs.py index fb2a1a0..3082e90 100644 --- a/validitysensor/blobs.py +++ b/validitysensor/blobs.py @@ -15,6 +15,8 @@ def __load_blob(blob: str) -> bytes: from . import blobs_9a as blobs elif usb.usb_dev().idProduct == 0x00b7: from . import blobs_9a as blobs # HP G6 series; same sensor type as 0x00ab + elif usb.usb_dev().idProduct == 0x00cb: + from . import blobs_00cb as blobs # HP Pavilion x360 14-dh; 0x969, own reset_blob globals()[blob] = getattr(blobs, blob) return globals()[blob] diff --git a/validitysensor/blobs_00cb.py b/validitysensor/blobs_00cb.py new file mode 100644 index 0000000..7689dd5 --- /dev/null +++ b/validitysensor/blobs_00cb.py @@ -0,0 +1,230 @@ +from .util import unhex + +# 06cb:00cb -- HP Pavilion x360 14-dh (sensor type 0x969, "57K0 FM-3439"). +# init_hardcoded / init_hardcoded_clean_slate / db_write_enable are byte-identical to +# blobs_9a; only reset_blob differs. This reset_blob was recovered by static RE of HP's +# Windows SGX driver (synaWudfBioUsb116SGX.dll) and confirmed on hardware -- it is accepted +# (status 0x0000) and wipes the flash, whereas the reset_blob from every existing set +# (blobs_90/97/9a/9d) is rejected 0x0404 / 0x04be on this chip. +from .blobs_9a import ( + init_hardcoded, + init_hardcoded_clean_slate, + db_write_enable, +) + +reset_blob = unhex(''' +0602000001d2fa601b3f7a293eb2b494430b9f0780ee6c4d566a550eaaa6b38b46c6a543df5d3eea820e0b45cab0200d3916638eed5bc31c +5cbe80605b7ac7e9746536a4e6429676dded7736b2f1dd5af224f9a4986fcace1610c5caa5be5d9ae6d285b1e608268e7a87bb0f6bf7923f +adee2dae8628c7b4f3850f3c52967e3c87d2f81cdbdd37367e5b6c86ee70b08d6d15e36a95bcaa4b46dc3d2ec77ab53cdebf365401a4436b +6b4d85c9b0e650413f9235704547fd6148e6e0e2f244b8da33448f96d08b255d34f0e4117f1fee1e76dbb259f0995af8fd77d8d7c4ea02d7 +ae3b35875ce4d1a1734f1d41177e2701f45b7e0b6a6e69ba0d121b3e0648c2b35d98f56b2be86b2cc47aadb1c3a5673ab48ecb98c6cd78fc +2e1788178bef05a943c44b716f1c86615f57c86602f3194456cddc9fc9861ada5050bdd46561fe19cff1cda09f4a0a672374462029ed80b0 +dfd924698129ba54525b0e8c0302e1330205dfda9f312aa48f86ab14831e8383729962ba36ea2c856f54a0f4a08551cac4b1f3b06a65a640 +f4d842207739e804e3fdc479e553614de45874bb0de22cce180f70216f59847b59dd73b33ece0e1504b5b87c0ab2d271ec852e67be7fcd40 +93b4ba59f5ca2887f773457b481da04c0df080a2c1d332baf8e2255d1a9dd831d334dc279cc32e69b695e10fb2673a5057bc2aa4ada8230f +259c8f7cf15cf364a4e93481aa0663f877375c3d17aff4ffc333bb741d58bc7bba30ea4f02318809d836be4a583f2aa72182ccc1f7eab12c +caabda2911a4c9a0d714436cc44ef0e4a784ee6bd107d41db2eee4ec82afd5b7cf38752af2033bf8494fd4454278360c606fe3911a3602a8 +2e51d080f37ccea86f32983918fa37567ebba63d442b26d714dde23f7950b8d3efc082b929c914d9f346f604f75ce6adc510565b998ed93d +d723e767f91553ebdaf009a8484bd5f2eb7634955418d719ed13778067981665d0944515c7ec5c330ce6bc7da13c905739905358e1158312 +78949fac315f66619f36509314b6354fecee1f2e2b67a774d3c36f59a20389b4269de15c27498df9a957292691640e84cb753438725ef385 +c9a2fac86391779e2137a1b32229e1db77bb961a0733498ab474a81e3425972dcd91c9abc1aa043d008d7eea32fd14c554f49332bcc0a1af +a66953468bde12d5cdf58c18c23e007ca0324ad9227d173a6a0932282f12e2cdf4644c4810b9522f897b982bcfdfa0adff24c9a319e3eaa4 +5b80d7f05918e23a3bfc037b8d2590821feb96b102e88df26ec59a7adb874f9d3b441d27496e5d829ad46282301c023496181d44ca8602a4 +898f9a44b3ae1feaa9b0f03bb5a16d1b1f4bee84eb94f871131f363012833796cc7b70035ea7953d2c34df48048c5a1ed1a089ee873a041c +733f903a973560bfb0f21478e7abbe31e4e8be25e142b207a32f14c9ac7e87df2ecfa5f834bcc386f98eeebe2778639bc92597f3fe35c489 +0b40ea6a4e6464e51dd7ee0ae576f383f760e90bca1b3eb2dae76cc8203ca9821b8294961cf0f47a01ed6f4f2fd3670ed72dbc3cad3fc97d +917f094510cb29b99c159bbe076aac2f6394b9dc23ee3efaf7fb26abc1f43cd5f4f05cc07ccda68cdf4b6cd9d24ecd6a71b0124ba4bd8a68 +3f9ffab0974fddc6213e48d0bd122ce040c2237dc96923f45f2f178ebae421d38112e7bedf2ffc21ea75504fe6d3ad6cca7e3d55533274fc +dd62f756d556412d73b7b3239092a012e4ca8a2d06a1b2f99316ba629a34e4c42a204aff8b0164f506a2fbafc82d275333ff7a08e1142088 +48cd74ebea814c65677ee08c91253520ef0eb633bbc185f2782ed15f3dcc0050ca5d82171d274565f37f9a79b704cc5cc9271051994c3f83 +3604e39da2af76d052d38731a966538de7e216ebd8dbe93e53ac166d12ec545e6c593c420dfb622b533b543e1d55f7759697fa72c43a74c5 +8329147cf195af1dac890d5929981d76384574f97f2b70cdee809c8fa0835e5bb6302c3a918ac7f029f61032ac70fac8f4a01e6c71af33e2 +77501795e7a65cd2673ea0e31d075304b980dcc0a8a759259f768471f901bf188cd721b6192c44bf0e0ed9a2aa4f82c25c6c91b87ad10525 +795a16bc9a0bf4db440c115d59ba041a972dbb76e59e81faaa8828be586406bc2a820b6070d02237b1d49c2c8ba77e6a16a811c1361bcea9 +17c250d4237fa702871013e082197da0889bfda7ef0296435f78ae9857a65ef22efc82e29cc89a709c91ed19dc2dc762d8ff911718ba76d4 +4412a64739701c13a66b39195ec29f3f1383d02167b68e166d0cfde5747e6ebfb0680e32922dcd1b39da794f9815c788156671699f8bd2fe +ce8380da706a77e6f23aff40e97b5f4e5b004f9d82317ca0eb6776a0bbeec9e796bf73b9eb1b8a408998c73b1443f9d46d1552d8af3c3fa5 +e2f5724feb0d60571467d869e99e12edf6b00c868ad22068413f9b3b5d08b517311817f946460025626b59ee3c1692339563e044db3dbbe4 +eafd1c1872b7ec09c13c98e0cc83958d8b25cf1a00874e7445bb0b0db0a0dfb12c1bc1b5324f62fdb7ef7dcb7794d28afe29c10c3a301147 +a9215c1050d65a20c09f4f303832add42ac29fd18370ff0e4fd6540ce35594d2d1dd28c7480f0c1c7eb55ae8045add9c96c1346eda69f58c +011127cfd617016d6feb6ea0d146c029c4a6065f2a25e54a5c8ffe672e5c54fd6e3eabe24b7fc15a900e3b1db23afa26631e3abf14500dc3 +f7323228265c781ae0863ab6cfc1204fff3c82a8fcbb6b6fb3e745a93f9ee385f26479335d63e17535161a61c88ae8088d6ffde8e91ea101 +18ac305f0f100f662784f288bde31ce32b31de2fdb268bcf8047099129f18c6b1f9726d27a070f514f7f52ccdbd71d64c809c5a3968b62a3 +d53eb011c3189836f1e2d71ba6ed57213daff9a6c1370206f60666ae21475a36952269dd600ccfbd923f767225a657ffb6281e32668eae49 +9430cfb484e2c7ce1e3ddfef16a871b7d55819fe3bfb1897beef0f44973a1c07b823e7a006ce8588a8851b078f5c799ef9b419c5867f1557 +d1b067d449b11e3eb778f9b46e522bf3ca6565fed4c4540f623c0a18244bacaeb2b6480ca898db8da9a108f6722a54e457e88c480cf8a4f8 +00b57d64f938dd9e30dab0d991c642455eeded98bc58b5bf437eff1de56b3639f014093b873a3f780dc17e7c4280790848fd5f491d6e2c48 +b5fc2325cd88952228c52432049e655802fe125a00cd949b9ed54470c71b092bfb282e0816107646814d0187b523c8700459ba2323ff2cd6 +6f8ee4e954bb8f13eaa2bcc7a2ce812046df9087bbc7ba7cc110e6aac465daf2c63b55c0930f0d4ca083a20df924f3d395871c39f296b1be +3bfaec1ae5aa3956dc5c4b497695b97a304cc1acd2708867bed635fedbe5a0567881bf2a691ffa55e9a922630f8de180c00d8a8d5f045dfd +9fb029cc8b53dfab9d5533d07ddd2893652004d9495efc2ccf502e4184228652723f956c80b62c566a89e81a862108b2c32b20ded004581d +cea6e5d5de730fae2a4bf2563e411078e41099b7e07aba9ee907ca4821e9633d74cbf0a2731d884b73374358e6f48077fcb49e4c57271085 +ce413e1ea0264b2a5fd94be7e86937ec246384c5cda5eac6b78944399e17d859d84727037b7539358e7b936687726233c0dff89ed5c2efab +a3d74d0924304d7090695ad38b6c1d5777782421802dab55db87a7589fe17240b9b04ef82f9f1d962422e5144e5199b8b056221a401513d0 +5e119022741711c0ebb4cd500d94bb462bca839e35cef0f2efb54a81f218f98a181fb6911cf51e12a7467c6ce95c4cadb90b51af7933eb66 +ca107eeced4fe9af1319416d02445811b78d0bed82b5c11e23699077c169f7e1475e27b8ce5e01d335b09878ca43ba06715181d303b117ba +cfe91948bc9a4ec19d1a2cf1fe86b1d4e7bdd72ae1cce0b3ff16d2eb707a758c96e18ae589cee08a1d6a2edc0197a698bfd027fed54c05be +b0263d22637761bc5c473d358f547939170b6d9a922e143ed46f13207cb9219f2bbc5b328a7c6be7bc6274b3189ec4d629570eaf3b53d3ac +f67bebc3858b68a2366ede7ad5fe823966f157431c8af929963cfc71a35910f36f2a54238f02d80d9b87d1de6c55cfde41135bdc1d3d8122 +82a219261ea1b89a37281840894d3a3aba6c054cd8c6b414fb151bd7a886fb05ad9ae52598da66821062bb9225f61709b858aa4d04507caa +c685ed1fba138b37fbc21c4b60168de2673e5a2fe97a42a193db17b85a9bf56b6b76216e252a2b7b546a2553f59b682f2fe8ffe5880fb6d4 +c71f167c9fd2dacefa86c815ff907533831c74ccf72926f8260166b0fcbc661a37533c2770a7c46da1e94c34f808eea1cf6fb25bcfd18820 +18d263f0d58733a1811788c7c02e43a80f5b2b51035ee366c9bde05cc8af9180ab677e5c6c430d42d73554e75c2095d555ba2df0686700b6 +a9a1d3b747178756b6c0a9dec0647eb65b48068e09d9b7ae494dfe78155a8d451ab58121f2e980c565d1414a7fa6d09ca4ee5cee33c4f3ce +90d342c6f440f309c85388cb04e6c2fe6316313d285c0297091a2adcab6ca3cf5d816072f9080d00192b1e3e2a61376cb07e1903d5155fc6 +7cab3e3872f2ea5c96dd0dd097c6e8be2da87285372581bdde00381bc3c467cbcc7bda35a474b45348d3a0f2a03c5578c83575822fd04c49 +31b01329fc5ae2c539665cece4434bbd3dabece80a849e2a8c9e00d9bd9a48557c6f15886d438670b5e4edc817303530ca046d1317c4913d +34e3bfda7400cf027bad9020b9ba1b59d719e7a50ede002e125d79b97c76ab35e3d0cd4ba0917749a22aee8ce3d1b7253f5f00eb06ec65aa +435e5e337b7d63a6f4030636cf5523863efc9af50ece0982ceebf51817ba9f4b354e090741a032fe0467c5ee1c2920e19713a9498165f214 +75f8655644eb91f52faa64e11b2d66fe1e4a5d1c8522c7d87958942d8d685dc75b2a81b7534fecfe3c166435a8acbcfd396f080cf7a574dc +992afa0862f2bda9a576e86fe63f6b86829728ad362b32be26bf8bdc58264500419887171ef3b0bfe846bd0d0fce41bfb1e7af16d49927ca +5efffa72ef8176228dd995601e981d13495476504ce4d396060e967c75afd1dfbb32bfa6d17ea022d8dc232ba5ff569ffa99fc00cb017019 +246b1299164c0f73434f8144176f396ae002a103cb745494fac01e95de19d059e0e18a5ccd9d827a3211d50ea58476ea4d78f669f2ff079c +582336be1d5f68d32a6c256f0b826ebc6cd829ebb81fbf2e2c592975686cb50ded7d825d218e4d3da3bf3e846ee33df0f0c644de6a88cdfb +78181130cbd495b8b590ba426377319dcc6d63aa14a757c4aa787a3fb9f995868362c8734281b33e01e8f508d886a8442dc620381d33c3f1 +010db797520cd051f368b4da5ed48fad00089f1d5a92fcb516443c0f4c2fb1c7e65ab49324d19e52c3bd623351409cbb5b3c2250fcc1dc84 +c4ea666bfb29d1f834f76598bf59eb005517a8accffe01772f63a358959dc99bdf9b3857d4fd84202206fb43e6b85fa34761023459a2991b +3b095b7c4ed13c7a30c7cc00b680af8810b15da9d86681c555504e526ae41e7067ce1338d171d7ccd8ebe3abc2bf2d88650ea20ee65b74f8 +bcb49b8fc0b821339d0a155f6c0b6a76d0106a74689b07f18b645a733009d55d2c041ffd0afaadc5a078246adc8c37e1c49d1bd3afe2d069 +762a66e9f542391f7cb4cd3b14e6b81b43db9ffe66b70252a0b08609951871d1f0d2fa0428b0c1ebb0511f9c5f1acef05e3cb0ba42e4bbb1 +0e75b121c5976698616b76fca02a133af96b5e0ea65daa56a710bb901c9b7a21bce1597b8853588fd63a56f3602288841a7ed27c48672b79 +4ba6b183579a70a8a6b57e543df8fabb57b3c8d36fae5398567db8ed2c1479b5e3df8a1aa7aca105310244c8aefd5df6ef5f0ac034ebd269 +62538073fb668d1759440683877d7c0974ef090a3fa2eb619027bf04fd4653b87e7774a7c314cd119bb29788f379289411eb8f5bd711c5d3 +6b51b124bae704f17638665fdf3dc7f9d9424322f9ae69a52368cad46cbd83e4c06774a11106a0cdbc0ddcf7ce14d6d18b366ec22641e3a3 +ec85340324b6fc9b931fe5fae2ddc3f1197d33700195dea7bd9d68794ad681a69cb52ed4a7bf1394527f46dbe55f588972435927a2368944 +bad5e8cdcc5a328d8bb80f25a0cec36bb57c6ff23ecca2609e51492b65fe6de3edb2c5f7abfbbfa1397f7b8aa8dc1947e4da0575e6743c51 +37fbc856391e6d630d86a6e993ec0073099f16aaa51d28230fa262e94c9287660d106bc1ddc29f24adeb29dbc20918d02f90ae1406fa4584 +c4f7385b5526e1a03aecd30c112d657a8f076ffdc3bf6e4eaa6a5c3e2e74fe97a4bc7a81e70d89eea52c70219ceca20d4abd723e6bc4f8f0 +38f251b3ed8e723713b4a6c20e5c124cacd1b089229776583a0a97f94cd259b3faeed3f8af7d4e587c32a70405b43cebaca583ec31b64065 +f9eaed500a9565a89da3bb4cb4b82daaf8b01ad29b24994d00fd0b1cdfe6d24d4b2c6dedb48fefcb5637696cde216d2e74dc27a01a40ce8f +ce23ac868f358f12f1ded5e7ff6a8c0bf625fcd53c64a424a54af8c9410309d69d4fa2e1dc1d55035b7577ae9b01bd891fd4da588569f385 +b927becdeb49a17595a9f7093818094087ed073e8d9631d9ecebbe15a5ae4698a8bdfe9a231a0aff79777af37be5a6b839a03fec653a550d +783e45115d7e75b3451304404db5d4f02a332067a26b29520e5d9460d439d9451827f44fab7fe98f62c29baeaabad015747e542d283a5652 +998002cd7fb18a69685b1747761d470b186e69291644031bfc164bb631958fab0f0a42e2606ef0805b5d184a694f997eea597663a3fb75f1 +25759e1990e99e927043c2a27a2492a0f1b501961c9d8b7589c5fb4ae0309f1d3a0300b1b749c8fb26357d6de1ac81444e35d378703ba570 +ad8e1b1c41d13e41268240f600a0c8f9131e9a4b8306f9835de89c8737cfe88bd4217e906f5504ce60b386e453a42753bcb8b4aa1bd0198d +d04baabeadcc07d4eb2337b94794231ecf73c25b3c9152f8a332f43fa6301a64e68f09d8b34a5525b612920dbdf89885c70153f91bb48362 +528d15b1e4dfecaea7d3eed3b3cfd24c06c35cebe6695a2f0256d293a18ff1642d417ba66f817ec92ff4a4e091ae81e3ede0ddaf50ac781a +0e1abe2d6b483029a7f488d22df9d411dec62c7b9e6f57d3507ba1cf46835b679a4b32e60ea4f48383494b04d9588b4a967bd056e0b855d2 +e318b20f0d995de3b1800ff197fe44c59f2f8dd98a46d90cc05b04e450617c0c70a3cbfb725c48bcf132b7ca45dad3085bbcdfe700aa24db +8f0e444ada4b045699dcccef864585abdcfafb68bcd2e86d53c1388d8e3495e1474e6e82a9a851e3cec6449016fe6c6494cf577a0fe69439 +964d21a3f7fabed0343d31c69cf5bfecbb3d32229bff3ba0229eda25a49b9967bfb9ce578f7d619c85e6587e958815cf4c35dae946473a2a +919fcafbf158e8bd463a8fc397e209a7cdd08f2061e5170b8eb14474d2ea38f7ce7194187cbfbb11c3f850476f53b5dc500df17cc6d85117 +fb35d4216a898ace5d703b066635ae942427385f0f08d78d556295a01a5be900c2473ca3b38d90b5d6f588f8faf4e5507f70838e84ad6492 +6d7e9a2683a40a174c89c73b4ae2812e6cae3c10d239f6ad9be00d471c8831db7977c3678ee5b4e15a1de9e79c057f8103ecf63ab879a183 +396d3540b57d205e779f65436dbea553af6659276afe1b65a96ad0b06ed413f599b81d94162b5967dbd2d03654b9dbb006ee8f2d41eb49e9 +0fa524d2c38130670eb12317db5b9d3f32ee403d035a1a70093dad4ddd00d6bc97f94aa306fc7845b0f9cf2edbab2ba62f3f85985410dd91 +dd36fe79006d593e935fc09522aa0c55d6aa5d52c22ce7fb3a0b785b13a2f7ecab723ebf23ceba7a4f481a4b4275bb63c74d3e3510d3d237 +fed4785f4bd8c7b088c6bf7d4c4e013e3055fd1104fed8ca4d5ef292e288730bb50157cfe0f99619a78cd2bb92fd56db50325819bf623b13 +44418ad7783fe5fe0003322889f460337d40943acbcca9a5e50e8216f056547a62cc55f3598aab83bda8a27eb3bd1d35aa2e3d176f523ee2 +f13ccb0624aa9f2ada406876b39a3842e859b31760e376db61d7db22e45f1b6e35262bd25b521b232b48d208af37d7fcdcf878f6e4e27521 +530f5959eedfffc3ce1ba8f2eee866c56360bef91fbdddc4fc6ee8f505aae0a3831d77c474c29be76105ccab3418cd810f2b72d58a425729 +bd78cc9b16ce2a95b48502229bdb3cb975041490b7e070b29fc63d315ec2b2571dc33b3cee93e5fb7cd6b55599440c6b2f28c66335b42f26 +e9cc56e374532174ac667a1f824414ead4f1f9319dd15bedc0d3c1f88717575f484431a7e9685350af7412caad2f305a0698c3f197b12db0 +99a54a4168f76c882e4fad7854db49c289f727b2d02a4ae642bae0cb7051a6cc8858fa8c4a8501c54cc7ef8141dbd8f966d89747f057b975 +db14794d81d566a7023e4b92733e9aec8d510fe5ccf63ecdaa4386e044a30da744bff020f7b539c45442a6af84d3c30b2e38a808d73cfdae +496aa7c546bc080fbd83c539d3b4aa6164e4f50b2f33816e81e665116a31b0b57ee0b2371da70a8f2150b4bbedd3e8ba6027212a3f0cb8eb +196d17a58f3965f89ba43ff82f2de139e9a6982590792681fbdb2b8d35342b3479b7a31321d547ca4c7fb055a452b49a900e7237e8c163e7 +b1fa4715d82654f736ea518b33223b10eb4a45c8a963a6c70b42a9affdd1497f41c01eb4f9c13e944743c0a79dc41e9fef5ede753f29b9bc +dd98558da7d0823004921dae6fcfc98b6ab1e0a1a6ac7dde74a52afc390041dfa7ee8329a9aa2928377d41a8883ca4545b1195e7a562b5dc +b52988b6d5b2febbd00daa845afadc1ed1226f2755909dbfd26dc79fb6f4a8e1ceef1540f8cf6be6fef8b7c00397b1bedc016e7bc9cdcc43 +5784fb2c8e859cc9a5be6b4b24cee7e1d53f6d60f39c02f23a782e860cf0ab22999889b5a2b39b9ae20880b0339e56982e03368ee8d2e28f +5fd8ca3968938feb0fefd3605e6d3669ef7d3fbda289d91a74d99f5d9a45e5b77dfb58ebb15f62522de726d460188d761bb50d6780d27079 +a901a6ca3f04b339cf1b5dbb728a2f26d07cb8d7b3e63c78abec69192f0ef915e523a00a0722d69e79a22cf61e99b66807467d3cad2b0b48 +548acf2224215642a5ce817e39645d320abfc11dbd65186b5be3700c3eae579b295f5d6db25fa350afbddde6fe6cb1565c369ee2ed1662c8 +265976f7c549015ad70f79fadcceddac5d57c46860246f293bdcbb46f58c0e02144040562159289a58f1e396894acb680f7a2520f386545f +e81b570b89cbb48318ecd8ba948ec648a9b12c9c97ab080814d3386114da29ea9019086ba02eefd1adc22dd1f55743c1e7f869958aed1a47 +cd8e4e479b000c88dde795209257f1d7d625df1ae27ab79b681a327f9db4a5a45d8e6c3870ac751225f4c98e41a44fe6dd571166fc7471a5 +8ca7224d3e4259c4ed8603b41e914a649b49392e77e9073fbed54a494f61a7f166116fa47429837a169ba056e189e642719873845f89f741 +3a15e856ad369945d301cc91baf842ff355d71ca4f45641c73e3a7b1f6c6df530bcd3a43fe6cea949ad5920029a827237a8497e236fda61f +1d86bbea9ee97089e46d7e52051f938b645ce43a97a18fae5832c2206874aa717e9d0a942be137094e5258c7bb1af7c5935eec00c08a8d26 +26bfe06cc1788de23d5d2513a919c56933a4312398da4d8644580ea552291c9fadb6a17823dfecf65d3c12abc65acafdefd96ffc57fb571c +280286fc7030757c51e58178d4088419fad8c5331e73504f6c0083cdc8ed054ddfc8d99b0dddf3ec705da169debbc15911d424c07bc8753d +c4ae4614cad71c1e3cb95f50ef03326986e8e3d4cd668c2da94da5fd1ee60279da0f1c1c4fd59d364b4d92e613fcfa3e8917e9b5ae3b350a +96bf6cdbca1150a3f34662126565da147b677b43fa53e6ff06435b1b6260ec1ea3856d60e86587507915af88198f0a5e9dfe2a4ef24bd1e3 +ab68b6db80a5225b62f69cafd89db1e5218e5c56640aaecae36f42a4c0c162f9d354aa05655e0ed86616da01425a6bee99c7f0b0512fae2d +e7e8ce9c2ab594e4ac6005392b3348a4bb9e6f046e850eb85008bc019ce746a557a731a906ec8aeb7012570ff31a2b2f38f7f62d2303857e +2e50770f85c56de514cf68268fac275e11c0236f1b395e40629e038db68757351587c8cf5071b80fca03437af67c915d70d3a12493a4c9a2 +bea32f62bd6e4806c3d65395f4ccb792d2fcb8d5592a06bcca0c40afb2fdec39664ad324ae73fda1107662e847e936f5599b0af587c71e8d +216252992657a3563e04747c9a46c36f64b1471143ddf0b3184f1ae51f0c6d7af85940df008c2320f9c796c5a0115c4407911625e1debc95 +3396a9ed5ac2b6f4b034c162092497f43a0de4ee8d5902a888e2f9b897ebcbf8cba366b1d0dbe94e1e3de656bc8b949fb575aa26a6f9e4cd +bc4a27d312e5f8e5d8d9ff815177d52c1a0628dff52fdd6e4d90e26268e01c9efd4427fce66cbd06775288d3dec9dd91b2ddcb12d153a547 +a764797c452c5fc0da3312391391fc3a48ed511ba35ff7b89fff9538b103695c4b0471b41d9fd1165e2a7ff68465f43e26ed14aa4172e53d +c1f5c18b8c54bf1306a631a3b162048f1ce36b4305bc7eb9821e9013a1266fdd4718458be9afdb23e7d4ee4ebb46c79f147f23f3ac3c5442 +2878be372409af0c8f022636a914aa19b499b6033584c9687b7de23ae61966cf10c662bc7e17ad1e4cd2ea5620de1de4e20e6be52c465102 +a8a8a2f7fb0258861697db83f786ce5ecf8fe5f745b35f4f0a8e2db07b4821085fe959ed3c19f5847c677878552157a6ac8ca60f3425e5b1 +126ea57526285e3cee038f57ffdc3e95e4c938145f7f71a433e19cdf61dbf3a5318c225ca9c1b26245ba89579888157647f7405d2efc1b70 +cd9cc02ba5a5fca1a49ed89ee5e89dcdf37865f8241054dab3ff459cd515a14635ac1ecfa387ac1e83522e815b67d3668effd17793b87938 +0e483d4368e6638365c68474ddd74be64cc46ab1435d4dda78171a56629b8f3a488c2705fd0d332c83a345ab337ae542eb803098eaacd0a7 +4a96518d56a410d57321eaec18ea1eddae63ef7a3b8152f50cddebd82f47d1cb773058db6d3b38d3e81fa897b988f3d04b7bcf26ad14d700 +3c988ec7b25332746f9bc4005102704f05f5d5ee5341ef3b30d318fc3904da34eac2ff3a246b1a6ec60145cbc66b8e79006dc08514a2dcea +9f5d89642edbc7b9f41d811ee1caf10343a286e1e4c5f17cdc640c8aa5e40dbc89de3a18975ba4558391e27650679a829ac644aecb00a430 +74a0aa358f5664876388ed742bf571d020b29059c8326f68411979d3967a314bff40126b5f656f1ca0ea4d7261b7a0149691ad08cd2b2b2f +2d9075afaa4e4c849ac650925b2c2f489075fe539f8cd7b1fc7cf4781808fa29484c4f3b9da3cf38cb31c58cb3ded4daf88de27c8f447cf0 +98b7ff69e7914cac7171b2290133fa1854b8a331e35d7f002ae75b1589758e6b2151072a43514973375185240b7e8caf231398fff3e6787a +28ef17829f4b42501fe0af78943f1e0cc419647ce0c2e9d1ab50f19f515d4bc75edc6e2d31bd17ef69f0a63d4e385a8f5bb00908f83bb86b +b20239b55d62730b43ddb783fa805e6ee4b4cf6eb66ac29bb5ce631e294b439517f5856ad1f2fcffae3936b40b14b2d6dd4e32c0a8091bce +bf24c0c64f54094366fb61269962feedb4072daf5c5a40641dc827849682a21850dc1554d53574d9608c97e4abcf94f9c6a28cf5b484d674 +36d3ecadd6e13ce57c11434a57e87aac9562079e22c6d2ab310b9ab1271d95bc268ba98404612ba78c3f032fc59322334870feff72f2520a +cb71682220f727928391051a74e6928e74706a7a5a036ed8a0d4be09f9088df632adc8ff816d7d830c8c3ee0074432b9dbf1e9ba49b12796 +d1eef1bacfc32b941e52b0ebda65241e198935d0dc4a72f997c8b26283ea93261d483754693f9834451a696fe4bb1b6e39f1cfab45f3e792 +fefd5fd2b94a698caca4eb8b2be7e1eb6c9a06a56bf22745ee3af4126dce6e5a77fb348a0ecf2416c055d2e6d4019174ec7326f7239727f7 +eafcdbf3f8323f1cb0b1f63d75f4522c575f82ba0188b03734c3fb374b90d9773c75ea981a23510a7add04ed0b13f80372b63fcb1135e8eb +3129bc970df3a6096b92d17d7f23a3256a7133b4c3c71cd581f68575751255c2517ff3c3e328fc8f50ec5b27d3868a99848103ff73b82478 +bb86e52e677923a905bfa608e80a571108e4e46cbbf4871b078457b8e06eff87a15ecbf3cfe59d597cb4cb2663de42d39bfd260442be708e +8985e5552cbe191a42fbf9eacc4505d4c857a67eb0a43b27cae86b6141279ebd32a2dcb65a94f9fa8e7e17fb8447d435311740cbfe3ddb2a +8f2826c69dec1750d4136406238b128bff704d9fed6444d14b7bebcb269fa4203a262d8bd0b33912199566b1896c03915d090d070488243b +b1566b9d464571f1b5870d959b0d0fbac9e6f783dee5cea9019110d02e76a92e5e963644519456cfbaf6b598da56ddea81ac549b93db86bf +e54930d67675775ba5525b7576c2df1965d7b317bdcec7150df1c15706f81d1c2ebea7269d9ed0ab10312c448f5907730d12447cbc9cd4d3 +6e09a8979a1960db9ca7058fd30bda25bdca5fd48ab4db2be3afcabe691ea64effd6b724eba76e2637e519cee7ff9f711061245b174363f7 +5c9da2fba536d2ab7b6fecd277129693d17f0bda731589df1905367bc80ef014b05e95dd520a9f08924c306ce25ce4fc343c66f9651e9edd +b57ca60d3b030f6046693811a022461ad45bed406f14722740a6633edde992ab2e2ac5d94dd70399d7cee5eab350b2c10ad668e6d705eddc +0b1d0e22292e34e416f0e9b361fd4ae2558a256175dae60caa8f00585f3401a209a25cc8e72632559e15e80600b4c741769c637d83da4768 +de13f619f8bfae3b7843b8ea46064b03dcb2bb2397cbf19a9ee773ac5a560f32fd7a3c95d7d344d924c715c43b4c0d644c0b1b28187c66ad +6a56e3c105b2fe83b585b3ef1f11e3f77d21d58eee9b513d52dbd8630c8bf194c30a64dbb96e707adda4e5caac59990e1e54bb9ee4816c8e +6822d69ac52f815e384cc8e8b3938f87d585681b38069426035d40abfa4ce7cab34f56a45e9ffdfedf53d81e0b08a5ac32441701c2cd1f26 +8bb722116912f55260c128fe5c55a197f6440dce2df4bd426b3178761ec043cafa57e41c02db17d1a712919dd2186fc0de1b3f4bdbb129ae +22f8965d4d84d8a687d7d6404f6760d2363a713ca19ff06d648d311c5871383594f2c34151b0b812c4ce12ea3e240754aeef9faf7a61f36f +835de3090e1ea22767c69cf376312bd3207ea4f939668ed3e700c72d50d3516778d0006b0da8bca7b65c5fa8ede67cbf1b1e9e79d259a392 +2c2328e8385f544283fca61778af7771e25b4fa50e3a1d9b04a19bb74450d772374846314cd6e8fc3ed05c19d72d0bdea8c3acd4095d1083 +4c1d12a97de5992bfd813c450c0704782c4806ef6e77f9c2b8184c26fc2bde78327923b81e48f186b972bb4d9267f9220e3c03e9f87048ad +2da7ec8dab8118adf777c155bd46091bf990374e5cd8dad3624fef8c646eb0446d0c3e8b18c93546f9592a86accbf6ab7b947ee7d279220c +0da062555199e5954639fdfebffa16078f49c7d6b1097c8ee10494143e743a7524a934746ff5e6e2b02c985d2780b4c667d43cd1c3ba6cdf +4752b120c3b481df2117f31d0cd49744da9b5ddabe5e58c7a4625b88370b1fb87887012c5636d8cbbe258d01083c31c01e4b359e7d12f230 +a1ee229dde4427e201a9c682e4b59a1509bfa8b7ad42ab952300b0dd032c9200512683ef4178a526039284ec43d4c6e93e9d76f1c97746eb +e0c23ad3c9736579f4192dde55c42723ee71a0c5cf16903b1f3ee9662cac9e357abc7b5cb1b3f322cf935db49ee440fa9626d98c035d5249 +949f9f5395c79eddffc354b3f6895daef19bab519e9c2cfaa663ef8d543846bd11c889dda634335b61dfbfcd1450c09f9391519ffd5c1fcc +335f2d167fe5829250718ab75c40a976b77866930a737b07f4afd00e2f377108f132e7280523acdb8d3912a797b90d707417326b705233ad +fb5511859de8e10530b5e23f4c253d89ef06545b29c67d0b9c42f8ba6f611946d42f1403e52d8807996b4bfabed64f1a19d96819db892394 +bcea025a460b189c53b32b131c2b94c12eeedc1ffd610f2418b5d5dfa92537dc75812785f45de70ce54c4730025554d72d274201c0830ac4 +6c1e857575879384a38ddab4a559bc528663428cc703c936a7aa0ce2f659502c044b96754710397b4a65a7d65caa3ea2125fd755a49aaa95 +92002b7df32f4783f4e80ddadcc7dff5ba4a5f83e4bb79c5496f2243665bc0caab4c0271bebdb7748a71b97171b5b788e3b098ec983fc336 +efb53e9cd17a4b25e83f3b4cdccbced384574e8ee61ec3bd3b3f79b00a4e4d97303605c74d5c30b61f41d5a19df90485f8c80c9846f141e7 +ff8d50b4a250be27b1e0fd326421a1cbdef3dc3712a4e759bad3340f4bfd3d80a5377259ada2af90e1b52537e672caf82497f3bd72e12e25 +0a02123bd3c0deb98d9862088c569e313a586a1c361964f98d82076005be95af73b93164ba21b5d86cc1f281228de49565cc070005e3f10d +9600c0a95f0ff09a4a5bf79ac23a9e03ac2ba4df5168b1c7229c77c26f621fd152320c5a07f74496afe8f552481d6095a35d820c1dcf02c9 +8b50f6b11ca84a15900cdf21c600c3820563efebba79ad96c6b09313bce1f0454c64b70cc95600cacfb9f14f94c9b700b845d2645ed407b4 +d1f8ec2723d7258d092a1454b23614054719a972dc12976fc3a2d966803e7d7358386a8553d1f5f1d2cca8ebbc705cdae15e567fd2d128bf +8e6f2c789db6f30048ae33bb7411bd38fbae92c2f75f712c9acce07555bdcd2031a82dab1851ebd84079b1bc2c5a94c01d607894595791d6 +41ac7ab56622f9f74083f5ea645f19c83da3542010a629027c34012924772bee02c8789a2f00ca9a669e9831d1748dbf3e7071dad7849847 +63dcedf4b3b1ba4fbf98d5d9f89e508bde3d87a7112c688077b44633e587ff69a689d87e32ee1d885034ecef6d65f99ac2c335fb2ed817e2 +eb9a53610012cfba2638ca40e03714ee273f01b9b616e03e18e547eb7317b985b5c95545f8caf2dbf988e8c8403420ac378d87194f86e324 +f203716a3a0cfaea529ce5391609a56f6d4dec894bb92db8bc3de0ed23e33927a76ea9f3ed22ad30e56b5fbef5c5b740facae9254000fa7d +ed8519c4bdcb9fbd13bb6836f0563ecc5403b6057dde2e0c5ca039d662e0039345ebf90aec9e7d50842764cf0c41b93b7e41985fe2c54e36 +8eaaf2b2895bd26e0bb7d7c1b90fde6dc8e6bc492ac1f1729592a524924df5516807a0e786292f899f2cff27a3ade0de2bbb5c07df519edb +a91987ca42bb0ad6ce917e9135720b88402f00f5362533c43b2797fadc62489e29155c1e23c597e0623c1e0295af7af5ce54ec207468b1cb +732f04a841f13faba6327efc9e93a31cf5d423b7e043cc4b281b2bdf9cc5f6d0a5c86d5da212cc5af7a16b25e5d167bd0f04320ae42c5b42 +965586cd87b54d22da51bffa00eb076084cc72203f8fcddedac5ffd67d433bab3074e95d9ac7fff67910f581467de9ebb9992bd735180957 +a4eea7f42d33ff57634d6b930cf43bebf086a2b82f269410123ff49abe84d138f34cce1f8f0664e0761e291172acca5e4d3bddc467db661a +dda79ca93ed9a9f7ecd28f2ae96944abc7c96ce24a4a5fd46967bfb302db248d76a01dbd1c66cf6e93d999ee97a8866fd059f97a41167d6b +f61e3f7f8b68082901e4f7036dcdfaa59c0cc4af8fa40dff4754c7ce7063bfa947032329047dbc9d8e3c44649e5dbd4aa893b344889124a5 +9fbf857b111abc5116781acd268a518d3e07acc2b8718b3043ff9fa877d2b109125046028ddf44564ecc6fef55a93d30b5a910525eb2056d +73d93fbc29ccac2d868c9d4f96061030f555e6a92dca9b48215006d4e7515475b5f9505d8250c11cc5f8b84028eb56e9606be62145bcffcd +feb9c5e777a5a6983b108386ddeac5c1349100173a19964ce49e247c0ee7a9e48f18711f156c46241c3dbec16a8e088dbd0c423cc5fa7af4 +efbbc4726ff697dd057ec4c4324883fcf16bc117f6078f09a3ff8a93b5655e93cadc603780244612b4be2f78b72aa344174503b0ced008b7 +2bddeff56db07f410fae3fc4a54fcce92a5a31745e921792d6433ca6599c32e9c2ce1957309d515fb853addb88b3a727a47c221452ec1e6f +b64a17816faa463aaceadf7214cfc535d944ff817ce48a7bfd70965e880af0232657c7bc89790697fa16d5467a9cecde060b0279d9348a07 +a82b6eea6428e5fcc8cffa5777ee034149f5c4779bc4a5ca7fdb87562bd2f8486a39b696b0e2a8ddf779544e3c0a043a5ed76334964d8165 +9fbd025c3d74d1eda2ec843cdd7c75bf333fedf02bc5bc31dd51038477f6e8ea0d435f3550774d8a0b7f2e6c65434cf96121996d258d0630 +af950f332bba569ccc2f3de94df8d7435fec0a68e92026d4e756a076e719dbb1a8247bf240814bc83811e7bf31 +''') diff --git a/validitysensor/firmware_tables.py b/validitysensor/firmware_tables.py index 5e3317c..b89c5a0 100644 --- a/validitysensor/firmware_tables.py +++ b/validitysensor/firmware_tables.py @@ -32,6 +32,11 @@ 'driver': 'https://ftp.hp.com/pub/softpaq/sp135501-136000/sp135736.exe', 'referral': 'https://support.hp.com/us-en/drivers', 'sha512': 'f9a91e2796a5070f1f40099e2318aa9716e2e6a31b9ba6a93986c450eedbfb0b323dff55c5e4536466946da3e01985f367b1db27bbd7b65f4c333ce0cd47b78c' + }, + SupportedDevices.DEV_CB: { + 'driver': 'https://ftp.hp.com/pub/softpaq/sp138001-138500/sp138431.exe', + 'referral': 'https://support.hp.com/us-en/drivers', + 'sha512': 'b9a268773ac948a4b6bfaa7a5762c58ab482aa47ea321a429ebc8dba3fcdd17ffe750d189791d8e0325b21e95161db5f751c61d7714d7460ee0a406055060a8f' } } @@ -45,4 +50,5 @@ # filename matches what extracted from HP's Windows driver (sp135736.exe). SupportedDevices.DEV_AB: '6_07f_hp_cmit_mis_qm.xpfwext', # HP EliteBook 840 G5 SupportedDevices.DEV_B7: '6_07f_hp_cmit_mis_qm.xpfwext', # HP G6 series (same chip family) + SupportedDevices.DEV_CB: '6_07f_hp_mis_qm.xpfwext', # HP Pavilion x360 14-dh (0x969) } diff --git a/validitysensor/usb.py b/validitysensor/usb.py index 0cd293e..6843ffd 100644 --- a/validitysensor/usb.py +++ b/validitysensor/usb.py @@ -21,6 +21,7 @@ class SupportedDevices(Enum): DEV_9a = (0x06cb, 0x009a) DEV_AB = (0x138a, 0x00ab) # HP EliteBook 840 G5 — sensor type 0xd51 DEV_B7 = (0x06cb, 0x00b7) # HP G6 series — sensor type 0xd51 + DEV_CB = (0x06cb, 0x00cb) # HP Pavilion x360 14-dh -- sensor type 0x969 @classmethod def from_usbid(cls, vendorid, productid): From 6a1ae05537b4498cef03298846911cfa5de356d4 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Sun, 2 Aug 2026 17:36:14 +0100 Subject: [PATCH 20/27] debian: include 06cb:00cb support in hp9 --- debian/changelog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debian/changelog b/debian/changelog index de6366c..2cf736d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -5,6 +5,8 @@ python-validity (0.16~hp9) noble; urgency=medium capture handling to stop working after the first reopen. * Persist calibration under /var/lib/python-validity instead of tmpfs-backed /var/run so enrolled templates remain usable across reboot. + * Add hardware-validated 06cb:00cb support, including its device-specific + reset blob and HP firmware package. -- Dev Tochukwu Sun, 02 Aug 2026 18:00:00 +0100 From d1a67b3e66d2f2eb78dc989d7701c3e3c930a8bb Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:09:01 +0100 Subject: [PATCH 21/27] serialize sensor operations and make cancellation race-free --- dbus_service/dbus-service | 29 +++++++++++++----- debian/changelog | 10 +++++++ tests/test_operation_controller.py | 43 +++++++++++++++++++++++++++ tests/test_usb_cancellation.py | 37 +++++++++++++++++++++++ validitysensor/operation.py | 47 ++++++++++++++++++++++++++++++ validitysensor/sensor.py | 5 +++- validitysensor/usb.py | 19 +++++++----- 7 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 tests/test_operation_controller.py create mode 100644 tests/test_usb_cancellation.py create mode 100644 validitysensor/operation.py diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 41aed4d..febf692 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -12,7 +12,6 @@ import time import typing from binascii import hexlify, unhexlify from pathlib import Path -from threading import Thread import dbus import dbus.mainloop.glib @@ -24,6 +23,7 @@ from usb import core as usb_core from validitysensor import init from validitysensor.db import subtype_to_string, db, SidIdentity, User from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR, init_data_dir +from validitysensor.operation import OperationBusy, OperationController from validitysensor.sensor import sensor, RebootException from validitysensor.sid import sid_from_string from validitysensor.tls import tls @@ -45,10 +45,26 @@ class NoEnrolledPrints(dbus.DBusException): super().__init__('No enrolled prints found') +class DeviceBusy(dbus.DBusException): + _dbus_error_name = 'net.reactivated.Fprint.Error.AlreadyInUse' + + def __init__(self): + super().__init__('Fingerprint sensor is finishing a previous operation') + + class Device(dbus.service.Object): def __init__(self, bus_name: dbus.Bus, config: typing.Dict[str, typing.Any]): dbus.service.Object.__init__(self, bus_name, '/io/github/uunicorn/Fprint/Device') self.config = config + self.operations = OperationController(sensor.begin_operation, sensor.cancel) + + def start_operation(self, target): + """Give one worker exclusive sensor ownership and retire its predecessor.""" + try: + self.operations.start(target) + except OperationBusy: + logging.error('Previous fingerprint operation did not stop within 2 seconds') + raise DeviceBusy() def user2identity(self, user: str) -> SidIdentity: """Compute UID to SID mapping""" @@ -67,10 +83,13 @@ class Device(dbus.service.Object): @dbus.service.method(dbus_interface=INTERFACE_NAME, in_signature='', out_signature='') def Suspend(self): logging.debug('In Suspend') + sensor.cancel() @dbus.service.method(dbus_interface=INTERFACE_NAME, in_signature='', out_signature='') def Resume(self): logging.debug('In Resume') + if not self.operations.cancel(timeout=2): + raise DeviceBusy() tls.reset() try: init.open_common() @@ -177,9 +196,7 @@ class Device(dbus.service.Object): logging.exception(e) self.VerifyStatus('verify-no-match', True) - thread = Thread(target=run) - thread.daemon = True - thread.start() + self.start_operation(run) @dbus.service.method(dbus_interface=INTERFACE_NAME, in_signature='', out_signature='') def Cancel(self): @@ -215,9 +232,7 @@ class Device(dbus.service.Object): winbio_name + ')') self.EnrollStatus('enroll-failed', True) else: - thread = Thread(target=run) - thread.daemon = True - thread.start() + self.start_operation(run) @dbus.service.signal(dbus_interface=INTERFACE_NAME, signature='sb') def VerifyStatus(self, result, done): diff --git a/debian/changelog b/debian/changelog index 2cf736d..8c31b69 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +python-validity (0.16~hp10) noble; urgency=medium + + * Make USB cancellation persistent until the owning operation exits, fixing + a race where wait_int discarded Cancel and left orphan verify workers. + * Enforce exclusive sensor ownership across verify, enroll, and resume so a + timed-out desktop or PAM client cannot keep scanning in the background and + eventually wedge the capture-quality gate. + + -- Dev Tochukwu Mon, 03 Aug 2026 09:00:00 +0100 + python-validity (0.16~hp9) noble; urgency=medium * Keep hardware-table identities immutable across sensor reopen. Profile diff --git a/tests/test_operation_controller.py b/tests/test_operation_controller.py new file mode 100644 index 0000000..daee5ad --- /dev/null +++ b/tests/test_operation_controller.py @@ -0,0 +1,43 @@ +import unittest +from threading import Event + +from validitysensor.operation import OperationBusy, OperationController + + +class OperationControllerTests(unittest.TestCase): + def test_new_operation_cancels_and_joins_abandoned_worker(self): + cancel = Event() + first_started = Event() + second_finished = Event() + begin_count = [] + controller = OperationController(lambda: begin_count.append(1), cancel.set) + + def first(): + first_started.set() + cancel.wait() + + controller.start(first) + self.assertTrue(first_started.wait(1)) + controller.start(second_finished.set) + self.assertTrue(second_finished.wait(1)) + self.assertEqual(len(begin_count), 2) + + def test_refuses_overlap_when_worker_ignores_cancellation(self): + release = Event() + started = Event() + controller = OperationController(lambda: None, lambda: None) + + def stuck(): + started.set() + release.wait() + + controller.start(stuck) + self.assertTrue(started.wait(1)) + with self.assertRaises(OperationBusy): + controller.start(lambda: None, retire_timeout=0.01) + release.set() + self.assertTrue(controller.cancel(timeout=1)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_usb_cancellation.py b/tests/test_usb_cancellation.py new file mode 100644 index 0000000..eb7ca64 --- /dev/null +++ b/tests/test_usb_cancellation.py @@ -0,0 +1,37 @@ +import errno +import unittest + +from usb.core import USBError + +from validitysensor.usb import CancelledException, Usb + + +class TimeoutDevice: + def __init__(self): + self.read_count = 0 + + def read(self, endpoint, size, timeout): + self.read_count += 1 + raise USBError('timed out', errno=errno.ETIMEDOUT) + + +class UsbCancellationTests(unittest.TestCase): + def test_early_cancel_is_not_cleared_by_wait(self): + transport = Usb() + transport.dev = TimeoutDevice() + transport.request_cancel() + + with self.assertRaises(CancelledException): + transport.wait_int() + + self.assertEqual(transport.dev.read_count, 0) + + def test_new_operation_explicitly_clears_previous_cancel(self): + transport = Usb() + transport.request_cancel() + transport.clear_cancel() + self.assertFalse(transport.cancel_event.is_set()) + + +if __name__ == '__main__': + unittest.main() diff --git a/validitysensor/operation.py b/validitysensor/operation.py new file mode 100644 index 0000000..d543dc0 --- /dev/null +++ b/validitysensor/operation.py @@ -0,0 +1,47 @@ +from threading import Lock, Thread, current_thread + + +class OperationBusy(Exception): + pass + + +class OperationController: + """Serialize access to a single sensor and retire abandoned workers.""" + + def __init__(self, begin, cancel): + self.begin = begin + self.cancel_callback = cancel + self.start_lock = Lock() + self.state_lock = Lock() + self.active_thread = None + + def cancel(self, timeout=None): + with self.state_lock: + active = self.active_thread + if active is None or not active.is_alive(): + return True + self.cancel_callback() + if timeout is not None: + active.join(timeout=timeout) + return not active.is_alive() + + def start(self, target, retire_timeout=2): + with self.start_lock: + if not self.cancel(timeout=retire_timeout): + raise OperationBusy() + + self.begin() + + def owned_target(): + try: + target() + finally: + with self.state_lock: + if self.active_thread is current_thread(): + self.active_thread = None + + thread = Thread(target=owned_target, daemon=True) + with self.state_lock: + self.active_thread = thread + thread.start() + return thread diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 19a8c46..0bb9f5c 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -761,7 +761,10 @@ def calibrate(self): self.save() def cancel(self): - usb.cancel = True + usb.request_cancel() + + def begin_operation(self): + usb.clear_cancel() def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: try: diff --git a/validitysensor/usb.py b/validitysensor/usb.py index 6843ffd..a46165c 100644 --- a/validitysensor/usb.py +++ b/validitysensor/usb.py @@ -5,6 +5,7 @@ from binascii import hexlify, unhexlify from enum import Enum from struct import unpack +from threading import Event import usb.core as ucore from usb.core import USBError @@ -39,7 +40,7 @@ class Usb: def __init__(self): self.trace_enabled = False self.dev: typing.Optional[ucore.Device] = None - self.cancel = False + self.cancel_event = Event() def open(self, vendor=None, product=None): if vendor is not None and product is not None: @@ -150,14 +151,18 @@ def read_82(self): self.trace('<130< Error: %s' % repr(e)) return None - # FIXME There is a chance of a race condition here - def cancel(self): - self.cancel = True + def request_cancel(self): + """Cancel the current operation without losing an early request.""" + self.cancel_event.set() - def wait_int(self): - self.cancel = False + def clear_cancel(self): + """Arm the USB transport for a new, exclusively-owned operation.""" + self.cancel_event.clear() + def wait_int(self): while True: + if self.cancel_event.is_set(): + raise CancelledException() try: resp = self.dev.read(131, 1024, timeout=100) resp = bytes(resp) @@ -165,7 +170,7 @@ def wait_int(self): return resp except USBError as e: if e.errno == errno.ETIMEDOUT: - if self.cancel: + if self.cancel_event.is_set(): raise CancelledException() else: raise e From c809f5d23b23a7628433fed0d1fe41c3dea96f0f Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:17:36 +0100 Subject: [PATCH 22/27] finish sensor sessions and harden zero-config installs --- bin/validity-sensors-firmware | 58 +++++++++++++++++++++++++-------- dbus_service/dbus-service | 17 ++++++++-- debian/changelog | 21 ++++++++++++ debian/control | 5 +-- debian/python3-validity.service | 6 ++-- debian/python3-validity.udev | 6 +++- tests/test_sensor_lifecycle.py | 32 ++++++++++++++++++ tests/test_state_dir.py | 21 +++++++++++- validitysensor/init_data_dir.py | 24 +++++++++++++- validitysensor/sensor.py | 38 ++++++++++++--------- validitysensor/upload_fwext.py | 4 +-- 11 files changed, 190 insertions(+), 42 deletions(-) create mode 100644 tests/test_sensor_lifecycle.py diff --git a/bin/validity-sensors-firmware b/bin/validity-sensors-firmware index 124fd75..ae685de 100755 --- a/bin/validity-sensors-firmware +++ b/bin/validity-sensors-firmware @@ -29,7 +29,10 @@ import urllib.request from usb import core as usb_core -from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR +from validitysensor.init_data_dir import ( + PYTHON_VALIDITY_FIRMWARE_DIR, + init_data_dir, +) from validitysensor.firmware_tables import FIRMWARE_NAMES, FIRMWARE_URIS from validitysensor.usb import SupportedDevices @@ -47,14 +50,17 @@ def download_and_extract_fw(dev_type, fwdir, fwuri=None): req.add_header('Referer', FIRMWARE_URIS[dev_type].get('referral', '')) req.add_header('User-Agent', 'Mozilla/5.0 (X11; U; Linux)') - hash = hashlib.sha512() + archive_hash = hashlib.sha512() with urllib.request.urlopen(req) as response: with open(fwarchive, 'wb') as out_file: - data = response.read() - hash.update(data) - out_file.write(data) - - actual_hash = hash.hexdigest() + while True: + data = response.read(1024 * 1024) + if not data: + break + archive_hash.update(data) + out_file.write(data) + + actual_hash = archive_hash.hexdigest() if actual_hash != expected_hash: raise Exception('Hash mismatch for driver download! Expected {}, got {}'.format( expected_hash, actual_hash)) @@ -65,27 +71,40 @@ def download_and_extract_fw(dev_type, fwdir, fwuri=None): subprocess.check_call([ 'innoextract', '--output-dir', fwdir, '--include', fwname, '--collisions', 'overwrite', fwarchive - ], stderr=subprocess.DEVNULL) + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except (subprocess.CalledProcessError, FileNotFoundError): try: # No -F filter: HP softpaqs nest the target under e.g. src/driver/INF/x64/, # and cabextract -F matches the full path. Extract everything; the find # call below locates the target file regardless of subdirectory. - subprocess.check_call(['cabextract', '-q', '-d', fwdir, fwarchive]) + subprocess.check_call( + ['cabextract', '-q', '-d', fwdir, fwarchive], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except (subprocess.CalledProcessError, FileNotFoundError) as e: raise Exception( 'Failed to extract {} from {}: neither innoextract nor cabextract ' 'could handle the archive ({}).'.format(fwname, fwarchive, e)) - fwpath = subprocess.check_output(['find', fwdir, '-name', fwname]).decode('utf-8').strip() - print('Found firmware at {}'.format(fwpath)) - - if not fwpath: + fwpath = None + for root, _, files in os.walk(fwdir): + if fwname in files: + fwpath = os.path.join(root, fwname) + break + if fwpath is None: raise Exception('No {} found in the archive'.format(fwname)) return fwpath +def install_firmware(source, destination): + temporary = destination + '.installing' + shutil.copyfile(source, temporary) + os.chmod(temporary, 0o600) + os.replace(temporary, destination) + + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument('--driver-uri') @@ -95,6 +114,8 @@ if __name__ == "__main__": if os.geteuid() != 0: raise Exception('This script needs to be executed as root') + init_data_dir() + dev_type = None for d in SupportedDevices: dev = usb_core.find(idVendor=d.value[0], idProduct=d.value[1]) @@ -104,6 +125,14 @@ if __name__ == "__main__": if not dev_type: raise Exception('No supported validity device found') + destination = os.path.join( + PYTHON_VALIDITY_FIRMWARE_DIR, + FIRMWARE_NAMES[dev_type], + ) + if os.path.isfile(destination): + print('Firmware already cached at {}'.format(destination)) + sys.exit(0) + have_extractor = False for tool in ('innoextract', 'cabextract'): try: @@ -118,4 +147,5 @@ if __name__ == "__main__": with tempfile.TemporaryDirectory() as fwdir: fwpath = download_and_extract_fw(dev_type, fwdir, fwuri=args.driver_uri) - shutil.copy(fwpath, PYTHON_VALIDITY_DATA_DIR) + install_firmware(fwpath, destination) + print('Firmware ready at {}'.format(destination)) diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index febf692..5f79bcb 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -18,6 +18,10 @@ import dbus.mainloop.glib import dbus.service import yaml from gi.repository import GLib +try: + from gi.repository import GLibUnix +except ImportError: # PyGObject versions before the split namespace + GLibUnix = None from usb import core as usb_core from validitysensor import init @@ -27,7 +31,7 @@ from validitysensor.operation import OperationBusy, OperationController from validitysensor.sensor import sensor, RebootException from validitysensor.sid import sid_from_string from validitysensor.tls import tls -from validitysensor.usb import usb +from validitysensor.usb import usb, CancelledException from validitysensor.fingerprint_constants import finger_ids dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) @@ -192,6 +196,9 @@ class Device(dbus.service.Object): logging.exception(e) self.VerifyStatus('verify-no-match', True) loop.quit() + except CancelledException: + logging.info('Fingerprint verification cancelled') + self.VerifyStatus('verify-no-match', True) except Exception as e: logging.exception(e) self.VerifyStatus('verify-no-match', True) @@ -223,6 +230,9 @@ class Device(dbus.service.Object): logging.exception(e) self.EnrollStatus('enroll-failed', True) loop.quit() + except CancelledException: + logging.info('Fingerprint enrollment cancelled') + self.EnrollStatus('enroll-failed', True) except Exception as e: logging.exception(e) self.EnrollStatus('enroll-failed', True) @@ -351,8 +361,9 @@ def main(): logging.info('Caught signal %d. Stopping...' % x) loop.quit() - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, die, signal.SIGINT) - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, die, signal.SIGINT) + signal_add = GLibUnix.signal_add if GLibUnix else GLib.unix_signal_add + signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, die, signal.SIGINT) + signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, die, signal.SIGTERM) try: loop.run() except Exception as e: diff --git a/debian/changelog b/debian/changelog index 8c31b69..c98dfc1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,24 @@ +python-validity (0.16~hp11) noble; urgency=medium + + * Always end the chip-side scan/glow session after identify, including normal + match and no-match returns. Previously only cancellation performed this + teardown, allowing repeated PAM and desktop verifies to accumulate stale + sensor state until a cold boot temporarily restored operation. + * Treat client cancellation as an expected outcome instead of logging a full + error traceback. + * Cache extracted proprietary firmware persistently under /var/lib and + migrate existing /var/run copies, avoiding a download on every reboot. + * Add complete 06cb:00cb udev activation, require the HP CAB extractor, and + restart the service automatically on operational failure. + * Disable verbose protocol tracing in the default systemd service so raw + fingerprint frames and TLS key material are not written to the journal. + * Keep matched internal readers out of USB runtime autosuspend; these chips + do not reliably restore their private capture state after an idle suspend. + * Use GLibUnix.signal_add on current PyGObject releases, with compatibility + fallback, and report SIGTERM correctly instead of labelling it SIGINT. + + -- Dev Tochukwu Mon, 03 Aug 2026 12:30:00 +0100 + python-validity (0.16~hp10) noble; urgency=medium * Make USB cancellation persistent until the owning operation exits, fixing diff --git a/debian/control b/debian/control index 99c2ac9..0a64514 100644 --- a/debian/control +++ b/debian/control @@ -18,8 +18,9 @@ Depends: ${python3:Depends}, python3-yaml, dbus, open-fprintd (>= 0.6~), - innoextract (>= 1.6~) -Recommends: libpam-fprintd, cabextract + innoextract (>= 1.6~), + cabextract +Recommends: libpam-fprintd Description: Validity Fingerprint Sensor DBus Driver This package adds support to some Validity sensors. . diff --git a/debian/python3-validity.service b/debian/python3-validity.service index 93c8c9f..6f747ef 100644 --- a/debian/python3-validity.service +++ b/debian/python3-validity.service @@ -1,11 +1,13 @@ [Unit] Description=python-validity driver dbus service +Wants=open-fprintd.service After=open-fprintd.service [Service] Type=simple -ExecStart=/usr/lib/python-validity/dbus-service --debug -Restart=no +ExecStart=/usr/lib/python-validity/dbus-service +Restart=on-failure +RestartSec=2s [Install] WantedBy=multi-user.target diff --git a/debian/python3-validity.udev b/debian/python3-validity.udev index bb4a74a..b2c0e3c 100644 --- a/debian/python3-validity.udev +++ b/debian/python3-validity.udev @@ -6,6 +6,7 @@ ATTRS{idVendor}=="138a", ATTRS{idProduct}=="0097", GOTO="python_validity_match" ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="009a", GOTO="python_validity_match" ATTRS{idVendor}=="138a", ATTRS{idProduct}=="00ab", GOTO="python_validity_match" ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="00b7", GOTO="python_validity_match" +ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="00cb", GOTO="python_validity_match" GOTO="python_validity_end" @@ -13,7 +14,10 @@ LABEL="python_validity_match" #TAG+="validity" -ACTION=="add|change", ATTR{power/control}="auto", RUN+="/bin/systemctl --no-block start python3-validity.service" +# These internal readers have device-specific runtime state which is not +# reliably restored after USB autosuspend. Keep only matched fingerprint +# devices awake; system suspend is still handled by the D-Bus lifecycle. +ACTION=="add|change", ATTR{power/control}="on", RUN+="/bin/systemctl --no-block start python3-validity.service" ACTION=="remove", RUN+="/bin/systemctl --no-block stop python3-validity.service" #ACTION=="add|change", RUN+="/bin/systemctl --no-block start python3-validity@usb-$env{BUSNUM}-$env{DEVNUM}.service" diff --git a/tests/test_sensor_lifecycle.py b/tests/test_sensor_lifecycle.py new file mode 100644 index 0000000..f30b698 --- /dev/null +++ b/tests/test_sensor_lifecycle.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import patch + +from validitysensor.sensor import sensor +from validitysensor.usb import CancelledException + + +class SensorLifecycleTests(unittest.TestCase): + @patch('validitysensor.sensor.glow_end_scan') + @patch('validitysensor.sensor.glow_start_scan') + def test_identify_always_ends_scan_after_success(self, glow_start, glow_end): + expected = (12, 3, b'hash') + with patch.object(sensor, 'capture'), \ + patch.object(sensor, 'match_finger', return_value=expected): + self.assertEqual(sensor.identify(lambda error: None), expected) + + glow_start.assert_called_once_with() + glow_end.assert_called_once_with() + + @patch('validitysensor.sensor.glow_end_scan') + @patch('validitysensor.sensor.glow_start_scan') + def test_identify_always_ends_scan_after_cancel(self, glow_start, glow_end): + with patch.object(sensor, 'capture', side_effect=CancelledException): + with self.assertRaises(CancelledException): + sensor.identify(lambda error: None) + + glow_start.assert_called_once_with() + glow_end.assert_called_once_with() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_state_dir.py b/tests/test_state_dir.py index 5869e07..8f3dddc 100644 --- a/tests/test_state_dir.py +++ b/tests/test_state_dir.py @@ -2,7 +2,10 @@ import tempfile import unittest -from validitysensor.init_data_dir import migrate_legacy_calibration +from validitysensor.init_data_dir import ( + migrate_legacy_calibration, + migrate_legacy_firmware, +) class CalibrationMigrationTests(unittest.TestCase): @@ -29,6 +32,22 @@ def test_migrates_legacy_calibration_once_without_overwrite(self): with open(state_path, 'rb') as calibration: self.assertEqual(calibration.read(), b'original') + def test_migrates_cached_firmware_out_of_runtime_tmpfs(self): + with tempfile.TemporaryDirectory() as root: + runtime_dir = os.path.join(root, 'run') + firmware_dir = os.path.join(root, 'lib', 'firmware') + os.mkdir(runtime_dir) + legacy_path = os.path.join(runtime_dir, 'sensor.xpfwext') + with open(legacy_path, 'wb') as firmware: + firmware.write(b'firmware') + + migrate_legacy_firmware(runtime_dir, firmware_dir) + + cached_path = os.path.join(firmware_dir, 'sensor.xpfwext') + with open(cached_path, 'rb') as firmware: + self.assertEqual(firmware.read(), b'firmware') + self.assertEqual(os.stat(cached_path).st_mode & 0o777, 0o600) + if __name__ == '__main__': unittest.main() diff --git a/validitysensor/init_data_dir.py b/validitysensor/init_data_dir.py index ec5d78b..ca2827e 100644 --- a/validitysensor/init_data_dir.py +++ b/validitysensor/init_data_dir.py @@ -3,6 +3,7 @@ PYTHON_VALIDITY_DATA_DIR = '/var/run/python-validity/' PYTHON_VALIDITY_STATE_DIR = '/var/lib/python-validity/' +PYTHON_VALIDITY_FIRMWARE_DIR = os.path.join(PYTHON_VALIDITY_STATE_DIR, 'firmware') def migrate_legacy_calibration(runtime_dir=PYTHON_VALIDITY_DATA_DIR, @@ -16,8 +17,29 @@ def migrate_legacy_calibration(runtime_dir=PYTHON_VALIDITY_DATA_DIR, os.replace(temporary_path, state_path) +def migrate_legacy_firmware(runtime_dir=PYTHON_VALIDITY_DATA_DIR, + firmware_dir=PYTHON_VALIDITY_FIRMWARE_DIR): + if not os.path.isdir(runtime_dir): + return + os.makedirs(firmware_dir, mode=0o700, exist_ok=True) + os.chmod(firmware_dir, 0o700) + for name in os.listdir(runtime_dir): + if not name.endswith('.xpfwext'): + continue + source = os.path.join(runtime_dir, name) + destination = os.path.join(firmware_dir, name) + if not os.path.isfile(source) or os.path.exists(destination): + continue + temporary_path = destination + '.migrating' + shutil.copyfile(source, temporary_path) + os.chmod(temporary_path, 0o600) + os.replace(temporary_path, destination) + + def init_data_dir(): - for path in (PYTHON_VALIDITY_DATA_DIR, PYTHON_VALIDITY_STATE_DIR): + for path in (PYTHON_VALIDITY_DATA_DIR, PYTHON_VALIDITY_STATE_DIR, + PYTHON_VALIDITY_FIRMWARE_DIR): os.makedirs(path, mode=0o700, exist_ok=True) os.chmod(path, 0o700) migrate_legacy_calibration() + migrate_legacy_firmware() diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 0bb9f5c..37f208d 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -987,22 +987,28 @@ def match_finger(self) -> typing.Tuple[int, int, bytes]: tls.app(unhexlify('6200000000')) def identify(self, update_cb: typing.Callable[[Exception], None]): - while True: - try: - glow_start_scan() - self.capture(CaptureMode.IDENTIFY) - break - except usb_core.USBError as e: - raise e - except CancelledException as e: - glow_end_scan() - raise e - except Exception as e: - # Capture failed, retry - update_cb(e) - sleep(1) - - return self.match_finger() + try: + while True: + try: + glow_start_scan() + self.capture(CaptureMode.IDENTIFY) + break + except usb_core.USBError as e: + raise e + except CancelledException as e: + raise e + except Exception as e: + # Capture failed, retry + update_cb(e) + sleep(1) + + return self.match_finger() + finally: + # A normal match used to leave scan/glow mode armed indefinitely; + # only cancellation shut it down. Always retire the chip-side scan + # session so repeated PAM and desktop verifies start from a clean + # state instead of degrading until the next cold boot. + glow_end_scan() def get_finger_blobs(self, usrid: int, subtype: int): usr = db.get_user(usrid) diff --git a/validitysensor/upload_fwext.py b/validitysensor/upload_fwext.py index 540297f..d5d3572 100644 --- a/validitysensor/upload_fwext.py +++ b/validitysensor/upload_fwext.py @@ -5,11 +5,11 @@ from .firmware_tables import FIRMWARE_NAMES from .flash import write_flash_all, write_fw_signature, get_fw_info -from .init_data_dir import PYTHON_VALIDITY_DATA_DIR +from .init_data_dir import PYTHON_VALIDITY_FIRMWARE_DIR from .sensor import reboot, write_hw_reg32, read_hw_reg32, identify_sensor from .usb import usb, SupportedDevices -firmware_home = PYTHON_VALIDITY_DATA_DIR +firmware_home = PYTHON_VALIDITY_FIRMWARE_DIR def default_fwext_name(): From ee925fd0605e7367851fe9076cfa4ffdb40272e8 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:27:30 +0100 Subject: [PATCH 23/27] retry explicit no-template results across d51 variants --- dbus_service/dbus-service | 21 +++++++++++-- debian/changelog | 8 +++++ tests/test_match_interrupts.py | 28 +++++++++++++++++ tests/test_verification.py | 55 ++++++++++++++++++++++++++++++++++ validitysensor/sensor.py | 19 ++++++++++++ validitysensor/verification.py | 16 ++++++++++ 6 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_match_interrupts.py create mode 100644 tests/test_verification.py create mode 100644 validitysensor/verification.py diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 5f79bcb..6fca101 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -28,10 +28,11 @@ from validitysensor import init from validitysensor.db import subtype_to_string, db, SidIdentity, User from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR, init_data_dir from validitysensor.operation import OperationBusy, OperationController -from validitysensor.sensor import sensor, RebootException +from validitysensor.sensor import sensor, RebootException, FingerNotMatchedException from validitysensor.sid import sid_from_string from validitysensor.tls import tls from validitysensor.usb import usb, CancelledException +from validitysensor.verification import identify_with_retries from validitysensor.fingerprint_constants import finger_ids dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) @@ -185,13 +186,29 @@ class Device(dbus.service.Object): retry_emitted[0] = True def run(): + max_match_attempts = 3 + def clean_no_match(attempt, maximum): + logging.info( + 'Valid fingerprint capture matched no template (%d/%d)', + attempt, maximum) + self.VerifyStatus('verify-no-match', False) + try: # TODO: pass down the user db record id and implement a proper Sensor.verify() method - usrid, subtype, hsh = sensor.identify(update_cb) + usrid, subtype, hsh = identify_with_retries( + sensor.identify, + update_cb, + clean_no_match, + max_attempts=max_match_attempts, + ) if usr.dbid == usrid: self.VerifyStatus('verify-match', True) else: + # A different user's (or Windows Hello) template really + # matched; retrying cannot change template ownership. self.VerifyStatus('verify-no-match', True) + except FingerNotMatchedException: + self.VerifyStatus('verify-no-match', True) except usb_core.USBError as e: logging.exception(e) self.VerifyStatus('verify-no-match', True) diff --git a/debian/changelog b/debian/changelog index c98dfc1..83bd7f2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +python-validity (0.16~hp12) noble; urgency=medium + + * Recognize the sensor-specific no-template interrupts (5 on 0xd51, 4 on + 0x969) and permit up to three physical scans for occasional on-chip matcher + false negatives. Matches owned by another user still fail immediately. + + -- Dev Tochukwu Mon, 03 Aug 2026 12:45:00 +0100 + python-validity (0.16~hp11) noble; urgency=medium * Always end the chip-side scan/glow session after identify, including normal diff --git a/tests/test_match_interrupts.py b/tests/test_match_interrupts.py new file mode 100644 index 0000000..67406f5 --- /dev/null +++ b/tests/test_match_interrupts.py @@ -0,0 +1,28 @@ +import unittest +from unittest.mock import patch + +from validitysensor.sensor import FingerNotMatchedException, sensor + + +class MatchInterruptTests(unittest.TestCase): + def assert_no_match_interrupt(self, sensor_type, interrupt): + original_type = getattr(sensor, 'real_device_type', None) + sensor.real_device_type = sensor_type + try: + with patch('validitysensor.sensor.tls.app', return_value=b'\x00\x00'), \ + patch('validitysensor.sensor.usb.wait_int', + return_value=bytes([interrupt, 0, 1, 0])): + with self.assertRaises(FingerNotMatchedException): + sensor.match_finger() + finally: + sensor.real_device_type = original_type + + def test_d51_uses_interrupt_5_for_no_template(self): + self.assert_no_match_interrupt(0xd51, 5) + + def test_969_uses_interrupt_4_for_no_template(self): + self.assert_no_match_interrupt(0x969, 4) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_verification.py b/tests/test_verification.py new file mode 100644 index 0000000..b4cf6e9 --- /dev/null +++ b/tests/test_verification.py @@ -0,0 +1,55 @@ +import unittest + +from validitysensor.sensor import FingerNotMatchedException +from validitysensor.verification import identify_with_retries + + +class VerificationRetryTests(unittest.TestCase): + def test_false_negative_can_recover_on_later_physical_scan(self): + outcomes = [FingerNotMatchedException(), (7, 3, b'hash')] + retries = [] + + def identify(_update_cb): + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + result = identify_with_retries( + identify, lambda error: None, lambda *args: retries.append(args)) + + self.assertEqual(result, (7, 3, b'hash')) + self.assertEqual(retries, [(1, 3)]) + + def test_stops_after_exactly_three_clean_no_matches(self): + calls = [] + retries = [] + + def identify(_update_cb): + calls.append(1) + raise FingerNotMatchedException() + + with self.assertRaises(FingerNotMatchedException): + identify_with_retries( + identify, lambda error: None, + lambda *args: retries.append(args), max_attempts=3) + + self.assertEqual(len(calls), 3) + self.assertEqual(retries, [(1, 3), (2, 3)]) + + def test_does_not_retry_unrelated_sensor_failure(self): + calls = [] + + def identify(_update_cb): + calls.append(1) + raise RuntimeError('transport failed') + + with self.assertRaises(RuntimeError): + identify_with_retries( + identify, lambda error: None, lambda *args: None) + + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 37f208d..7587635 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -23,6 +23,11 @@ # TODO: this should be specific to an individual device (system may have more than one sensor) calib_data_path = PYTHON_VALIDITY_STATE_DIR + 'calib-data.bin' + +class FingerNotMatchedException(Exception): + """A valid capture completed, but no on-chip template matched.""" + + line_update_type1_devices = [ 0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199, 0xD51, # HP EliteBook 840 G5 (138a:00ab) / HP G6 series (06cb:00b7) @@ -963,6 +968,20 @@ def match_finger(self) -> typing.Tuple[int, int, bytes]: assert_status(rsp) b = usb.wait_int() + + # These related chips use different interrupts for a successful + # high-quality capture that matched no enrolled template. Keep + # this distinct from capture-quality failures so the D-Bus layer + # can offer a bounded retry for occasional matcher false negatives. + no_match_interrupt = { + 0xd51: 5, + 0x969: 4, + }.get(getattr(self, 'real_device_type', None)) + if no_match_interrupt is not None and b[0] == no_match_interrupt: + raise FingerNotMatchedException( + 'No-template interrupt for sensor 0x%x: %s' + % (self.real_device_type, hexlify(b).decode())) + if b[0] != 3: raise Exception('Finger not recognized: %s' % hexlify(b).decode()) diff --git a/validitysensor/verification.py b/validitysensor/verification.py new file mode 100644 index 0000000..4349881 --- /dev/null +++ b/validitysensor/verification.py @@ -0,0 +1,16 @@ +from .sensor import FingerNotMatchedException + + +def identify_with_retries(identify, update_cb, retry_cb, max_attempts=3): + """Retry only clean on-chip no-template results, up to a fixed limit.""" + if max_attempts < 1: + raise ValueError('max_attempts must be at least one') + + for attempt in range(1, max_attempts + 1): + try: + return identify(update_cb) + except FingerNotMatchedException: + if attempt == max_attempts: + raise + retry_cb(attempt, max_attempts) + From 9ca5fc398feb1852682c12251f7e18363fd6c999 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:35:07 +0100 Subject: [PATCH 24/27] reset scan state between rejected capture attempts --- debian/changelog | 11 +++++++++++ tests/test_sensor_lifecycle.py | 16 ++++++++++++++++ validitysensor/sensor.py | 22 +++++++++++----------- validitysensor/verification.py | 1 - 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/debian/changelog b/debian/changelog index 83bd7f2..bec3de2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +python-validity (0.16~hp13) noble; urgency=medium + + * Pair every glow/scan start with a stop before retrying a rejected capture. + The previous whole-verification teardown still stacked chip-side scan + sessions when poor-contact frames repeated, recreating the quality-gate + wedge after enough retries. + * Add a regression test that rejects one capture, succeeds on the next, and + proves both attempts receive independent start/stop lifecycle pairs. + + -- Dev Tochukwu Mon, 03 Aug 2026 13:00:00 +0100 + python-validity (0.16~hp12) noble; urgency=medium * Recognize the sensor-specific no-template interrupts (5 on 0xd51, 4 on diff --git a/tests/test_sensor_lifecycle.py b/tests/test_sensor_lifecycle.py index f30b698..17af9a3 100644 --- a/tests/test_sensor_lifecycle.py +++ b/tests/test_sensor_lifecycle.py @@ -27,6 +27,22 @@ def test_identify_always_ends_scan_after_cancel(self, glow_start, glow_end): glow_start.assert_called_once_with() glow_end.assert_called_once_with() + @patch('validitysensor.sensor.sleep') + @patch('validitysensor.sensor.glow_end_scan') + @patch('validitysensor.sensor.glow_start_scan') + def test_each_rejected_capture_ends_before_retrying( + self, glow_start, glow_end, _sleep): + expected = (12, 3, b'hash') + rejected = RuntimeError('capture quality rejected') + updates = [] + with patch.object(sensor, 'capture', side_effect=[rejected, None]), \ + patch.object(sensor, 'match_finger', return_value=expected): + self.assertEqual(sensor.identify(updates.append), expected) + + self.assertEqual(updates, [rejected]) + self.assertEqual(glow_start.call_count, 2) + self.assertEqual(glow_end.call_count, 2) + if __name__ == '__main__': unittest.main() diff --git a/validitysensor/sensor.py b/validitysensor/sensor.py index 7587635..c3c2bf8 100644 --- a/validitysensor/sensor.py +++ b/validitysensor/sensor.py @@ -1006,12 +1006,11 @@ def match_finger(self) -> typing.Tuple[int, int, bytes]: tls.app(unhexlify('6200000000')) def identify(self, update_cb: typing.Callable[[Exception], None]): - try: - while True: + while True: + glow_start_scan() + try: try: - glow_start_scan() self.capture(CaptureMode.IDENTIFY) - break except usb_core.USBError as e: raise e except CancelledException as e: @@ -1020,14 +1019,15 @@ def identify(self, update_cb: typing.Callable[[Exception], None]): # Capture failed, retry update_cb(e) sleep(1) + continue - return self.match_finger() - finally: - # A normal match used to leave scan/glow mode armed indefinitely; - # only cancellation shut it down. Always retire the chip-side scan - # session so repeated PAM and desktop verifies start from a clean - # state instead of degrading until the next cold boot. - glow_end_scan() + return self.match_finger() + finally: + # Pair every scan start with a stop, including each rejected + # capture before the next retry. Deferring this until the whole + # identify call exits stacks scan sessions during poor-contact + # retries and eventually wedges the chip's quality gate. + glow_end_scan() def get_finger_blobs(self, usrid: int, subtype: int): usr = db.get_user(usrid) diff --git a/validitysensor/verification.py b/validitysensor/verification.py index 4349881..9636a10 100644 --- a/validitysensor/verification.py +++ b/validitysensor/verification.py @@ -13,4 +13,3 @@ def identify_with_retries(identify, update_cb, retry_cb, max_attempts=3): if attempt == max_attempts: raise retry_cb(attempt, max_attempts) - From adc6d98d091108058069ab7f317a64d1049ee091 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:37:34 +0100 Subject: [PATCH 25/27] retry open-fprintd registration across startup races --- dbus_service/dbus-service | 26 ++++++++++--- tests/test_registration.py | 69 ++++++++++++++++++++++++++++++++++ validitysensor/registration.py | 35 +++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 tests/test_registration.py create mode 100644 validitysensor/registration.py diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 6fca101..034ca61 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -28,6 +28,7 @@ from validitysensor import init from validitysensor.db import subtype_to_string, db, SidIdentity, User from validitysensor.init_data_dir import PYTHON_VALIDITY_DATA_DIR, init_data_dir from validitysensor.operation import OperationBusy, OperationController +from validitysensor.registration import RetryingRegistrar from validitysensor.sensor import sensor, RebootException, FingerNotMatchedException from validitysensor.sid import sid_from_string from validitysensor.tls import tls @@ -360,14 +361,29 @@ def main(): svc = Device(bus, config) + def register_device(name): + mgr = bus.get_object(name, '/net/reactivated/Fprint/Manager') + mgr = dbus.Interface(mgr, 'net.reactivated.Fprint.Manager') + mgr.RegisterDevice(svc) + logging.info('Fingerprint device registered with open-fprintd') + + def registration_failed(error, attempt, delay): + logging.warning( + 'open-fprintd registration attempt %d failed: %s; retrying in %ds', + attempt, error, delay) + + registrar = RetryingRegistrar( + register_device, + GLib.timeout_add_seconds, + registration_failed, + ) + def watch_cb(name): if name == '': - logging.debug('Manager is offline') + logging.info('open-fprintd manager is offline') else: - logging.debug('Manager is back online, registering') - mgr = bus.get_object(name, '/net/reactivated/Fprint/Manager') - mgr = dbus.Interface(mgr, 'net.reactivated.Fprint.Manager') - mgr.RegisterDevice(svc) + logging.info('open-fprintd manager is online') + registrar.owner_changed(name) watcher = bus.watch_name_owner('net.reactivated.Fprint', watch_cb) diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..ed20b9d --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,69 @@ +import unittest + +from validitysensor.registration import RetryingRegistrar + + +class RegistrationRetryTests(unittest.TestCase): + def test_retries_startup_race_then_registers(self): + attempts = [] + scheduled = [] + errors = [] + + def register(owner): + attempts.append(owner) + if len(attempts) == 1: + raise RuntimeError('manager not ready') + + registrar = RetryingRegistrar( + register, + lambda delay, callback, *args: scheduled.append( + (delay, callback, args)), + lambda *args: errors.append(args), + ) + registrar.owner_changed(':1.10') + + self.assertEqual(attempts, [':1.10']) + self.assertEqual(errors[0][1:], (1, 1)) + delay, callback, args = scheduled.pop() + self.assertEqual(delay, 1) + self.assertFalse(callback(*args)) + self.assertEqual(attempts, [':1.10', ':1.10']) + self.assertEqual(scheduled, []) + + def test_owner_change_invalidates_scheduled_retry(self): + attempts = [] + scheduled = [] + + def register(owner): + attempts.append(owner) + if owner == ':1.10': + raise RuntimeError('old manager disappeared') + + registrar = RetryingRegistrar( + register, + lambda delay, callback, *args: scheduled.append( + (callback, args)), + ) + registrar.owner_changed(':1.10') + old_callback, old_args = scheduled.pop() + registrar.owner_changed(':1.11') + + self.assertFalse(old_callback(*old_args)) + self.assertEqual(attempts, [':1.10', ':1.11']) + + def test_retry_delay_is_capped(self): + scheduled = [] + registrar = RetryingRegistrar( + lambda owner: (_ for _ in ()).throw(RuntimeError('not ready')), + lambda delay, callback, *args: scheduled.append(delay), + initial_delay=2, + max_delay=30, + ) + + registrar.owner_changed(':1.10') + registrar._attempt(registrar.generation, 10) + self.assertEqual(scheduled, [2, 30]) + + +if __name__ == '__main__': + unittest.main() diff --git a/validitysensor/registration.py b/validitysensor/registration.py new file mode 100644 index 0000000..b53a34f --- /dev/null +++ b/validitysensor/registration.py @@ -0,0 +1,35 @@ +class RetryingRegistrar: + """Register against a replaceable D-Bus owner with capped backoff.""" + + def __init__(self, register, schedule, on_error=None, + initial_delay=1, max_delay=30): + self.register = register + self.schedule = schedule + self.on_error = on_error or (lambda error, attempt, delay: None) + self.initial_delay = initial_delay + self.max_delay = max_delay + self.generation = 0 + self.owner = '' + + def owner_changed(self, owner): + self.generation += 1 + self.owner = owner + if owner: + self._attempt(self.generation, 1) + + def _attempt(self, generation, attempt): + # GLib timeout callbacks must return False to run only once. A stale + # callback from a previous D-Bus owner must never register against the + # replacement manager. + if generation != self.generation or not self.owner: + return False + try: + self.register(self.owner) + except Exception as error: + delay = min( + self.initial_delay * (2 ** (attempt - 1)), + self.max_delay, + ) + self.on_error(error, attempt, delay) + self.schedule(delay, self._attempt, generation, attempt + 1) + return False From a4fc26c1e2bcf741a36e592c7258ae0945f20613 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 12:41:31 +0100 Subject: [PATCH 26/27] debian: package startup registration retry as hp14 --- debian/changelog | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/debian/changelog b/debian/changelog index bec3de2..0c5841f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +python-validity (0.16~hp14) noble; urgency=medium + + * Retry open-fprintd device registration when fprintd appears before the + python-validity D-Bus service is ready. Registration now uses bounded + exponential backoff, ignores callbacks from stale owner generations, and + remains idempotent across service restarts, eliminating a boot-time race + that previously required a manual daemon restart. + + -- Dev Tochukwu Mon, 03 Aug 2026 14:00:00 +0100 + python-validity (0.16~hp13) noble; urgency=medium * Pair every glow/scan start with a stop before retrying a rejected capture. From 9ee51a880011df932ea6e7beabfe86d5e0b17361 Mon Sep 17 00:00:00 2001 From: SimpleX-T Date: Mon, 3 Aug 2026 15:56:55 +0100 Subject: [PATCH 27/27] add read-only clean-slate sensor probe --- scripts/clean-slate-probe.py | 67 +++++++++++++++++++++++ tests/test_clean_slate_probe.py | 54 +++++++++++++++++++ validitysensor/clean_slate_probe.py | 82 +++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 scripts/clean-slate-probe.py create mode 100644 tests/test_clean_slate_probe.py create mode 100644 validitysensor/clean_slate_probe.py diff --git a/scripts/clean-slate-probe.py b/scripts/clean-slate-probe.py new file mode 100644 index 0000000..7c23e7b --- /dev/null +++ b/scripts/clean-slate-probe.py @@ -0,0 +1,67 @@ +#!/usr/bin/python3 +"""Collect non-destructive metadata from a zero-partition sensor.""" + +import json +import sys + +import usb.core +import usb.util + +from validitysensor.clean_slate_probe import probe +from validitysensor.usb import supported_devices + + +def find_device(): + devices = list(usb.core.find( + find_all=True, + custom_match=lambda candidate: ( + candidate.idVendor, candidate.idProduct) in supported_devices, + )) + if len(devices) != 1: + raise RuntimeError( + 'Expected exactly one supported sensor, found %d' % len(devices)) + return devices[0] + + +def main(): + device = find_device() + if device.is_kernel_driver_active(0): + raise RuntimeError( + 'A kernel driver owns interface 0; refusing to detach it in a ' + 'read-only probe') + + try: + # Do not call set_configuration() or reset(): the service has already + # configured the USB device, and this probe must preserve its state. + device.get_active_configuration() + usb.util.claim_interface(device, 0) + + def command(request): + device.write(0x01, request) + return bytes(device.read(0x81, 100 * 1024)) + + result = { + 'schema': 1, + 'usb': { + 'vendor_id': device.idVendor, + 'product_id': device.idProduct, + 'bcd_device': device.bcdDevice, + 'bus': device.bus, + 'address': device.address, + }, + 'commands': probe(command), + } + print(json.dumps(result, indent=2, sort_keys=True)) + finally: + try: + usb.util.release_interface(device, 0) + finally: + usb.util.dispose_resources(device) + + +if __name__ == '__main__': + try: + main() + except Exception as error: + print('clean-slate probe failed: %s' % error, file=sys.stderr) + sys.exit(1) diff --git a/tests/test_clean_slate_probe.py b/tests/test_clean_slate_probe.py new file mode 100644 index 0000000..0b4ff94 --- /dev/null +++ b/tests/test_clean_slate_probe.py @@ -0,0 +1,54 @@ +import unittest +from struct import pack + +from validitysensor.clean_slate_probe import ( + READ_ONLY_COMMANDS, + decode_response, + probe, +) + + +class CleanSlateProbeTests(unittest.TestCase): + def test_command_set_is_read_only_and_fixed(self): + self.assertEqual( + READ_ONLY_COMMANDS, + ( + ('flash-info', b'\x3e'), + ('rom-info', b'\x01'), + ('sensor-identity', b'\x75'), + ('firmware-info', b'\x43\x02'), + ), + ) + + def test_decodes_zero_partition_flash(self): + response = b'\0\0' + pack( + '= 14: + jid0, jid1, blocks, unknown0, blocksize, unknown1, partitions = unpack( + '= 16: + timestamp, build, major, minor, product, unknown = unpack( + '