fingerprintd/interfaces/Fingerprintd-Ta.cppm

557 lines
27 KiB
Text
Raw Permalink Normal View History

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.
2026-09-02 16:46:00 +02:00
// 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,
QueryEventStatus = 0x101d,
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.
2026-09-02 16:46:00 +02:00
SaveData = 0x1014,
UPDATE_TEMPLATE: the command stock learns with, and the field that was killing it Stock rewrites the stored template on every successful press. Its post-match loop is QUERY_FINGER_STATUS, CAPTURE_IMAGE, 0x1015 UPDATE_TEMPLATE while the finger stays down, with no REPORT_EVENT in it -- so the matcher does not re-run and the verdict cannot change. Forty-six of those against eighty-six captures in one reference session, and the stored body measurably grows: 333278 bytes at enrolment, 360822 at the next session's load, 371734 after one authentication session. This daemon sent none of them. The command shares REPORT_EVENT's event context. The stock wrapper memsets 732 bytes and writes six fields: a zero byte at +0x2a4, the scan-slot count, a zero word, the count of frames folded so far in this press, a flags word of 0x00080000 with bit 6 set on the frame whose event was FingerTouched, and a zero at +0x2d8. Declared length 0x2dc. +0x2d8 is the one that matters, and it matters by staying zero. The dispatcher stub reads it after the handler returns and only if it is non-zero does it read +0x2dc and make that the response length. Every earlier attempt in this project set both fields and varied the declared length across 0x2e0, 0x400 and 0x1000; all of them answered -90, the trustlet gone, and the conclusion recorded was "do not retry until a template is loaded". A loaded template was necessary but not sufficient. The stock HAL sets neither field. Measured on the device with two templates loaded and no finger: both branches answer rc=0 and ENUMERATE still reports 2, so the app did not fault. Those fields are little endian, assembled low-address-first by the handler. An earlier reading called them big endian, off the bfi order, and was wrong. Bit 6 selects which algorithm entry runs: clear takes libfp_template_x_update, set takes the other, which also reads the scan-slot count.
2026-09-03 17:45:27 +02:00
UpdateTemplate = 0x1015,
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.
2026-09-02 16:46:00 +02:00
ReportEvent = 0x1018,
WorkMode = 0x1020,
PreEnroll = 0x2000,
Enroll = 0x2001,
PostEnroll = 0x2002,
Cancel = 0x2004,
Add enrolment, and let it choose its own namespace Enrolment is the first thing here that writes: template containers through the gpfile listener and counter records through RPMB. It refuses to run unless both --sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if the sample count did not reach zero, because a partial template is worse than none. The sequence is stock's: cancel, reset-lockout, authenticate, cancel, reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop, POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture session, which is why it appears in an enrolment at all. Enrolment takes one sample per PRESS: touch on the rising edge, release on the falling one, nothing in between. Stock's entire enrolment trace contains no image-ready event, and feeding every held frame gives the algorithm near-duplicate images from a single press. Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as a "timeout"; the trustlet reports it back as the GROUP ID, and filling a mislabelled field with a plausible number is the entire provenance of gid 60. It is the gid now, so an enrolment can choose its own group. And --group-path exposes the namespace key the trustlet hashes into the group's directory name. It defaults to Android's, which is where this device's existing store lives and how that template is readable. But SAVE_DATA rewrites the group's index container, and an index QTEE later fails to verify takes every template listed in it -- so enrolling into a DIFFERENT namespace is complete isolation from a store we did not write.
2026-09-02 20:12:24 +02:00
ResetLockout = 0x200a,
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.
2026-09-02 16:46:00 +02:00
Enumerate = 0x2005,
Delete a finger's template, not just its name FF_CMD_TA_REMOVE, recovered the way AUTHENTICATE was: read the stub, read the handler. The 0x2006 stub at 0xa15c is a bare `ldp w0, w1, [payload]`, so the request is two u32s -- gid at +0, fid at +4 -- and the 0x2000 dispatcher validates no length. Walking the jump table reproduces authenticate at 0xa180, which is the address already on record, so the table read is sound. Three preconditions, all the trustlet's own. The gid must be the ACTIVE group (it compares against device+0x30, the field SET_ACTIVE_GROUP writes). The fid must be non-zero: zero is not "remove all", it is an error the trustlet logs and refuses. And the fid must be among the loaded templates, because it removes by the SLOT INDEX it finds, not by id. It persists: on a hit the trustlet formats ff_template_<gid>_<slot>.bin and calls ff_file_delete, which arrives on our gpfile listener as an unlink -- so this only works with the store served writable. Proven harmlessly first. --probe-remove sends one command with no map involvement, and a fid the group does not hold answers rc=-2 with the real template untouched -- which is what established that both words are read where we send them, before anything was deleted. Then for real, through fprintd-delete: both 347202-byte containers and their .bak companions unlinked, templates loaded 1 -> 0, and a re-enrolment afterwards completed 20 stages with SAVE_DATA rc=0, so the store is consistent after a removal rather than merely emptier. The ordering the transcript shows is worth keeping: the group index is rewritten and the RPMB anti-rollback counter bumped BEFORE each unlink. That is precisely why an orderly removal leaves a valid store where restoring an older container leaves a tampered one -- the counter has already moved past it. The delete reply now waits for the worker, because only that thread invokes the trustlet and fprintd's Delete methods are synchronous. Names are dropped before templates on purpose: a template that survives a failed removal is a slot leak, while a name that survives a successful one keeps offering a finger that can no longer match.
2026-09-05 04:27:14 +02:00
Remove = 0x2006,
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.
2026-09-02 16:46:00 +02:00
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;
Capture works: idle floor 133, matching the reference measurement The finger-free path is complete. On the phone, from a cold start: client env -> loader -> trustlet -> config -> sensor rail -> init chain calibrating the idle floor (5 samples) idle 1/5: rc=-11 metric=133 ... idle floor = 133, finger threshold = 266 133 is the number the journal records for this sensor, so the port reproduces the reference measurement rather than merely producing one. Two things had to be right at once, and the first attempt had neither. The memory region: CAPTURE_IMAGE reads an output-buffer pointer out of payload+0x00, and QTEE only patches an address there if the location is named in embeddedBufOffsets and the region handed over in an object slot. The instrumented dump shows it working -- payload+0x00 came back holding 0x088db98000 -- which is what made the remaining failure legible instead of mysterious. And two fields inside the capture payload that an all-zero request leaves unset: a frame count at +0x0c and a branch selector at +0x10. Selector 0 returns metric 0. Sending zeros gets -201 with the region correctly attached, which reads exactly like a broken region and is not one. They are named constants now, with the note that the metric is PER FRAME so a threshold calibrated at one frame count means nothing at another. The flags word at payload+0x18 stays past the declared length of 0x14 on purpose: the trustlet range-checks that length to exactly 0x14 and reads the flags anyway. --verbose keeps the region and reqOut dumps, which is what turned this from guesswork into reading.
2026-09-02 18:27:35 +02:00
// Two fields inside that payload which an all-zero request leaves unset.
//
// +0x0c frame count how many frames this capture takes
// +0x10 branch selector which capture path runs; 0 returns metric 0
//
// Both matter for reading the result as much as for getting one: the
// metric is PER FRAME, so a count of 4 reads roughly four times a count of
// 1 and a threshold calibrated at one count is meaningless at another.
// Sending zeros gets -201.
inline constexpr std::size_t CaptureFrameCountOff = 0x0c;
inline constexpr std::size_t CaptureSelectorOff = 0x10;
inline constexpr std::uint32_t CaptureFrameCountDefault = 1;
inline constexpr std::uint32_t CaptureSelectorDefault = 1;
inline void BuildCapturePayload(std::span<std::byte> out,
std::uint32_t frames = CaptureFrameCountDefault,
std::uint32_t selector = CaptureSelectorDefault) {
std::ranges::fill(out.first(CaptureDeclaredLen), std::byte{0});
detail::StoreU32(out, CaptureFrameCountOff, frames);
detail::StoreU32(out, CaptureSelectorOff, selector);
}
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.
2026-09-02 16:46:00 +02:00
// ---- 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);
UPDATE_TEMPLATE: the command stock learns with, and the field that was killing it Stock rewrites the stored template on every successful press. Its post-match loop is QUERY_FINGER_STATUS, CAPTURE_IMAGE, 0x1015 UPDATE_TEMPLATE while the finger stays down, with no REPORT_EVENT in it -- so the matcher does not re-run and the verdict cannot change. Forty-six of those against eighty-six captures in one reference session, and the stored body measurably grows: 333278 bytes at enrolment, 360822 at the next session's load, 371734 after one authentication session. This daemon sent none of them. The command shares REPORT_EVENT's event context. The stock wrapper memsets 732 bytes and writes six fields: a zero byte at +0x2a4, the scan-slot count, a zero word, the count of frames folded so far in this press, a flags word of 0x00080000 with bit 6 set on the frame whose event was FingerTouched, and a zero at +0x2d8. Declared length 0x2dc. +0x2d8 is the one that matters, and it matters by staying zero. The dispatcher stub reads it after the handler returns and only if it is non-zero does it read +0x2dc and make that the response length. Every earlier attempt in this project set both fields and varied the declared length across 0x2e0, 0x400 and 0x1000; all of them answered -90, the trustlet gone, and the conclusion recorded was "do not retry until a template is loaded". A loaded template was necessary but not sufficient. The stock HAL sets neither field. Measured on the device with two templates loaded and no finger: both branches answer rc=0 and ENUMERATE still reports 2, so the app did not fault. Those fields are little endian, assembled low-address-first by the handler. An earlier reading called them big endian, off the bfi order, and was wrong. Bit 6 selects which algorithm entry runs: clear takes libfp_template_x_update, set takes the other, which also reads the scan-slot count.
2026-09-03 17:45:27 +02:00
// ---- UPDATE_TEMPLATE --------------------------------------------------
//
// TEMPLATE LEARNING. Stock folds the frames of a successful press back
// into the stored template and the template GROWS as a result: on the
// reference device ff_template_0_0.bin went 333278 bytes at enrolment ->
// 360822 at the next session's load -> 371734 after one authentication
// session. Over the same session the HAL issued 46 of these against 86
// captures. This daemon issued none, so every rate this project has ever
// measured was against a day-zero template that no stock user lives with.
//
// The command shares REPORT_EVENT's ff_trustlet_event_context_t. The stock
// wrapper (fingerprint.default.so 0xb8d84, "checking the template...")
// memsets 732 bytes and writes exactly six fields:
//
// +0x2a4 (676) u8 0
// +0x2c8 (712) u32 scan-slot count (as REPORT_EVENT)
// +0x2cc (716) u32 0
// +0x2d0 (720) u32 slot index = frames folded so far in this press
// +0x2d4 (724) u32 0x00080000, |0x40 when the frame's event was 5
// +0x2d8 (728) u32 0
//
// and calls it with a declared length of 0x2dc (0xbefec).
//
// +0x2d8 IS THE FIELD THAT MATTERS, and it matters by staying zero. The
// dispatcher stub reads it AFTER the handler returns (0x9ee0-0x9f00) and
// only if it is non-zero does it read +0x2dc and compute the response
// length as that value + 0x2dc. Every previous attempt in this project set
// both fields and varied the declared length (0x2e0 / 0x400 / 0x1000);
// all of them answered -90, i.e. the trustlet was gone. The stock HAL
// never sets either one.
//
// Endianness: the handler assembles these bytewise low-address-first
// (0x1300c-0x13028), so they are LITTLE endian. An earlier ledger entry
// called them big endian; it was read off the bfi order and was wrong.
inline constexpr std::size_t UpdateTemplatePayloadSize = 0x2dc; // 732
inline constexpr std::size_t UpdZeroByteOff = 0x2a4;
inline constexpr std::size_t UpdScanSlotsOff = EvScanSlotsOff; // 712
inline constexpr std::size_t UpdZeroAOff = EvZeroAOff; // 716
inline constexpr std::size_t UpdSlotIndexOff = EvSlotIndexOff; // 720
inline constexpr std::size_t UpdFlagsOff = EvFlagsOff; // 724
inline constexpr std::size_t UpdRespLenOff = EvZeroBOff; // 728
// The flags word. Note it is NOT the event context's 0x08080000: the
// update wrapper builds its own value from scratch.
inline constexpr std::uint32_t UpdFlagsBase = 0x00080000;
// Bit 6 selects which of the algorithm's two update entries runs: clear
// takes libfp_template_x_update (0x20bf4), set takes 0x212e0, which also
// reads the scan-slot count. Stock sets it on the frame whose event was
// FingerTouched, i.e. the first frame of a press.
inline constexpr std::uint32_t UpdFlagsTouchFrame = 0x40;
inline void BuildUpdateTemplate(std::span<std::byte> out, std::uint32_t slotIndex,
bool touchFrame,
std::uint32_t scanSlots = EvDefaultScanSlots) {
std::ranges::fill(out.first(UpdateTemplatePayloadSize), std::byte{0});
out[UpdZeroByteOff] = std::byte{0};
detail::StoreU32(out, UpdScanSlotsOff, scanSlots);
detail::StoreU32(out, UpdZeroAOff, 0);
detail::StoreU32(out, UpdSlotIndexOff, slotIndex);
detail::StoreU32(out, UpdFlagsOff,
UpdFlagsBase | (touchFrame ? UpdFlagsTouchFrame : 0u));
// Left zero deliberately. See above: a non-zero value here sends the
// stub off to compute a response length from +0x2dc.
detail::StoreU32(out, UpdRespLenOff, 0);
}
static_assert(UpdRespLenOff + 4 == UpdateTemplatePayloadSize,
"the response-length trigger is the last word of the payload");
static_assert((UpdFlagsBase & UpdFlagsTouchFrame) == 0,
"the touch bit must not already be in the base value");
static_assert(UpdFlagsTouchFrame == (1u << 6),
"the handler tests bit 6 of the LOW byte at +0x2d4");
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.
2026-09-02 16:46:00 +02:00
// ---- ENROLL / AUTHENTICATE payloads -----------------------------------
Add enrolment, and let it choose its own namespace Enrolment is the first thing here that writes: template containers through the gpfile listener and counter records through RPMB. It refuses to run unless both --sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if the sample count did not reach zero, because a partial template is worse than none. The sequence is stock's: cancel, reset-lockout, authenticate, cancel, reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop, POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture session, which is why it appears in an enrolment at all. Enrolment takes one sample per PRESS: touch on the rising edge, release on the falling one, nothing in between. Stock's entire enrolment trace contains no image-ready event, and feeding every held frame gives the algorithm near-duplicate images from a single press. Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as a "timeout"; the trustlet reports it back as the GROUP ID, and filling a mislabelled field with a plausible number is the entire provenance of gid 60. It is the gid now, so an enrolment can choose its own group. And --group-path exposes the namespace key the trustlet hashes into the group's directory name. It defaults to Android's, which is where this device's existing store lives and how that template is readable. But SAVE_DATA rewrites the group's index container, and an index QTEE later fails to verify takes every template listed in it -- so enrolling into a DIFFERENT namespace is complete isolation from a store we did not write.
2026-09-02 20:12:24 +02:00
// ENROLL takes a 69-byte hw_auth_token, a u32 at +69 and a u8 flag at +73
// (stub 0xa0c8).
//
// The u32 at +69 was recorded in this project as a "timeout". It is not:
// the trustlet reports it back as the GROUP ID. Setting it to 60 is where
// `gid = 60` came from, and the whole gid-60 store exists because a
// mislabelled field was filled with a plausible-looking number. Naming it
// honestly is what makes an enrolment able to choose its own group.
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.
2026-09-02 16:46:00 +02:00
inline constexpr std::size_t EnrollPayloadSize = 74;
inline constexpr std::size_t EnrollTokenSize = 69;
Add enrolment, and let it choose its own namespace Enrolment is the first thing here that writes: template containers through the gpfile listener and counter records through RPMB. It refuses to run unless both --sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if the sample count did not reach zero, because a partial template is worse than none. The sequence is stock's: cancel, reset-lockout, authenticate, cancel, reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop, POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture session, which is why it appears in an enrolment at all. Enrolment takes one sample per PRESS: touch on the rising edge, release on the falling one, nothing in between. Stock's entire enrolment trace contains no image-ready event, and feeding every held frame gives the algorithm near-duplicate images from a single press. Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as a "timeout"; the trustlet reports it back as the GROUP ID, and filling a mislabelled field with a plausible number is the entire provenance of gid 60. It is the gid now, so an enrolment can choose its own group. And --group-path exposes the namespace key the trustlet hashes into the group's directory name. It defaults to Android's, which is where this device's existing store lives and how that template is readable. But SAVE_DATA rewrites the group's index container, and an index QTEE later fails to verify takes every template listed in it -- so enrolling into a DIFFERENT namespace is complete isolation from a store we did not write.
2026-09-02 20:12:24 +02:00
inline constexpr std::size_t EnrollGidOff = 69;
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.
2026-09-02 16:46:00 +02:00
// 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.
Add enrolment, and let it choose its own namespace Enrolment is the first thing here that writes: template containers through the gpfile listener and counter records through RPMB. It refuses to run unless both --sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if the sample count did not reach zero, because a partial template is worse than none. The sequence is stock's: cancel, reset-lockout, authenticate, cancel, reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop, POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture session, which is why it appears in an enrolment at all. Enrolment takes one sample per PRESS: touch on the rising edge, release on the falling one, nothing in between. Stock's entire enrolment trace contains no image-ready event, and feeding every held frame gives the algorithm near-duplicate images from a single press. Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as a "timeout"; the trustlet reports it back as the GROUP ID, and filling a mislabelled field with a plausible number is the entire provenance of gid 60. It is the gid now, so an enrolment can choose its own group. And --group-path exposes the namespace key the trustlet hashes into the group's directory name. It defaults to Android's, which is where this device's existing store lives and how that template is readable. But SAVE_DATA rewrites the group's index container, and an index QTEE later fails to verify takes every template listed in it -- so enrolling into a DIFFERENT namespace is complete isolation from a store we did not write.
2026-09-02 20:12:24 +02:00
inline void BuildEnrollPayload(std::span<std::byte> out, std::uint32_t gid) {
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.
2026-09-02 16:46:00 +02:00
std::ranges::fill(out.first(EnrollPayloadSize), std::byte{0});
Add enrolment, and let it choose its own namespace Enrolment is the first thing here that writes: template containers through the gpfile listener and counter records through RPMB. It refuses to run unless both --sfs-writable and --rpmb-write are given, and it refuses to call SAVE_DATA if the sample count did not reach zero, because a partial template is worse than none. The sequence is stock's: cancel, reset-lockout, authenticate, cancel, reset-lockout, PRE_ENROLL, authenticate, cancel, ENROLL, the sample loop, POST_ENROLL, SAVE_DATA with bit 30 set. AUTHENTICATE is what arms the capture session, which is why it appears in an enrolment at all. Enrolment takes one sample per PRESS: touch on the rising edge, release on the falling one, nothing in between. Stock's entire enrolment trace contains no image-ready event, and feeding every held frame gives the algorithm near-duplicate images from a single press. Two things named honestly. The ENROLL payload's u32 at +69 was recorded here as a "timeout"; the trustlet reports it back as the GROUP ID, and filling a mislabelled field with a plausible number is the entire provenance of gid 60. It is the gid now, so an enrolment can choose its own group. And --group-path exposes the namespace key the trustlet hashes into the group's directory name. It defaults to Android's, which is where this device's existing store lives and how that template is readable. But SAVE_DATA rewrites the group's index container, and an index QTEE later fails to verify takes every template listed in it -- so enrolling into a DIFFERENT namespace is complete isolation from a store we did not write.
2026-09-02 20:12:24 +02:00
detail::StoreU32(out, EnrollGidOff, gid);
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.
2026-09-02 16:46:00 +02:00
}
// 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);
}
Delete a finger's template, not just its name FF_CMD_TA_REMOVE, recovered the way AUTHENTICATE was: read the stub, read the handler. The 0x2006 stub at 0xa15c is a bare `ldp w0, w1, [payload]`, so the request is two u32s -- gid at +0, fid at +4 -- and the 0x2000 dispatcher validates no length. Walking the jump table reproduces authenticate at 0xa180, which is the address already on record, so the table read is sound. Three preconditions, all the trustlet's own. The gid must be the ACTIVE group (it compares against device+0x30, the field SET_ACTIVE_GROUP writes). The fid must be non-zero: zero is not "remove all", it is an error the trustlet logs and refuses. And the fid must be among the loaded templates, because it removes by the SLOT INDEX it finds, not by id. It persists: on a hit the trustlet formats ff_template_<gid>_<slot>.bin and calls ff_file_delete, which arrives on our gpfile listener as an unlink -- so this only works with the store served writable. Proven harmlessly first. --probe-remove sends one command with no map involvement, and a fid the group does not hold answers rc=-2 with the real template untouched -- which is what established that both words are read where we send them, before anything was deleted. Then for real, through fprintd-delete: both 347202-byte containers and their .bak companions unlinked, templates loaded 1 -> 0, and a re-enrolment afterwards completed 20 stages with SAVE_DATA rc=0, so the store is consistent after a removal rather than merely emptier. The ordering the transcript shows is worth keeping: the group index is rewritten and the RPMB anti-rollback counter bumped BEFORE each unlink. That is precisely why an orderly removal leaves a valid store where restoring an older container leaves a tampered one -- the counter has already moved past it. The delete reply now waits for the worker, because only that thread invokes the trustlet and fprintd's Delete methods are synchronous. Names are dropped before templates on purpose: a template that survives a failed removal is a slot leak, while a name that survives a successful one keeps offering a finger that can no longer match.
2026-09-05 04:27:14 +02:00
// REMOVE (TA 0xd7b0, reached from the 0x2006 stub at 0xa15c, which is a
// bare `ldp w0, w1, [payload]`):
// +0x00 u32 gid
// +0x04 u32 fid
// Declared length 0x08. The 0x2000-range dispatcher range-checks the
// command id and jumps; it validates no length, so the payload is exactly
// the two fields.
//
// Three preconditions, all of them the trustlet's own:
//
// gid must equal the ACTIVE group. ff_trustlet_remove compares it
// against device+0x30 -- the same field SET_ACTIVE_GROUP writes and
// AUTHENTICATE checks -- and logs "templates with gid(%u != %u) hasn't
// been loaded." on a mismatch.
//
// fid must be NON-ZERO. Zero is not "remove them all": the trustlet
// logs "error at %s[%s:%u]: removing template with fid equ 0." and
// refuses. Removing every finger means calling this once per fid.
//
// The fid must be among the templates currently LOADED. The trustlet
// walks its loaded list for a matching id and removes by SLOT INDEX,
// not by id -- libfp_template_remove takes the index it found.
//
// It persists. On a hit the trustlet logs "template (gid = %u, fid = %u)
// is found at slot %d.", formats "%s/ff_template_%d_%d.bin" and calls
// ff_file_delete, which arrives on the gpfile listener as an unlink -- so
// the daemon must be serving the store WRITABLE or the container survives
// the call that reported success.
inline constexpr std::size_t RemovePayloadSize = 0x08;
inline constexpr std::size_t RemoveGidOff = 0x00;
inline constexpr std::size_t RemoveFidOff = 0x04;
inline void BuildRemovePayload(std::span<std::byte> out, std::uint32_t gid,
std::uint32_t fid) {
std::ranges::fill(out.first(RemovePayloadSize), std::byte{0});
detail::StoreU32(out, RemoveGidOff, gid);
detail::StoreU32(out, RemoveFidOff, fid);
}
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.
2026-09-02 16:46:00 +02:00
// 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.
Serve QTEE's storage: the enrolled template loads The whole storage path now works from the daemon. On the phone, against the real store: listener 0x7000 sb=516096 -> result=0 REGISTERED listener 0x2000 sb=25600 -> result=0 REGISTERED SET_ACTIVE_GROUP gid=60 path='/data/vendor_de/0/fpdata' gpfile READ .../1lPrxAL0vXRvWPeDkW2c off=4096 len=252114 ... CMD 0x2005 -> result=0 rc=1 templates loaded: 1 QTEE read a 252114-byte enrolled template through our gpfile listener, verified it, and loaded it. Since QTEE unlinks any container whose keyed integrity tag fails, a load is proof the framing is right -- the read/write offset split, the container chunking, and the RPMB anti-rollback read that has to succeed before QTEE will trust any of it. RPMB is served too: SECURITY PROTOCOL IN/OUT against the RPMB well-known LUN, retrying the unit attention the LUN raises once after a reset. Writes are refused unless asked for, because they advance a counter that cannot be moved back, and key programming is refused unconditionally. The store was served READ-ONLY throughout, which is the point. A listener that serves bytes at the wrong offset does not merely fail: QTEE deletes the container it cannot verify, and that is an enrolled fingerprint gone. Read-only makes a wrong build harmless, so it is the default and writing is opt-in. Two ordering facts, both of which produce -2 with no storage read at all -- indistinguishable from a broken listener: * a template reload needs the device init chain to have run FIRST, because that chain allocates the per-slot array the reload writes through; * SET_ACTIVE_GROUP's second field is a NAMESPACE path, not a filesystem one and not the gid again. The trustlet hashes it into the group's directory name, so it has to match what the store was written under. Also: a positive rc is not an error code. ENUMERATE returns the template count there, and running that through the error table printed "unknown" for a good answer.
2026-09-02 18:42:20 +02:00
//
// Its payload is {u32 gid; char path[]} and the path is NOT a filesystem
// path we control: the trustlet hashes it into the SFS group's directory
// name, so it is a namespace key and it has to match whatever the store
// was written under. The store on this device was written by the Android
// stack under its data directory, and every group in it derives from that
// string. Passing anything else resolves a different group, finds nothing
// and answers -2 -- with no storage read at all, which reads like a
// listener failure and is not one.
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.
2026-09-02 16:46:00 +02:00
inline constexpr std::size_t SetActiveGroupGidOff = 0;
Serve QTEE's storage: the enrolled template loads The whole storage path now works from the daemon. On the phone, against the real store: listener 0x7000 sb=516096 -> result=0 REGISTERED listener 0x2000 sb=25600 -> result=0 REGISTERED SET_ACTIVE_GROUP gid=60 path='/data/vendor_de/0/fpdata' gpfile READ .../1lPrxAL0vXRvWPeDkW2c off=4096 len=252114 ... CMD 0x2005 -> result=0 rc=1 templates loaded: 1 QTEE read a 252114-byte enrolled template through our gpfile listener, verified it, and loaded it. Since QTEE unlinks any container whose keyed integrity tag fails, a load is proof the framing is right -- the read/write offset split, the container chunking, and the RPMB anti-rollback read that has to succeed before QTEE will trust any of it. RPMB is served too: SECURITY PROTOCOL IN/OUT against the RPMB well-known LUN, retrying the unit attention the LUN raises once after a reset. Writes are refused unless asked for, because they advance a counter that cannot be moved back, and key programming is refused unconditionally. The store was served READ-ONLY throughout, which is the point. A listener that serves bytes at the wrong offset does not merely fail: QTEE deletes the container it cannot verify, and that is an enrolled fingerprint gone. Read-only makes a wrong build harmless, so it is the default and writing is opt-in. Two ordering facts, both of which produce -2 with no storage read at all -- indistinguishable from a broken listener: * a template reload needs the device init chain to have run FIRST, because that chain allocates the per-slot array the reload writes through; * SET_ACTIVE_GROUP's second field is a NAMESPACE path, not a filesystem one and not the gid again. The trustlet hashes it into the group's directory name, so it has to match what the store was written under. Also: a positive rc is not an error code. ENUMERATE returns the template count there, and running that through the error table printed "unknown" for a good answer.
2026-09-02 18:42:20 +02:00
inline constexpr std::size_t SetActiveGroupPathOff = 4;
inline constexpr std::string_view GroupNamespacePath = "/data/vendor_de/0/fpdata";
inline std::vector<std::byte> BuildSetActiveGroup(
std::uint32_t gid, std::string_view path = GroupNamespacePath) {
std::vector<std::byte> out(SetActiveGroupPathOff + path.size() + 1, std::byte{0});
detail::StoreU32(out, SetActiveGroupGidOff, gid);
for (std::size_t i = 0; i < path.size(); i++)
out[SetActiveGroupPathOff + i] = static_cast<std::byte>(path[i]);
return out;
}
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.
2026-09-02 16:46:00 +02:00
// ---- The request/response envelope ------------------------------------
//
// sendRequest carries two buffers in and two back. The request is:
//
// +0x00 u32 command id
// +0x04 u32 declared payload length
// +0x10 the payload
//
// and the returned copy of it carries the trustlet's own return code and
// the capture metric in the header, ahead of the payload:
//
// +0x08 i32 rc the trustlet's result, distinct from QTEE's
// +0x0c i32 metric CAPTURE_IMAGE's finger signal
//
// The metric is a HEADER field. It has been called "payload+12" in this
// project's notes and it is not; it tracks the finger reproducibly and
// every recorded number depends on reading it here.
inline constexpr std::size_t ReqCmdOff = 0x00;
inline constexpr std::size_t ReqLenOff = 0x04;
inline constexpr std::size_t ReqPayloadOff = 0x10;
inline constexpr std::size_t RespRcOff = 0x08;
inline constexpr std::size_t RespMetricOff = 0x0c;
inline void BuildRequest(std::span<std::byte> req, Cmd cmd,
std::span<const std::byte> payload) {
std::ranges::fill(req, std::byte{0});
detail::StoreU32(req, ReqCmdOff, static_cast<std::uint32_t>(cmd));
if (!payload.empty()) {
detail::StoreU32(req, ReqLenOff, static_cast<std::uint32_t>(payload.size()));
std::ranges::copy(payload, req.begin() + static_cast<std::ptrdiff_t>(ReqPayloadOff));
}
}
inline std::int32_t ResultCode(std::span<const std::byte> reqOut) {
return static_cast<std::int32_t>(detail::LoadU32(reqOut, RespRcOff));
}
inline std::int32_t CaptureMetric(std::span<const std::byte> reqOut) {
return static_cast<std::int32_t>(detail::LoadU32(reqOut, RespMetricOff));
}
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.
2026-09-02 16:46:00 +02:00
// ---- 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;
}