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");
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 18:19:26 +02:00
|
|
|
// ---- 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");
|
Fix the poison offset: a released finger was reading as a rejection
PoisonFid takes the payload and offsets to the fid field internally. It was
being handed a span already offset by the payload offset, so the poison landed
at payload+0x20 and the real fid field stayed zero. A frame where the matcher
never ran then looks exactly like a frame where it ran and rejected -- which is
the specific failure this project has recorded three times and is precisely
what the poison exists to prevent.
Visible in a real run: the frames marked REJECTED were 138, 138, 133, 137, 134
against a floor of 136, i.e. every one of them was a finger-RELEASE frame with
nothing on the sensor. Five rejections that never happened.
The two offsets are numerically equal, which is why double-applying is silent,
so the test now pins both directions: poisoning the payload marks the fid
field, and poisoning an already-offset span leaves it zero and misclassifies.
Also adds --rescan=N, which patches common.max_authentication_rescan_times into
the config. The stock budget lets a whole run end with no terminal verdict --
correct for shipping, useless as a measurement, because a wrong-finger control
that never reaches a verdict has not demonstrated a rejection. Forcing 0 makes
every frame terminal. It prints MEASUREMENT ONLY because a rate taken that way
is a per-frame figure with the retry mechanism disabled, and is not a shipping
reject rate.
2026-09-02 19:16:50 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|