195 lines
9.9 KiB
Text
195 lines
9.9 KiB
Text
|
|
// 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<Listener, 3> 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: <uid>, AttrSystemTime: <ms> }
|
||
|
|
//
|
||
|
|
// encoded as a2 01 <uid> 06 1b <u64 ms> 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<std::byte>& out, std::uint64_t v) {
|
||
|
|
auto push = [&](std::uint8_t b) { out.push_back(static_cast<std::byte>(b)); };
|
||
|
|
auto pushBe = [&](std::uint64_t x, int n) {
|
||
|
|
for (int i = n - 1; i >= 0; i--) push(static_cast<std::uint8_t>((x >> (8 * i)) & 0xFF));
|
||
|
|
};
|
||
|
|
if (v < 24) push(static_cast<std::uint8_t>(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<std::byte> BuildCredentials(std::uint32_t uid, std::uint64_t systemTimeMs) {
|
||
|
|
std::vector<std::byte> out;
|
||
|
|
out.push_back(std::byte{0xA2}); // map(2)
|
||
|
|
AppendCborUint(out, static_cast<std::uint64_t>(CredAttr::Uid));
|
||
|
|
AppendCborUint(out, uid);
|
||
|
|
AppendCborUint(out, static_cast<std::uint64_t>(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<std::byte>((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<std::size_t>(offset);
|
||
|
|
return { true, static_cast<std::size_t>(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
|
||
|
|
}
|