FF_CMD_TA_REMOVE, recovered the way AUTHENTICATE was: read the stub, read the handler. The 0x2006 stub at 0xa15c is a bare `ldp w0, w1, [payload]`, so the request is two u32s -- gid at +0, fid at +4 -- and the 0x2000 dispatcher validates no length. Walking the jump table reproduces authenticate at 0xa180, which is the address already on record, so the table read is sound. Three preconditions, all the trustlet's own. The gid must be the ACTIVE group (it compares against device+0x30, the field SET_ACTIVE_GROUP writes). The fid must be non-zero: zero is not "remove all", it is an error the trustlet logs and refuses. And the fid must be among the loaded templates, because it removes by the SLOT INDEX it finds, not by id. It persists: on a hit the trustlet formats ff_template_<gid>_<slot>.bin and calls ff_file_delete, which arrives on our gpfile listener as an unlink -- so this only works with the store served writable. Proven harmlessly first. --probe-remove sends one command with no map involvement, and a fid the group does not hold answers rc=-2 with the real template untouched -- which is what established that both words are read where we send them, before anything was deleted. Then for real, through fprintd-delete: both 347202-byte containers and their .bak companions unlinked, templates loaded 1 -> 0, and a re-enrolment afterwards completed 20 stages with SAVE_DATA rc=0, so the store is consistent after a removal rather than merely emptier. The ordering the transcript shows is worth keeping: the group index is rewritten and the RPMB anti-rollback counter bumped BEFORE each unlink. That is precisely why an orderly removal leaves a valid store where restoring an older container leaves a tampered one -- the counter has already moved past it. The delete reply now waits for the worker, because only that thread invokes the trustlet and fprintd's Delete methods are synchronous. Names are dropped before templates on purpose: a template that survives a failed removal is a slot leak, while a name that survives a successful one keeps offering a finger that can no longer match.
557 lines
27 KiB
C++
557 lines
27 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,
|
|
Remove = 0x2006,
|
|
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);
|
|
}
|
|
|
|
// REMOVE (TA 0xd7b0, reached from the 0x2006 stub at 0xa15c, which is a
|
|
// bare `ldp w0, w1, [payload]`):
|
|
// +0x00 u32 gid
|
|
// +0x04 u32 fid
|
|
// Declared length 0x08. The 0x2000-range dispatcher range-checks the
|
|
// command id and jumps; it validates no length, so the payload is exactly
|
|
// the two fields.
|
|
//
|
|
// Three preconditions, all of them the trustlet's own:
|
|
//
|
|
// gid must equal the ACTIVE group. ff_trustlet_remove compares it
|
|
// against device+0x30 -- the same field SET_ACTIVE_GROUP writes and
|
|
// AUTHENTICATE checks -- and logs "templates with gid(%u != %u) hasn't
|
|
// been loaded." on a mismatch.
|
|
//
|
|
// fid must be NON-ZERO. Zero is not "remove them all": the trustlet
|
|
// logs "error at %s[%s:%u]: removing template with fid equ 0." and
|
|
// refuses. Removing every finger means calling this once per fid.
|
|
//
|
|
// The fid must be among the templates currently LOADED. The trustlet
|
|
// walks its loaded list for a matching id and removes by SLOT INDEX,
|
|
// not by id -- libfp_template_remove takes the index it found.
|
|
//
|
|
// It persists. On a hit the trustlet logs "template (gid = %u, fid = %u)
|
|
// is found at slot %d.", formats "%s/ff_template_%d_%d.bin" and calls
|
|
// ff_file_delete, which arrives on the gpfile listener as an unlink -- so
|
|
// the daemon must be serving the store WRITABLE or the container survives
|
|
// the call that reported success.
|
|
inline constexpr std::size_t RemovePayloadSize = 0x08;
|
|
inline constexpr std::size_t RemoveGidOff = 0x00;
|
|
inline constexpr std::size_t RemoveFidOff = 0x04;
|
|
|
|
inline void BuildRemovePayload(std::span<std::byte> out, std::uint32_t gid,
|
|
std::uint32_t fid) {
|
|
std::ranges::fill(out.first(RemovePayloadSize), std::byte{0});
|
|
detail::StoreU32(out, RemoveGidOff, gid);
|
|
detail::StoreU32(out, RemoveFidOff, fid);
|
|
}
|
|
|
|
// 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;
|
|
}
|