fingerprintd/interfaces/Fingerprintd-Rpmb.cppm
Jorijn van der Graaf 1db0762078 Port the RPMB listener's wire format, guard included
Fingerprintd:Rpmb is the other half of QTEE's storage. Where gpfile moves the
container bytes, RPMB is the anti-rollback: an authenticated, monotonically
counted area of the UFS device that lets QTEE tell a genuine store from an old
one replayed back at it. Framing and policy only; the SCSI transport stays in
the daemon shell.

The guard is the reason this module has tests rather than just constants.
req_resp 0x0001 is Authentication Key Programming, and the RPMB key is one-time
programmable in the UFS device -- relaying such a frame destroys that part's
RPMB permanently and no reflash recovers it. QTEE has no legitimate reason to
send one, so it is refused unconditionally, whatever the write policy says. It
is tested for scanning every frame rather than the first, and for refusing a
request that claims more frames than the buffer holds instead of reading past
the end.

The out-parameter at +0x08 is the field that failed every RPMB transaction for
a week. librpmb passes it by address, so it reports bytes transferred, and QTEE
compares it against what it expected and rejects the transaction on a mismatch.
A read posts one request frame however large nblocks is while a write posts
nblocks * 512, so the two directions genuinely do not report the same thing --
tested as such, because leaving the request's frame size there is the bug.

+0x0c is kept exactly as the request supplied it. QTEE looks for the response
frames at req + req[0x0c] and the request arrives with 0x18; librpmb's
hardcoded 20 points four bytes early.

Chunking refuses a remainder rather than following the reference, which
silently drops one -- a partial authenticated write leaves the store
inconsistent with a counter that cannot be moved back.

Verified by mutation: checking only the first frame for key programming,
reporting a flat frame size as bytes transferred, and admitting a remainder
each fail the suite.
2026-09-02 16:48:57 +02:00

