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

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

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

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

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

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

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

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

View file

@ -0,0 +1,28 @@
# Recorded authentication verdicts
Verdict lines excerpted from three real authentication runs on the dev phone,
2026-09-02. Only the `AUTH` lines are carried: the full transcripts are ~40 KB
each and are thick with the device's SFS container names, none of which this
test needs. Originals are in the fp6 journal under
`journal/fingerprint/captures/`.
| file | run | recorded outcome |
|---|---|---|
| `auth-enrolled-finger.txt` | the enrolled finger, `max_authentication_rescan_times: 0` | 15 match, 5 rejected, 5 frames the matcher never saw |
| `auth-wrong-finger.txt` | a different finger, same config | 0 match, 19 rejected, 2 never ran |
| `auth-stock-budget.txt` | the enrolled finger at the stock rescan budget | 8 match, 31 "no match", 9 never ran |
The third file is the important one and uses an **older label**: its 31 "no
match" lines are `rc=-11` frames, which mean *not identified yet, attempts
remain* — not rejections. Counting them as rejections turns an 8-for-8 run into
an apparent 8-of-39 and reads as a flaky sensor. That mislabelling happened
three separate times before the comparison producing `-11` was actually read,
which is why the counting rule has a test at all.
The two forced-terminal runs set the rescan budget to 0 so every frame yields a
verdict. Their 15/20 is therefore a per-frame figure measured with the retry
mechanism disabled, **not** a shipping reject rate; per press it was 5 of 5.
`fid=1296911490` is the enrolled template's identifier on one dev phone. It is
an opaque id, not biometric data — a template is a separate 258850-byte
QTEE-encrypted container that never leaves the TEE.

View file

@ -0,0 +1,27 @@
# Verdict lines excerpted from 2026-09-02-auth-enrolled-finger-15-of-20.txt
# fp6 journal: journal/fingerprint/captures/2026-09-02-auth-enrolled-finger-15-of-20.txt
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH *** MATCH *** gid=60 fid=1296911490 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)

View file

@ -0,0 +1,50 @@
# Verdict lines excerpted from 2026-09-02-auth-run-eight-matches.txt
# fp6 journal: journal/fingerprint/captures/2026-09-02-auth-run-eight-matches.txt
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH matcher never ran gid=0 fid=0
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH matcher never ran gid=0 fid=0
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH *** MATCH *** gid=60 fid=1296911490
AUTH no match gid=0 fid=0
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH matcher never ran gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0
AUTH no match gid=0 fid=0

View file

@ -0,0 +1,23 @@
# Verdict lines excerpted from 2026-09-02-auth-wrong-finger-0-of-19.txt
# fp6 journal: journal/fingerprint/captures/2026-09-02-auth-wrong-finger-0-of-19.txt
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH REJECTED gid=0 fid=0 (rc=0)
AUTH matcher never ran gid=0 fid=0 (rc=0)

234
tests/Ta/main.cpp Normal file
View file

@ -0,0 +1,234 @@
// 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");
// ---- 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");
// ---- 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");
Check(Get32(tok, EnrollTimeoutOff) == 60, "timeout at +69");
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");
}
// ---- 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");
}
// ---- 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");
}
// ---- 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;
}