The templates live on the persist partition and survive a reflash; the
finger-name map lives on the rootfs and does not. A reflashed phone comes
back with the trustlet holding every template it had and the daemon
knowing none of them by name -- and with five of them the group is full, so
every enrolment from Settings answered -207 "Up to the limit" (fennek182,
2026-10-13). No D-Bus client could get out of that: the list answered from
the map, delete removed only the fids the map knew, and ENUMERATE was sent
with an empty payload and read for its count alone.
A finger re-enrolled under its old name produced the same state one
template at a time: the map took the new fid and the old template stayed
in its slot.
ENUMERATE now asks with room for every slot the group can hold, and the
reply names the templates: one 8-byte entry per loaded one, its fid in the
second word. Verified live: the three fids the map names came back among
the four listed. With the list in hand:
- An enrolment removes the templates no name accounts for before ENROLL.
They can match nothing (a verify filters on the map's fids, standby
arms from them), and each is a slot the enrolment may need or the
duplicate the trustlet would refuse it for.
- Deleting every finger deletes them too, and the job is posted even
when the map is empty, which after a reflash it is.
- Re-enrolling a finger drops its name at EnrollStart, so the old
template is unnamed and goes under the first rule. The trustlet forces
the order: with duplicate checking on, a re-enrolment of a finger it
still holds does not complete (2026-09-04: one attempt 0 accepted of 7
presses, another stalled at sample 8 of 20).
- The claim log counts them: "4 template(s) loaded, 1 of them unnamed".
The map now decides what an enrolment keeps, so the Store module says so,
and three rules keep that from turning a damaged map into lost templates.
A line the decoder cannot use -- a typo in a hand rebuild, a finger named
twice -- makes the map untrusted, and no enrolment removes anything on its
account. A line it cannot read at all is written back, word for word, by
every save but a delete of every finger, so the distrust outlasts the next
enrolment; a line it can read but not seat, the repeated name or a sixth
finger, is kept as the finger and fid it names and goes with that finger's
delete, template included, instead of coming back as the finger's name;
and the finger named twice now answers to its FIRST line, where before
the last line's fid replaced the earlier one's. A file that will not open
or read, for any reason but its absence, refuses
enrolment and deletion outright and is never written over. And the map is
written with GLib's g_file_set_contents_full under its consistent and
durable flags, and a save that did not land is reported: the old map
stays, a re-enrolment whose name drop did not land is refused with the
name put back, a delete whose save did not land removes nothing, and an
enrolment whose name did not land is reported to the client as failed,
not completed. Of the daemon's own paths, deleting every finger is the
one removal of unnamed templates on the user's word rather than the
map's: in a managed group it takes every template, named or not. A delete is refused while the
claimant's enrolment or verify for that user is in progress, because the
running job's keep-list came from the claim's map; a standby verify for
that user is stopped and the delete goes ahead. Group 0 is never tidied:
every daemon path that removes a template the map does not name, and the
name drop before a re-enrolment, asks one predicate, and it says no for
group 0 -- root's,
which no session user is, and the group a stock Android install's primary
user also writes. Root re-enrolling a finger behaves as it did before.
The --probe-remove diagnostic removes the fid it is given in any group,
with no map involved, as before.
A result belongs to the operation that asked for it. EnrollStart and
VerifyStart number the operation, the job and every event it posts carry
the number, and a Stop or the claim's end clears it: a job still queued
under a cleared number exits at dequeue instead of running an operation
nobody wants, and a result still in flight is neither named nor reported
-- its template, unnamed, goes with the group's next enrolment. Without
this, a stop followed by a new start could name a late result under the
new finger and remove the just-saved template as unnamed. An enrolment is
also refused before anything changes when the store is read-only, and the
claim's map is re-read from disk at every EnrollStart, so a file rebuilt
by hand under a held claim counts.
The probe passes the loaded list as the named one. A diagnostic does not
tidy the group it is probing. fplearn.sh's header no longer says that
re-enrolment adds a template with no trustlet-side remove, and its `enrol`
refusal gives the protocol's own reason (a template with no history)
instead of the trustlet's duplicate refusal.
Tested on the dev phone. An earlier build of this change, before the
shared predicate and the damaged-map rule, removed the phone's one stray
template at an EnrollStart for a finger not in the map (fid 1313616972,
8 -> 6 containers), reloaded the group as the three named ones, got rc=0
from ENROLL and cancelled cleanly. A build of this series then ran the
same claim and EnrollStart on the group it left behind: three loaded, none
unnamed, nothing removed, ENROLL rc=0, cancelled cleanly. The build of this
series' tip then enrolled a fourth finger to completion (20 samples
accepted, one refused), which the map saved and listed, and deleted it
again under the same claim: its template removed, the group and the map
back to the three they started with.
409 lines
20 KiB
C++
409 lines
20 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");
|
|
}
|
|
|
|
// ---- ENUMERATE: room for N entries in, {gid, fid} per template out
|
|
{
|
|
std::vector<std::byte> en(EnumeratePayloadSize);
|
|
BuildEnumeratePayload(en, 5);
|
|
Check(en.size() == 4 && Get32(en, 0) == 5, "the capacity is the one word");
|
|
|
|
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);
|
|
};
|
|
std::size_t e0 = ResponsePayloadOff + EnumerateEntriesOff;
|
|
put32(e0 + 0, 10000); put32(e0 + 4, 1376187901);
|
|
put32(e0 + 8, 10000); put32(e0 + 12, 1103829000);
|
|
put32(e0 + 16, 10000); put32(e0 + 20, 7);
|
|
auto fids = EnumeratedFids(resp, 2);
|
|
Check(fids.size() == 2, "as many entries as the count says");
|
|
Check(fids.size() == 2 && fids[0] == 1376187901 && fids[1] == 1103829000,
|
|
"the fid is the second word of each 8-byte entry");
|
|
Check(EnumeratedFids(resp, 0).empty(), "a count of 0 lists nothing");
|
|
Check(EnumeratedFids(resp, -2).empty(), "an error code lists nothing");
|
|
// The count is the trustlet's word, the buffer is ours: a count past
|
|
// the buffer stops at the buffer.
|
|
Check(EnumeratedFids(resp, 1000).size() == (resp.size() - e0) / EnumerateEntrySize,
|
|
"bounded by the reply buffer, never by the count");
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|