Stock rewrites the stored template on every successful press. Its post-match loop is QUERY_FINGER_STATUS, CAPTURE_IMAGE, 0x1015 UPDATE_TEMPLATE while the finger stays down, with no REPORT_EVENT in it -- so the matcher does not re-run and the verdict cannot change. Forty-six of those against eighty-six captures in one reference session, and the stored body measurably grows: 333278 bytes at enrolment, 360822 at the next session's load, 371734 after one authentication session. This daemon sent none of them. The command shares REPORT_EVENT's event context. The stock wrapper memsets 732 bytes and writes six fields: a zero byte at +0x2a4, the scan-slot count, a zero word, the count of frames folded so far in this press, a flags word of 0x00080000 with bit 6 set on the frame whose event was FingerTouched, and a zero at +0x2d8. Declared length 0x2dc. +0x2d8 is the one that matters, and it matters by staying zero. The dispatcher stub reads it after the handler returns and only if it is non-zero does it read +0x2dc and make that the response length. Every earlier attempt in this project set both fields and varied the declared length across 0x2e0, 0x400 and 0x1000; all of them answered -90, the trustlet gone, and the conclusion recorded was "do not retry until a template is loaded". A loaded template was necessary but not sufficient. The stock HAL sets neither field. Measured on the device with two templates loaded and no finger: both branches answer rc=0 and ENUMERATE still reports 2, so the app did not fault. Those fields are little endian, assembled low-address-first by the handler. An earlier reading called them big endian, off the bfi order, and was wrong. Bit 6 selects which algorithm entry runs: clear takes libfp_template_x_update, set takes the other, which also reads the scan-slot count.
517 lines
26 KiB
C++
517 lines
26 KiB
C++
// 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,
|
|
QueryEventStatus = 0x101d,
|
|
SaveData = 0x1014,
|
|
UpdateTemplate = 0x1015,
|
|
ReportEvent = 0x1018,
|
|
WorkMode = 0x1020,
|
|
|
|
PreEnroll = 0x2000,
|
|
Enroll = 0x2001,
|
|
PostEnroll = 0x2002,
|
|
Cancel = 0x2004,
|
|
ResetLockout = 0x200a,
|
|
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;
|
|
|
|
// Two fields inside that payload which an all-zero request leaves unset.
|
|
//
|
|
// +0x0c frame count how many frames this capture takes
|
|
// +0x10 branch selector which capture path runs; 0 returns metric 0
|
|
//
|
|
// Both matter for reading the result as much as for getting one: the
|
|
// metric is PER FRAME, so a count of 4 reads roughly four times a count of
|
|
// 1 and a threshold calibrated at one count is meaningless at another.
|
|
// Sending zeros gets -201.
|
|
inline constexpr std::size_t CaptureFrameCountOff = 0x0c;
|
|
inline constexpr std::size_t CaptureSelectorOff = 0x10;
|
|
inline constexpr std::uint32_t CaptureFrameCountDefault = 1;
|
|
inline constexpr std::uint32_t CaptureSelectorDefault = 1;
|
|
|
|
inline void BuildCapturePayload(std::span<std::byte> out,
|
|
std::uint32_t frames = CaptureFrameCountDefault,
|
|
std::uint32_t selector = CaptureSelectorDefault) {
|
|
std::ranges::fill(out.first(CaptureDeclaredLen), std::byte{0});
|
|
detail::StoreU32(out, CaptureFrameCountOff, frames);
|
|
detail::StoreU32(out, CaptureSelectorOff, selector);
|
|
}
|
|
|
|
// ---- 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);
|
|
|
|
// ---- UPDATE_TEMPLATE --------------------------------------------------
|
|
//
|
|
// TEMPLATE LEARNING. Stock folds the frames of a successful press back
|
|
// into the stored template and the template GROWS as a result: on the
|
|
// reference device ff_template_0_0.bin went 333278 bytes at enrolment ->
|
|
// 360822 at the next session's load -> 371734 after one authentication
|
|
// session. Over the same session the HAL issued 46 of these against 86
|
|
// captures. This daemon issued none, so every rate this project has ever
|
|
// measured was against a day-zero template that no stock user lives with.
|
|
//
|
|
// The command shares REPORT_EVENT's ff_trustlet_event_context_t. The stock
|
|
// wrapper (fingerprint.default.so 0xb8d84, "checking the template...")
|
|
// memsets 732 bytes and writes exactly six fields:
|
|
//
|
|
// +0x2a4 (676) u8 0
|
|
// +0x2c8 (712) u32 scan-slot count (as REPORT_EVENT)
|
|
// +0x2cc (716) u32 0
|
|
// +0x2d0 (720) u32 slot index = frames folded so far in this press
|
|
// +0x2d4 (724) u32 0x00080000, |0x40 when the frame's event was 5
|
|
// +0x2d8 (728) u32 0
|
|
//
|
|
// and calls it with a declared length of 0x2dc (0xbefec).
|
|
//
|
|
// +0x2d8 IS THE FIELD THAT MATTERS, and it matters by staying zero. The
|
|
// dispatcher stub reads it AFTER the handler returns (0x9ee0-0x9f00) and
|
|
// only if it is non-zero does it read +0x2dc and compute the response
|
|
// length as that value + 0x2dc. Every previous attempt in this project set
|
|
// both fields and varied the declared length (0x2e0 / 0x400 / 0x1000);
|
|
// all of them answered -90, i.e. the trustlet was gone. The stock HAL
|
|
// never sets either one.
|
|
//
|
|
// Endianness: the handler assembles these bytewise low-address-first
|
|
// (0x1300c-0x13028), so they are LITTLE endian. An earlier ledger entry
|
|
// called them big endian; it was read off the bfi order and was wrong.
|
|
inline constexpr std::size_t UpdateTemplatePayloadSize = 0x2dc; // 732
|
|
inline constexpr std::size_t UpdZeroByteOff = 0x2a4;
|
|
inline constexpr std::size_t UpdScanSlotsOff = EvScanSlotsOff; // 712
|
|
inline constexpr std::size_t UpdZeroAOff = EvZeroAOff; // 716
|
|
inline constexpr std::size_t UpdSlotIndexOff = EvSlotIndexOff; // 720
|
|
inline constexpr std::size_t UpdFlagsOff = EvFlagsOff; // 724
|
|
inline constexpr std::size_t UpdRespLenOff = EvZeroBOff; // 728
|
|
|
|
// The flags word. Note it is NOT the event context's 0x08080000: the
|
|
// update wrapper builds its own value from scratch.
|
|
inline constexpr std::uint32_t UpdFlagsBase = 0x00080000;
|
|
// Bit 6 selects which of the algorithm's two update entries runs: clear
|
|
// takes libfp_template_x_update (0x20bf4), set takes 0x212e0, which also
|
|
// reads the scan-slot count. Stock sets it on the frame whose event was
|
|
// FingerTouched, i.e. the first frame of a press.
|
|
inline constexpr std::uint32_t UpdFlagsTouchFrame = 0x40;
|
|
|
|
inline void BuildUpdateTemplate(std::span<std::byte> out, std::uint32_t slotIndex,
|
|
bool touchFrame,
|
|
std::uint32_t scanSlots = EvDefaultScanSlots) {
|
|
std::ranges::fill(out.first(UpdateTemplatePayloadSize), std::byte{0});
|
|
out[UpdZeroByteOff] = std::byte{0};
|
|
detail::StoreU32(out, UpdScanSlotsOff, scanSlots);
|
|
detail::StoreU32(out, UpdZeroAOff, 0);
|
|
detail::StoreU32(out, UpdSlotIndexOff, slotIndex);
|
|
detail::StoreU32(out, UpdFlagsOff,
|
|
UpdFlagsBase | (touchFrame ? UpdFlagsTouchFrame : 0u));
|
|
// Left zero deliberately. See above: a non-zero value here sends the
|
|
// stub off to compute a response length from +0x2dc.
|
|
detail::StoreU32(out, UpdRespLenOff, 0);
|
|
}
|
|
|
|
static_assert(UpdRespLenOff + 4 == UpdateTemplatePayloadSize,
|
|
"the response-length trigger is the last word of the payload");
|
|
static_assert((UpdFlagsBase & UpdFlagsTouchFrame) == 0,
|
|
"the touch bit must not already be in the base value");
|
|
static_assert(UpdFlagsTouchFrame == (1u << 6),
|
|
"the handler tests bit 6 of the LOW byte at +0x2d4");
|
|
|
|
// ---- ENROLL / AUTHENTICATE payloads -----------------------------------
|
|
|
|
// ENROLL takes a 69-byte hw_auth_token, a u32 at +69 and a u8 flag at +73
|
|
// (stub 0xa0c8).
|
|
//
|
|
// The u32 at +69 was recorded in this project as a "timeout". It is not:
|
|
// the trustlet reports it back as the GROUP ID. Setting it to 60 is where
|
|
// `gid = 60` came from, and the whole gid-60 store exists because a
|
|
// mislabelled field was filled with a plausible-looking number. Naming it
|
|
// honestly is what makes an enrolment able to choose its own group.
|
|
inline constexpr std::size_t EnrollPayloadSize = 74;
|
|
inline constexpr std::size_t EnrollTokenSize = 69;
|
|
inline constexpr std::size_t EnrollGidOff = 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 gid) {
|
|
std::ranges::fill(out.first(EnrollPayloadSize), std::byte{0});
|
|
detail::StoreU32(out, EnrollGidOff, gid);
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Its payload is {u32 gid; char path[]} and the path is NOT a filesystem
|
|
// path we control: the trustlet hashes it into the SFS group's directory
|
|
// name, so it is a namespace key and it has to match whatever the store
|
|
// was written under. The store on this device was written by the Android
|
|
// stack under its data directory, and every group in it derives from that
|
|
// string. Passing anything else resolves a different group, finds nothing
|
|
// and answers -2 -- with no storage read at all, which reads like a
|
|
// listener failure and is not one.
|
|
inline constexpr std::size_t SetActiveGroupGidOff = 0;
|
|
inline constexpr std::size_t SetActiveGroupPathOff = 4;
|
|
inline constexpr std::string_view GroupNamespacePath = "/data/vendor_de/0/fpdata";
|
|
|
|
inline std::vector<std::byte> BuildSetActiveGroup(
|
|
std::uint32_t gid, std::string_view path = GroupNamespacePath) {
|
|
std::vector<std::byte> out(SetActiveGroupPathOff + path.size() + 1, std::byte{0});
|
|
detail::StoreU32(out, SetActiveGroupGidOff, gid);
|
|
for (std::size_t i = 0; i < path.size(); i++)
|
|
out[SetActiveGroupPathOff + i] = static_cast<std::byte>(path[i]);
|
|
return out;
|
|
}
|
|
|
|
// ---- The request/response envelope ------------------------------------
|
|
//
|
|
// sendRequest carries two buffers in and two back. The request is:
|
|
//
|
|
// +0x00 u32 command id
|
|
// +0x04 u32 declared payload length
|
|
// +0x10 the payload
|
|
//
|
|
// and the returned copy of it carries the trustlet's own return code and
|
|
// the capture metric in the header, ahead of the payload:
|
|
//
|
|
// +0x08 i32 rc the trustlet's result, distinct from QTEE's
|
|
// +0x0c i32 metric CAPTURE_IMAGE's finger signal
|
|
//
|
|
// The metric is a HEADER field. It has been called "payload+12" in this
|
|
// project's notes and it is not; it tracks the finger reproducibly and
|
|
// every recorded number depends on reading it here.
|
|
inline constexpr std::size_t ReqCmdOff = 0x00;
|
|
inline constexpr std::size_t ReqLenOff = 0x04;
|
|
inline constexpr std::size_t ReqPayloadOff = 0x10;
|
|
inline constexpr std::size_t RespRcOff = 0x08;
|
|
inline constexpr std::size_t RespMetricOff = 0x0c;
|
|
|
|
inline void BuildRequest(std::span<std::byte> req, Cmd cmd,
|
|
std::span<const std::byte> payload) {
|
|
std::ranges::fill(req, std::byte{0});
|
|
detail::StoreU32(req, ReqCmdOff, static_cast<std::uint32_t>(cmd));
|
|
if (!payload.empty()) {
|
|
detail::StoreU32(req, ReqLenOff, static_cast<std::uint32_t>(payload.size()));
|
|
std::ranges::copy(payload, req.begin() + static_cast<std::ptrdiff_t>(ReqPayloadOff));
|
|
}
|
|
}
|
|
|
|
inline std::int32_t ResultCode(std::span<const std::byte> reqOut) {
|
|
return static_cast<std::int32_t>(detail::LoadU32(reqOut, RespRcOff));
|
|
}
|
|
inline std::int32_t CaptureMetric(std::span<const std::byte> reqOut) {
|
|
return static_cast<std::int32_t>(detail::LoadU32(reqOut, RespMetricOff));
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|