Initial commit: the gpfile wire format, pinned by two real containers

fingerprintd will own the FP6's fingerprint sensor: the rail, the QTEE session,
the storage callbacks QTEE makes back into the normal world, and
net.reactivated.Fprint so pam_fprintd and the desktop need no changes. None of
that runs yet. What is here is the first core module and the machinery around
it.

Fingerprintd:Sfs is the gpfile listener's frame -- the callback that carries
47 of 66 storage requests during an enrolment. It is parse, reply and root
mapping only: no file I/O, no TEE, no allocation of the shared buffer. The
daemon shell supplies those, which is what lets every byte-level decision be
tested on a dev box with no phone.

The module exists mainly to hold one fact. READ answers at req+0x00c and WRITE
reads its payload from req+0x110, because the frame is a union: a WRITE still
needs its path while the payload is copied out, so it sits past the 256-byte
path field, while a READ has consumed the path and packs its reply over it.
Conflating them is wrong in both directions with the same symptom -- the
container does not round-trip, QTEE's HMAC check fails, and the file is
unlinked as tampered on the next session.

So the tests do not assert the constants against themselves. They load two real
containers off the phone -- one written correctly, one written with the offsets
conflated -- and re-derive the bug: the broken one opens with ASCII path text
rather than a binary HMAC, that text is the group name from character 8 because
the read offset is 8 bytes into the path field, and the real container sits
exactly 0x104 further in. Then a write-store-read round trip must be the
identity, and the same round trip through a single offset must not be.

O_TRUNC gets a static_assert of its own. 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; it unlinks a file it means to
shorten rather than relying on the opener.

Verified by mutation: conflating the offsets, making DataOffset return the read
offset for writes, and setting O_TRUNC each fail the suite.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 16:02:46 +02:00
commit 1d26852a6b
12 changed files with 2242 additions and 0 deletions

View file

@ -0,0 +1,227 @@
// 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 <fcntl.h> 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<std::string_view, 4> 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<const std::byte> f, std::size_t off) {
std::uint32_t v = 0;
for (std::size_t i = 0; i < 4; i++)
v |= static_cast<std::uint32_t>(std::to_integer<unsigned>(f[off + i])) << (8 * i);
return v;
}
inline void StoreU32(std::span<std::byte> f, std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
f[off + i] = static_cast<std::byte>((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<const std::byte> 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<char>(std::to_integer<unsigned char>(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<Request> ParseRequest(std::span<const std::byte> 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<Action>(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<std::int32_t>(detail::LoadU32(frame, OffsetOff));
r.length = detail::LoadU32(frame, LengthOff);
if (r.length > MaxLen)
r.length = static_cast<std::uint32_t>(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<const std::byte> 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<std::byte> 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<std::byte> frame,
std::uint32_t value = ConfigPathInitReply) {
detail::StoreU32(frame, 4, value);
}
// Root-relative path under `base`, e.g. base/persist-data/<group>/<name>.
// 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<std::string> 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);
}
}