227 lines
10 KiB
Text
227 lines
10 KiB
Text
|
|
// 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);
|
||
|
|
}
|
||
|
|
}
|