// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types /* Fingerprintd:Tee — the QTEE object protocol's constants and its one encoded structure. Reaching the trustlet is a chain of object invocations over /dev/tee0: get the root object, exchange credentials for a client env, open the QSEECOM-compat loader on that env, load the trustlet, then invoke it. Registering the storage listeners QTEE calls back into runs off the same env. The transport is libqcomtee's. What lives here is the part that is ours to get right: the service and op numbers, the listener table, and the credentials blob — a CBOR map QTEE parses, so its bytes have to be exact. Building that blob here rather than with QCBOR drops the only dependency libqcomtee has beyond libc, and puts a reverse-engineered byte structure under test instead of inside a vendored library. */ export module Fingerprintd:Tee; import std; export namespace fingerprintd::tee { inline constexpr std::string_view DevTee = "/dev/tee0"; // ---- Services --------------------------------------------------------- // // QTEE service UIDs, opened on a client env. inline constexpr std::uint32_t UidQseecomCompatAppLoader = 122; inline constexpr std::uint32_t UidListenerCbo = 87; // Root object ops (IClientEnv). SAFETY: never invoke 4, 8 or 9 — // NOTIFY_DOMAIN_CHANGE, ADCI_ACCEPT and ADCI_SHUTDOWN. ADCI_ACCEPT donates // a thread to QTEE and does not return; an ADCI arc cost a string of // forced reboots and was ultimately refuted as an explanation for // anything. qcomtee_root_object_check() blocks them as destructive. enum class RootOp : std::uint32_t { RegisterLegacy = 1, // credentials as a CBOR BUFFER Register = 2, // credentials as a live callback OBJECT RegisterWithCredentials = 5, // the kernel's privileged NULL path }; // We use Register (op 2) with a live credentials object, because that is // what the harness that actually enrolled and matched a finger used. The // journal records op 1 working too, and all three constructions behaving // identically as far as storage is concerned — but the proven path is the // one that produced the working flow, and this is not the place to change // a known-good variable. inline constexpr RootOp ClientEnvOp = RootOp::Register; // The app object's sendRequest, arity 0x0424 = 4 input buffers, 2 output // buffers, 4 object slots. inline constexpr std::uint32_t AppSendRequestOp = 0; // ---- Listeners -------------------------------------------------------- // // Ids and shared-buffer sizes are qseecomd's, decoded from its service // table. The share column is measured across 224 trustlet commands during // a real enrolment on stock Android. struct Listener { std::uint32_t id; std::size_t bufferSize; std::string_view name; }; inline constexpr std::array Listeners = {{ { 0x7000, 516096, "gpfile" }, // 47 of 66 callbacks { 0x2000, 25600, "rpmb" }, // 16 of 66 { 10, 20480, "fs" }, // 3 of 66; never called on our path }}; // Registration semantics, settled by measurement. A fresh id returns 0 in // under 2 ms; the SAME id again returns -99 in 21-47 ms, identically from // another env in the same process or from a different process — so the // table is GLOBAL to QTEE, not per-env. QTEE releases the callback object // it was handed on every -99 and keeps it on every 0. // // The consequence for a daemon: a registration is held for the life of the // process, so exactly one process may own these ids, and it must not exit // and restart casually within a boot. inline constexpr std::int32_t ResultIdAlreadyTaken = -99; // One callback object PER registration. Sharing a single object across // registrations overwrites its id and buffer, and every multi-listener // result taken that way was void — six sessions of hypotheses rested on it. inline constexpr bool OneObjectPerRegistration = true; // ---- Credentials ------------------------------------------------------ // // A CBOR map QTEE parses. Attribute keys come from SmcInvokeCredAPI.h via // minkipc's TZCom, and libqcomtee builds the same two-entry map: // // { AttrUid: , AttrSystemTime: } // // encoded as a2 01 06 1b for a small uid. Only 1 and 6 have // ever been used; 2-5 are untried and there is a measured 10-byte read cap // that makes larger content doubtful. enum class CredAttr : std::uint8_t { Uid = 1, PkgFlags = 2, PkgName = 3, PkgCert = 4, Permissions = 5, SystemTime = 6, }; // Minimal CBOR unsigned encoder (major type 0). QTEE's parser wants the // shortest form, which is what QCBOR emits. inline void AppendCborUint(std::vector& out, std::uint64_t v) { auto push = [&](std::uint8_t b) { out.push_back(static_cast(b)); }; auto pushBe = [&](std::uint64_t x, int n) { for (int i = n - 1; i >= 0; i--) push(static_cast((x >> (8 * i)) & 0xFF)); }; if (v < 24) push(static_cast(v)); else if (v <= 0xFF) { push(0x18); pushBe(v, 1); } else if (v <= 0xFFFF) { push(0x19); pushBe(v, 2); } else if (v <= 0xFFFFFFFFull) { push(0x1A); pushBe(v, 4); } else { push(0x1B); pushBe(v, 8); } } // The credentials blob. `systemTimeMs` is wall-clock milliseconds, which is // the only value that changes between sessions — it was the leading // suspect for a session-scoped storage key and is REFUTED: pinning it to a // constant changed nothing. Do not re-test that. inline std::vector BuildCredentials(std::uint32_t uid, std::uint64_t systemTimeMs) { std::vector out; out.push_back(std::byte{0xA2}); // map(2) AppendCborUint(out, static_cast(CredAttr::Uid)); AppendCborUint(out, uid); AppendCborUint(out, static_cast(CredAttr::SystemTime)); // Always the 8-byte form for the timestamp: it is what the reference // emits and what the on-device verification used. out.push_back(std::byte{0x1B}); for (int i = 7; i >= 0; i--) out.push_back(static_cast((systemTimeMs >> (8 * i)) & 0xFF)); return out; } // ---- The credentials object's protocol -------------------------------- // // QTEE does not take the blob directly on the Register path: it takes an // object and calls back into it to read the bytes. Two ops, confirmed // against both our reversal of libminkdescriptor.so and libqcomtee's own // source: // // op 0 GET_LENGTH one output buffer of exactly 8: write the length // op 1 READ_AT_OFFSET one input buffer of 8 (u64 offset) + one output; // serve min(out.size, len - offset) bytes and set // the output size to what was served // // The reference does not memcpy into QTEE's buffer — it points the output // at its own storage and lets the framework marshal. That is correct for // the userspace layer, and it is not optional: // // ON THE CALLBACK PATH A UBUF_OUTPUT PARAM ARRIVES WITH addr = NULL. // // `size` is the capacity QTEE will accept; the dispatcher supplies the // buffer. A handler that writes through the incoming address dereferences // NULL and takes the supplicant thread with it. That is exactly how the // first run of this daemon against real QTEE died, with the correct // behaviour already written in this comment. An INPUT param does carry a // real address; only outputs arrive empty. // // The storage a handler points at must outlive the dispatch, so it belongs // to the object, not to the stack frame. enum class CredOp : std::uint32_t { GetLength = 0, ReadAtOffset = 1 }; inline constexpr std::size_t CredLengthReplySize = 8; // How many bytes to serve for a read at `offset`, and whether the request // is valid at all. An offset at or past the end is an error, not an empty // read — the reference returns QCOMTEE_ERROR_INVALID there. An earlier // reversal of ours guessed 10; libqcomtee settled it. struct ReadPlan { bool valid = false; std::size_t offset = 0; std::size_t count = 0; }; inline ReadPlan PlanRead(std::size_t blobLen, std::uint64_t offset, std::size_t outCapacity) { if (offset >= blobLen) return {}; std::size_t avail = blobLen - static_cast(offset); return { true, static_cast(offset), std::min(avail, outCapacity) }; } // ---- Shared memory for capture ---------------------------------------- // // CAPTURE_IMAGE needs a real memory REGION or the trustlet returns success // having done nothing but a raw scan — the entire flat-metric history. // The region goes in an object slot, and IB2 carries embeddedBufOffsets: // u32 offsets into the request naming where an embedded pointer sits. // 0x10 means "at request+0x10", which is payload+0x00, which the trustlet // reads as its output-buffer pointer. inline constexpr std::size_t CaptureRegionSize = 16384; inline constexpr std::uint32_t EmbeddedBufOffsetValue = 0x10; // Two traps, both paid for. The offsets array applies to EVERY command in // a run, and patching a pointer into SYNC_CONFIG's request breaks it — so // it is scoped to one command id. And an invoke CONSUMES its input // objects, so the region must be allocated fresh per command. inline constexpr std::uint32_t RegionScopedToCommand = 0x1013; // CAPTURE_IMAGE }