Port the trustlet command surface, and pin the counting rule to recorded runs

Fingerprintd:Ta is the second core module: request payloads, response fields,
the error table, and the rule that decides what a frame meant. Payload building
and response reading only -- no TEE, no transport.

Very little of this is guessable, so each constant carries where it came from.
Three were found only because QTEE recorded a fault naming the instruction that
read them:

  * the event context's scan-slot count at +712, which do_enroll branches on to
    skip the entire slot loop -- an all-zero payload logged "groups->,
    results->" and read exactly like a gate failing deep in the trustlet, when
    it was zero iterations;
  * CAPTURE_IMAGE's flags at payload+0x18, without which preprocessing, the
    classifier and the enrol grouper never run at all, whatever is on the
    sensor;
  * SYNC_STATISTICS, whose absence leaves g_statistics NULL so the first enrol
    frame that gets far enough takes a data abort and every later command
    answers -90.

The verdict rule gets the most attention because it was mislabelled three times
before the comparison producing it was read. A frame is one of three things and
only the third is a verdict: the poison intact means the matcher never ran,
rc=-11 means not identified yet with attempts remaining, and only rc=0 carries
a match or a rejection. The poison exists because a zero-initialised buffer
cannot tell a released finger from a rejected one.

The tests are in two halves that cannot prop each other up. Explicit wire
conditions pin the classifier; three recorded runs pin the counting policy,
which is what actually went wrong. In the stock-budget run 31 of 48 frames
answered "not identified yet" and every frame that carried an image matched --
counting those 31 as attempts turns 8-for-8 into 8-of-39 and reads as a flaky
sensor. The wrong-finger control pins zero false accepts.

Fixtures are verdict-line excerpts, not the 40 KB transcripts, which are thick
with the device's SFS container names the test has no use for.

Verified by mutation: classifying -11 as a rejection, dropping SYNC_STATISTICS
from the init chain, and forgetting the +0x10 response payload offset each fail
the suite.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 16:46:00 +02:00
commit ffad29ba4e
8 changed files with 719 additions and 1 deletions

View file

