Serve QTEE's storage: the enrolled template loads

The whole storage path now works from the daemon. On the phone, against the
real store:

    listener 0x7000  sb=516096  -> result=0  REGISTERED
    listener 0x2000  sb=25600   -> result=0  REGISTERED
    SET_ACTIVE_GROUP gid=60 path='/data/vendor_de/0/fpdata'
      gpfile READ .../1lPrxAL0vXRvWPeDkW2c off=4096 len=252114
      ...
      CMD 0x2005 -> result=0 rc=1
      templates loaded: 1

QTEE read a 252114-byte enrolled template through our gpfile listener, verified
it, and loaded it. Since QTEE unlinks any container whose keyed integrity tag
fails, a load is proof the framing is right -- the read/write offset split, the
container chunking, and the RPMB anti-rollback read that has to succeed before
QTEE will trust any of it.

RPMB is served too: SECURITY PROTOCOL IN/OUT against the RPMB well-known LUN,
retrying the unit attention the LUN raises once after a reset. Writes are
refused unless asked for, because they advance a counter that cannot be moved
back, and key programming is refused unconditionally.

The store was served READ-ONLY throughout, which is the point. A listener that
serves bytes at the wrong offset does not merely fail: QTEE deletes the
container it cannot verify, and that is an enrolled fingerprint gone. Read-only
makes a wrong build harmless, so it is the default and writing is opt-in.

Two ordering facts, both of which produce -2 with no storage read at all --
indistinguishable from a broken listener:

  * a template reload needs the device init chain to have run FIRST, because
    that chain allocates the per-slot array the reload writes through;
  * SET_ACTIVE_GROUP's second field is a NAMESPACE path, not a filesystem one
    and not the gid again. The trustlet hashes it into the group's directory
    name, so it has to match what the store was written under.

Also: a positive rc is not an error code. ENUMERATE returns the template count
there, and running that through the error table printed "unknown" for a good
answer.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 18:42:20 +02:00
commit 03023284ba
3 changed files with 432 additions and 7 deletions

View file

