From b228287c5bc999805c3a2b09a8fd741c80158892 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 5 Sep 2026 04:01:06 +0200 Subject: [PATCH] A verify nobody answered is not a failure, and the trustlet is not ours to ship Two things the packaging left behind. A verify that ran its 600-frame budget without the sensor being touched was reported to the client as verify-unknown-error. Nothing had gone wrong: nobody had pressed. It cost three verifications during packaging, each reading as a broken daemon. fprintd's contract is that a verify runs until the client stops it, so the frame cap bounds one trustlet scan session rather than the user's patience, and a window with no press simply runs again. Verified across the rollover: 600 frames untouched, "still waiting", then a press matching on its first contact frame in 44 ms. Presses that happen and never reach a verdict now report verify-retry-scan -- a bad scan, which fprintd has a word for, and not the matcher saying no. The cost is that an unanswered verify polls every ~200 ms for as long as the client holds it. The cure is measured and available -- gpio75 is silent at idle and bursts on contact -- but it would make the IRQ the only way a press is ever noticed, deleting the poll under every rate this daemon has been measured at. Noted where the loop waits, not done. And the trustlet: focal64.mbn is a proprietary OEM-signed blob, so the package ships a fp6-vendor-blobs manifest fragment instead, the same mechanism soc-fairphone-fp6-audio uses for the amp config. It needed a new directive there -- a QTEE image is an ELF header file plus one payload per program header, not one file -- and reassembly on the phone reproduces the image QTEE has accepted since August, byte for byte. --- implementations/main.cpp | 38 ++++++++++++++++++++++++++++++++-- packaging/20-focal64.manifest | 12 +++++++++++ packaging/APKBUILD | 16 ++++++++++++-- packaging/fingerprintd.service | 5 ++++- packaging/make-bin-tarball.sh | 1 + 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 packaging/20-focal64.manifest diff --git a/implementations/main.cpp b/implementations/main.cpp index e3657af..bad0016 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -66,7 +66,7 @@ namespace { // Bumping this is what publishes a package: the registry answers 409 for a // version it already has, which a build treats as a no-op. -constexpr const char* Version = "0.1.1"; +constexpr const char* Version = "0.1.2"; bool g_verbose = false; // 500 ms was the research harness's pace, chosen so a human could read the @@ -1623,6 +1623,16 @@ public: // returns immediately and the loop runs as fast as QTEE allows -- // exactly what a press wants. The timeout is the idle fallback, so // a release is still noticed promptly. + // + // IDLE WAIT, NOT DONE: since a verify now runs until the client + // stops it, an unanswered one polls the trustlet every ~200 ms for + // as long as the lock screen is up. The measurement above says the + // cure is available -- gpio75 is silent at idle and bursts on + // contact -- so an idle frame could wait on the edge for seconds + // instead of capturing. It is not done here because it would make + // the IRQ the only way a press is ever noticed, deleting the poll + // that is currently the safety net under every rate this daemon + // has been measured at, and that trade needs a finger to settle. if (g_edgeWake) sensor_.WaitEdges(g_frameGapMs); else std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs)); } @@ -1991,7 +2001,27 @@ private: break; } case Job::Kind::Verify: { + // fprintd's contract is that a verify runs until the client + // stops it. The frame cap bounds one trustlet scan session, + // not the user's patience, so a window in which the sensor was + // never touched is not an outcome -- it is nothing having + // happened yet, and the loop simply runs again. Reporting it + // instead cost three verifications during packaging, each + // ending in verify-unknown-error, which reads as a broken + // daemon and meant only that nobody pressed. + // + // The cost of waiting is a poll: the loop captures every + // ~200 ms whether or not a finger is there. It is bounded in + // practice by the client -- pam_fprintd stops on its own + // timeout, and a claimant that vanishes from the bus has its + // claim dropped -- and by VerifyStop, which is what sets + // cancel_. See the idle-wait note in Verify(). auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); + while (!o.decided && !o.cancelled && o.presses == 0) { + std::println("verify: {} frame(s), sensor never touched -- still waiting", + o.frames); + o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); + } std::println("verify: {} over {} press(es), {} frame(s)", o.cancelled ? "cancelled" : !o.decided ? "undecided" : o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames); @@ -1999,7 +2029,11 @@ private: ev->done = true; ev->fid = o.fid; if (o.cancelled) { ev->status = ""; ev->done = false; } - else if (!o.decided) ev->status = "verify-unknown-error"; + // Presses happened and none of them reached a verdict: every + // frame was a finger arriving but never settling. That is a + // bad scan, which fprintd has a word for, and it is not the + // same as the matcher saying no. + else if (!o.decided) ev->status = "verify-retry-scan"; else if (o.matched) ev->status = "verify-match"; else ev->status = "verify-no-match"; PostEvent(std::move(ev)); diff --git a/packaging/20-focal64.manifest b/packaging/20-focal64.manifest new file mode 100644 index 0000000..b27cadd --- /dev/null +++ b/packaging/20-focal64.manifest @@ -0,0 +1,12 @@ +# FocalTech FT9391 trustlet (proprietary, OEM-signed) - reassembled from the +# stock modem partition on-device instead of being shipped (fp6 repo +# journal/blobs/, journal/fingerprint/). It is the matcher: every pixel the +# sensor produces stays inside it, and no part of the daemon can substitute +# for it. QTEE's signature gate is fused OEM root, measured 2026-09-03, so +# there is no version of this that is our own code. +# +# Not one file: image/focal64.mdt plus focal64.b00..b08, placed at each ELF +# segment's p_offset. Slot A first, slot B as fallback. The sha256 is of the +# reassembled image and is the one QTEE has actually accepted since +# 2026-08-24. +mbn modem_a,modem_b image focal64 /usr/lib/firmware/focal64.mbn 1930c490d67e6f006ec346d3bee9f73d812bc0b7374bfc112873dc97ebb6cd68 diff --git a/packaging/APKBUILD b/packaging/APKBUILD index c4be451..2d2b5c4 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -10,7 +10,7 @@ # Alpine, so an APKBUILD that compiled from source could not be built by # anyone but us either. pkgname=fingerprintd -pkgver=0.1.1 +pkgver=0.1.2 pkgrel=0 pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)" url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd" @@ -25,7 +25,12 @@ license="GPL-3.0-only" # (i:fprintd=1.94.5-r1), so the provides below breaks that condition and apk # would purge it as no-longer-needed. Depending on it explicitly is what keeps # it. It has no dependency on fprintd itself, so nothing is being forced. -depends="dbus glib libc++ fprintd-pam" +# +# fp6-vendor-blobs runs the manifest fragment below, which reassembles the +# trustlet out of the stock modem partition on first boot. Without it there is +# no matcher and the unit stays inert on its ConditionPathExists -- so this is +# a real dependency, not a nicety. It is an FP6 device package; so is this. +depends="dbus glib libc++ fprintd-pam fp6-vendor-blobs" # The versioned provides both satisfies plasma-workspace's fprintd dependency # — its Users KCM is the fingerprint enrolment UI and speaks exactly this bus # name — and EXCLUDES the real package, which is required rather than tidy: @@ -89,6 +94,13 @@ package() { # two policy keys in it were each forced by a measurement. install -Dm644 fingerprintd.json \ "$pkgdir"/usr/lib/firmware/fingerprintd.json + + # the trustlet is NOT in this package and never will be: it is a + # proprietary OEM-signed blob. This tells fp6-vendor-blobs how to + # reassemble it from the phone's own stock partitions, which is the + # same mechanism soc-fairphone-fp6-audio uses for the amp config. + install -Dm644 20-focal64.manifest \ + "$pkgdir"/usr/share/fp6-vendor-blobs/manifest.d/20-focal64.manifest } systemd() { diff --git a/packaging/fingerprintd.service b/packaging/fingerprintd.service index d2facd2..856b9ac 100644 --- a/packaging/fingerprintd.service +++ b/packaging/fingerprintd.service @@ -16,7 +16,10 @@ ConditionPathExists=/dev/tee0 # through the SFS root's persist-data/root3 symlinks. RequiresMountsFor=/mnt/persist Requires=dbus.service -After=dbus.service +# fp6-vendor-blobs reassembles the trustlet from the stock modem +# partition; it runs in sysinit so this ordering already holds, and +# saying so keeps the ConditionPathExists above from looking arbitrary. +After=dbus.service fp6-vendor-blobs.service [Service] Type=simple diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 1f675dc..5f1ff77 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -24,6 +24,7 @@ cp packaging/fingerprintd.service \ packaging/fingerprintd.tmpfiles.conf \ packaging/fingerprintd.modules-load.conf \ packaging/fingerprintd.json \ + packaging/20-focal64.manifest \ "$stage/fingerprintd-$VER/" tar -C "$stage" -czf "fingerprintd-$VER.tar.gz" "fingerprintd-$VER" echo "wrote fingerprintd-$VER.tar.gz ($(du -h "fingerprintd-$VER.tar.gz" | cut -f1))"