Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b850fd1
Add support for sensor type 0xd51 (138a:00ab, 06cb:00b7)
SimpleX-T May 19, 2026
20df336
Add FIRMWARE_URIS entries for DEV_AB and DEV_B7
SimpleX-T May 20, 2026
4ac8bbd
Replace existing finger record on same-subtype re-enroll
SimpleX-T May 21, 2026
90d5d67
validity-sensors-firmware: fall back to cabextract for non-Inno archives
SimpleX-T May 21, 2026
d1936a1
Debian packaging: PAM auto-enable, cabextract dep, version 0.16~hp3
SimpleX-T May 21, 2026
bc5dd32
Emit verify-retry-scan at most once per VerifyStart cycle
SimpleX-T May 21, 2026
122a147
Exclude local build artifacts from the source tarball
SimpleX-T May 21, 2026
baec76e
Add diagnostic logging for chip geometry and per-verify retry count
SimpleX-T May 21, 2026
cd0def0
usb.py: defensive USB reset at open_dev() entry
SimpleX-T May 24, 2026
0c00f0a
Add sensor type 0x969 (HP ZBook Studio x360 G5, 138a:00ab)
ggiesen Jul 6, 2026
3b23194
sensor.py: recover 0x969 chips reporting 0x199 post-resume
SimpleX-T Jul 7, 2026
4ba618b
Surface 0404 at reset_blob with an actionable error
SimpleX-T Jul 7, 2026
82584e1
db.py: fix del_record; raise a clear error when the DB partition is full
SimpleX-T Jul 7, 2026
2f4dc2e
README: document 0xd51/0x969 caveats surfaced by PR #256 reviewers
SimpleX-T Jul 7, 2026
7925b97
dbus-service: watchdog on VerifyStart for wedged-chip state
SimpleX-T Jul 7, 2026
a825eaf
debian/changelog: 0.16~hp8 for noble
SimpleX-T Jul 7, 2026
b790263
Add clean no-match handling for 0xd51 and 0x969 sensors
Karloss1234 Jul 30, 2026
669af11
Add fingerprint retry handling for clean no-match
Karloss1234 Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 27 additions & 8 deletions bin/validity-sensors-firmware
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down
94 changes: 80 additions & 14 deletions dbus_service/dbus-service
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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.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
Expand Down Expand Up @@ -114,23 +114,89 @@ 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]

# 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):
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 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

def run():
try:
# TODO: pass down the user db record id and implement a proper Sensor.verify() method
usrid, subtype, hsh = sensor.identify(update_cb)
if usr.dbid == usrid:
self.VerifyStatus('verify-match', True)
else:
max_attempts = 3
attempts = 0
while attempts < max_attempts:
try:
# TODO: pass down the user db record id and implement a proper Sensor.verify() method
usrid, subtype, hsh = sensor.identify(update_cb)
if usr.dbid == usrid:
self.VerifyStatus('verify-match', True)
break
else:
logging.info("Fingerprint matched another user template")
self.VerifyStatus('verify-no-match', True)
break
except FingerNotMatchedException:
attempts += 1
logging.info(
"Fingerprint captured but no template found (%d/%d)",
attempts,
max_attempts
)
self.VerifyStatus('verify-no-match', False)
time.sleep(1)
continue
except usb_core.USBError as e:
logging.exception(e)
self.VerifyStatus('verify-no-match', True)
except usb_core.USBError as e:
logging.exception(e)
self.VerifyStatus('verify-no-match', True)
loop.quit()
except Exception as e:
logging.exception(e)
loop.quit()
break
except Exception as e:
logging.exception(e)
self.VerifyStatus('verify-no-match', True)
time.sleep(1)
continue
if attempts >= max_attempts:
logging.info("Maximum fingerprint attempts reached")
self.VerifyStatus('verify-no-match', True)

thread = Thread(target=run)
Expand Down
Loading