257 lines
12 KiB
C++

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Fingerprintd:Rpmb — the RPMB listener's wire format.
The other half of QTEE's storage. Where gpfile moves the container bytes, RPMB
is the anti-rollback: an authenticated, monotonically counted area of the UFS
device that lets QTEE tell a genuine store from an old one replayed back at it.
16 of 66 callbacks during a stock enrolment land here.
Everything in this module is framing and policy — request decode, reply
framing, the JEDEC result codes, and the two guards that make a write safe to
relay. The SCSI transport lives in the daemon shell.
Read out of librpmb.so and out of QTEE's own checking code, not guessed. The
FP6 is UFS, so the device path is SECURITY PROTOCOL OUT/IN against the RPMB
well-known LUN.
*/
export module Fingerprintd:Rpmb;
import std;
export namespace fingerprintd::rpmb {
// ---- Transport --------------------------------------------------------
// The RPMB well-known LUN is UPIU 0xC4, which maps to SCSI WLUN 0xC144 =
// 49476. /dev/bsg/ufs-bsg0 is the UPIU passthrough node and is NOT the
// right target for these SCSI commands — aiming there fails in a way that
// looks like the device refusing the request.
inline constexpr std::string_view BsgDevice = "/dev/bsg/0:0:0:49476";
inline constexpr std::uint32_t RpmbWlun = 49476;
inline constexpr std::uint8_t SecurityProtocolUfs = 0xEC; // JEDEC UFS
inline constexpr std::uint16_t SecurityProtocolSpecific = 0x0001; // RPMB
inline constexpr std::size_t FrameSize = 512;
// The first command after a device reset answers sense key 6, ASC 0x29/02
// — a unit attention, not a failure. The reference has a whole function
// for exactly this. Retry once rather than reporting an error.
inline constexpr std::uint8_t SenseKeyUnitAttention = 6;
inline constexpr std::uint8_t AscPowerOnReset = 0x29;
// ---- Requests ---------------------------------------------------------
//
// +0x00 u32 op
// +0x04 u32 nblocks ... and on the way out, the status
// +0x08 u32 framesize ... and on the way out, bytes transferred
// +0x0c u32 dataoff where the frames sit, relative to the request
// +0x14 u32 blocks-per-op chunk size, for writes
// +dataoff the 512-byte JEDEC frames
enum class Op : std::uint32_t {
Init = 0x101,
Read = 0x102,
Write = 0x103,
PartitionConfig = 0x104,
};
inline constexpr std::size_t OpOff = 0x00;
inline constexpr std::size_t NblocksOff = 0x04;
inline constexpr std::size_t StatusOff = 0x04;
inline constexpr std::size_t FrameSizeOff = 0x08;
inline constexpr std::size_t TransferredOff = 0x08;
inline constexpr std::size_t DataOffOff = 0x0c;
inline constexpr std::size_t BlocksPerOpOff = 0x14;
inline constexpr std::size_t SharedBufferSize = 25600;
struct Request {
Op op = Op::Read;
std::uint32_t nblocks = 0;
std::uint32_t frameSize = 0;
std::uint32_t dataOff = 0;
std::uint32_t blocksPerOp = 0;
};
namespace detail {
inline std::uint32_t LoadU32(std::span<const std::byte> b, 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>(b[off + i])) << (8 * i);
return v;
}
inline void StoreU32(std::span<std::byte> b, std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
b[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
}
// JEDEC frame fields are BIG endian.
inline std::uint16_t LoadBe16(std::span<const std::byte> b, std::size_t off) {
return static_cast<std::uint16_t>(
(std::to_integer<unsigned>(b[off]) << 8) | std::to_integer<unsigned>(b[off + 1]));
}
}
inline std::optional<Request> ParseRequest(std::span<const std::byte> frame) {
if (frame.size() < BlocksPerOpOff + 4)
return std::nullopt;
Request r;
r.op = static_cast<Op>(detail::LoadU32(frame, OpOff));
r.nblocks = detail::LoadU32(frame, NblocksOff);
r.frameSize = detail::LoadU32(frame, FrameSizeOff);
r.dataOff = detail::LoadU32(frame, DataOffOff);
// Read before the first transfer: the reference reuses this word as
// its result buffer, so by the end of a write it no longer holds the
// chunk size.
r.blocksPerOp = detail::LoadU32(frame, BlocksPerOpOff);
return r;
}
// Where the JEDEC frames sit. QTEE supplies this and it arrives as 0x18,
// even though librpmb hardcodes 20 on the way out.
inline bool FramesInBounds(std::span<const std::byte> buf, const Request& r) {
std::size_t need = static_cast<std::size_t>(r.dataOff) +
static_cast<std::size_t>(r.nblocks) * FrameSize;
return r.nblocks > 0 && need <= buf.size();
}
// ---- Replies ----------------------------------------------------------
//
// Both reply fields are INPUTS on the way in, so they may only be written
// after the transfer.
//
// +0x08 is the one that cost a week. librpmb passes it to rpmb_ufs_read
// BY ADDRESS (0x8248: add x3, x19, #0x8), so it is an out-parameter for
// bytes transferred — and QTEE checks it (0x156eb9f8: cmp x24, x8; b.ne),
// whose failure block sets -2 and logs (50000d)/(50000e fffffffe). Leaving
// the request's frame size there failed every transaction.
//
// +0x0c is where QTEE looks for the response frames
// (req + req[0x0c], bounds-checked at 0x156eba10). librpmb stores a
// hardcoded 20; the request arrives with 0x18 and that is where the frames
// actually are, so writing 20 sends QTEE four bytes early. Keep what the
// request supplied.
inline constexpr std::uint32_t LibrpmbHardcodedDataOff = 20;
// A read posts ONE request frame however large nblocks is; a write posts
// nblocks * 512. So the transferred count is direction-dependent, and the
// reference's write path sets it to a flat 512.
inline std::uint32_t BytesTransferred(Op op, std::uint32_t nblocks) {
return op == Op::Write ? static_cast<std::uint32_t>(FrameSize)
: nblocks * static_cast<std::uint32_t>(FrameSize);
}
inline void WriteReply(std::span<std::byte> buf, std::int32_t status,
std::uint32_t bytesTransferred) {
detail::StoreU32(buf, StatusOff, static_cast<std::uint32_t>(status));
detail::StoreU32(buf, TransferredOff, bytesTransferred);
// DataOffOff is deliberately left as the request supplied it.
}
// ---- JEDEC frames -----------------------------------------------------
//
// Offsets from the end of a 512-byte frame, all big endian.
inline constexpr std::size_t FrameWriteCounterOff = 500;
inline constexpr std::size_t FrameAddressOff = 504;
inline constexpr std::size_t FrameBlockCountOff = 506;
inline constexpr std::size_t FrameResultOff = 508;
inline constexpr std::size_t FrameReqRespOff = 510;
enum class ReqResp : std::uint16_t {
AuthKeyProgram = 0x0001,
ReadWriteCounter = 0x0002,
AuthDataWrite = 0x0003,
AuthDataRead = 0x0004,
ResultRead = 0x0005,
};
inline std::uint16_t ReqRespOf(std::span<const std::byte> frame) {
return detail::LoadBe16(frame, FrameReqRespOff);
}
inline std::uint16_t ResultOf(std::span<const std::byte> frame) {
return detail::LoadBe16(frame, FrameResultOff);
}
inline std::uint32_t WriteCounterOf(std::span<const std::byte> frame) {
std::uint32_t v = 0;
for (std::size_t i = 0; i < 4; i++)
v = (v << 8) | std::to_integer<unsigned>(frame[FrameWriteCounterOff + i]);
return v;
}
// Result codes. Bit 7 set means the write counter has expired.
inline constexpr std::uint16_t ResultOk = 0x0000;
inline constexpr std::uint16_t ResultCounterExpired = 0x0080;
inline constexpr std::string_view ResultString(std::uint16_t r) {
switch (r & 0x007F) {
case 0x0000: return "OK";
case 0x0001: return "general failure";
case 0x0002: return "authentication failure";
case 0x0003: return "counter failure";
case 0x0004: return "address failure";
case 0x0005: return "write failure";
case 0x0006: return "read failure";
case 0x0007: return "key not yet programmed";
default: return "unknown";
}
}
// ---- The guard --------------------------------------------------------
//
// NEVER REMOVE THIS.
//
// req_resp 0x0001 is Authentication Key Programming. The RPMB key is
// ONE-TIME programmable in the UFS device: if it were ever reprogrammed,
// this part's RPMB is spent permanently and no reflash recovers it. QTEE
// has no legitimate reason to send it — the key is provisioned at
// manufacture — so a frame carrying it is a bug or an attack, and it is
// refused unconditionally regardless of whether writes are otherwise
// allowed.
inline bool IsKeyProgramming(std::span<const std::byte> frame) {
return ReqRespOf(frame) == static_cast<std::uint16_t>(ReqResp::AuthKeyProgram);
}
// Scan every frame in the request, not just the first: the guard is only
// as good as its coverage.
inline bool AnyKeyProgramming(std::span<const std::byte> buf, const Request& r) {
for (std::uint32_t k = 0; k < r.nblocks; k++) {
std::size_t off = r.dataOff + static_cast<std::size_t>(k) * FrameSize;
if (off + FrameSize > buf.size()) return true; // malformed: refuse
if (IsKeyProgramming(buf.subspan(off, FrameSize))) return true;
}
return false;
}
// ---- The authenticated write sequence ---------------------------------
//
// Per chunk, three SCSI commands (librpmb rpmb_ufs_write, 0x9ebc):
// SECURITY PROTOCOL OUT bpo * 512 the data frames
// SECURITY PROTOCOL OUT 512 a Result Read Request
// SECURITY PROTOCOL IN 512 the result frame
//
// The Result Read Request is a 512-byte constant in librpmb's .data whose
// only non-zero bytes are req_resp = 0x0005.
inline void BuildResultReadRequest(std::span<std::byte> frame) {
std::ranges::fill(frame.first(FrameSize), std::byte{0});
frame[FrameReqRespOff] = std::byte{0x00};
frame[FrameReqRespOff + 1] = std::byte{0x05};
}
// The reference runs nblocks / bpo chunks and silently drops a remainder,
// which would commit a partial transaction and leave the store
// inconsistent with QTEE's counter. Refuse instead.
struct ChunkPlan { std::uint32_t chunks = 0; bool exact = false; };
inline ChunkPlan PlanChunks(std::uint32_t nblocks, std::uint32_t blocksPerOp) {
if (blocksPerOp == 0 || nblocks == 0) return {};
std::uint32_t chunks = nblocks / blocksPerOp;
return { chunks, chunks != 0 && chunks * blocksPerOp == nblocks };
}
// An RPMB write advances a monotonic counter in the device and cannot be
// undone. The daemon keeps it behind an explicit opt-in, the way the
// harness did, so a first authentication run cannot touch the enrolled
// template.
inline constexpr std::int32_t StatusRefused = -1;
inline constexpr std::int32_t StatusOk = 0;
}