fingerprintd/tests/Ta/main.cpp

367 lines
18 KiB
C++
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 unit tests.
Two halves, deliberately separate so neither can prop the other up:
* the payload layouts and the verdict rule, driven by explicit inputs that
spell out what each wire condition means;
* the counting policy, driven by three recorded authentication runs.
The recorded runs cannot pin Classify's inputs a transcript prints a decoded
label, so feeding the label back in would be circular. What they pin is the
thing that actually went wrong repeatedly: how frames are tallied. A run where
31 of 48 frames answered "not identified yet" was read as 8 matches out of 39
attempts, which invents 31 rejections that never happened.
*/
import std;
import Fingerprintd;
using namespace fingerprintd::ta;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::uint32_t Get32(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;
}
// A recorded run, reduced to the counts the journal states.
struct Tally {
int match = 0, rejected = 0, neverRan = 0, notIdentifiedYet = 0;
int Terminal() const { return match + rejected; }
int Frames() const { return match + rejected + neverRan + notIdentifiedYet; }
};
Tally Parse(std::string_view name) {
Tally t;
std::string path = std::format("tests/Ta/fixtures/{}", name);
std::ifstream f(path);
if (!f) {
std::println(std::cerr, "FAIL: cannot open fixture {}", path);
++Failures;
return t;
}
std::string line;
while (std::getline(f, line)) {
if (line.starts_with("#")) continue;
if (!line.contains("AUTH ")) continue;
if (line.contains("*** MATCH ***")) t.match++;
else if (line.contains("matcher never ran")) t.neverRan++;
else if (line.contains("REJECTED")) t.rejected++;
// The older label for rc=-11. It is NOT a rejection.
else if (line.contains("no match")) t.notIdentifiedYet++;
}
return t;
}
}
int main() {
// ---- The verdict rule, from explicit wire conditions
//
// Each case states what the trustlet actually left in the response, not
// what a transcript called it.
Check(Classify(0, FidPoison) == Verdict::MatcherNeverRan,
"poison intact -> the matcher never ran");
Check(Classify(RcTryAgain, 0) == Verdict::NotIdentifiedYet,
"rc=-11 -> not identified yet");
Check(Classify(0, 1296911490) == Verdict::Match,
"rc=0 with a fid -> match");
Check(Classify(0, 0) == Verdict::Rejected,
"rc=0 with the fid zeroed -> rejected");
// -11 is not a rejection, and this is the assertion that would have
// stopped the mislabelling.
Check(Classify(RcTryAgain, 0) != Verdict::Rejected,
"rc=-11 must never classify as a rejection");
Check(!IsTerminal(Classify(RcTryAgain, 0)), "rc=-11 is not terminal");
Check(!IsTerminal(Classify(0, FidPoison)), "a released finger is not terminal");
Check(IsTerminal(Classify(0, 0)) && IsTerminal(Classify(0, 7)),
"both real verdicts are terminal");
// The poison outranks rc: a released frame also carries rc=0, so without
// it a release is indistinguishable from a rejection.
Check(Classify(0, FidPoison) != Verdict::Rejected,
"a zero-init buffer would confuse release with rejection");
// ---- The counting policy, against three recorded runs
{
Tally enrolled = Parse("auth-enrolled-finger.txt");
Check(enrolled.match == 15 && enrolled.rejected == 5 && enrolled.neverRan == 5,
"enrolled-finger run: 15 match / 5 rejected / 5 never ran");
Check(enrolled.Terminal() == 20, "enrolled-finger run: 20 terminal frames");
Tally wrong = Parse("auth-wrong-finger.txt");
Check(wrong.match == 0 && wrong.rejected == 19, "wrong-finger control: 0 of 19");
Check(wrong.Terminal() == 19, "wrong-finger run: 19 terminal frames");
// The claim that actually matters about this device.
Check(wrong.match == 0, "zero false accepts");
// The stock-budget run: most of the traffic is "not identified yet".
Tally stock = Parse("auth-stock-budget.txt");
Check(stock.match == 8, "stock-budget run: 8 matches");
Check(stock.notIdentifiedYet == 31, "stock-budget run: 31 rc=-11 frames");
Check(stock.neverRan == 9, "stock-budget run: 9 frames the matcher never saw");
Check(stock.rejected == 0, "stock-budget run: not one real rejection");
// Every frame that carried an image matched. Counting -11 frames as
// attempts turns that into 8 of 39.
Check(stock.Terminal() == 8, "stock-budget run: 8 terminal frames, all matches");
Check(stock.Frames() == 48, "stock-budget run: 48 frames total");
Check(stock.Terminal() != stock.Frames() - stock.neverRan,
"the wrong denominator is 39, and it is not the terminal count");
}
// ---- Event context
{
std::vector<std::byte> ev(EventContextSize);
BuildEventContext(ev, { .event = Event::ImageReady });
Check(Get32(ev, EvEventOff) == 7, "event id little endian at +4");
Check(Get32(ev, EvScanSlotsOff) == 1, "scan slot count defaults to 1");
Check(Get32(ev, EvFlagsOff) == 0x08080000, "flags");
Check(Get32(ev, EvZeroAOff) == 0 && Get32(ev, EvZeroBOff) == 0, "the two zero words");
// A zero scan-slot count is the bug that ran the enrol loop zero times
// while logging as though it had run.
BuildEventContext(ev, { .event = Event::FingerTouched, .scanSlots = 0 });
Check(Get32(ev, EvScanSlotsOff) == 0, "an explicit zero is still writable");
Check(Get32(ev, EvEventOff) == 5, "touch event id");
// Big-endian would put event 7 at 0x07000000, fail the 5..14 bound
// check, and silently do nothing while returning rc=0.
BuildEventContext(ev, { .event = Event::ImageReady });
Check(std::to_integer<unsigned>(ev[EvEventOff]) == 7, "low byte carries the id");
Check(std::to_integer<unsigned>(ev[EvEventOff + 3]) == 0, "not big endian");
}
// ---- Capture flags
Check(CaptureFlagsEnrol == 0xC0040002, "stock enrol capture flags");
Check((CaptureFlagsEnrol & CaptureFlagsUseCallerFrame) == 0, "bit 0 stays clear");
Check((CaptureFlagsEnrol & 0x40000002) != 0, "bit 1 or 30 set, or nothing runs");
Check(CaptureFlagsOff == 0x18 && CaptureDeclaredLen == 0x14,
"the flags word sits past the declared length on purpose");
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
// ---- The capture payload's two fields
{
std::vector<std::byte> cap(CaptureDeclaredLen);
BuildCapturePayload(cap);
Check(Get32(cap, CaptureFrameCountOff) == 1, "frame count defaults to 1");
Check(Get32(cap, CaptureSelectorOff) == 1, "selector defaults to 1");
Check(Get32(cap, 0) == 0, "payload+0 is left for QTEE to patch the region into");
// An all-zero payload is what -201 looks like on the wire.
std::vector<std::byte> zero(CaptureDeclaredLen, std::byte{0});
Check(Get32(zero, CaptureSelectorOff) == 0, "selector 0 returns metric 0");
// The fields must fit inside the declared length.
Check(CaptureSelectorOff + 4 <= CaptureDeclaredLen, "selector fits the payload");
Check(CaptureFrameCountOff < CaptureSelectorOff, "count precedes selector");
// ...while the flags word deliberately does not.
Check(CaptureFlagsOff >= CaptureDeclaredLen, "the flags word sits past it");
}
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 masks: bit 30 is the whole discriminator
Check((SaveMaskTemplate & (1u << 30)) != 0, "template save sets bit 30");
Check((SaveMaskCalibration & (1u << 30)) == 0, "calibration save clears bit 30");
Check(SaveMaskTemplate != SaveMaskCalibration, "the two masks differ");
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
{
std::vector<std::byte> up(UpdateTemplatePayloadSize);
BuildUpdateTemplate(up, /*slotIndex*/ 0, /*touchFrame*/ true);
// The declared length stock sends. 0x2e0 was tried in this project and
// answered -90; the length is not a free parameter.
Check(UpdateTemplatePayloadSize == 0x2dc, "declared length is 732, as stock sends");
Check(Get32(up, UpdScanSlotsOff) == 1, "scan slots default to 1, as REPORT_EVENT");
Check(Get32(up, UpdZeroAOff) == 0, "+716 is zero");
Check(Get32(up, UpdSlotIndexOff) == 0, "the first folded frame is slot 0");
Check(Get32(up, UpdFlagsOff) == 0x00080040, "a touch frame sets bit 6 over the base");
// THE invariant. The dispatcher stub reads this word after the handler
// returns and, if it is non-zero, computes the response length from
// +0x2dc. Every attempt in this project that set it killed the app.
Check(Get32(up, UpdRespLenOff) == 0,
"+728 MUST be zero or the stub computes a response length");
BuildUpdateTemplate(up, /*slotIndex*/ 3, /*touchFrame*/ false);
Check(Get32(up, UpdSlotIndexOff) == 3, "the slot index counts folded frames");
Check(Get32(up, UpdFlagsOff) == 0x00080000, "a held frame leaves bit 6 clear");
Check(Get32(up, UpdRespLenOff) == 0, "+728 stays zero on every frame");
// The flags word is NOT the event context's, and confusing the two is
// an easy mistake because the payloads are otherwise the same struct.
Check(UpdFlagsBase != EvDefaultFlags,
"the update flags are 0x00080000, not the event context's 0x08080000");
// The fields it shares with REPORT_EVENT really are at the same
// offsets; that is why one struct serves both commands.
Check(UpdScanSlotsOff == EvScanSlotsOff && UpdSlotIndexOff == EvSlotIndexOff
&& UpdFlagsOff == EvFlagsOff,
"the update payload reuses the event context's field offsets");
// The event id is deliberately NOT written: stock memsets and never
// touches +4, and this command must not re-run the matcher.
Check(Get32(up, EvEventOff) == 0, "no event id -- the matcher must not re-run");
// Every byte outside the written fields stays zero: the whole 732-byte
// payload carries three non-zero bytes here -- the scan-slot count,
// the slot index, and the one set byte of 0x00080000. Anything else
// non-zero means a field was written that stock does not write.
std::size_t nonZero = 0;
for (std::size_t i = 0; i < UpdateTemplatePayloadSize; i++)
if (up[i] != std::byte{0}) nonZero++;
Check(nonZero == 3, "only scan slots, slot index and the flags byte are set");
}
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 payload
{
std::vector<std::byte> au(AuthPayloadSize);
BuildAuthPayload(au, 1, 60);
Check(Get32(au, 0) == 1, "operation id");
Check(Get32(au, AuthGidOff) == 60, "gid at +8");
Check(std::to_integer<unsigned>(au[AuthRelightOff]) == 1, "relight defaults set");
Check(std::to_integer<unsigned>(au[AuthCoveredOff]) == 1, "covered defaults set");
Check(AuthPayloadSize == 0x0e, "declared length");
BuildAuthPayload(au, 1, 60, false, false);
Check(std::to_integer<unsigned>(au[AuthRelightOff]) == 0, "flags clearable");
}
// ---- ENROLL payload: an all-zero token is accepted when trusted
// enrolment is off, which is why pmOS needs no Gatekeeper.
{
std::vector<std::byte> tok(EnrollPayloadSize);
BuildEnrollPayload(tok, 60);
Check(EnrollPayloadSize == 74 && EnrollTokenSize == 69, "enroll payload sizes");
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
// +69 is the GID, not a timeout. The trustlet reports it back as the
// group, which is the entire provenance of gid 60.
Check(Get32(tok, EnrollGidOff) == 60, "gid at +69");
BuildEnrollPayload(tok, 1000);
Check(Get32(tok, EnrollGidOff) == 1000, "an enrolment chooses 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
bool tokenZero = true;
for (std::size_t i = 0; i < EnrollTokenSize; i++)
if (tok[i] != std::byte{0}) tokenZero = false;
Check(tokenZero, "the 69-byte auth token is all zero");
}
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
// ---- SET_ACTIVE_GROUP: a gid and a NAMESPACE path, not a file path
{
auto sag = BuildSetActiveGroup(60);
Check(Get32(sag, SetActiveGroupGidOff) == 60, "gid at +0");
std::string path;
for (std::size_t i = SetActiveGroupPathOff; i < sag.size() - 1; i++)
path.push_back(static_cast<char>(std::to_integer<unsigned char>(sag[i])));
Check(path == "/data/vendor_de/0/fpdata", "the Android namespace path");
Check(sag.back() == std::byte{0}, "NUL-terminated");
Check(sag.size() == SetActiveGroupPathOff + GroupNamespacePath.size() + 1,
"length is 4 + path + NUL");
// The path is a key the trustlet hashes into the group directory name,
// so it is not ours to invent. A gid rendered as text is not it.
Check(GroupNamespacePath != "60", "the second field is not the gid again");
Check(GroupNamespacePath.starts_with('/'), "it looks like a path because it is 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
// ---- Responses: the payload starts at +0x10, and forgetting that reads
// a confident zero.
{
std::vector<std::byte> resp(256);
auto put32 = [&](std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
resp[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
};
put32(ResponsePayloadOff + RespSamplesRemainingOff, 9);
put32(ResponsePayloadOff + RespGidOff, 60);
put32(ResponsePayloadOff + RespFidOff, 1296911490);
Check(SamplesRemaining(resp) == 9, "samples remaining at payload+36");
Check(MatchedGid(resp) == 60, "gid at payload+0x0c");
Check(MatchedFid(resp) == 1296911490, "fid at payload+0x10");
Check(Get32(resp, RespSamplesRemainingOff) != 9,
"reading at the payload offset directly gives the wrong word");
}
// ---- The request/response envelope
{
std::vector<std::byte> req(256);
std::array<std::byte, 4> payload{ std::byte{1}, std::byte{2},
std::byte{3}, std::byte{4} };
BuildRequest(req, Cmd::SyncConfig, payload);
Check(Get32(req, ReqCmdOff) == 0x100d, "command id at +0");
Check(Get32(req, ReqLenOff) == 4, "declared length at +4");
Check(std::to_integer<unsigned>(req[ReqPayloadOff]) == 1, "payload at +0x10");
Check(ReqPayloadOff == ResponsePayloadOff, "request and response payloads share the offset");
// An empty payload leaves the declared length zero rather than
// pointing at uninitialised bytes.
BuildRequest(req, Cmd::Enumerate, {});
Check(Get32(req, ReqLenOff) == 0, "no payload, no declared length");
Check(Get32(req, ReqCmdOff) == 0x2005, "ENUMERATE");
// rc and the metric are HEADER fields, ahead of the payload, and are
// distinct from each other.
std::vector<std::byte> out(256);
auto put = [&](std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
out[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
};
put(RespRcOff, static_cast<std::uint32_t>(-11));
put(RespMetricOff, 345);
Check(ResultCode(out) == -11, "rc at +8, signed");
Check(CaptureMetric(out) == 345, "metric at +0x0c");
Check(RespRcOff != RespMetricOff && RespMetricOff < ResponsePayloadOff,
"both sit in the header, ahead of the payload");
}
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
// ---- Poisoning
{
std::vector<std::byte> payload(64);
PoisonFid(payload);
Check(Get32(payload, RespFidOff) == FidPoison, "poison written at +0x10");
Check(Classify(0, Get32(payload, RespFidOff)) == Verdict::MatcherNeverRan,
"an untouched poisoned payload classifies as never-ran");
// PoisonFid takes the PAYLOAD and offsets internally. Handing it a
// span already offset by ResponsePayloadOff double-counts and poisons
// payload+0x20, leaving the real fid field zero -- which makes every
// released finger read as a rejection. That shipped once.
Check(RespFidOff == ResponsePayloadOff,
"the two offsets are equal, which is exactly why double-applying is silent");
std::vector<std::byte> wrong(64);
PoisonFid(std::span(wrong).subspan(ResponsePayloadOff));
Check(Get32(wrong, RespFidOff) != FidPoison,
"double-offsetting leaves the fid field unpoisoned");
Check(Classify(0, Get32(wrong, RespFidOff)) == Verdict::Rejected,
"and an unpoisoned release is then misread as a rejection");
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
}
// ---- Init chain
Check(InitChain.size() == 6, "six init steps");
Check(InitChain.back() == Cmd::SyncStatistics,
"SYNC_STATISTICS last, or the first enrol frame faults on a NULL");
Check(InitChain.front() == Cmd::InitSpi, "SPI first");
Check(std::ranges::find(InitChain, Cmd::TaInit) != InitChain.end(), "TA_INIT present");
// ---- Error table
Check(StrError(-201) == "Null pointer", "-201");
Check(StrError(-205) == "Device not found", "-205");
Check(StrError(-11) == "Try again", "-11");
Check(StrError(-200) == "Bad parameter(s)", "-200 (a gid mismatch)");
Check(StrError(0) == "Success", "0");
Check(StrError(-90) == "unknown", "-90 is QTEE's, not the trustlet's");
Check(QteeAppGone == -90, "QTEE app-gone");
Check(RcDeviceNotFound == -205, "second init in one power cycle");
if (Failures == 0) std::println("Ta: all tests passed");
return Failures;
}