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.
367 lines
18 KiB
C++
367 lines
18 KiB
C++
// 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");
|
|
|
|
// ---- 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");
|
|
}
|
|
|
|
// ---- 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<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");
|
|
}
|
|
|
|
// ---- 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");
|
|
// +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<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");
|
|
}
|
|
|
|
// ---- 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");
|
|
}
|
|
|
|
// ---- 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");
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|