diff --git a/implementations/main.cpp b/implementations/main.cpp index 15dcb20..ccaf859 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -33,6 +33,8 @@ extern "C" { } #include +#include +#include #include #include #include @@ -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, 8> outBufs{}; +}; + +void ListenerRelease(qcomtee_object* object) { + delete reinterpret_cast(object); +} + +// Serve one gpfile request out of the shared buffer, in place. +void ServeGpFile(std::span 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(req->length, + sfs::Capacity(sb, sfs::Action::Read)); + f.read(reinterpret_cast(sb.data() + sfs::ReadDataOff), + static_cast(want)); + auto got = static_cast(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(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(n); + } + ::fsync(fd); + ::close(fd); + sfs::WriteReply(sb, 0, static_cast(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 cdb{}; + std::array 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(cdb.data()); + io.max_response_len = sense.size(); + io.response = reinterpret_cast(sense.data()); + io.timeout = 15000; + if (isIn) { + io.din_xfer_len = len; + io.din_xferp = reinterpret_cast(buf); + } else { + io.dout_xfer_len = len; + io.dout_xferp = reinterpret_cast(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 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(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(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(object); + if (g_verbose) + std::println(" *** QTEE called listener 0x{:x} op={} params={}", self->id, + static_cast(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(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 sb(static_cast(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(result), + result == 0 ? " REGISTERED" + : static_cast(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(cmd)); return; } - std::println(" CMD 0x{:04x} -> result={} rc={} ({})", static_cast(cmd), - static_cast(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(cmd), + static_cast(r.result), r.rc); + else + std::println(" CMD 0x{:04x} -> result={} rc={} ({})", static_cast(cmd), + static_cast(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::stoul(std::string(a.substr(6)))); } if (probe) return Probe(); diff --git a/interfaces/Fingerprintd-Ta.cppm b/interfaces/Fingerprintd-Ta.cppm index f2325ec..a90f834 100644 --- a/interfaces/Fingerprintd-Ta.cppm +++ b/interfaces/Fingerprintd-Ta.cppm @@ -253,7 +253,27 @@ export namespace fingerprintd::ta { // "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 ------------------------------------ // diff --git a/tests/Ta/main.cpp b/tests/Ta/main.cpp index ed0d1b8..4914f6e 100644 --- a/tests/Ta/main.cpp +++ b/tests/Ta/main.cpp @@ -202,6 +202,23 @@ int main() { Check(tokenZero, "the 69-byte auth token is all zero"); } + // ---- SET_ACTIVE_GROUP: a gid and a NAMESPACE path, not a file path + { + auto sag = BuildSetActiveGroup(60); + Check(Get32(sag, SetActiveGroupGidOff) == 60, "gid at +0"); + std::string path; + for (std::size_t i = SetActiveGroupPathOff; i < sag.size() - 1; i++) + path.push_back(static_cast(std::to_integer(sag[i]))); + Check(path == "/data/vendor_de/0/fpdata", "the Android namespace path"); + Check(sag.back() == std::byte{0}, "NUL-terminated"); + Check(sag.size() == SetActiveGroupPathOff + GroupNamespacePath.size() + 1, + "length is 4 + path + NUL"); + // The path is a key the trustlet hashes into the group directory name, + // so it is not ours to invent. A gid rendered as text is not it. + Check(GroupNamespacePath != "60", "the second field is not the gid again"); + Check(GroupNamespacePath.starts_with('/'), "it looks like a path because it is one"); + } + // ---- Responses: the payload starts at +0x10, and forgetting that reads // a confident zero. {