From a91fb2ff583e09813a8fec73033deb9c085423e8 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 2 Sep 2026 18:02:28 +0200 Subject: [PATCH] Reach QTEE: credentials, client env and the app loader, with no QCBOR fingerprintd's own code now talks to QTEE. On the phone: root object on /dev/tee0 client env obtained (uid 0, 13-byte credentials) QSEECOM-compat app loader (UID 122) opened The credentials object is ours rather than libqcomtee's. Upstream's exists only to build a thirteen-byte CBOR map and drags in QCBOR to do it, so packaging/make-libqcomtee.sh compiles the two sources that matter and drops credentials_obj.c entirely -- nothing else references it, and the library then has no dependency beyond libc. The map is built in Fingerprintd:Tee where it is pinned byte-for-byte against the string verified on-device, and the object's two-op read protocol is served here. Three interop details, all of which cost a build cycle: * libqcomtee's headers carry no extern "C" guard, having only ever been consumed from C, so everything came out C++-mangled. They also pull in and , which under libc++ drag in templates that may not appear inside extern "C" -- so those are included first. * tee_call_t's second parameter is unsigned long on glibc and int on musl. The native build is glibc and the phone is musl; both forms are compiled. * On the callback path a UBUF_OUTPUT param arrives with addr = NULL. The dispatcher supplies the buffer, so a handler POINTS the param at its own storage rather than writing through the incoming address. Doing the latter is a null dereference that takes the supplicant thread with it, which is how the first run against real QTEE ended -- with the correct behaviour already spelled out in the module comment above the code that ignored it. That comment now says so in as many words. --- implementations/main.cpp | 267 ++++++++++++++++++++++++++++--- interfaces/Fingerprintd-Tee.cppm | 195 ++++++++++++++++++++++ interfaces/Fingerprintd.cppm | 1 + packaging/make-libqcomtee.sh | 82 ++++++++++ project.cpp | 35 +++- tests/Tee/main.cpp | 151 +++++++++++++++++ 6 files changed, 707 insertions(+), 24 deletions(-) create mode 100644 interfaces/Fingerprintd-Tee.cppm create mode 100755 packaging/make-libqcomtee.sh create mode 100644 tests/Tee/main.cpp diff --git a/implementations/main.cpp b/implementations/main.cpp index d2cc82f..6ceda8b 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -2,49 +2,270 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types +// lint-disable-file no-char-pointer /* fingerprintd — the daemon shell. -Nothing here talks to hardware yet. The core (Fingerprintd:Sfs and the modules -that follow it) is being ported from the fp6 harness one wire format at a time, -each landing with tests that run without a phone; this entry point exists so -the executable target builds alongside them and so the version constant that -gates a package publish has a home. +Everything that touches hardware lives here; the decisions live in +fingerprintd-core, which is tested without a phone. Right now this reaches QTEE +and stops: root object, credentials, client env, the QSEECOM-compat loader. +Enough to prove the transport, not yet to drive the sensor. -What this will own, and why it has to be one long-lived process: - - * the sensor rail (gpio29), reset (gpio74) and IRQ (gpio75) — one sensor - reset buys exactly one trustlet init, so whatever powers the sensor must - also hold the session; - * the QTEE session on /dev/tee0: client env, the QSEECOM-compat loader, and - the focal64 trustlet, loaded once; - * the storage listeners QTEE calls back into — gpfile 0x7000 and RPMB - 0x2000 — served from a supplicant thread that must outlive every request, - because QTEE's listener table is global and an id is taken for as long as - the registration is held; - * net.reactivated.Fprint, so pam_fprintd and the desktop need no changes. +Why the process must be long-lived, once it does more: a listener registration +is held for as long as the process lives and QTEE's listener table is global to +the boot, and one sensor reset buys exactly one trustlet init. So the process +that powers the sensor has to be the process that holds the session. */ +// libqcomtee is a C library and its headers carry no extern "C" guard -- it +// has only ever been consumed from C. Without one every symbol would be +// C++-mangled and none would link. +// +// The headers pull in , and , and under +// libc++ those drag in C++ templates, which may not appear inside an +// extern "C" block. Including them first makes the nested includes no-ops. +#include +#include +#include +extern "C" { +#include +#include +#include +} + +#include +#include +#include +#include +#include +#include +#include + import std; import Fingerprintd; namespace { - // Bumping this is what publishes a package: the registry answers 409 for a - // version it already has, which a build treats as a no-op. - constexpr const char* Version = "0.0.1"; + +constexpr const char* Version = "0.0.2"; + +qcomtee_object* g_root = QCOMTEE_OBJECT_NULL; + +// The ioctl trampoline libqcomtee calls. Cancellation is made asynchronous +// around it so the supplicant thread can be stopped while blocked in the +// kernel waiting for QTEE. +// +// tee_call_t's second parameter is `unsigned long` on glibc and `int` on musl +// (qcomtee_object.h keys it off __GLIBC__), so the signature has to match or +// the function pointer will not convert. The native build is glibc and the +// phone is musl, so both forms are compiled here. +#ifdef __GLIBC__ +int TeeCall(int fd, unsigned long op, ...) { +#else +int TeeCall(int fd, int op, ...) { +#endif + va_list ap; + va_start(ap, op); + void* arg = va_arg(ap, void*); + va_end(ap); + pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, nullptr); + int ret = ::ioctl(fd, static_cast(op), arg); + pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, nullptr); + return ret; } +// QTEE's callbacks are serviced here. Nothing QTEE asks of us happens without +// this running. +void* Supplicant(void*) { + for (;;) { + pthread_testcancel(); + if (qcomtee_object_process_one(g_root)) + break; + } + return nullptr; +} + +std::uint64_t NowMs() { + timeval tv{}; + ::gettimeofday(&tv, nullptr); + return static_cast(tv.tv_sec) * 1000 + + static_cast(tv.tv_usec) / 1000; +} + +// ---- The credentials object +// +// QTEE will not take the credentials blob directly on the Register path: it +// takes an object and calls back into it, twice, while our invoke is still in +// flight. Two ops, GET_LENGTH then READ_AT_OFFSET. +// +// libqcomtee ships one of these, but only by pulling in QCBOR to build the +// map. The map is thirteen bytes and lives in Fingerprintd:Tee under test, so +// this serves it and the library needs no dependency beyond libc. +struct CredentialsObject { + qcomtee_object object; // must be first: we cast between them + std::vector blob; + std::uint64_t lenStorage = 0; // op 0's answer, pointed at not copied +}; + +void CredentialsRelease(qcomtee_object* object) { + delete reinterpret_cast(object); +} + +qcomtee_result_t CredentialsDispatch(qcomtee_object* object, qcomtee_op_t op, + qcomtee_param* params, int num) { + auto* self = reinterpret_cast(object); + + // On the CALLBACK path a QCOMTEE_UBUF_OUTPUT param arrives with + // addr = NULL and size = the capacity QTEE will accept: the dispatcher + // supplies the buffer, so the handler POINTS the param at storage of its + // own and lets the framework marshal it. Writing through the incoming addr + // is a null dereference, which is exactly how this crashed the first time + // it ran against real QTEE. + if (op == static_cast(fingerprintd::tee::CredOp::GetLength)) { + if (num != 1 || params[0].attr != QCOMTEE_UBUF_OUTPUT) + return QCOMTEE_ERROR_INVALID; + if (params[0].ubuf.size < fingerprintd::tee::CredLengthReplySize) + return QCOMTEE_ERROR_INVALID; + self->lenStorage = static_cast(self->blob.size()); + params[0].ubuf.addr = &self->lenStorage; + params[0].ubuf.size = sizeof(self->lenStorage); + return QCOMTEE_OK; + } + + if (op == static_cast(fingerprintd::tee::CredOp::ReadAtOffset)) { + if (num != 2 || params[0].attr != QCOMTEE_UBUF_INPUT + || params[1].attr != QCOMTEE_UBUF_OUTPUT) + return QCOMTEE_ERROR_INVALID; + // An INPUT param does carry a real address; only outputs arrive NULL. + if (params[0].ubuf.size < sizeof(std::uint64_t) || !params[0].ubuf.addr) + return QCOMTEE_ERROR_INVALID; + std::uint64_t offset = 0; + ::memcpy(&offset, params[0].ubuf.addr, sizeof(offset)); + + auto plan = fingerprintd::tee::PlanRead(self->blob.size(), offset, + params[1].ubuf.size); + if (!plan.valid) + return QCOMTEE_ERROR_INVALID; + // Same again: point at the blob, do not copy into QTEE's buffer. The + // storage has to outlive the dispatch, which the object owns. + params[1].ubuf.addr = self->blob.data() + plan.offset; + params[1].ubuf.size = plan.count; + return QCOMTEE_OK; + } + + return QCOMTEE_ERROR_INVALID; +} + +qcomtee_object_ops g_credOps = { + /* release */ CredentialsRelease, + /* dispatch */ CredentialsDispatch, + /* error */ nullptr, + /* supported */ nullptr, +}; + +qcomtee_object* MakeCredentials(std::uint32_t uid) { + auto* c = new CredentialsObject{}; + c->blob = fingerprintd::tee::BuildCredentials(uid, NowMs()); + if (qcomtee_object_cb_init(&c->object, &g_credOps, g_root)) { + delete c; + return QCOMTEE_OBJECT_NULL; + } + return &c->object; +} + +// ROOT op 2: hand QTEE a live credentials object and get a client env back. +// QTEE calls into the object while this invoke is outstanding, which is why +// the supplicant has to be running first. +qcomtee_object* GetClientEnv(std::uint32_t uid) { + qcomtee_object* creds = MakeCredentials(uid); + if (creds == QCOMTEE_OBJECT_NULL) { + std::println(std::cerr, "credentials object init failed"); + return QCOMTEE_OBJECT_NULL; + } + qcomtee_param p[2] = {}; + p[0].attr = QCOMTEE_OBJREF_INPUT; + p[0].object = creds; + p[1].attr = QCOMTEE_OBJREF_OUTPUT; + qcomtee_result_t result = 0; + if (qcomtee_object_invoke(g_root, + static_cast(fingerprintd::tee::ClientEnvOp), + p, 2, &result) || result) { + std::println(std::cerr, "ROOT op {} failed, result={}", + static_cast(fingerprintd::tee::ClientEnvOp), + static_cast(result)); + return QCOMTEE_OBJECT_NULL; + } + return p[1].object; +} + +// IClientEnv op 0: open a service by UID on the env. +qcomtee_object* OpenService(qcomtee_object* env, std::uint32_t uid) { + qcomtee_param p[2] = {}; + p[0].attr = QCOMTEE_UBUF_INPUT; + p[0].ubuf.addr = &uid; + p[0].ubuf.size = sizeof(uid); + p[1].attr = QCOMTEE_OBJREF_OUTPUT; + qcomtee_result_t result = 0; + if (qcomtee_object_invoke(env, 0, p, 2, &result) || result) { + std::println(std::cerr, "IClientEnv.open({}) failed, result={}", uid, + static_cast(result)); + return QCOMTEE_OBJECT_NULL; + } + return p[1].object; +} + +int Probe() { + namespace tee = fingerprintd::tee; + + std::string dev(tee::DevTee); + g_root = qcomtee_object_root_init(dev.c_str(), TeeCall, nullptr, nullptr); + if (g_root == QCOMTEE_OBJECT_NULL) { + std::println(std::cerr, "root object on {}: {}", tee::DevTee, + ::strerror(errno)); + return 1; + } + std::println("root object on {}", tee::DevTee); + + pthread_t th{}; + if (pthread_create(&th, nullptr, Supplicant, nullptr) != 0) { + std::println(std::cerr, "supplicant thread failed to start"); + return 1; + } + + std::uint32_t uid = ::getuid(); + qcomtee_object* env = GetClientEnv(uid); + if (env == QCOMTEE_OBJECT_NULL) + return 1; + std::println("client env obtained (uid {}, {}-byte credentials)", uid, + tee::BuildCredentials(uid, 0).size()); + + qcomtee_object* loader = OpenService(env, tee::UidQseecomCompatAppLoader); + if (loader == QCOMTEE_OBJECT_NULL) + return 1; + std::println("QSEECOM-compat app loader (UID {}) opened", + tee::UidQseecomCompatAppLoader); + + std::println("\nreached QTEE. Not driving the sensor yet."); + pthread_cancel(th); + pthread_join(th, nullptr); + return 0; +} + +} // namespace + int main(int argc, char** argv) { std::span args(argv, static_cast(argc)); + bool probe = false; for (std::string_view a : args.subspan(1)) { if (a == "--version") { std::println("fingerprintd {}", Version); return 0; } + if (a == "--probe-tee") probe = true; } + if (probe) + return Probe(); std::println(std::cerr, - "fingerprintd {}: no runtime yet -- the core is still being " - "ported. Run `crafter-build test` for what does work.", - Version); + "fingerprintd {}: no runtime yet. --probe-tee reaches QTEE; " + "`crafter-build test` covers the core.", Version); return 1; } diff --git a/interfaces/Fingerprintd-Tee.cppm b/interfaces/Fingerprintd-Tee.cppm new file mode 100644 index 0000000..c86187d --- /dev/null +++ b/interfaces/Fingerprintd-Tee.cppm @@ -0,0 +1,195 @@ +// 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 +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index 28c188b..0521742 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -17,3 +17,4 @@ export import :Rpmb; export import :Ta; export import :Engine; export import :Store; +export import :Tee; diff --git a/packaging/make-libqcomtee.sh b/packaging/make-libqcomtee.sh new file mode 100755 index 0000000..bfbe9ff --- /dev/null +++ b/packaging/make-libqcomtee.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# make-libqcomtee.sh — build libqcomtee for a target, without QCBOR. +# +# packaging/make-libqcomtee.sh [--target= --sysroot=] [outdir] +# +# libqcomtee is Qualcomm's BSD-3 userspace client for QTEE (quic/quic-teec). +# Upstream's CMake does find_package(QCBOR REQUIRED) and falls back to libcbor, +# but that dependency exists for exactly one file, src/objects/credentials_obj.c, +# which builds the {AttrUid, AttrSystemTime} CBOR map. +# +# fingerprintd builds that map itself in Fingerprintd:Tee, where it is thirteen +# bytes under test rather than a library dependency, and implements the +# credentials object's two-op read protocol in its own shell. So only two of +# the three sources are needed: +# +# src/qcomtee_object.c the object/marshalling core +# src/objects/mem_obj.c memory objects (CAPTURE_IMAGE needs a real region) +# +# Nothing in either references credentials_obj, so dropping it leaves the +# library with no dependency beyond libc. +set -eu + +REPO="${QUIC_TEEC_REPO:-https://github.com/quic/quic-teec.git}" +# Pinned. Upstream is small and moves rarely; an unpinned clone would change +# the marshalling under us without notice. +COMMIT="${QUIC_TEEC_COMMIT:-736419e}" +SRC="${QUIC_TEEC_DIR:-$HOME/.cache/fingerprintd/quic-teec}" + +TARGET="" +SYSROOT="" +MARCH="" +OUT="" +for a in "$@"; do + case "$a" in + --target=*) TARGET="${a#--target=}" ;; + --sysroot=*) SYSROOT="${a#--sysroot=}" ;; + --march=*) MARCH="${a#--march=}" ;; + -*) echo "unknown option: $a" >&2; exit 1 ;; + *) OUT="$a" ;; + esac +done +[ -n "$OUT" ] || OUT="$HOME/.cache/fingerprintd/libqcomtee${TARGET:+-$TARGET}" + +if [ ! -d "$SRC/.git" ]; then + echo ">> cloning $REPO -> $SRC" + mkdir -p "$(dirname "$SRC")" + git clone -q "$REPO" "$SRC" + git -C "$SRC" checkout -q "$COMMIT" +elif [ -n "${QUIC_TEEC_DIR:-}" ]; then + # A checkout the caller pointed us at is THEIRS. Never move its HEAD -- + # silently checking out a pin in someone's working tree is how you lose + # uncommitted work. Report the mismatch and let them decide. + have=$(git -C "$SRC" rev-parse --short HEAD) + case "$COMMIT" in + "$have"*) : ;; + *) echo "!! $SRC is at $have, not the pinned $COMMIT." >&2 + echo "!! Building from it anyway; unset QUIC_TEEC_DIR to use a pinned clone." >&2 ;; + esac +else + git -C "$SRC" fetch -q --all 2>/dev/null || true + git -C "$SRC" checkout -q "$COMMIT" +fi + +mkdir -p "$OUT" +set -- -O2 -Wall -fPIC +[ -n "$TARGET" ] && set -- "$@" --target="$TARGET" +[ -n "$SYSROOT" ] && set -- "$@" --sysroot="$SYSROOT" +[ -n "$MARCH" ] && set -- "$@" -march="$MARCH" + +echo ">> building libqcomtee.a (no QCBOR) for ${TARGET:-native}" +for f in src/qcomtee_object.c src/objects/mem_obj.c; do + clang "$@" -I"$SRC/libqcomtee/include" -I"$SRC/libqcomtee/src" \ + -c "$SRC/libqcomtee/$f" -o "$OUT/$(basename "$f" .c).o" +done +rm -f "$OUT/libqcomtee.a" +llvm-ar rcs "$OUT/libqcomtee.a" "$OUT"/*.o +mkdir -p "$OUT/include" +cp "$SRC/libqcomtee/include/"*.h "$OUT/include/" + +echo ">> done: $OUT/libqcomtee.a" diff --git a/project.cpp b/project.cpp index 056ba0c..effe973 100644 --- a/project.cpp +++ b/project.cpp @@ -8,6 +8,34 @@ import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; +// libqcomtee — Qualcomm's BSD-3 userspace client for QTEE, built by +// packaging/make-libqcomtee.sh into a per-target cache dir. It is not vendored +// here: it is upstream code we pin, and the script builds it WITHOUT QCBOR +// (see the script for why that dependency is avoidable). +static void ApplyQcomteeFlags(Configuration& cfg) { + fs::path base = fs::path(std::getenv("HOME") ? std::getenv("HOME") : ".") + / ".cache" / "fingerprintd"; + // make-libqcomtee.sh names its output dir after --target, and plain + // "libqcomtee" when built for the host. cfg.target is always populated + // (it defaults to the host triple), so try the target-specific dir first + // and fall back to the host one. + std::error_code ec; + fs::path root = base / ("libqcomtee-" + cfg.target); + if (!fs::exists(root / "libqcomtee.a", ec)) + root = base / "libqcomtee"; + + if (!fs::exists(root / "libqcomtee.a", ec)) { + std::println(std::cerr, + "libqcomtee not built for '{}'. Run:\n" + " packaging/make-libqcomtee.sh{}{}", + cfg.target.empty() ? std::string("native") : cfg.target, + cfg.target.empty() ? std::string() : " --target=" + cfg.target, + cfg.sysroot.empty() ? std::string() : " --sysroot=" + cfg.sysroot); + } + cfg.compileFlags.push_back("-I" + (root / "include").string()); + cfg.linkFlags.push_back((root / "libqcomtee.a").string()); +} + extern "C" Configuration CrafterBuildProject(std::span args) { // fingerprintd-core — the wire formats and state machines as a static // library of pure C++ modules. Deliberately free of GLib, libqcomtee and @@ -21,13 +49,14 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Fingerprintd", "interfaces/Fingerprintd-Sfs", "interfaces/Fingerprintd-Rpmb", "interfaces/Fingerprintd-Ta", "interfaces/Fingerprintd-Engine", "interfaces/Fingerprintd-Store", + "interfaces/Fingerprintd-Tee", }; std::array impls = {}; Core->GetInterfacesAndImplementations(ifaces, impls); @@ -48,11 +77,15 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.GetInterfacesAndImplementations(ifaces, impls); } + ApplyQcomteeFlags(cfg); + cfg.linkFlags.push_back("-lpthread"); // the supplicant thread + cfg.AddTest("Sfs").Dependencies({ Core.get() }); cfg.AddTest("Rpmb").Dependencies({ Core.get() }); cfg.AddTest("Ta").Dependencies({ Core.get() }); cfg.AddTest("Engine").Dependencies({ Core.get() }); cfg.AddTest("Store").Dependencies({ Core.get() }); + cfg.AddTest("Tee").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Tee/main.cpp b/tests/Tee/main.cpp new file mode 100644 index 0000000..d0edf0f --- /dev/null +++ b/tests/Tee/main.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Tee unit tests. + +The credentials blob is CBOR that QTEE parses, so it is pinned against the +exact byte string verified on the device: a2 01 00 06 1b . Building it +here instead of with QCBOR is what lets libqcomtee be compiled with no +dependency beyond libc, so the encoder has to be right rather than +approximately right. + +The rest is constants that were expensive to establish and are cheap to undo by +accident: the listener table, and the three root ops that must never be +invoked. +*/ +import std; +import Fingerprintd; + +using namespace fingerprintd::tee; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + std::string Hex(std::span b) { + std::string s; + for (std::byte x : b) s += std::format("{:02x}", std::to_integer(x)); + return s; + } +} + +int main() { + // ---- The credentials blob, against the byte string verified on-device + { + // {AttrUid: 0, AttrSystemTime: 0} -> a2 01 00 06 1b 00*8 + auto c = BuildCredentials(0, 0); + Check(Hex(c) == "a20100061b0000000000000000", "root, zero time: exact bytes"); + Check(c.size() == 13, "13 bytes for a small uid"); + + // The map header and both keys are fixed. + Check(std::to_integer(c[0]) == 0xA2, "map(2)"); + Check(std::to_integer(c[1]) == 0x01, "key AttrUid = 1"); + Check(std::to_integer(c[3]) == 0x06, "key AttrSystemTime = 6"); + Check(std::to_integer(c[4]) == 0x1B, "timestamp is the 8-byte form"); + + // A real timestamp, big endian. + auto t = BuildCredentials(0, 0x0000019283746555ull); + Check(Hex(t) == "a20100061b0000019283746555", "timestamp big endian"); + + // A non-root uid takes the shortest CBOR form, which is what QCBOR + // emits and what QTEE's parser expects. + Check(Hex(BuildCredentials(23, 0)).starts_with("a20117"), "uid 23 is one byte"); + Check(Hex(BuildCredentials(24, 0)).starts_with("a2011818"), "uid 24 needs 0x18"); + Check(Hex(BuildCredentials(1000, 0)).starts_with("a2011903e8"), "uid 1000 needs 0x19"); + Check(Hex(BuildCredentials(70000, 0)).starts_with("a2011a00011170"), "uid 70000 needs 0x1a"); + } + + // ---- The CBOR uint encoder's boundaries + { + auto enc = [](std::uint64_t v) { + std::vector o; AppendCborUint(o, v); return Hex(o); + }; + Check(enc(0) == "00", "0"); + Check(enc(23) == "17", "23 is the last single-byte value"); + Check(enc(24) == "1818", "24 crosses into the one-byte form"); + Check(enc(255) == "18ff", "255"); + Check(enc(256) == "190100", "256 crosses into the two-byte form"); + Check(enc(65535) == "19ffff", "65535"); + Check(enc(65536) == "1a00010000", "65536 crosses into the four-byte form"); + Check(enc(0xFFFFFFFFull) == "1affffffff", "u32 max"); + Check(enc(0x100000000ull) == "1b0000000100000000", "past u32 takes eight bytes"); + } + + // ---- The credentials object's read protocol + { + // A 13-byte blob, QTEE offering a 4096-byte buffer. + auto p = PlanRead(13, 0, 4096); + Check(p.valid && p.offset == 0 && p.count == 13, "whole blob in one read"); + + // A short output buffer serves what fits. + auto q = PlanRead(13, 0, 8); + Check(q.valid && q.count == 8, "clamped to the output capacity"); + + // Continuing from where that stopped. + auto r = PlanRead(13, 8, 8); + Check(r.valid && r.offset == 8 && r.count == 5, "the remainder"); + + // At or past the end is an ERROR, not an empty read. libqcomtee + // returns QCOMTEE_ERROR_INVALID; an earlier reversal of ours guessed + // 10 and was wrong. + Check(!PlanRead(13, 13, 8).valid, "offset == length is invalid"); + Check(!PlanRead(13, 99, 8).valid, "offset past the end is invalid"); + Check(!PlanRead(0, 0, 8).valid, "an empty blob has nothing to read"); + + Check(CredLengthReplySize == 8, "GET_LENGTH answers into exactly 8 bytes"); + Check(static_cast(CredOp::GetLength) == 0, "IIO_OP_GET_LENGTH"); + Check(static_cast(CredOp::ReadAtOffset) == 1, "IIO_OP_READ_AT_OFFSET"); + } + + // ---- The listener table + { + Check(Listeners.size() == 3, "three listeners"); + Check(Listeners[0].id == 0x7000 && Listeners[0].bufferSize == 516096, "gpfile"); + Check(Listeners[1].id == 0x2000 && Listeners[1].bufferSize == 25600, "rpmb"); + Check(Listeners[2].id == 10 && Listeners[2].bufferSize == 20480, "fs"); + // The buffer sizes are qseecomd's and are not ours to round off. + Check(Listeners[0].bufferSize > Listeners[1].bufferSize, "gpfile's buffer is the large one"); + // Ids must be distinct: the table is global to QTEE and a collision + // means one registration silently loses. + Check(Listeners[0].id != Listeners[1].id && Listeners[1].id != Listeners[2].id, + "ids are distinct"); + Check(ResultIdAlreadyTaken == -99, "-99 means the id is taken, not a storage error"); + Check(OneObjectPerRegistration, "one callback object per registration"); + } + + // ---- Services and ops + { + Check(UidQseecomCompatAppLoader == 122, "the loader UID"); + Check(UidListenerCbo == 87, "CListenerCBO"); + Check(AppSendRequestOp == 0, "sendRequest is op 0"); + Check(ClientEnvOp == RootOp::Register, "we use the proven op-2 path"); + + // The three destructive root ops must not appear in the enum we can + // reach for. This is a guard against a future sweep. + auto isDefined = [](std::uint32_t op) { + return op == static_cast(RootOp::RegisterLegacy) + || op == static_cast(RootOp::Register) + || op == static_cast(RootOp::RegisterWithCredentials); + }; + Check(!isDefined(4), "NOTIFY_DOMAIN_CHANGE is not reachable"); + Check(!isDefined(8), "ADCI_ACCEPT is not reachable"); + Check(!isDefined(9), "ADCI_SHUTDOWN is not reachable"); + } + + // ---- The capture region + { + Check(CaptureRegionSize == 16384, "16 KB region"); + Check(EmbeddedBufOffsetValue == 0x10, "request+0x10 is payload+0x00"); + Check(RegionScopedToCommand == 0x1013, + "scoped to CAPTURE_IMAGE; applying it to SYNC_CONFIG breaks that command"); + } + + if (Failures == 0) std::println("Tee: all tests passed"); + return Failures; +}