// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types /* Fingerprintd:Sfs — the gpfile listener's wire format. QTEE cannot reach a filesystem. When the fingerprint trustlet stores or loads a template it calls back into the normal world through listener 0x7000 ("gpfile", 47 of 66 callbacks in a stock enrolment), hands over a shared buffer holding a request frame, and expects the frame edited in place with the answer. QTEE does the crypto and the anti-rollback; this side only moves opaque bytes. This module is the frame, and nothing else: parse a request, build a reply, map a storage root to a directory. No file I/O, no TEE, no allocation of the shared buffer — the daemon shell supplies those, so every byte-level decision here is testable without a phone. The layout was read out of stock's `smci_gpdispatch` in libdrmfs.so, not guessed. `op` is packed: `op & 3` is the action, `op >> 2` is the storage root. */ export module Fingerprintd:Sfs; import std; export namespace fingerprintd::sfs { // ---- Frame layout ----------------------------------------------------- // // A request frame: // // +0x000 u32 op action | root << 2 // +0x004 char path[256] NUL-terminated, root-relative // +0x104 i32 offset ... or a SECOND path, for RENAME // +0x108 u32 length // +0x110 u8[] payload WRITE data starts here // // A reply overwrites the head of the same frame: // // +0x004 u32 errno 0 on success // +0x008 u32 count bytes transferred // +0x00c u8[] payload READ data starts here // inline constexpr std::size_t PathOff = 0x004; inline constexpr std::size_t ErrnoOff = 0x004; inline constexpr std::size_t CountOff = 0x008; inline constexpr std::size_t OffsetOff = 0x104; inline constexpr std::size_t LengthOff = 0x108; inline constexpr std::size_t PathMax = 256; // THE OFFSET SPLIT. READ and WRITE do not share a data offset, and getting // this wrong is the single most expensive bug in this project's history. // // Both directions go through one worker in stock's dispatcher (0xa008, // whose x4 argument is the buffer handed to read()/write()), and the two // call sites are four instructions apart: // // READ ops 0/4/8 9c5c: add x4, x19, #0xc -> data at req+0x00c // WRITE ops 1/5/9 9cd4: add x4, x19, #0x110 -> data at req+0x110 // // The frame is a union. A WRITE still needs its path while the payload is // being copied out, so the payload sits past the 256-byte path field. A // READ has consumed the path by the time it answers, so its reply packs // {errno, count, data} over where the path was. // // Using one offset for both is wrong in both directions and the symptom is // identical either way: the container does not round-trip, so QTEE's HMAC // check fails and it unlinks the file as tampered on the next session. Two // separate weeks were spent on that failure from whichever side was broken. inline constexpr std::size_t ReadDataOff = 0x00c; inline constexpr std::size_t WriteDataOff = 0x110; // The distance the payload moves if the two are conflated. It is also the // fingerprint of the bug on disk: a container written from ReadDataOff // begins with characters 8-11 of the request path, and the real container // starts exactly this far in. inline constexpr std::size_t OffsetSkew = WriteDataOff - ReadDataOff; static_assert(OffsetSkew == 0x104); // Longest transfer accepted in one call. GPF_MAXLEN + WriteDataOff fits // the 516096-byte shared buffer qseecomd registers for this listener. inline constexpr std::size_t MaxLen = 0x7d000; inline constexpr std::size_t SharedBufferSize = 516096; static_assert(MaxLen + WriteDataOff <= SharedBufferSize); // ---- Open flags ------------------------------------------------------- // Stock's flags for the WRITE path, as one immediate: // 9ccc: mov w2, #0x1042 // 9cec: movk w2, #0x10, lsl #16 -> 0x00101042 // = O_RDWR | O_CREAT | O_SYNC on Linux, and critically NOT O_TRUNC. // // QTEE writes a container as write(0,4096), write(4096,N), write(0,4096). // Truncating on each open leaves a 4096-byte file where a 258850-byte // template belongs. QTEE unlinks a file it means to shorten; it never // relies on the opener to do it. inline constexpr std::uint32_t StockWriteOpenFlags = 0x00101042; // Linux O_TRUNC, spelled out so the guard below is readable without // pulling into a module that must stay free of system headers. inline constexpr std::uint32_t LinuxOTrunc = 0x0200; static_assert((StockWriteOpenFlags & LinuxOTrunc) == 0, "O_TRUNC truncates multi-chunk containers to 4096 bytes"); // ---- Requests --------------------------------------------------------- enum class Action { Read, Write, Unlink, Rename }; // op 12 carries an otherwise empty frame and is asked first, before any // path. It is not a stub: the answer stored at +0x04 is LATCHED for the // whole boot, so QTEE stops asking in every later process of that boot. // Any experiment on its value needs its own clean boot. inline constexpr std::uint32_t OpConfigPathInit = 12; inline constexpr std::uint32_t ConfigPathInitReply = 2; struct Request { std::uint32_t op = 0; unsigned root = 0; Action action = Action::Read; std::string path; std::string path2; // RENAME destination; empty otherwise std::int32_t offset = 0; std::uint32_t length = 0; }; // Storage roots are `op >> 2`. QTEE asks for template containers under // ROOT 2, which is the persist path — an earlier table that put persist at // index 1 answered every request against the wrong directory. inline constexpr std::array RootNames = { "tzstorage", "misc", "persist-data", "root3", }; inline constexpr unsigned PersistRoot = 2; static_assert(RootNames[PersistRoot] == "persist-data"); namespace detail { inline std::uint32_t LoadU32(std::span f, std::size_t off) { std::uint32_t v = 0; for (std::size_t i = 0; i < 4; i++) v |= static_cast(std::to_integer(f[off + i])) << (8 * i); return v; } inline void StoreU32(std::span f, std::size_t off, std::uint32_t v) { for (std::size_t i = 0; i < 4; i++) f[off + i] = static_cast((v >> (8 * i)) & 0xFF); } // A NUL-terminated, length-capped field. Stock caps at 256 and the // frame's next field begins there, so an unterminated path must not // run into it. inline std::string Field(std::span f, std::size_t off, std::size_t cap) { std::string s; for (std::size_t i = 0; i < cap && off + i < f.size(); i++) { char c = static_cast(std::to_integer(f[off + i])); if (c == '\0') break; s.push_back(c); } return s; } } // Decode a request frame. Returns nothing for an op the dispatcher does // not define; op 12 decodes with action Read and an empty path, and the // caller must check for it before treating the request as a file access. inline std::optional ParseRequest(std::span frame) { if (frame.size() < LengthOff + 4) return std::nullopt; Request r; r.op = detail::LoadU32(frame, 0); if (r.op > OpConfigPathInit) return std::nullopt; r.root = r.op >> 2; r.action = static_cast(r.op & 3); if (r.op == OpConfigPathInit) return r; r.path = detail::Field(frame, PathOff, PathMax); // +0x104 is an offset for READ/WRITE and a second path for RENAME. // Decode both; the action says which one is real. r.path2 = detail::Field(frame, OffsetOff, PathMax); r.offset = static_cast(detail::LoadU32(frame, OffsetOff)); r.length = detail::LoadU32(frame, LengthOff); if (r.length > MaxLen) r.length = static_cast(MaxLen); return r; } // Where this action's payload lives in the frame. inline constexpr std::size_t DataOffset(Action a) { return a == Action::Write ? WriteDataOff : ReadDataOff; } // How many bytes of payload the frame can still hold after its data // offset. A transfer is clamped to this, never allowed to run past the // shared buffer. inline std::size_t Capacity(std::span frame, Action a) { std::size_t off = DataOffset(a); return frame.size() > off ? frame.size() - off : 0; } // ---- Replies ---------------------------------------------------------- // Edit the reply into the frame. `err` is a Linux errno (0 = success), // `count` the bytes transferred. Written after any transfer, never before: // both words overlay fields the request still needs. inline void WriteReply(std::span frame, std::uint32_t err, std::uint32_t count) { detail::StoreU32(frame, ErrnoOff, err); detail::StoreU32(frame, CountOff, count); } // op 12's whole answer. inline void WriteConfigPathInitReply(std::span frame, std::uint32_t value = ConfigPathInitReply) { detail::StoreU32(frame, 4, value); } // Root-relative path under `base`, e.g. base/persist-data//. // Rejects anything that could escape the root: QTEE's own names are // base64-ish and never contain a separator run or a dot segment, so a // request that does is not one of its. inline std::optional ResolvePath(std::string_view base, unsigned root, std::string_view path) { if (root >= RootNames.size() || path.empty() || path.front() == '/') return std::nullopt; if (path.contains("..") || path.contains("//")) return std::nullopt; return std::format("{}/{}/{}", base, RootNames[root], path); } }