parent
1934822554
commit
2da03d1595
1 changed files with 8 additions and 127 deletions
135
README.md
135
README.md
|
|
@ -2,44 +2,6 @@
|
||||||
|
|
||||||
Fingerprint daemon for the Fairphone 6 (`milos`, SM7635) on mainline Linux.
|
Fingerprint daemon for the Fairphone 6 (`milos`, SM7635) on mainline Linux.
|
||||||
|
|
||||||
## Why a daemon
|
|
||||||
|
|
||||||
The sensor is a FocalTech FT9391 on a TrustZone-owned SPI bus. `spi@a88000` is
|
|
||||||
`disabled` in both the mainline and the stock Android device tree, and the pads
|
|
||||||
are XPU-protected — touching them from the normal world is an instant SError
|
|
||||||
reboot. Every pixel the sensor produces stays inside the TEE: capture,
|
|
||||||
preprocessing, the classifier, enrolment and matching all run in the `focal64`
|
|
||||||
trustlet, which reports a matched finger id and nothing else. A libfprint-style
|
|
||||||
driver cannot exist on this device.
|
|
||||||
|
|
||||||
So the normal world's job is narrower than usual, and none of it is per-request
|
|
||||||
work:
|
|
||||||
|
|
||||||
* **Power the sensor.** Rail on gpio29, reset on gpio74, interrupt on gpio75 —
|
|
||||||
the same division of labour the downstream driver uses. One sensor reset buys
|
|
||||||
exactly one trustlet init, so whatever powers the sensor must also hold the
|
|
||||||
session open.
|
|
||||||
* **Be QTEE's filesystem.** QTEE cannot reach storage. When the trustlet saves
|
|
||||||
or loads a template it calls back into the normal world through the `gpfile`
|
|
||||||
(0x7000) and RPMB (0x2000) listeners, and expects them served. QTEE does the
|
|
||||||
crypto and the anti-rollback; this side moves opaque bytes and performs the
|
|
||||||
authenticated RPMB transactions against the UFS device.
|
|
||||||
* **Speak a biometrics API.** The daemon owns `net.reactivated.Fprint`, so
|
|
||||||
`pam_fprintd`, the Plasma fingerprint KCM and `fprintd-enroll(1)` work
|
|
||||||
against it unmodified.
|
|
||||||
|
|
||||||
A listener registration is held for as long as the process lives and QTEE's
|
|
||||||
listener table is global to the boot, so this has to be one long-lived process
|
|
||||||
rather than a tool spawned per request.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
interfaces/ Fingerprintd{,-Sfs}.cppm the core: pure C++ modules
|
|
||||||
implementations/main.cpp the daemon shell
|
|
||||||
tests/ one suite per core module
|
|
||||||
```
|
|
||||||
|
|
||||||
`fingerprintd-core` is a static library with no GLib, no libqcomtee and no
|
`fingerprintd-core` is a static library with no GLib, no libqcomtee and no
|
||||||
system headers. Everything in it is a wire format or a state machine that was
|
system headers. Everything in it is a wire format or a state machine that was
|
||||||
recovered by reverse-engineering, so all of it is pinned by tests that run on a
|
recovered by reverse-engineering, so all of it is pinned by tests that run on a
|
||||||
|
|
@ -73,11 +35,6 @@ to a real Alpine sysroot.
|
||||||
Verified on the device: all five suites pass cross-built and run **on the phone
|
Verified on the device: all five suites pass cross-built and run **on the phone
|
||||||
itself**, not only under emulation.
|
itself**, not only under emulation.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
**It works end to end through fprintd's own clients.** On the Fairphone 6, as a
|
|
||||||
systemd unit owning `net.reactivated.Fprint`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
fprintd-enroll -f right-index-finger user ten stages, enroll-completed
|
fprintd-enroll -f right-index-finger user ten stages, enroll-completed
|
||||||
fprintd-list user - #0: right-index-finger
|
fprintd-list user - #0: right-index-finger
|
||||||
|
|
@ -85,92 +42,16 @@ fprintd-verify user (wrong finger) verify-no-match, on the first press
|
||||||
fprintd-verify user (enrolled finger) verify-match, on the first press
|
fprintd-verify user (enrolled finger) verify-match, on the first press
|
||||||
```
|
```
|
||||||
|
|
||||||
Nothing in fprintd was modified; the daemon speaks its interface.
|
|
||||||
|
|
||||||
| module | what it holds |
|
|
||||||
|---|---|
|
|
||||||
| `:Sfs` | the gpfile frame — the read/write offset split, the `O_TRUNC` guard, root mapping, path-traversal rejection |
|
|
||||||
| `:Rpmb` | request/reply framing, the bytes-transferred out-parameter, JEDEC result codes, chunking, the one-time-programmable key guard |
|
|
||||||
| `:Ta` | command surface, the 740-byte event context, capture flags, save masks, enrol/auth payloads, the error table, the verdict rule |
|
|
||||||
| `:Engine` | baseline calibration, touch edges, enrolment progress, the accounting |
|
|
||||||
| `:Store` | the finger name map |
|
|
||||||
| `:Tee` | QTEE service/op numbers, the listener table, the 13-byte CBOR credentials blob |
|
|
||||||
| `:Sensor` | the pins, the timings, and the XPU guard |
|
|
||||||
|
|
||||||
Every constant that was recovered by reverse-engineering carries where it came
|
|
||||||
from, and the tests are written to fail if it is undone rather than to restate
|
|
||||||
it. Several replay real captures off the phone.
|
|
||||||
|
|
||||||
Two policy decisions live in the daemon and both were forced by measurement.
|
|
||||||
Verification is judged **per press**: any matching frame wins, only rejections
|
|
||||||
is no-match — both correct presses in the acceptance run had rejected frames
|
|
||||||
before the one that matched, so a first-frame rule would have failed them. And
|
|
||||||
the shipped config sets `max_authentication_rescan_times` to 0, because at the
|
|
||||||
stock budget a wrong finger never yields a terminal frame and a PAM client
|
|
||||||
waits forever for the `verify-no-match` it needs.
|
|
||||||
|
|
||||||
**Template learning is implemented and OFF, because it makes matching worse on
|
|
||||||
this hardware.** Measured, with learning disabled during each measurement so
|
|
||||||
nothing moved underneath the numbers:
|
|
||||||
|
|
||||||
| template | folds | rate |
|
|
||||||
|---|---|---|
|
|
||||||
| fresh 20-sample enrolment | 0 | **30/30** |
|
|
||||||
| same lineage, later | 40 | 12/15 |
|
|
||||||
| same lineage, later still | 185 | total failure |
|
|
||||||
|
|
||||||
The frames one press contributes are near-duplicates of a single image from one
|
|
||||||
finger position. Folding them spends the template's 96 slots on that position
|
|
||||||
and evicts the diversity the enrolment put there. There is no upside to weigh
|
|
||||||
against it either: a plain enrolment already measures 30/30, and learning has
|
|
||||||
never once been observed to raise a rate under controlled conditions.
|
|
||||||
|
|
||||||
`--learn=1` enables it for experiments. What follows describes how it works.
|
|
||||||
|
|
||||||
**Template learning.** Stock rewrites the stored template on every successful
|
|
||||||
press — `0x1015 UPDATE_TEMPLATE` while the finger is still down, then a deferred
|
|
||||||
`SAVE_DATA` — and the stored body measurably grows over a template's life
|
|
||||||
(333278 bytes at enrolment to 371734 after one authentication session on the
|
|
||||||
reference device). This daemon does the same as of 0.1.0. It matters more than
|
|
||||||
any tuning knob: before it, every match rate measured against this device was
|
|
||||||
measured against a day-zero template that no stock user lives with.
|
|
||||||
|
|
||||||
The harvest sends no event, so the matcher does not re-run and a verdict cannot
|
|
||||||
be revised by it. A quick tap pays almost nothing, because the finger is gone by
|
|
||||||
the time a verdict lands; a held press contributes the frames it was held for —
|
|
||||||
which means the frames learned from are, by construction, frames from real
|
|
||||||
unlock presses. The save is deferred until after the verdict reaches the client,
|
|
||||||
because ~350 ms of RPMB traffic does not belong on an unlock path; stock defers
|
|
||||||
it the same way. `--learn=0` turns the whole thing off so the comparison can be
|
|
||||||
made on one binary, which is the only way it is single-variable.
|
|
||||||
|
|
||||||
**Reading the trustlet.** There is no `tzdbg` on mainline, but focal64 writes
|
|
||||||
its log into the response buffer, so `--ta-log` surfaces the matcher's own
|
|
||||||
verdicts (`auth success score`, `identify fail! FtVerifyByTemplate() = -2`, the
|
|
||||||
per-frame `image quality / coverage / humidity`). The log ring is ~150 lines per
|
|
||||||
session and is never reset, so the config dump alone can overflow it: the
|
|
||||||
shipped verbose config leaves the framework log off to reserve the ring for the
|
|
||||||
algorithm. Lower levels are more verbose and 6 is off.
|
|
||||||
|
|
||||||
**Known about the loop, from real use.** A verify frame is four QTEE round
|
|
||||||
trips (~200 ms idle); a `REPORT_EVENT` that runs the matcher is ~300 ms, and
|
|
||||||
the rising edge pays it twice because events 5 and 7 both reach the matcher.
|
|
||||||
The first frame of a press is the finger landing and usually rejects; matches
|
|
||||||
land at frame 3, 5, 8 — so a quick tap is often a miss. Two attempts to fix
|
|
||||||
that (drop event 5; recapture after 50 ms) went in together and produced zero
|
|
||||||
matches; both are reverted and recorded. gpio75 is a ~1 ms pulse, not a
|
|
||||||
level — an IRQ-driven idle needs GPIO edge events. Changes to this loop are
|
|
||||||
made one variable at a time, on a measured baseline.
|
|
||||||
|
|
||||||
**Not done:** packaging (`provides="fprintd=…"` so this replaces the fprintd
|
|
||||||
daemon package while `fprintd-pam` stays), the shipped storage policy, polkit
|
|
||||||
(a caller-uid rule stands in), trustlet-side template removal (deletes drop the
|
|
||||||
name only), and the kernel config change — `CONFIG_QCOMTEE`, which selects the
|
|
||||||
SHM bridge — that gates any public image. A claim held by a client that leaves
|
|
||||||
the bus is dropped.
|
|
||||||
|
|
||||||
## Runtime dependencies, not carried here
|
## Runtime dependencies, not carried here
|
||||||
|
|
||||||
The `focal64` trustlet is proprietary and is **not** in this repo. It is
|
The `focal64` trustlet is proprietary and is **not** in this repo. It is
|
||||||
extracted from the device's own stock Android partition on first boot by the
|
extracted from the device's own stock Android partition on first boot by the
|
||||||
`fp6-vendor-blobs` mechanism, the same way the audio firmware is.
|
`fp6-vendor-blobs` mechanism, the same way the audio firmware is.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0-only, see LICENSE.
|
||||||
|
|
||||||
|
## Copyright
|
||||||
|
|
||||||
|
Copyright (C) 2026 Catcrafts® catcrafts.net
|
||||||
Loading…
Reference in a new issue