// 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 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; // 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 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 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 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 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); } // 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 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 BuildSetActiveGroup( std::uint32_t gid, std::string_view path = GroupNamespacePath) { std::vector 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(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 req, Cmd cmd, std::span payload) { std::ranges::fill(req, std::byte{0}); detail::StoreU32(req, ReqCmdOff, static_cast(cmd)); if (!payload.empty()) { detail::StoreU32(req, ReqLenOff, static_cast(payload.size())); std::ranges::copy(payload, req.begin() + static_cast(ReqPayloadOff)); } } inline std::int32_t ResultCode(std::span reqOut) { return static_cast(detail::LoadU32(reqOut, RespRcOff)); } inline std::int32_t CaptureMetric(std::span reqOut) { return static_cast(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 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; }