@ -0,0 +1,353 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Fingerprintd:Ta — the focal64 trustlet's command surface.
Requests and responses only: build a payload, read a response, name an error.
No TEE, no transport. The daemon shell invokes; this module decides what bytes
go in and what the bytes coming back mean.
Almost nothing here is guessable. The layouts were read out of the stock HAL
(`fingerprint.default.so`, unoptimised and unstripped) and out of the trustlet
itself, and three of the fields were found only because QTEE recorded a fault
naming the instruction that read them. Each one is annotated with where it came
from, because "we tried values until it worked" is exactly what did not work.
*/
export module Fingerprintd:Ta;
import std;
export namespace fingerprintd::ta {
// ---- Commands ---------------------------------------------------------
enum class Cmd : std::uint32_t {
TaInit = 0x1004,
InitSpi = 0x1006,
ProbeDevice = 0x100a,
InitDevice = 0x100b,
SyncConfig = 0x100d,
SyncStatistics = 0x100e,
StartScanning = 0x1012,
CaptureImage = 0x1013,
SaveData = 0x1014,
ReportEvent = 0x1018,
WorkMode = 0x1020,
PreEnroll = 0x2000,
Enroll = 0x2001,
PostEnroll = 0x2002,
Cancel = 0x2004,
Enumerate = 0x2005,
SetActiveGroup = 0x2007,
Authenticate = 0x2008,
};
// The device init chain, in order. Every step returns rc=0 on a healthy
// sensor, ending in the trustlet's own "TA is successfully initialized."
//
// SyncStatistics is not optional and is not cosmetic. `g_statistics` is
// statically NULL and command 0x100e is its only writer; do_enroll stores a
// timestamp through it without a null check, so the first enrol frame that
// ever gets that far takes a data abort at TA offset 0x15f74. The trustlet
// dies, every later command answers -90, and nothing in the log says why —
// it was found in QTEE's fault ring, not by varying inputs.
inline constexpr std::array<Cmd, 6> InitChain = {
Cmd::InitSpi, Cmd::ProbeDevice, Cmd::InitDevice,
Cmd::TaInit, Cmd::WorkMode, Cmd::SyncStatistics,
};
// A reload needs the same chain. Without it the per-slot enroll-template
// array is never allocated and FtInitEnrollTplData writes through a NULL
// at TA 0xceb70 — the same shape of bug as g_statistics, found the same
// way, and harmless right up until a template is actually reachable.
inline constexpr std::size_t SyncStatisticsPayloadSize = 560;
// ---- Work modes and events -------------------------------------------
enum class WorkMode : std::uint32_t {
Idle = 0, WaitTouch = 1, WaitLeave = 2, Gesture = 6,
};
// Event ids for REPORT_EVENT. The trustlet never polls for a finger: the
// normal world takes the sensor IRQ and tells it what happened.
enum class Event : std::uint32_t {
FingerTouched = 5,
FingerReleased = 6,
ImageReady = 7,
};
// Event 7 reaches the matcher unconditionally; event 5 only when
// device+0x10a8 is 1 or 2. Enrolment mirrors stock by sending 5 on touch
// and 6 on release — stock's whole enrolment trace contains no event 7,
// and sending it on every held frame feeds the algorithm near-duplicate
// images from one press.
inline constexpr Event EnrolTouchEvent = Event::FingerTouched;
inline constexpr Event AuthEvent = Event::ImageReady;
// ---- The event context -----------------------------------------------
//
// ff_trustlet_event_context_t, the REPORT_EVENT payload. The stock HAL
// memsets 732 bytes and writes exactly six fields
// (fingerprint.default.so, device_irq_event_thread 0xb82f8-0xb8374).
inline constexpr std::size_t EventContextSize = 740;
inline constexpr std::size_t EventDeclaredLen = 732;
inline constexpr std::size_t EvEventOff = 4;
inline constexpr std::size_t EvScanSlotsOff = 712;
inline constexpr std::size_t EvZeroAOff = 716;
inline constexpr std::size_t EvSlotIndexOff = 720;
inline constexpr std::size_t EvFlagsOff = 724;
inline constexpr std::size_t EvZeroBOff = 728;
inline constexpr std::uint32_t EvDefaultFlags = 0x08080000;
inline constexpr std::uint32_t EvDefaultScanSlots = 1;
// The scan-slot count is load-bearing and was zero in every run this
// project made for weeks. do_enroll assembles it bytewise and
// `cbz w9, 0x162e8` jumps past the entire slot loop — past the enrol call
// and past every flag read — straight to the "scan slot %u: groups->%s"
// log with both buffers still zeroed. So an all-zero payload printed
// `groups->, results->`, which reads exactly like a gate failing deep in
// the trustlet and is really zero iterations.
struct EventContext {
Event event = Event::ImageReady;
std::uint32_t scanSlots = EvDefaultScanSlots;
std::uint32_t slotIndex = 0;
std::uint32_t flags = EvDefaultFlags;
};
namespace detail {
inline void StoreU32(std::span<std::byte> b, std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
b[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
}
inline std::uint32_t LoadU32(std::span<const std::byte> b, std::size_t off) {
std::uint32_t v = 0;
for (std::size_t i = 0; i < 4; i++)
v |= static_cast<std::uint32_t>(std::to_integer<unsigned>(b[off + i])) << (8 * i);
return v;
}
}
// Build the event payload. The event id is LITTLE endian: the trustlet
// assembles it as p[4] | p[5]<<8 | ... at 0x152c0. Written big endian,
// event 7 arrives as 0x07000000, fails the 5..14 bound check at 0x152e4,
// and silently takes the default path — returning rc=0 while doing nothing.
inline void BuildEventContext(std::span<std::byte> out, const EventContext& ev) {
std::ranges::fill(out.first(EventContextSize), std::byte{0});
detail::StoreU32(out, EvEventOff, static_cast<std::uint32_t>(ev.event));
detail::StoreU32(out, EvScanSlotsOff, ev.scanSlots);
detail::StoreU32(out, EvZeroAOff, 0);
detail::StoreU32(out, EvSlotIndexOff, ev.slotIndex);
detail::StoreU32(out, EvFlagsOff, ev.flags);
detail::StoreU32(out, EvZeroBOff, 0);
}
// ---- Capture ----------------------------------------------------------
//
// CAPTURE_IMAGE's flags word at payload+0x18. With neither bit 1 nor bit
// 30 set, `0x14b18: tst w8, #0x40000002 / b.eq 0x14d9c` skips image
// preprocessing, the frame classifier and the enrol grouper entirely — and
// the grouper is the only thing that ever writes the slot flag do_enroll
// gates on. So the trustlet returns rc=0 having done nothing but a raw
// scan, whatever is on the sensor.
//
// Bit 0 means "the caller appends the frame to the request". We do not, so
// it must stay clear.
inline constexpr std::size_t CaptureFlagsOff = 0x18;
inline constexpr std::uint32_t CaptureFlagsUseCallerFrame = 0x1;
// What the stock HAL's enrol path builds: |= 0xC0040000 then |= 2
// (fingerprint.default.so 0xb7d7c / 0xb7dbc).
inline constexpr std::uint32_t CaptureFlagsEnrol = 0xC0040002;
static_assert((CaptureFlagsEnrol & CaptureFlagsUseCallerFrame) == 0,
"bit 0 would promise the trustlet a frame we do not append");
static_assert((CaptureFlagsEnrol & 0x40000002) != 0,
"without bit 1 or bit 30 the capture does no preprocessing");
// CAPTURE_IMAGE's declared payload length is range-checked to exactly
// 0x14, and the trustlet reads the flags at +0x18 regardless — so the word
// is written past the declared length on purpose. 0x20 gives -201.
inline constexpr std::uint32_t CaptureDeclaredLen = 0x14;
// ---- SAVE_DATA --------------------------------------------------------
//
// payload+0x00 is a bitmask and the handler's first test is
// `0x139ec: tbz w22, #30`: bit 30 clear takes the calibration path, set
// falls through toward libfp_template_export / ff_template_save.
inline constexpr std::uint32_t SaveMaskTemplate = 0x40000000;
inline constexpr std::uint32_t SaveMaskCalibration = 0x80000000;
static_assert((SaveMaskTemplate & (1u << 30)) != 0);
static_assert((SaveMaskCalibration & (1u << 30)) == 0);
// ---- ENROLL / AUTHENTICATE payloads -----------------------------------
// ENROLL takes a 69-byte hw_auth_token, a u32 timeout at +69 and a u8 flag
// at +73 (stub 0xa0c8).
inline constexpr std::size_t EnrollPayloadSize = 74;
inline constexpr std::size_t EnrollTokenSize = 69;
inline constexpr std::size_t EnrollTimeoutOff = 69;
// No Gatekeeper is needed. ff_trustlet_enroll reads config
// trustlet.enable_trusted_enrollment and, when false, skips the version
// check, the PRE_ENROLL challenge compare and the token HMAC verify
// outright (0xce34 tbz -> 0xd198), so an all-zero token is accepted.
// pmOS has no Gatekeeper to mint one and nothing there verifies auth
// tokens anyway.
inline void BuildEnrollPayload(std::span<std::byte> out, std::uint32_t timeoutSeconds) {
std::ranges::fill(out.first(EnrollPayloadSize), std::byte{0});
detail::StoreU32(out, EnrollTimeoutOff, timeoutSeconds);
}
// AUTHENTICATE (TA 0xea88 takes these as x0/w1/w2/w3):
// +0x00 u64 operation_id
// +0x08 u32 gid
// +0x0c u8 b_relight
// +0x0d u8 b_covered
// Declared length 0x0e.
inline constexpr std::size_t AuthPayloadSize = 0x0e;
inline constexpr std::size_t AuthGidOff = 0x08;
inline constexpr std::size_t AuthRelightOff = 0x0c;
inline constexpr std::size_t AuthCoveredOff = 0x0d;
// With both flags zero the trustlet calls ff_trustlet_query_finger_status
// first and starts mode 1 or 2 from the answer; 1,1 skips that and starts
// mode 1 directly, which is what the query returns with no finger down.
inline void BuildAuthPayload(std::span<std::byte> out, std::uint64_t operationId,
std::uint32_t gid, bool relight = true, bool covered = true) {
std::ranges::fill(out.first(AuthPayloadSize), std::byte{0});
for (std::size_t i = 0; i < 8; i++)
out[i] = static_cast<std::byte>((operationId >> (8 * i)) & 0xFF);
detail::StoreU32(out, AuthGidOff, gid);
out[AuthRelightOff] = static_cast<std::byte>(relight ? 1 : 0);
out[AuthCoveredOff] = static_cast<std::byte>(covered ? 1 : 0);
}
// SET_ACTIVE_GROUP writes its gid to device+0x30 and AUTHENTICATE compares
// its own against the same field (0xeb08), logging
// "templates with gid(%u != %u) hasn't been loaded." and returning -200 on
// a mismatch. So the two only have to agree with each other — the value
// itself is the caller's to choose.
inline constexpr std::size_t SetActiveGroupGidOff = 0;
// ---- Responses --------------------------------------------------------
//
// THE TRAP. The buffer that comes back is the whole REQUEST, and the
// payload starts at +0x10 — proved by reading back the event id we sent at
// reqo+0x14. Reading a payload field at its payload offset directly gives
// a confident, wrong zero, which is what happened to "samples remaining"
// for a whole session.
inline constexpr std::size_t ResponsePayloadOff = 0x10;
// Enrolment progress, without needing the trustlet's log: do_enroll copies
// g_context+56 (samples remaining) into the response payload at +36
// bytewise on the common path, whether or not the sample was accepted.
// That matters because the log starves exactly when a frame is accepted.
inline constexpr std::size_t RespSamplesRemainingOff = 36;
// The match result. ff_trustlet_event's success path writes the gid to
// payload+0x0c and the matched fid to payload+0x10 one byte at a time
// (0x1642c / 0x16454), and "authentication failed." explicitly zeroes the
// fid (0x164a4). So a non-zero fid there can only have come from the path
// that logged a match.
inline constexpr std::size_t RespGidOff = 0x0c;
inline constexpr std::size_t RespFidOff = 0x10;
inline std::int32_t SamplesRemaining(std::span<const std::byte> response) {
return static_cast<std::int32_t>(
detail::LoadU32(response, ResponsePayloadOff + RespSamplesRemainingOff));
}
inline std::uint32_t MatchedGid(std::span<const std::byte> response) {
return detail::LoadU32(response, ResponsePayloadOff + RespGidOff);
}
inline std::uint32_t MatchedFid(std::span<const std::byte> response) {
return detail::LoadU32(response, ResponsePayloadOff + RespFidOff);
}
// ---- The verdict ------------------------------------------------------
//
// A frame is one of three things and only the third is a verdict. This was
// mislabelled three separate times before the comparison producing it was
// actually read, and each mistake invented rejections that never happened
// and made the sensor look flaky.
//
// 16a0c: ldr w9, [x12, #0x8c] ; common.max_authentication_rescan_times
// 16a30: cmp w8, w9 ; w8 = counter at x20+0x2d0
// 16a34: mov w8, #-0xb
// 16a38: csel w8, w8, wzr, lo ; below the limit -> -11, else terminal
//
// Proven with no finger on the sensor: at the default budget those frames
// return -11; with the key set to 0 the same frames return rc=0 with the
// fid zeroed, which is a real rejection.
enum class Verdict {
MatcherNeverRan, // finger released; the poison is intact
NotIdentifiedYet, // rc=-11: ran, attempts remain. NOT a rejection
Match,
Rejected,
};
// The caller poisons the fid field before the call, because a
// zero-initialised buffer cannot distinguish "the matcher never ran" from
// "the matcher ran and rejected the finger" — both leave zero there.
inline constexpr std::uint32_t FidPoison = 0xAAAAAAAA;
inline constexpr std::int32_t RcTryAgain = -11;
inline void PoisonFid(std::span<std::byte> payload) {
detail::StoreU32(payload, RespFidOff, FidPoison);
}
inline Verdict Classify(std::int32_t rc, std::uint32_t fid) {
if (fid == FidPoison) return Verdict::MatcherNeverRan;
if (rc == RcTryAgain) return Verdict::NotIdentifiedYet;
if (rc != 0) return Verdict::NotIdentifiedYet;
return fid != 0 ? Verdict::Match : Verdict::Rejected;
}
// Only a terminal verdict counts toward an accept/reject rate. Counting
// NotIdentifiedYet as a rejection is the specific error above.
inline bool IsTerminal(Verdict v) {
return v == Verdict::Match || v == Verdict::Rejected;
}
// ---- Errors -----------------------------------------------------------
//
// ff_strerror (TA 0x7218) is two jump-table ranges. Every error path in
// the trustlet passes its result through this, so every named error in
// every log we hold converts back to a number and vice versa.
inline constexpr std::string_view StrError(std::int32_t rc) {
switch (rc) {
case 0: return "Success";
case -1: return "Internal error";
case -2: return "No such file or directory";
case -4: return "Interrupted";
case -5: return "I/O error";
case -11: return "Try again";
case -12: return "Out of memory";
case -16: return "Resource busy/Timeout";
case -200: return "Bad parameter(s)";
case -201: return "Null pointer";
case -202: return "Buffer overflow";
case -203: return "Bad protocol";
case -204: return "Wrong sensor dimension";
case -205: return "Device not found";
case -206: return "Device is dead";
case -207: return "Up to the limit";
case -208: return "Untrusted enrollment";
case -209: return "Template store in REE";
default: return "unknown";
}
}
// -90 is not a trustlet error at all: it is QTEE's "the app is gone",
// which is what every command answers once the trustlet has taken a fault.
// A -90 yields no trustlet log, so the fault ring is the only instrument.
inline constexpr std::int32_t QteeAppGone = -90;
// -205 is what a second TA init in one sensor power cycle returns. One
// sensor reset buys exactly one init, which is why the process that powers
// the sensor has to be the process that holds the session.
inline constexpr std::int32_t RcDeviceNotFound = -205;
}