// 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 b, std::size_t off) { std::uint32_t v = 0; for (std::size_t i = 0; i < 4; i++) v |= static_cast(std::to_integer(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 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(ev[EvEventOff]) == 7, "low byte carries the id"); Check(std::to_integer(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"); // ---- The capture payload's two fields { std::vector 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 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"); } // ---- 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: template learning { std::vector 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"); } // ---- AUTHENTICATE payload { std::vector 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(au[AuthRelightOff]) == 1, "relight defaults set"); Check(std::to_integer(au[AuthCoveredOff]) == 1, "covered defaults set"); Check(AuthPayloadSize == 0x0e, "declared length"); BuildAuthPayload(au, 1, 60, false, false); Check(std::to_integer(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 tok(EnrollPayloadSize); BuildEnrollPayload(tok, 60); Check(EnrollPayloadSize == 74 && EnrollTokenSize == 69, "enroll payload sizes"); // +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"); 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"); } // ---- 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(std::to_integer(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"); } // ---- Responses: the payload starts at +0x10, and forgetting that reads // a confident zero. { std::vector 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((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 req(256); std::array 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(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 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((v >> (8 * i)) & 0xFF); }; put(RespRcOff, static_cast(-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"); } // ---- Poisoning { std::vector 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 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"); } // ---- 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; }