fingerprintd/interfaces/Fingerprintd-Ta.cppm

443 lines
21 KiB
Text
Raw 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,
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,
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);
// ---- 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);
}
// 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;
}