diff --git a/interfaces/Fingerprintd-Ta.cppm b/interfaces/Fingerprintd-Ta.cppm new file mode 100644 index 0000000..7fdce66 --- /dev/null +++ b/interfaces/Fingerprintd-Ta.cppm @@ -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 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 b, std::size_t off, std::uint32_t v) { + for (std::size_t i = 0; i < 4; i++) + b[off + i] = static_cast((v >> (8 * i)) & 0xFF); + } + inline std::uint32_t LoadU32(std::span b, std::size_t off) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; i++) + v |= static_cast(std::to_integer(b[off + i])) << (8 * i); + return v; + } + } + + // 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 out, const EventContext& ev) { + std::ranges::fill(out.first(EventContextSize), std::byte{0}); + detail::StoreU32(out, EvEventOff, static_cast(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 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 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((operationId >> (8 * i)) & 0xFF); + detail::StoreU32(out, AuthGidOff, gid); + out[AuthRelightOff] = static_cast(relight ? 1 : 0); + out[AuthCoveredOff] = static_cast(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 response) { + return static_cast( + detail::LoadU32(response, ResponsePayloadOff + RespSamplesRemainingOff)); + } + inline std::uint32_t MatchedGid(std::span response) { + return detail::LoadU32(response, ResponsePayloadOff + RespGidOff); + } + inline std::uint32_t MatchedFid(std::span 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 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; +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index 964157a..7c212a1 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -13,3 +13,4 @@ the normal world, and reports the matched finger id. export module Fingerprintd; export import :Sfs; +export import :Ta; diff --git a/project.cpp b/project.cpp index 1759647..5adc481 100644 --- a/project.cpp +++ b/project.cpp @@ -21,9 +21,10 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Fingerprintd", "interfaces/Fingerprintd-Sfs", + "interfaces/Fingerprintd-Ta", }; std::array impls = {}; Core->GetInterfacesAndImplementations(ifaces, impls); @@ -45,6 +46,7 @@ extern "C" Configuration CrafterBuildProject(std::span a } cfg.AddTest("Sfs").Dependencies({ Core.get() }); + cfg.AddTest("Ta").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Ta/fixtures/README.md b/tests/Ta/fixtures/README.md new file mode 100644 index 0000000..5e9d171 --- /dev/null +++ b/tests/Ta/fixtures/README.md @@ -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. diff --git a/tests/Ta/fixtures/auth-enrolled-finger.txt b/tests/Ta/fixtures/auth-enrolled-finger.txt new file mode 100644 index 0000000..d3b9ae5 --- /dev/null +++ b/tests/Ta/fixtures/auth-enrolled-finger.txt @@ -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) diff --git a/tests/Ta/fixtures/auth-stock-budget.txt b/tests/Ta/fixtures/auth-stock-budget.txt new file mode 100644 index 0000000..bd46b3a --- /dev/null +++ b/tests/Ta/fixtures/auth-stock-budget.txt @@ -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 diff --git a/tests/Ta/fixtures/auth-wrong-finger.txt b/tests/Ta/fixtures/auth-wrong-finger.txt new file mode 100644 index 0000000..f90f640 --- /dev/null +++ b/tests/Ta/fixtures/auth-wrong-finger.txt @@ -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) diff --git a/tests/Ta/main.cpp b/tests/Ta/main.cpp new file mode 100644 index 0000000..b24251f --- /dev/null +++ b/tests/Ta/main.cpp @@ -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 b, std::size_t off) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; i++) + v |= static_cast(std::to_integer(b[off + i])) << (8 * i); + return v; + } + + // A recorded run, reduced to the counts the journal states. + struct Tally { + int match = 0, rejected = 0, neverRan = 0, notIdentifiedYet = 0; + int Terminal() const { return match + rejected; } + int Frames() const { return match + rejected + neverRan + notIdentifiedYet; } + }; + + Tally Parse(std::string_view name) { + Tally t; + std::string path = std::format("tests/Ta/fixtures/{}", name); + std::ifstream f(path); + if (!f) { + std::println(std::cerr, "FAIL: cannot open fixture {}", path); + ++Failures; + return t; + } + std::string line; + while (std::getline(f, line)) { + if (line.starts_with("#")) continue; + if (!line.contains("AUTH ")) continue; + if (line.contains("*** MATCH ***")) t.match++; + else if (line.contains("matcher never ran")) t.neverRan++; + else if (line.contains("REJECTED")) t.rejected++; + // The older label for rc=-11. It is NOT a rejection. + else if (line.contains("no match")) t.notIdentifiedYet++; + } + return t; + } +} + +int main() { + // ---- The verdict rule, from explicit wire conditions + // + // Each case states what the trustlet actually left in the response, not + // what a transcript called it. + Check(Classify(0, FidPoison) == Verdict::MatcherNeverRan, + "poison intact -> the matcher never ran"); + Check(Classify(RcTryAgain, 0) == Verdict::NotIdentifiedYet, + "rc=-11 -> not identified yet"); + Check(Classify(0, 1296911490) == Verdict::Match, + "rc=0 with a fid -> match"); + Check(Classify(0, 0) == Verdict::Rejected, + "rc=0 with the fid zeroed -> rejected"); + + // -11 is not a rejection, and this is the assertion that would have + // stopped the mislabelling. + Check(Classify(RcTryAgain, 0) != Verdict::Rejected, + "rc=-11 must never classify as a rejection"); + Check(!IsTerminal(Classify(RcTryAgain, 0)), "rc=-11 is not terminal"); + Check(!IsTerminal(Classify(0, FidPoison)), "a released finger is not terminal"); + Check(IsTerminal(Classify(0, 0)) && IsTerminal(Classify(0, 7)), + "both real verdicts are terminal"); + + // The poison outranks rc: a released frame also carries rc=0, so without + // it a release is indistinguishable from a rejection. + Check(Classify(0, FidPoison) != Verdict::Rejected, + "a zero-init buffer would confuse release with rejection"); + + // ---- The counting policy, against three recorded runs + { + Tally enrolled = Parse("auth-enrolled-finger.txt"); + Check(enrolled.match == 15 && enrolled.rejected == 5 && enrolled.neverRan == 5, + "enrolled-finger run: 15 match / 5 rejected / 5 never ran"); + Check(enrolled.Terminal() == 20, "enrolled-finger run: 20 terminal frames"); + + Tally wrong = Parse("auth-wrong-finger.txt"); + Check(wrong.match == 0 && wrong.rejected == 19, "wrong-finger control: 0 of 19"); + Check(wrong.Terminal() == 19, "wrong-finger run: 19 terminal frames"); + // The claim that actually matters about this device. + Check(wrong.match == 0, "zero false accepts"); + + // The stock-budget run: most of the traffic is "not identified yet". + Tally stock = Parse("auth-stock-budget.txt"); + Check(stock.match == 8, "stock-budget run: 8 matches"); + Check(stock.notIdentifiedYet == 31, "stock-budget run: 31 rc=-11 frames"); + Check(stock.neverRan == 9, "stock-budget run: 9 frames the matcher never saw"); + Check(stock.rejected == 0, "stock-budget run: not one real rejection"); + + // Every frame that carried an image matched. Counting -11 frames as + // attempts turns that into 8 of 39. + Check(stock.Terminal() == 8, "stock-budget run: 8 terminal frames, all matches"); + Check(stock.Frames() == 48, "stock-budget run: 48 frames total"); + Check(stock.Terminal() != stock.Frames() - stock.neverRan, + "the wrong denominator is 39, and it is not the terminal count"); + } + + // ---- Event context + { + std::vector ev(EventContextSize); + BuildEventContext(ev, { .event = Event::ImageReady }); + Check(Get32(ev, EvEventOff) == 7, "event id little endian at +4"); + Check(Get32(ev, EvScanSlotsOff) == 1, "scan slot count defaults to 1"); + Check(Get32(ev, EvFlagsOff) == 0x08080000, "flags"); + Check(Get32(ev, EvZeroAOff) == 0 && Get32(ev, EvZeroBOff) == 0, "the two zero words"); + + // A zero scan-slot count is the bug that ran the enrol loop zero times + // while logging as though it had run. + BuildEventContext(ev, { .event = Event::FingerTouched, .scanSlots = 0 }); + Check(Get32(ev, EvScanSlotsOff) == 0, "an explicit zero is still writable"); + Check(Get32(ev, EvEventOff) == 5, "touch event id"); + + // Big-endian would put event 7 at 0x07000000, fail the 5..14 bound + // check, and silently do nothing while returning rc=0. + BuildEventContext(ev, { .event = Event::ImageReady }); + Check(std::to_integer(ev[EvEventOff]) == 7, "low byte carries the id"); + Check(std::to_integer(ev[EvEventOff + 3]) == 0, "not big endian"); + } + + // ---- Capture flags + Check(CaptureFlagsEnrol == 0xC0040002, "stock enrol capture flags"); + Check((CaptureFlagsEnrol & CaptureFlagsUseCallerFrame) == 0, "bit 0 stays clear"); + Check((CaptureFlagsEnrol & 0x40000002) != 0, "bit 1 or 30 set, or nothing runs"); + Check(CaptureFlagsOff == 0x18 && CaptureDeclaredLen == 0x14, + "the flags word sits past the declared length on purpose"); + + // ---- 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 au(AuthPayloadSize); + BuildAuthPayload(au, 1, 60); + Check(Get32(au, 0) == 1, "operation id"); + Check(Get32(au, AuthGidOff) == 60, "gid at +8"); + Check(std::to_integer(au[AuthRelightOff]) == 1, "relight defaults set"); + Check(std::to_integer(au[AuthCoveredOff]) == 1, "covered defaults set"); + Check(AuthPayloadSize == 0x0e, "declared length"); + + BuildAuthPayload(au, 1, 60, false, false); + Check(std::to_integer(au[AuthRelightOff]) == 0, "flags clearable"); + } + + // ---- ENROLL payload: an all-zero token is accepted when trusted + // enrolment is off, which is why pmOS needs no Gatekeeper. + { + std::vector tok(EnrollPayloadSize); + BuildEnrollPayload(tok, 60); + Check(EnrollPayloadSize == 74 && EnrollTokenSize == 69, "enroll payload sizes"); + 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 resp(256); + auto put32 = [&](std::size_t off, std::uint32_t v) { + for (std::size_t i = 0; i < 4; i++) + resp[off + i] = static_cast((v >> (8 * i)) & 0xFF); + }; + put32(ResponsePayloadOff + RespSamplesRemainingOff, 9); + put32(ResponsePayloadOff + RespGidOff, 60); + put32(ResponsePayloadOff + RespFidOff, 1296911490); + Check(SamplesRemaining(resp) == 9, "samples remaining at payload+36"); + Check(MatchedGid(resp) == 60, "gid at payload+0x0c"); + Check(MatchedFid(resp) == 1296911490, "fid at payload+0x10"); + Check(Get32(resp, RespSamplesRemainingOff) != 9, + "reading at the payload offset directly gives the wrong word"); + } + + // ---- Poisoning + { + std::vector payload(64); + PoisonFid(payload); + Check(Get32(payload, RespFidOff) == FidPoison, "poison written at +0x10"); + Check(Classify(0, Get32(payload, RespFidOff)) == Verdict::MatcherNeverRan, + "an untouched poisoned payload classifies as never-ran"); + } + + // ---- 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; +}