0.2.4: the trustlet comes from the active slot, unpinned, and a refusal says why
All checks were successful
package / package (push) Successful in 1m22s

0.2.3's manifest pinned the sha256 of one Android build's focal64
(16.82.0, the build the dev phone runs). Fairphone re-signs the trustlet
every release, so that pin matched exactly one of the six builds seen,
and two of two field reports had no sensor: one user on 16.100.0 edited
the manifest by hand, another ended up with a file QTEE refuses.

The manifest now carries '-' instead of a hash and depends on
fp6-vendor-blobs 1-r3, which tries the active slot first and verifies the
image's structure; QTEE's signature check is the gate it always was (one
flipped byte -> ERROR_ELF_SIGNATURE_ERROR, measured 2026-09-03 and again
today).

post-install reassembles the trustlet right away, so 'apk add' no longer
needs a boot for it. post-upgrade re-derives it from the active slot,
which replaces a hand-placed or wrongly pinned file, and then restarts
the daemon -- a plain restart, so a daemon that exited on a refused
trustlet comes back up on the re-derived one.

loadFromBuffer failures name the loader's verdict. The field's first
report was a bare result=12; it now reads ERROR_ELF_SIGNATURE_ERROR with
what to do about it. Probed on the phone with this build: a one-byte
tampered image and 100000 random bytes both print it, the pristine image
loads, and the suites pass 8/8.
This commit is contained in:
Jorijn van der Graaf 2026-09-11 13:04:35 +02:00
commit 823c710b15
6 changed files with 76 additions and 24 deletions

View file

@ -68,7 +68,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.2.3";
constexpr const char* Version = "0.2.4";
bool g_verbose = false;
// 500 ms was the research harness's pace, chosen so a human could read the
@ -952,6 +952,24 @@ void UnloadStale(qcomtee_object* loader) {
qcomtee_object_refs_dec(p[2].object);
}
// The loader's verdict on the image itself (IQSEEComCompatAppLoader.h in
// Qualcomm's securemsm-kernel), for the codes a phone in the field has produced
// or plausibly will. Named because the first field report was a bare 12: QTEE
// refusing an image whose signature or per-segment hashes do not check out,
// which on this device means a trustlet that is not the active slot's own.
const char* LoaderVerdict(qcomtee_result_t result) {
switch (result) {
case 11: return " ERROR_PIL_ROLLBACK_FAILURE: the image is older than the anti-rollback state allows";
case 12: return " ERROR_ELF_SIGNATURE_ERROR: QTEE refused the image's signature or segment hashes;"
" the trustlet is damaged or not this slot's build. Re-derive it from the"
" active slot: /usr/lib/fp6-vendor-blobs/extract --refresh";
case 13: return " ERROR_METADATA_INVALID: not a QTEE trustlet image";
case 16: return " ERROR_ALREADY_LOADED";
case 28: return " ERROR_ELF_LOADING";
default: return "";
}
}
qcomtee_object* LoadTrustlet(qcomtee_object* loader, const std::string& path) {
UnloadStale(loader);
@ -981,8 +999,8 @@ qcomtee_object* LoadTrustlet(qcomtee_object* loader, const std::string& path) {
p[3].attr = QCOMTEE_OBJREF_OUTPUT;
qcomtee_result_t result = 0;
if (qcomtee_object_invoke(loader, 1, p, 4, &result) || result) {
std::println(std::cerr, "loadFromBuffer failed, result={}",
static_cast<int>(result));
std::println(std::cerr, "loadFromBuffer failed, result={}{}",
static_cast<int>(result), LoaderVerdict(result));
return QCOMTEE_OBJECT_NULL;
}
std::println("trustlet loaded from {} ({} bytes), distName='{}'", path,

View file

@ -6,7 +6,15 @@
# 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
# segment's p_offset. The extractor tries the ACTIVE slot first (its TZ is
# the one running) and the other slot as fallback.
#
# No sha256 pin ('-'): Fairphone re-signs this trustlet every Android
# release, so a whole-image hash matches exactly one build - six builds, six
# hashes, one trustlet - and the pin 0.2.3 shipped (16.82.0's) left every
# unit on another build without a sensor (two of two field reports,
# 2026-09-11). The extractor verifies the structure; QTEE verifies the
# signature and refuses a damaged or foreign image (one flipped byte ->
# ERROR_ELF_SIGNATURE_ERROR, measured 2026-09-03), and the daemon names that
# verdict in its log.
mbn modem_a,modem_b image focal64 /usr/lib/firmware/focal64.mbn -

View file

@ -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.2.3
pkgver=0.2.4
pkgrel=0
pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)"
url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd"
@ -30,10 +30,12 @@ license="GPL-3.0-only"
# 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.
# The mbn directive the fragment uses arrived in 1-r2; the extractor before it
# exits on an unknown directive, which on a first boot would fail the blobs
# unit after the audio fragment had run and before this one could.
depends="dbus glib libc++ fprintd-pam fp6-vendor-blobs>=1-r2"
# The mbn directive the fragment uses arrived in 1-r2; the unpinned form
# ('-' as the sha256, active slot first) and --refresh arrived in 1-r3. An
# older extractor would read '-' as a hash and refuse every image, which on a
# first boot fails the blobs unit after the audio fragment ran and before
# this one could.
depends="dbus glib libc++ fprintd-pam fp6-vendor-blobs>=1-r3"
# 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:
@ -55,9 +57,11 @@ subpackages="$pkgname-agent $pkgname-systemd"
# traced against an x86_64 build host
options="!check !tracedeps"
source="fingerprintd-$pkgver.tar.gz"
# apk swaps the binary on disk and leaves the running daemon alone; the
# script restarts it so an upgrade does not leave the old code answering.
install="$pkgname.post-upgrade"
# post-install: reassemble the trustlet now, so a fresh 'apk add' has a sensor
# without waiting for a boot. post-upgrade: re-derive it from the active slot
# (heals a hand-placed or wrongly pinned file), then restart the daemon --
# apk swaps the binary on disk and leaves the running one alone.
install="$pkgname.post-install $pkgname.post-upgrade"
package() {
cd "$srcdir/fingerprintd-$pkgver"

View file

@ -100,7 +100,7 @@ rm -rf "$PKG"
mkdir -p "$PKG"
cp "$SRC/packaging/APKBUILD" "$PKG/APKBUILD"
# install= scripts are read from the aport dir, not from source=
cp "$SRC/packaging/fingerprintd.post-upgrade" "$PKG/"
cp "$SRC/packaging/fingerprintd.post-install" "$SRC/packaging/fingerprintd.post-upgrade" "$PKG/"
mv "fingerprintd-$VER.tar.gz" "$PKG/"
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
# a throwaway signing key: phones trust the registry-signed APKINDEX, not

View file

@ -0,0 +1,16 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# The trustlet is not in this package: fp6-vendor-blobs reassembles it from
# the phone's own modem partition (packaging/20-focal64.manifest). On an
# image's first boot its unit does that before udev; on a phone that is
# already running, do it now, so a fresh 'apk add fingerprintd' does not wait
# for a boot to have a sensor. --if-device makes this a quiet no-op inside
# build/CI chroots; --refresh re-derives the file from the active slot even if
# one is present, and never removes a file it cannot replace.
#
# The daemon itself starts at the next boot: its unit, module load and
# tmpfiles arrive with the -systemd subpackage, after this script has run.
/usr/lib/fp6-vendor-blobs/extract --if-device --refresh || :
exit 0

22
packaging/fingerprintd.post-upgrade Normal file → Executable file
View file

@ -2,14 +2,20 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# apk replaces /usr/bin/fingerprintd on disk and leaves the running daemon
# alone. The phone is then a working matcher running the OLD code: the journal
# says MATCH and whatever the upgrade added is dead until a reboot, which is
# how 0.2.2's FingerMatched signal first presented (fp6 journal, fingerprint
# lane, 2026-09-05). Restart it if it is running.
# Two things an upgrade must do on a running phone. First re-derive the
# trustlet from the active slot: 0.2.3 pinned one Android build's hash, so a
# phone on any other build has no trustlet, or a hand-placed one, and
# --refresh replaces it with the phone's own (fp6 journal, fingerprint lane,
# 2026-09-11). --if-device keeps this a no-op inside build/CI chroots.
/usr/lib/fp6-vendor-blobs/extract --if-device --refresh || :
#
# try-restart: a daemon that is not running -- no /dev/tee0, no trustlet --
# stays not running. Inside a build or CI chroot there is no systemd to ask.
# Then restart the daemon: apk replaces /usr/bin/fingerprintd on disk and
# leaves the running one alone, so the phone would keep matching with the OLD
# code until a reboot (how 0.2.2's FingerMatched signal first presented,
# 2026-09-05). A plain restart rather than try-restart: a daemon that exited
# on a refused trustlet is not running and must come up on the re-derived
# file, and the unit's ConditionPathExists lines keep this inert on a phone
# with no trustlet or no /dev/tee0. No systemd inside a chroot.
[ -d /run/systemd/system ] || exit 0
systemctl try-restart fingerprintd.service 2>/dev/null || :
systemctl restart fingerprintd.service 2>/dev/null || :
exit 0