fingerprintd/tests/Ta/main.cpp
Jorijn van der Graaf 41e86f84f4 Delete a finger's template, not just its name
FF_CMD_TA_REMOVE, recovered the way AUTHENTICATE was: read the stub, read the
handler. The 0x2006 stub at 0xa15c is a bare `ldp w0, w1, [payload]`, so the
request is two u32s -- gid at +0, fid at +4 -- and the 0x2000 dispatcher
validates no length. Walking the jump table reproduces authenticate at 0xa180,
which is the address already on record, so the table read is sound.

Three preconditions, all the trustlet's own. The gid must be the ACTIVE group
(it compares against device+0x30, the field SET_ACTIVE_GROUP writes). The fid
must be non-zero: zero is not "remove all", it is an error the trustlet logs
and refuses. And the fid must be among the loaded templates, because it
removes by the SLOT INDEX it finds, not by id.

It persists: on a hit the trustlet formats ff_template_<gid>_<slot>.bin and
calls ff_file_delete, which arrives on our gpfile listener as an unlink -- so
this only works with the store served writable.

Proven harmlessly first. --probe-remove sends one command with no map
involvement, and a fid the group does not hold answers rc=-2 with the real
template untouched -- which is what established that both words are read where
we send them, before anything was deleted.

Then for real, through fprintd-delete: both 347202-byte containers and their
.bak companions unlinked, templates loaded 1 -> 0, and a re-enrolment
afterwards completed 20 stages with SAVE_DATA rc=0, so the store is consistent
after a removal rather than merely emptier.

The ordering the transcript shows is worth keeping: the group index is
rewritten and the RPMB anti-rollback counter bumped BEFORE each unlink. That
is precisely why an orderly removal leaves a valid store where restoring an
older container leaves a tampered one -- the counter has already moved past
it.

The delete reply now waits for the worker, because only that thread invokes
the trustlet and fprintd's Delete methods are synchronous. Names are dropped
before templates on purpose: a template that survives a failed removal is a
slot leak, while a name that survives a successful one keeps offering a finger
that can no longer match.
2026-09-05 04:27:14 +02:00

382 lines
19 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");
}
// ---- REMOVE payload. Recovered from the 0x2006 stub at 0xa15c, which is
// a bare `ldp w0, w1, [payload]` into ff_trustlet_remove.
{
std::vector<std::byte> rm(RemovePayloadSize);
BuildRemovePayload(rm, 10000, 1768306590);
Check(RemovePayloadSize == 0x08, "declared length is exactly the two fields");
Check(Get32(rm, RemoveGidOff) == 10000, "gid at +0");
Check(Get32(rm, RemoveFidOff) == 1768306590, "fid at +4");
// Order matters and is not symmetric: the trustlet compares the FIRST
// word against device+0x30 (the active group) and searches its loaded
// list for the SECOND. Swap them and it reports the wrong-group error.
Check(RemoveGidOff < RemoveFidOff, "gid precedes fid");
Check(static_cast<std::uint32_t>(Cmd::Remove) == 0x2006, "command id");
}
// ---- 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;
}