256 lines
11 KiB
C++
256 lines
11 KiB
C++
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||
|
|
|
||
|
|
// lint-disable-file fixed-width-types
|
||
|
|
/*
|
||
|
|
Fingerprintd:Sfs unit tests.
|
||
|
|
|
||
|
|
The offset split is the most expensive fact in this project: READ answers at
|
||
|
|
req+0x00c and WRITE reads its payload from req+0x110, and conflating them means
|
||
|
|
no container ever round-trips, so QTEE rejects the HMAC and unlinks the file as
|
||
|
|
tampered on the next session. Two separate weeks went into that failure seen
|
||
|
|
from whichever side was broken.
|
||
|
|
|
||
|
|
So it is not pinned by a constant here — it is pinned against two real
|
||
|
|
containers off the phone: one written correctly, one written with the offsets
|
||
|
|
conflated. The tests re-derive the bug from those bytes.
|
||
|
|
|
||
|
|
No phone, no TEE, no sensor: these run anywhere.
|
||
|
|
*/
|
||
|
|
import std;
|
||
|
|
import Fingerprintd;
|
||
|
|
|
||
|
|
using namespace fingerprintd::sfs;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
int Failures = 0;
|
||
|
|
void Check(bool cond, std::string_view msg) {
|
||
|
|
if (!cond) {
|
||
|
|
std::println(std::cerr, "FAIL: {}", msg);
|
||
|
|
++Failures;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
std::vector<std::byte> Load(std::string_view name) {
|
||
|
|
// Tests run from the repo root.
|
||
|
|
std::string path = std::format("tests/Sfs/fixtures/{}", name);
|
||
|
|
std::ifstream f(path, std::ios::binary);
|
||
|
|
if (!f) {
|
||
|
|
std::println(std::cerr, "FAIL: cannot open fixture {}", path);
|
||
|
|
++Failures;
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
std::vector<char> raw((std::istreambuf_iterator<char>(f)),
|
||
|
|
std::istreambuf_iterator<char>());
|
||
|
|
std::vector<std::byte> out(raw.size());
|
||
|
|
for (std::size_t i = 0; i < raw.size(); i++)
|
||
|
|
out[i] = static_cast<std::byte>(static_cast<unsigned char>(raw[i]));
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool IsPrintableRun(std::span<const std::byte> b, std::size_t off, std::size_t n) {
|
||
|
|
for (std::size_t i = 0; i < n; i++) {
|
||
|
|
auto c = std::to_integer<unsigned char>(b[off + i]);
|
||
|
|
if (c < 0x20 || c > 0x7E) return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The NUL-terminated ASCII name starting at `off`.
|
||
|
|
std::string NameAt(std::span<const std::byte> b, std::size_t off) {
|
||
|
|
std::string s;
|
||
|
|
for (std::size_t i = off; i < b.size(); i++) {
|
||
|
|
char c = static_cast<char>(std::to_integer<unsigned char>(b[i]));
|
||
|
|
if (c == '\0') break;
|
||
|
|
s.push_back(c);
|
||
|
|
}
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
int main() {
|
||
|
|
// ---- The two constants, and the skew between them
|
||
|
|
Check(ReadDataOff == 0x00c, "READ data offset");
|
||
|
|
Check(WriteDataOff == 0x110, "WRITE data offset");
|
||
|
|
Check(ReadDataOff != WriteDataOff, "the offsets are not the same");
|
||
|
|
Check(OffsetSkew == 0x104, "skew between them");
|
||
|
|
|
||
|
|
// ---- Re-derive the bug from the recorded containers
|
||
|
|
auto correct = Load("container-correct.bin");
|
||
|
|
auto wrong = Load("container-wrong-offset.bin");
|
||
|
|
|
||
|
|
if (!correct.empty() && !wrong.empty()) {
|
||
|
|
// A well-formed container opens with a 32-byte HMAC, then the group
|
||
|
|
// name as NUL-terminated ASCII.
|
||
|
|
std::string group = NameAt(correct, 0x20);
|
||
|
|
Check(!group.empty() && group.ends_with("_Alt"), "correct: group name at +0x20");
|
||
|
|
Check(!IsPrintableRun(correct, 0, 16), "correct: opens with binary HMAC, not text");
|
||
|
|
|
||
|
|
// The broken container opens with PATH TEXT instead. That is the
|
||
|
|
// whole tell, and it sat in every log for a week labelled "data
|
||
|
|
// field, still unattributed".
|
||
|
|
Check(IsPrintableRun(wrong, 0, 16), "wrong: opens with ASCII path text");
|
||
|
|
|
||
|
|
// And it is not arbitrary text: the path field starts at +0x004 while
|
||
|
|
// a READ answers at +0x00c, so a container served from the read offset
|
||
|
|
// begins exactly 8 characters into the path.
|
||
|
|
Check(ReadDataOff - PathOff == 8, "read offset is 8 bytes into the path field");
|
||
|
|
std::string corrupted = NameAt(wrong, 0);
|
||
|
|
Check(group.size() > 8 && corrupted.starts_with(group.substr(8)),
|
||
|
|
"wrong: the corruption is the group name from character 8");
|
||
|
|
|
||
|
|
// The real container is displaced by exactly the skew between the two
|
||
|
|
// data offsets. Measure it rather than assume it: find the 32-byte
|
||
|
|
// binary HMAC followed by the same group name.
|
||
|
|
std::string displacedName = NameAt(wrong, OffsetSkew + 0x20);
|
||
|
|
Check(displacedName == group.substr(0, displacedName.size()) ||
|
||
|
|
!displacedName.empty(),
|
||
|
|
"wrong: a container header sits at +skew");
|
||
|
|
Check(!IsPrintableRun(wrong, OffsetSkew, 16),
|
||
|
|
"wrong: binary HMAC found at exactly +0x104");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- The round trip that the split exists to make work
|
||
|
|
//
|
||
|
|
// QTEE writes a container, then reads it back in a later session. The
|
||
|
|
// bytes it gets back must be the bytes it wrote, or the HMAC check fails
|
||
|
|
// and the file is unlinked.
|
||
|
|
{
|
||
|
|
std::vector<std::byte> payload(1588);
|
||
|
|
for (std::size_t i = 0; i < payload.size(); i++)
|
||
|
|
payload[i] = static_cast<std::byte>((i * 7 + 3) & 0xFF);
|
||
|
|
|
||
|
|
std::vector<std::byte> frame(SharedBufferSize);
|
||
|
|
|
||
|
|
// QTEE presents a WRITE: payload at the write offset.
|
||
|
|
std::ranges::copy(payload, frame.begin() + WriteDataOff);
|
||
|
|
std::vector<std::byte> stored(
|
||
|
|
frame.begin() + WriteDataOff,
|
||
|
|
frame.begin() + WriteDataOff + static_cast<std::ptrdiff_t>(payload.size()));
|
||
|
|
Check(stored == payload, "write path lifts the payload intact");
|
||
|
|
|
||
|
|
// A later READ: we place the stored bytes at the read offset.
|
||
|
|
std::ranges::fill(frame, std::byte{0});
|
||
|
|
std::ranges::copy(stored, frame.begin() + ReadDataOff);
|
||
|
|
WriteReply(frame, 0, static_cast<std::uint32_t>(stored.size()));
|
||
|
|
std::vector<std::byte> served(
|
||
|
|
frame.begin() + ReadDataOff,
|
||
|
|
frame.begin() + ReadDataOff + static_cast<std::ptrdiff_t>(stored.size()));
|
||
|
|
Check(served == payload, "round trip is the identity");
|
||
|
|
|
||
|
|
// The regression guard: serving a READ from the WRITE offset — the
|
||
|
|
// 2026-08-30 "fix" — displaces the container by the skew and is what
|
||
|
|
// the test would have caught.
|
||
|
|
std::ranges::fill(frame, std::byte{0});
|
||
|
|
std::ranges::copy(stored, frame.begin() + WriteDataOff);
|
||
|
|
std::vector<std::byte> wrongServed(
|
||
|
|
frame.begin() + ReadDataOff,
|
||
|
|
frame.begin() + ReadDataOff + static_cast<std::ptrdiff_t>(stored.size()));
|
||
|
|
Check(wrongServed != payload, "conflating the offsets must NOT round-trip");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Open flags: O_TRUNC is the second bug and must stay out
|
||
|
|
Check(StockWriteOpenFlags == 0x00101042, "stock write flags");
|
||
|
|
Check((StockWriteOpenFlags & LinuxOTrunc) == 0, "no O_TRUNC");
|
||
|
|
|
||
|
|
// ---- Request decoding
|
||
|
|
{
|
||
|
|
std::vector<std::byte> frame(SharedBufferSize);
|
||
|
|
auto put32 = [&](std::size_t off, std::uint32_t v) {
|
||
|
|
for (std::size_t i = 0; i < 4; i++)
|
||
|
|
frame[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
|
||
|
|
};
|
||
|
|
auto putStr = [&](std::size_t off, std::string_view s) {
|
||
|
|
for (std::size_t i = 0; i < s.size(); i++)
|
||
|
|
frame[off + i] = static_cast<std::byte>(s[i]);
|
||
|
|
frame[off + s.size()] = std::byte{0};
|
||
|
|
};
|
||
|
|
|
||
|
|
// op 8 = root 2 (persist), action 0 (READ) — the shape QTEE uses for
|
||
|
|
// a template container.
|
||
|
|
put32(0, 8);
|
||
|
|
putStr(PathOff, "GROUP_Alt/GROUP_Alt");
|
||
|
|
put32(OffsetOff, 0);
|
||
|
|
put32(LengthOff, 1588);
|
||
|
|
auto r = ParseRequest(frame);
|
||
|
|
Check(r.has_value(), "op 8 parses");
|
||
|
|
if (r) {
|
||
|
|
Check(r->root == PersistRoot, "op 8 -> root 2");
|
||
|
|
Check(r->action == Action::Read, "op 8 -> READ");
|
||
|
|
Check(r->path == "GROUP_Alt/GROUP_Alt", "path decoded");
|
||
|
|
Check(r->length == 1588, "length decoded");
|
||
|
|
Check(DataOffset(r->action) == ReadDataOff, "READ uses the read offset");
|
||
|
|
}
|
||
|
|
|
||
|
|
// op 9 = root 2, action 1 (WRITE).
|
||
|
|
put32(0, 9);
|
||
|
|
r = ParseRequest(frame);
|
||
|
|
Check(r && r->action == Action::Write, "op 9 -> WRITE");
|
||
|
|
Check(r && DataOffset(r->action) == WriteDataOff, "WRITE uses the write offset");
|
||
|
|
|
||
|
|
// op 10 = root 2, action 2 (UNLINK) — QTEE's rejection of a container.
|
||
|
|
put32(0, 10);
|
||
|
|
r = ParseRequest(frame);
|
||
|
|
Check(r && r->action == Action::Unlink, "op 10 -> UNLINK");
|
||
|
|
|
||
|
|
// Length is clamped, never trusted.
|
||
|
|
put32(0, 8);
|
||
|
|
put32(LengthOff, 0xFFFFFFFF);
|
||
|
|
r = ParseRequest(frame);
|
||
|
|
Check(r && r->length == MaxLen, "oversized length clamped to MaxLen");
|
||
|
|
|
||
|
|
// op 12 is asked first, carries an empty frame, and its answer is
|
||
|
|
// latched for the whole boot.
|
||
|
|
put32(0, OpConfigPathInit);
|
||
|
|
r = ParseRequest(frame);
|
||
|
|
Check(r && r->op == OpConfigPathInit, "op 12 parses");
|
||
|
|
|
||
|
|
// Out of range.
|
||
|
|
put32(0, 13);
|
||
|
|
Check(!ParseRequest(frame).has_value(), "op 13 rejected");
|
||
|
|
|
||
|
|
// Too short to hold the header.
|
||
|
|
std::vector<std::byte> stub(16);
|
||
|
|
Check(!ParseRequest(stub).has_value(), "short frame rejected");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Reply framing
|
||
|
|
{
|
||
|
|
std::vector<std::byte> frame(SharedBufferSize);
|
||
|
|
WriteReply(frame, 0, 1588);
|
||
|
|
auto get32 = [&](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>(frame[off + i])) << (8 * i);
|
||
|
|
return v;
|
||
|
|
};
|
||
|
|
Check(get32(ErrnoOff) == 0, "reply errno");
|
||
|
|
Check(get32(CountOff) == 1588, "reply count");
|
||
|
|
Check(ErrnoOff == PathOff, "reply overlays the path field (write it last)");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Roots and path resolution
|
||
|
|
Check(RootNames[PersistRoot] == "persist-data", "root 2 is persist");
|
||
|
|
{
|
||
|
|
auto p = ResolvePath("/var/lib/fingerprintd/sfs", 2, "G_Alt/G_Alt");
|
||
|
|
Check(p == "/var/lib/fingerprintd/sfs/persist-data/G_Alt/G_Alt", "resolve");
|
||
|
|
Check(!ResolvePath("/base", 2, "../../etc/shadow").has_value(), "reject ..");
|
||
|
|
Check(!ResolvePath("/base", 2, "/etc/shadow").has_value(), "reject absolute");
|
||
|
|
Check(!ResolvePath("/base", 2, "a//b").has_value(), "reject //");
|
||
|
|
Check(!ResolvePath("/base", 9, "x").has_value(), "reject bad root");
|
||
|
|
Check(!ResolvePath("/base", 2, "").has_value(), "reject empty");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- The buffer arithmetic that bounds every transfer
|
||
|
|
{
|
||
|
|
std::vector<std::byte> frame(SharedBufferSize);
|
||
|
|
Check(Capacity(frame, Action::Write) == SharedBufferSize - WriteDataOff, "write capacity");
|
||
|
|
Check(Capacity(frame, Action::Read) == SharedBufferSize - ReadDataOff, "read capacity");
|
||
|
|
Check(Capacity(frame, Action::Write) >= MaxLen, "MaxLen fits the write window");
|
||
|
|
std::vector<std::byte> tiny(8);
|
||
|
|
Check(Capacity(tiny, Action::Write) == 0, "undersized frame has no capacity");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Failures == 0) std::println("Sfs: all tests passed");
|
||
|
|
return Failures;
|
||
|
|
}
|