@ -33,6 +33,8 @@ extern "C" {
}
#include <linux/gpio.h>
#include <linux/bsg.h>
#include <scsi/sg.h>
#include <pthread.h>
#include <fcntl.h>
#include <sys/ioctl.h>
@ -50,6 +52,8 @@ namespace {
constexpr const char* Version = "0.0.3";
bool g_verbose = false;
bool g_listeners = false;
std::uint32_t g_gid = 0;
std::string g_taPath = "/lib/firmware/focal64.mbn";
std::string g_cfgPath = "/lib/firmware/fingerprintd.json";
@ -218,6 +222,351 @@ qcomtee_object* OpenService(qcomtee_object* env, std::uint32_t uid) {
return p[1].object;
}
// ---- The storage listeners
//
// QTEE cannot reach a filesystem, so it calls back into the normal world for
// every template read and write. This serves those callbacks. The framing is
// Fingerprintd:Sfs; what lives here is the file I/O and the registration.
//
// READ-ONLY MODE EXISTS FOR A REASON. QTEE deletes a container whose keyed
// integrity tag does not verify, so a listener that serves bytes at the wrong
// offset does not merely fail -- it makes QTEE unlink an enrolled template.
// That is unrecoverable. Until a build has been shown to round-trip a
// container, it should serve read-only, where an unlink is refused with EROFS
// and the store cannot be damaged.
bool g_sfsReadOnly = true;
std::string g_sfsRoot = "/var/lib/fingerprintd/sfs";
struct ListenerObject {
qcomtee_object object; // must be first
std::uint32_t id = 0;
qcomtee_object* shared = QCOMTEE_OBJECT_NULL;
std::array<std::array<std::byte, 64>, 8> outBufs{};
};
void ListenerRelease(qcomtee_object* object) {
delete reinterpret_cast<ListenerObject*>(object);
}
// Serve one gpfile request out of the shared buffer, in place.
void ServeGpFile(std::span<std::byte> sb) {
namespace sfs = fingerprintd::sfs;
auto req = sfs::ParseRequest(sb);
if (!req) {
std::println(" gpfile: undecodable request");
sfs::WriteReply(sb, EINVAL, 0);
return;
}
if (req->op == sfs::OpConfigPathInit) {
// Asked first, with an empty frame. The answer is LATCHED for the
// whole boot, so an experiment on its value needs a fresh boot.
std::println(" gpfile op 12 (path init) -> {}", sfs::ConfigPathInitReply);
sfs::WriteConfigPathInitReply(sb);
return;
}
auto full = sfs::ResolvePath(g_sfsRoot, req->root, req->path);
if (!full) {
std::println(" gpfile: refusing path '{}' under root {}", req->path, req->root);
sfs::WriteReply(sb, EINVAL, 0);
return;
}
switch (req->action) {
case sfs::Action::Read: {
std::println(" gpfile READ {} off={} len={}", *full, req->offset, req->length);
std::ifstream f(*full, std::ios::binary);
if (!f) { sfs::WriteReply(sb, ENOENT, 0); return; }
if (req->offset > 0) f.seekg(req->offset);
std::size_t want = std::min<std::size_t>(req->length,
sfs::Capacity(sb, sfs::Action::Read));
f.read(reinterpret_cast<char*>(sb.data() + sfs::ReadDataOff),
static_cast<std::streamsize>(want));
auto got = static_cast<std::uint32_t>(f.gcount());
std::println(" read {} bytes into +0x{:03x}", got, sfs::ReadDataOff);
sfs::WriteReply(sb, 0, got);
return;
}
case sfs::Action::Write: {
std::println(" gpfile WRITE {} off={} len={}", *full, req->offset, req->length);
if (g_sfsReadOnly) {
std::println(" REFUSED: read-only");
sfs::WriteReply(sb, EROFS, 0);
return;
}
// O_RDWR | O_CREAT | O_SYNC and never O_TRUNC: QTEE writes a container
// as write(0,4096), write(4096,N), write(0,4096), so truncating on open
// leaves 4096 bytes where a 258850-byte template belongs.
int fd = ::open(full->c_str(), O_RDWR | O_CREAT | O_SYNC, 0600);
if (fd < 0) { sfs::WriteReply(sb, errno, 0); return; }
if (req->offset > 0 && ::lseek(fd, req->offset, SEEK_SET) < 0) {
int e = errno; ::close(fd); sfs::WriteReply(sb, e, 0); return;
}
std::size_t want = std::min<std::size_t>(req->length,
sfs::Capacity(sb, sfs::Action::Write));
std::size_t done = 0;
while (done < want) { // short writes are real; the reference loops
ssize_t n = ::write(fd, sb.data() + sfs::WriteDataOff + done, want - done);
if (n <= 0) break;
done += static_cast<std::size_t>(n);
}
::fsync(fd);
::close(fd);
sfs::WriteReply(sb, 0, static_cast<std::uint32_t>(done));
return;
}
case sfs::Action::Unlink:
std::println(" gpfile UNLINK {}", *full);
if (g_sfsReadOnly) {
std::println(" REFUSED: read-only (this is what protects an enrolled template)");
sfs::WriteReply(sb, EROFS, 0);
return;
}
sfs::WriteReply(sb, ::unlink(full->c_str()) ? errno : 0, 0);
return;
case sfs::Action::Rename: {
auto to = sfs::ResolvePath(g_sfsRoot, req->root, req->path2);
std::println(" gpfile RENAME {} -> {}", *full, to ? *to : std::string("?"));
if (g_sfsReadOnly || !to) { sfs::WriteReply(sb, EROFS, 0); return; }
sfs::WriteReply(sb, ::rename(full->c_str(), to->c_str()) ? errno : 0, 0);
return;
}
}
}
// ---- RPMB
//
// The anti-rollback half. QTEE will not trust a container until it has read
// its counter record out of the UFS device's replay-protected area, and it
// cannot reach the device itself. This serves that read.
//
// A WRITE advances a monotonic counter that can never be moved back, so it is
// refused unless explicitly enabled. Key programming is refused ALWAYS -- the
// RPMB key is one-time programmable and relaying such a frame destroys this
// part's RPMB permanently.
bool g_rpmbWrite = false;
// SECURITY PROTOCOL IN/OUT against the RPMB well-known LUN. Returns 0 on
// success, 1 on unit attention (retryable), -1 on error.
int SecurityProtocol(int fd, bool isIn, std::byte* buf, std::uint32_t len) {
namespace rp = fingerprintd::rpmb;
std::array<unsigned char, 12> cdb{};
std::array<unsigned char, 64> sense{};
cdb[0] = isIn ? 0xA2 : 0xB5;
cdb[1] = rp::SecurityProtocolUfs;
cdb[2] = (rp::SecurityProtocolSpecific >> 8) & 0xFF;
cdb[3] = rp::SecurityProtocolSpecific & 0xFF;
cdb[4] = 0; // INC_512 = 0: the length is in bytes
cdb[6] = (len >> 24) & 0xFF;
cdb[7] = (len >> 16) & 0xFF;
cdb[8] = (len >> 8) & 0xFF;
cdb[9] = len & 0xFF;
sg_io_v4 io{};
io.guard = 'Q';
io.protocol = BSG_PROTOCOL_SCSI;
io.subprotocol = BSG_SUB_PROTOCOL_SCSI_CMD;
io.request_len = cdb.size();
io.request = reinterpret_cast<std::uintptr_t>(cdb.data());
io.max_response_len = sense.size();
io.response = reinterpret_cast<std::uintptr_t>(sense.data());
io.timeout = 15000;
if (isIn) {
io.din_xfer_len = len;
io.din_xferp = reinterpret_cast<std::uintptr_t>(buf);
} else {
io.dout_xfer_len = len;
io.dout_xferp = reinterpret_cast<std::uintptr_t>(buf);
}
if (::ioctl(fd, SG_IO, &io) < 0) {
std::println(" SP{} ioctl failed: {}", isIn ? "I" : "O", ::strerror(errno));
return -1;
}
if (io.driver_status || io.transport_status || io.device_status) {
unsigned key = sense[2] & 0x0F;
std::println(" SP{} status drv={} trans={} dev={} sense key={} asc=0x{:02x}/{:02x}",
isIn ? "I" : "O", io.driver_status, io.transport_status,
io.device_status, key, sense[12], sense[13]);
// The RPMB LUN raises UNIT ATTENTION on the first command after a
// reset and clears it by reporting it once. Retryable, not an error.
return key == fingerprintd::rpmb::SenseKeyUnitAttention ? 1 : -1;
}
return 0;
}
int SecurityProtocolRetry(int fd, bool isIn, std::byte* buf, std::uint32_t len) {
for (int t = 0; t < 4; t++) {
int rc = SecurityProtocol(fd, isIn, buf, len);
if (rc != 1) return rc;
std::println(" (unit attention cleared, retrying)");
}
return -1;
}
void ServeRpmb(std::span<std::byte> sb) {
namespace rp = fingerprintd::rpmb;
auto req = rp::ParseRequest(sb);
if (!req) { rp::WriteReply(sb, rp::StatusRefused, 0); return; }
if (g_verbose)
std::println(" rpmb op=0x{:x} nblocks={} framesz={} dataoff=0x{:x}",
static_cast<unsigned>(req->op), req->nblocks, req->frameSize,
req->dataOff);
if (!rp::FramesInBounds(sb, *req)) {
std::println(" rpmb: frames out of bounds, refusing");
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
// NEVER RELAYED, whatever the write policy says. The RPMB authentication
// key is one-time programmable: reprogramming it destroys this part's RPMB
// permanently and no reflash recovers it. QTEE has no legitimate reason to
// send one.
if (rp::AnyKeyProgramming(sb, *req)) {
std::println(" *** REFUSED: RPMB KEY PROGRAMMING frame. Irreversible. ***");
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
if (req->op == rp::Op::Write && !g_rpmbWrite) {
std::println(" rpmb WRITE refused (advances an irreversible counter)");
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
if (req->op != rp::Op::Read && req->op != rp::Op::Write) {
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
int fd = ::open(std::string(rp::BsgDevice).c_str(), O_RDWR);
if (fd < 0) {
std::println(" rpmb: open {}: {}", rp::BsgDevice, ::strerror(errno));
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
std::byte* frames = sb.data() + req->dataOff;
std::uint32_t total = req->nblocks * static_cast<std::uint32_t>(rp::FrameSize);
int rc = -1;
if (req->op == rp::Op::Read) {
// A read posts ONE request frame however large nblocks is, then
// collects nblocks * 512 back.
if (SecurityProtocolRetry(fd, false, frames, rp::FrameSize) == 0)
rc = SecurityProtocolRetry(fd, true, frames, total);
}
::close(fd);
if (rc != 0) {
rp::WriteReply(sb, rp::StatusRefused, 0);
return;
}
if (g_verbose)
std::println(" rpmb read ok: resp=0x{:04x} result=0x{:04x} counter={}",
rp::ReqRespOf(std::span(frames, rp::FrameSize)),
rp::ResultOf(std::span(frames, rp::FrameSize)),
rp::WriteCounterOf(std::span(frames, rp::FrameSize)));
// +0x08 is an OUT parameter QTEE checks against what it expected to be
// transferred; leaving the request's frame size there fails every
// transaction. +0x0c is left exactly as the request supplied it.
rp::WriteReply(sb, rp::StatusOk, rp::BytesTransferred(req->op, req->nblocks));
}
qcomtee_result_t ListenerDispatch(qcomtee_object* object, qcomtee_op_t op,
qcomtee_param* params, int num) {
auto* self = reinterpret_cast<ListenerObject*>(object);
if (g_verbose)
std::println(" *** QTEE called listener 0x{:x} op={} params={}", self->id,
static_cast<unsigned>(op), num);
for (int i = 0; i < num; i++) {
switch (params[i].attr) {
case QCOMTEE_UBUF_OUTPUT: {
// addr arrives NULL on the callback path; point it at our own
// storage. Zeros are the answer QTEE expects here.
std::size_t want = std::min<std::size_t>(params[i].ubuf.size,
self->outBufs[0].size());
if (i < 8) {
self->outBufs[i].fill(std::byte{0});
params[i].ubuf.addr = self->outBufs[i].data();
params[i].ubuf.size = want;
}
break;
}
case QCOMTEE_OBJREF_OUTPUT:
// MUST be set. cb_marshal_in leaves .object uninitialised and
// marshal_out then calls typeof() on stack garbage -- a SIGSEGV in
// the supplicant the moment QTEE first dispatches.
params[i].object = QCOMTEE_OBJECT_NULL;
break;
default:
break;
}
}
// The request itself rides in the registered shared buffer, not in params.
void* addr = qcomtee_memory_object_addr(self->shared);
std::size_t size = qcomtee_memory_object_size(self->shared);
if (addr) {
std::span<std::byte> sb(static_cast<std::byte*>(addr), size);
if (self->id == 0x7000)
ServeGpFile(sb);
else if (self->id == 0x2000)
ServeRpmb(sb);
else
std::println(" (listener 0x{:x}: no handler yet)", self->id);
}
return QCOMTEE_OK;
}
qcomtee_object_ops g_listenerOps = {
/* release */ ListenerRelease,
/* dispatch */ ListenerDispatch,
/* error */ nullptr,
/* supported */ nullptr,
};
// One callback object PER registration. Sharing one across registrations
// overwrites its id and buffer, and every multi-listener result taken that way
// is void -- six sessions of hypotheses rested on exactly that bug.
bool RegisterListener(qcomtee_object* env, std::uint32_t id, std::size_t bufSize) {
qcomtee_object* svc = OpenService(env, fingerprintd::tee::UidListenerCbo);
if (svc == QCOMTEE_OBJECT_NULL) return false;
qcomtee_object* shared = QCOMTEE_OBJECT_NULL;
if (qcomtee_memory_object_alloc(bufSize, g_root, &shared)) {
std::println(std::cerr, "listener 0x{:x}: shared buffer alloc failed", id);
return false;
}
auto* lo = new ListenerObject{};
lo->id = id;
lo->shared = shared;
if (qcomtee_object_cb_init(&lo->object, &g_listenerOps, g_root)) {
delete lo;
return false;
}
std::uint32_t lid = id;
qcomtee_param p[3] = {};
p[0].attr = QCOMTEE_UBUF_INPUT; p[0].ubuf.addr = &lid; p[0].ubuf.size = sizeof(lid);
p[1].attr = QCOMTEE_OBJREF_INPUT; p[1].object = &lo->object;
p[2].attr = QCOMTEE_OBJREF_INPUT; p[2].object = shared;
qcomtee_result_t result = 0;
if (qcomtee_object_invoke(svc, 0, p, 3, &result)) {
std::println(std::cerr, "listener 0x{:x}: invoke failed", id);
return false;
}
std::println("listener 0x{:<5x} sb={:<7} -> result={}{}", id, bufSize,
static_cast<int>(result),
result == 0 ? " REGISTERED"
: static_cast<int>(result) == fingerprintd::tee::ResultIdAlreadyTaken
? " (id already taken)" : "");
return result == 0;
}
// ---- The sensor rail
//
// GPIO v2 chardev ioctls directly: libgpiod is not on the phone and this is
@ -483,8 +832,15 @@ void Report(fingerprintd::ta::Cmd cmd, const CommandResult& r) {
std::println(" CMD 0x{:04x} -> INVOKE FAILED", static_cast<unsigned>(cmd));
return;
}
std::println(" CMD 0x{:04x} -> result={} rc={} ({})", static_cast<unsigned>(cmd),
static_cast<int>(r.result), r.rc, ta::StrError(r.rc));
// A POSITIVE rc is not an error code. ENUMERATE returns the template
// count there, so running it through the error table prints "unknown" for
// a perfectly good answer.
if (r.rc > 0)
std::println(" CMD 0x{:04x} -> result={} rc={}", static_cast<unsigned>(cmd),
static_cast<int>(r.result), r.rc);
else
std::println(" CMD 0x{:04x} -> result={} rc={} ({})", static_cast<unsigned>(cmd),
static_cast<int>(r.result), r.rc, ta::StrError(r.rc));
}
int Probe() {
@ -512,6 +868,17 @@ int Probe() {
std::println("client env obtained (uid {}, {}-byte credentials)", uid,
tee::BuildCredentials(uid, 0).size());
// Register the storage listeners BEFORE loading the trustlet, so any
// storage QTEE wants during init has somewhere to go.
if (g_listeners) {
for (const auto& l : tee::Listeners) {
if (l.id == 10) continue; // never called on the fingerprint path
RegisterListener(env, l.id, l.bufferSize);
}
std::println("SFS root {} ({})", g_sfsRoot,
g_sfsReadOnly ? "READ-ONLY" : "writable");
}
qcomtee_object* loader = OpenService(env, tee::UidQseecomCompatAppLoader);
if (loader == QCOMTEE_OBJECT_NULL)
return 1;
@ -547,11 +914,6 @@ int Probe() {
return 1;
}
// A storage read needs no sensor. It exercises the whole SFS listener path
// if listeners are registered, and answers with no templates when they are
// not.
auto e = SendCommand(app, fingerprintd::ta::Cmd::Enumerate, {});
Report(fingerprintd::ta::Cmd::Enumerate, e);
// ---- The sensor, and the init chain that needs it powered
Sensor sensor;
@ -601,6 +963,24 @@ int Probe() {
return 1;
}
// NOW the store can be read. A template reload needs the device init
// chain to have run first: the per-slot enroll-template array is allocated
// by that chain, and without it FtInitEnrollTplData writes through a NULL
// the moment a template becomes reachable. Running SET_ACTIVE_GROUP before
// the chain answers -2 and loads nothing, which reads like a missing
// container and is an ordering bug.
if (g_listeners) {
auto sag = fingerprintd::ta::BuildSetActiveGroup(g_gid);
std::println("\nSET_ACTIVE_GROUP gid={} path='{}'", g_gid,
fingerprintd::ta::GroupNamespacePath);
auto g = SendCommand(app, fingerprintd::ta::Cmd::SetActiveGroup, sag);
Report(fingerprintd::ta::Cmd::SetActiveGroup, g);
auto e = SendCommand(app, fingerprintd::ta::Cmd::Enumerate, {});
Report(fingerprintd::ta::Cmd::Enumerate, e);
std::println(" templates loaded: {}", e.rc);
}
// With the sensor initialised and a region supplied, a capture returns a
// real metric. No finger is needed to establish the idle floor, and the
// floor is the only meaningful reference: the metric is per frame and
@ -648,6 +1028,14 @@ int main(int argc, char** argv) {
if (a.starts_with("--ta=")) g_taPath = a.substr(5);
if (a.starts_with("--config=")) g_cfgPath = a.substr(9);
if (a == "--verbose") g_verbose = true;
if (a == "--listeners") g_listeners = true;
// Serving the store writable lets QTEE UNLINK a container it rejects,
// which destroys an enrolled template. Opt in explicitly.
if (a == "--sfs-writable") g_sfsReadOnly = false;
if (a == "--rpmb-write") g_rpmbWrite = true;
if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11);
if (a.starts_with("--gid=")) g_gid = static_cast<std::uint32_t>(
std::stoul(std::string(a.substr(6))));
}
if (probe)
return Probe();