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:
parent
1d26852a6b
commit
ffad29ba4e
8 changed files with 719 additions and 1 deletions
353
interfaces/Fingerprintd-Ta.cppm
Normal file
353
interfaces/Fingerprintd-Ta.cppm
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Fingerprintd:Ta — the focal64 trustlet's command surface.
|
||||
|
||||
Requests and responses only: build a payload, read a response, name an error.
|
||||
No TEE, no transport. The daemon shell invokes; this module decides what bytes
|
||||
go in and what the bytes coming back mean.
|
||||
|
||||
Almost nothing here is guessable. The layouts were read out of the stock HAL
|
||||
(`fingerprint.default.so`, unoptimised and unstripped) and out of the trustlet
|
||||
itself, and three of the fields were found only because QTEE recorded a fault
|
||||
naming the instruction that read them. Each one is annotated with where it came
|
||||
from, because "we tried values until it worked" is exactly what did not work.
|
||||
*/
|
||||
|
||||
export module Fingerprintd:Ta;
|
||||
import std;
|
||||
|
||||
export namespace fingerprintd::ta {
|
||||
|
||||
// ---- Commands ---------------------------------------------------------
|
||||
|
||||
enum class Cmd : std::uint32_t {
|
||||
TaInit = 0x1004,
|
||||
InitSpi = 0x1006,
|
||||
ProbeDevice = 0x100a,
|
||||
InitDevice = 0x100b,
|
||||
SyncConfig = 0x100d,
|
||||
SyncStatistics = 0x100e,
|
||||
StartScanning = 0x1012,
|
||||
CaptureImage = 0x1013,
|
||||
SaveData = 0x1014,
|
||||
ReportEvent = 0x1018,
|
||||
WorkMode = 0x1020,
|
||||
|
||||
PreEnroll = 0x2000,
|
||||
Enroll = 0x2001,
|
||||
PostEnroll = 0x2002,
|
||||
Cancel = 0x2004,
|
||||
Enumerate = 0x2005,
|
||||
SetActiveGroup = 0x2007,
|
||||
Authenticate = 0x2008,
|
||||
};
|
||||
|
||||
// The device init chain, in order. Every step returns rc=0 on a healthy
|
||||
// sensor, ending in the trustlet's own "TA is successfully initialized."
|
||||
//
|
||||
// SyncStatistics is not optional and is not cosmetic. `g_statistics` is
|
||||
// statically NULL and command 0x100e is its only writer; do_enroll stores a
|
||||
// timestamp through it without a null check, so the first enrol frame that
|
||||
// ever gets that far takes a data abort at TA offset 0x15f74. The trustlet
|
||||
// dies, every later command answers -90, and nothing in the log says why —
|
||||
// it was found in QTEE's fault ring, not by varying inputs.
|
||||
inline constexpr std::array<Cmd, 6> InitChain = {
|
||||
Cmd::InitSpi, Cmd::ProbeDevice, Cmd::InitDevice,
|
||||
Cmd::TaInit, Cmd::WorkMode, Cmd::SyncStatistics,
|
||||
};
|
||||
|
||||
// A reload needs the same chain. Without it the per-slot enroll-template
|
||||
// array is never allocated and FtInitEnrollTplData writes through a NULL
|
||||
// at TA 0xceb70 — the same shape of bug as g_statistics, found the same
|
||||
// way, and harmless right up until a template is actually reachable.
|
||||
inline constexpr std::size_t SyncStatisticsPayloadSize = 560;
|
||||
|
||||
// ---- Work modes and events -------------------------------------------
|
||||
|
||||
enum class WorkMode : std::uint32_t {
|
||||
Idle = 0, WaitTouch = 1, WaitLeave = 2, Gesture = 6,
|
||||
};
|
||||
|
||||
// Event ids for REPORT_EVENT. The trustlet never polls for a finger: the
|
||||
// normal world takes the sensor IRQ and tells it what happened.
|
||||
enum class Event : std::uint32_t {
|
||||
FingerTouched = 5,
|
||||
FingerReleased = 6,
|
||||
ImageReady = 7,
|
||||
};
|
||||
|
||||
// Event 7 reaches the matcher unconditionally; event 5 only when
|
||||
// device+0x10a8 is 1 or 2. Enrolment mirrors stock by sending 5 on touch
|
||||
// and 6 on release — stock's whole enrolment trace contains no event 7,
|
||||
// and sending it on every held frame feeds the algorithm near-duplicate
|
||||
// images from one press.
|
||||
inline constexpr Event EnrolTouchEvent = Event::FingerTouched;
|
||||
inline constexpr Event AuthEvent = Event::ImageReady;
|
||||
|
||||
// ---- The event context -----------------------------------------------
|
||||
//
|
||||
// ff_trustlet_event_context_t, the REPORT_EVENT payload. The stock HAL
|
||||
// memsets 732 bytes and writes exactly six fields
|
||||
// (fingerprint.default.so, device_irq_event_thread 0xb82f8-0xb8374).
|
||||
inline constexpr std::size_t EventContextSize = 740;
|
||||
inline constexpr std::size_t EventDeclaredLen = 732;
|
||||
|
||||
inline constexpr std::size_t EvEventOff = 4;
|
||||
inline constexpr std::size_t EvScanSlotsOff = 712;
|
||||
inline constexpr std::size_t EvZeroAOff = 716;
|
||||
inline constexpr std::size_t EvSlotIndexOff = 720;
|
||||
inline constexpr std::size_t EvFlagsOff = 724;
|
||||
inline constexpr std::size_t EvZeroBOff = 728;
|
||||
|
||||
inline constexpr std::uint32_t EvDefaultFlags = 0x08080000;
|
||||
inline constexpr std::uint32_t EvDefaultScanSlots = 1;
|
||||
|
||||
// The scan-slot count is load-bearing and was zero in every run this
|
||||
// project made for weeks. do_enroll assembles it bytewise and
|
||||
// `cbz w9, 0x162e8` jumps past the entire slot loop — past the enrol call
|
||||
// and past every flag read — straight to the "scan slot %u: groups->%s"
|
||||
// log with both buffers still zeroed. So an all-zero payload printed
|
||||
// `groups->, results->`, which reads exactly like a gate failing deep in
|
||||
// the trustlet and is really zero iterations.
|
||||
|
||||
struct EventContext {
|
||||
Event event = Event::ImageReady;
|
||||
std::uint32_t scanSlots = EvDefaultScanSlots;
|
||||
std::uint32_t slotIndex = 0;
|
||||
std::uint32_t flags = EvDefaultFlags;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
inline void StoreU32(std::span<std::byte> b, std::size_t off, std::uint32_t v) {
|
||||
for (std::size_t i = 0; i < 4; i++)
|
||||
b[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
|
||||
}
|
||||
inline std::uint32_t LoadU32(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;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the event payload. The event id is LITTLE endian: the trustlet
|
||||
// assembles it as p[4] | p[5]<<8 | ... at 0x152c0. Written big endian,
|
||||
// event 7 arrives as 0x07000000, fails the 5..14 bound check at 0x152e4,
|
||||
// and silently takes the default path — returning rc=0 while doing nothing.
|
||||
inline void BuildEventContext(std::span<std::byte> out, const EventContext& ev) {
|
||||
std::ranges::fill(out.first(EventContextSize), std::byte{0});
|
||||
detail::StoreU32(out, EvEventOff, static_cast<std::uint32_t>(ev.event));
|
||||
detail::StoreU32(out, EvScanSlotsOff, ev.scanSlots);
|
||||
detail::StoreU32(out, EvZeroAOff, 0);
|
||||
detail::StoreU32(out, EvSlotIndexOff, ev.slotIndex);
|
||||
detail::StoreU32(out, EvFlagsOff, ev.flags);
|
||||
detail::StoreU32(out, EvZeroBOff, 0);
|
||||
}
|
||||
|
||||
// ---- Capture ----------------------------------------------------------
|
||||
//
|
||||
// CAPTURE_IMAGE's flags word at payload+0x18. With neither bit 1 nor bit
|
||||
// 30 set, `0x14b18: tst w8, #0x40000002 / b.eq 0x14d9c` skips image
|
||||
// preprocessing, the frame classifier and the enrol grouper entirely — and
|
||||
// the grouper is the only thing that ever writes the slot flag do_enroll
|
||||
// gates on. So the trustlet returns rc=0 having done nothing but a raw
|
||||
// scan, whatever is on the sensor.
|
||||
//
|
||||
// Bit 0 means "the caller appends the frame to the request". We do not, so
|
||||
// it must stay clear.
|
||||
inline constexpr std::size_t CaptureFlagsOff = 0x18;
|
||||
inline constexpr std::uint32_t CaptureFlagsUseCallerFrame = 0x1;
|
||||
// What the stock HAL's enrol path builds: |= 0xC0040000 then |= 2
|
||||
// (fingerprint.default.so 0xb7d7c / 0xb7dbc).
|
||||
inline constexpr std::uint32_t CaptureFlagsEnrol = 0xC0040002;
|
||||
static_assert((CaptureFlagsEnrol & CaptureFlagsUseCallerFrame) == 0,
|
||||
"bit 0 would promise the trustlet a frame we do not append");
|
||||
static_assert((CaptureFlagsEnrol & 0x40000002) != 0,
|
||||
"without bit 1 or bit 30 the capture does no preprocessing");
|
||||
|
||||
// CAPTURE_IMAGE's declared payload length is range-checked to exactly
|
||||
// 0x14, and the trustlet reads the flags at +0x18 regardless — so the word
|
||||
// is written past the declared length on purpose. 0x20 gives -201.
|
||||
inline constexpr std::uint32_t CaptureDeclaredLen = 0x14;
|
||||
|
||||
// ---- SAVE_DATA --------------------------------------------------------
|
||||
//
|
||||
// payload+0x00 is a bitmask and the handler's first test is
|
||||
// `0x139ec: tbz w22, #30`: bit 30 clear takes the calibration path, set
|
||||
// falls through toward libfp_template_export / ff_template_save.
|
||||
inline constexpr std::uint32_t SaveMaskTemplate = 0x40000000;
|
||||
inline constexpr std::uint32_t SaveMaskCalibration = 0x80000000;
|
||||
static_assert((SaveMaskTemplate & (1u << 30)) != 0);
|
||||
static_assert((SaveMaskCalibration & (1u << 30)) == 0);
|
||||
|
||||
// ---- ENROLL / AUTHENTICATE payloads -----------------------------------
|
||||
|
||||
// ENROLL takes a 69-byte hw_auth_token, a u32 timeout at +69 and a u8 flag
|
||||
// at +73 (stub 0xa0c8).
|
||||
inline constexpr std::size_t EnrollPayloadSize = 74;
|
||||
inline constexpr std::size_t EnrollTokenSize = 69;
|
||||
inline constexpr std::size_t EnrollTimeoutOff = 69;
|
||||
|
||||
// No Gatekeeper is needed. ff_trustlet_enroll reads config
|
||||
// trustlet.enable_trusted_enrollment and, when false, skips the version
|
||||
// check, the PRE_ENROLL challenge compare and the token HMAC verify
|
||||
// outright (0xce34 tbz -> 0xd198), so an all-zero token is accepted.
|
||||
// pmOS has no Gatekeeper to mint one and nothing there verifies auth
|
||||
// tokens anyway.
|
||||
inline void BuildEnrollPayload(std::span<std::byte> out, std::uint32_t timeoutSeconds) {
|
||||
std::ranges::fill(out.first(EnrollPayloadSize), std::byte{0});
|
||||
detail::StoreU32(out, EnrollTimeoutOff, timeoutSeconds);
|
||||
}
|
||||
|
||||
// AUTHENTICATE (TA 0xea88 takes these as x0/w1/w2/w3):
|
||||
// +0x00 u64 operation_id
|
||||
// +0x08 u32 gid
|
||||
// +0x0c u8 b_relight
|
||||
// +0x0d u8 b_covered
|
||||
// Declared length 0x0e.
|
||||
inline constexpr std::size_t AuthPayloadSize = 0x0e;
|
||||
inline constexpr std::size_t AuthGidOff = 0x08;
|
||||
inline constexpr std::size_t AuthRelightOff = 0x0c;
|
||||
inline constexpr std::size_t AuthCoveredOff = 0x0d;
|
||||
|
||||
// With both flags zero the trustlet calls ff_trustlet_query_finger_status
|
||||
// first and starts mode 1 or 2 from the answer; 1,1 skips that and starts
|
||||
// mode 1 directly, which is what the query returns with no finger down.
|
||||
inline void BuildAuthPayload(std::span<std::byte> out, std::uint64_t operationId,
|
||||
std::uint32_t gid, bool relight = true, bool covered = true) {
|
||||
std::ranges::fill(out.first(AuthPayloadSize), std::byte{0});
|
||||
for (std::size_t i = 0; i < 8; i++)
|
||||
out[i] = static_cast<std::byte>((operationId >> (8 * i)) & 0xFF);
|
||||
detail::StoreU32(out, AuthGidOff, gid);
|
||||
out[AuthRelightOff] = static_cast<std::byte>(relight ? 1 : 0);
|
||||
out[AuthCoveredOff] = static_cast<std::byte>(covered ? 1 : 0);
|
||||
}
|
||||
|
||||
// SET_ACTIVE_GROUP writes its gid to device+0x30 and AUTHENTICATE compares
|
||||
// its own against the same field (0xeb08), logging
|
||||
// "templates with gid(%u != %u) hasn't been loaded." and returning -200 on
|
||||
// a mismatch. So the two only have to agree with each other — the value
|
||||
// itself is the caller's to choose.
|
||||
inline constexpr std::size_t SetActiveGroupGidOff = 0;
|
||||
|
||||
// ---- Responses --------------------------------------------------------
|
||||
//
|
||||
// THE TRAP. The buffer that comes back is the whole REQUEST, and the
|
||||
// payload starts at +0x10 — proved by reading back the event id we sent at
|
||||
// reqo+0x14. Reading a payload field at its payload offset directly gives
|
||||
// a confident, wrong zero, which is what happened to "samples remaining"
|
||||
// for a whole session.
|
||||
inline constexpr std::size_t ResponsePayloadOff = 0x10;
|
||||
|
||||
// Enrolment progress, without needing the trustlet's log: do_enroll copies
|
||||
// g_context+56 (samples remaining) into the response payload at +36
|
||||
// bytewise on the common path, whether or not the sample was accepted.
|
||||
// That matters because the log starves exactly when a frame is accepted.
|
||||
inline constexpr std::size_t RespSamplesRemainingOff = 36;
|
||||
|
||||
// The match result. ff_trustlet_event's success path writes the gid to
|
||||
// payload+0x0c and the matched fid to payload+0x10 one byte at a time
|
||||
// (0x1642c / 0x16454), and "authentication failed." explicitly zeroes the
|
||||
// fid (0x164a4). So a non-zero fid there can only have come from the path
|
||||
// that logged a match.
|
||||
inline constexpr std::size_t RespGidOff = 0x0c;
|
||||
inline constexpr std::size_t RespFidOff = 0x10;
|
||||
|
||||
inline std::int32_t SamplesRemaining(std::span<const std::byte> response) {
|
||||
return static_cast<std::int32_t>(
|
||||
detail::LoadU32(response, ResponsePayloadOff + RespSamplesRemainingOff));
|
||||
}
|
||||
inline std::uint32_t MatchedGid(std::span<const std::byte> response) {
|
||||
return detail::LoadU32(response, ResponsePayloadOff + RespGidOff);
|
||||
}
|
||||
inline std::uint32_t MatchedFid(std::span<const std::byte> response) {
|
||||
return detail::LoadU32(response, ResponsePayloadOff + RespFidOff);
|
||||
}
|
||||
|
||||
// ---- The verdict ------------------------------------------------------
|
||||
//
|
||||
// A frame is one of three things and only the third is a verdict. This was
|
||||
// mislabelled three separate times before the comparison producing it was
|
||||
// actually read, and each mistake invented rejections that never happened
|
||||
// and made the sensor look flaky.
|
||||
//
|
||||
// 16a0c: ldr w9, [x12, #0x8c] ; common.max_authentication_rescan_times
|
||||
// 16a30: cmp w8, w9 ; w8 = counter at x20+0x2d0
|
||||
// 16a34: mov w8, #-0xb
|
||||
// 16a38: csel w8, w8, wzr, lo ; below the limit -> -11, else terminal
|
||||
//
|
||||
// Proven with no finger on the sensor: at the default budget those frames
|
||||
// return -11; with the key set to 0 the same frames return rc=0 with the
|
||||
// fid zeroed, which is a real rejection.
|
||||
enum class Verdict {
|
||||
MatcherNeverRan, // finger released; the poison is intact
|
||||
NotIdentifiedYet, // rc=-11: ran, attempts remain. NOT a rejection
|
||||
Match,
|
||||
Rejected,
|
||||
};
|
||||
|
||||
// The caller poisons the fid field before the call, because a
|
||||
// zero-initialised buffer cannot distinguish "the matcher never ran" from
|
||||
// "the matcher ran and rejected the finger" — both leave zero there.
|
||||
inline constexpr std::uint32_t FidPoison = 0xAAAAAAAA;
|
||||
inline constexpr std::int32_t RcTryAgain = -11;
|
||||
|
||||
inline void PoisonFid(std::span<std::byte> payload) {
|
||||
detail::StoreU32(payload, RespFidOff, FidPoison);
|
||||
}
|
||||
|
||||
inline Verdict Classify(std::int32_t rc, std::uint32_t fid) {
|
||||
if (fid == FidPoison) return Verdict::MatcherNeverRan;
|
||||
if (rc == RcTryAgain) return Verdict::NotIdentifiedYet;
|
||||
if (rc != 0) return Verdict::NotIdentifiedYet;
|
||||
return fid != 0 ? Verdict::Match : Verdict::Rejected;
|
||||
}
|
||||
|
||||
// Only a terminal verdict counts toward an accept/reject rate. Counting
|
||||
// NotIdentifiedYet as a rejection is the specific error above.
|
||||
inline bool IsTerminal(Verdict v) {
|
||||
return v == Verdict::Match || v == Verdict::Rejected;
|
||||
}
|
||||
|
||||
// ---- Errors -----------------------------------------------------------
|
||||
//
|
||||
// ff_strerror (TA 0x7218) is two jump-table ranges. Every error path in
|
||||
// the trustlet passes its result through this, so every named error in
|
||||
// every log we hold converts back to a number and vice versa.
|
||||
inline constexpr std::string_view StrError(std::int32_t rc) {
|
||||
switch (rc) {
|
||||
case 0: return "Success";
|
||||
case -1: return "Internal error";
|
||||
case -2: return "No such file or directory";
|
||||
case -4: return "Interrupted";
|
||||
case -5: return "I/O error";
|
||||
case -11: return "Try again";
|
||||
case -12: return "Out of memory";
|
||||
case -16: return "Resource busy/Timeout";
|
||||
case -200: return "Bad parameter(s)";
|
||||
case -201: return "Null pointer";
|
||||
case -202: return "Buffer overflow";
|
||||
case -203: return "Bad protocol";
|
||||
case -204: return "Wrong sensor dimension";
|
||||
case -205: return "Device not found";
|
||||
case -206: return "Device is dead";
|
||||
case -207: return "Up to the limit";
|
||||
case -208: return "Untrusted enrollment";
|
||||
case -209: return "Template store in REE";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// -90 is not a trustlet error at all: it is QTEE's "the app is gone",
|
||||
// which is what every command answers once the trustlet has taken a fault.
|
||||
// A -90 yields no trustlet log, so the fault ring is the only instrument.
|
||||
inline constexpr std::int32_t QteeAppGone = -90;
|
||||
|
||||
// -205 is what a second TA init in one sensor power cycle returns. One
|
||||
// sensor reset buys exactly one init, which is why the process that powers
|
||||
// the sensor has to be the process that holds the session.
|
||||
inline constexpr std::int32_t RcDeviceNotFound = -205;
|
||||
}
|
||||
|
|
@ -13,3 +13,4 @@ the normal world, and reports the matched finger id.
|
|||
|
||||
export module Fingerprintd;
|
||||
export import :Sfs;
|
||||
export import :Ta;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
ApplyStandardArgs(*Core, args);
|
||||
Core->type = ConfigurationType::LibraryStatic;
|
||||
{
|
||||
std::array<fs::path, 2> ifaces = {
|
||||
std::array<fs::path, 3> ifaces = {
|
||||
"interfaces/Fingerprintd",
|
||||
"interfaces/Fingerprintd-Sfs",
|
||||
"interfaces/Fingerprintd-Ta",
|
||||
};
|
||||
std::array<fs::path, 0> impls = {};
|
||||
Core->GetInterfacesAndImplementations(ifaces, impls);
|
||||
|
|
@ -45,6 +46,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
}
|
||||
|
||||
cfg.AddTest("Sfs").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Ta").Dependencies({ Core.get() });
|
||||
|
||||
ProjectLint::AddProjectLintRules(cfg);
|
||||
|
||||
|
|
|
|||
28
tests/Ta/fixtures/README.md
Normal file
28
tests/Ta/fixtures/README.md
Normal 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.
|
||||
27
tests/Ta/fixtures/auth-enrolled-finger.txt
Normal file
27
tests/Ta/fixtures/auth-enrolled-finger.txt
Normal 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)
|
||||
50
tests/Ta/fixtures/auth-stock-budget.txt
Normal file
50
tests/Ta/fixtures/auth-stock-budget.txt
Normal 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
|
||||
23
tests/Ta/fixtures/auth-wrong-finger.txt
Normal file
23
tests/Ta/fixtures/auth-wrong-finger.txt
Normal 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
234
tests/Ta/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue