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: diff --git a/dbus_service/dbus-service b/dbus_service/dbus-service index 1c63e8d..98909e6 100755 --- a/dbus_service/dbus-service +++ b/dbus_service/dbus-service @@ -114,8 +114,27 @@ 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] + retry_count = [0] + def update_cb(e): - self.VerifyStatus('verify-retry-scan', False) + # 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 def run(): try: diff --git a/debian/changelog b/debian/changelog index 8d7c453..c3becb3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,102 @@ +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): + - 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, + __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 + 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 + 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 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/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" 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..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' } } @@ -29,5 +39,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..7ed5e79 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) @@ -254,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:] @@ -702,14 +729,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()) @@ -811,11 +847,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() diff --git a/validitysensor/usb.py b/validitysensor/usb.py index 464b092..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 @@ -18,6 +19,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): @@ -61,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()