diff --git a/interfaces/Fingerprintd-Rpmb.cppm b/interfaces/Fingerprintd-Rpmb.cppm new file mode 100644 index 0000000..5721f3c --- /dev/null +++ b/interfaces/Fingerprintd-Rpmb.cppm @@ -0,0 +1,257 @@ +// 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 b, std::size_t off) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; i++) + v |= static_cast(std::to_integer(b[off + i])) << (8 * i); + return v; + } + inline void StoreU32(std::span b, std::size_t off, std::uint32_t v) { + for (std::size_t i = 0; i < 4; i++) + b[off + i] = static_cast((v >> (8 * i)) & 0xFF); + } + // JEDEC frame fields are BIG endian. + inline std::uint16_t LoadBe16(std::span b, std::size_t off) { + return static_cast( + (std::to_integer(b[off]) << 8) | std::to_integer(b[off + 1])); + } + } + + inline std::optional ParseRequest(std::span frame) { + if (frame.size() < BlocksPerOpOff + 4) + return std::nullopt; + Request r; + r.op = static_cast(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 buf, const Request& r) { + std::size_t need = static_cast(r.dataOff) + + static_cast(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(FrameSize) + : nblocks * static_cast(FrameSize); + } + + inline void WriteReply(std::span buf, std::int32_t status, + std::uint32_t bytesTransferred) { + detail::StoreU32(buf, StatusOff, static_cast(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 frame) { + return detail::LoadBe16(frame, FrameReqRespOff); + } + inline std::uint16_t ResultOf(std::span frame) { + return detail::LoadBe16(frame, FrameResultOff); + } + inline std::uint32_t WriteCounterOf(std::span frame) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; i++) + v = (v << 8) | std::to_integer(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 frame) { + return ReqRespOf(frame) == static_cast(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 buf, const Request& r) { + for (std::uint32_t k = 0; k < r.nblocks; k++) { + std::size_t off = r.dataOff + static_cast(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 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; +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index 7c212a1..b7f73f1 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -13,4 +13,5 @@ the normal world, and reports the matched finger id. export module Fingerprintd; export import :Sfs; +export import :Rpmb; export import :Ta; diff --git a/project.cpp b/project.cpp index 5adc481..b7a2feb 100644 --- a/project.cpp +++ b/project.cpp @@ -21,9 +21,10 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Fingerprintd", "interfaces/Fingerprintd-Sfs", + "interfaces/Fingerprintd-Rpmb", "interfaces/Fingerprintd-Ta", }; std::array impls = {}; @@ -46,6 +47,7 @@ extern "C" Configuration CrafterBuildProject(std::span a } cfg.AddTest("Sfs").Dependencies({ Core.get() }); + cfg.AddTest("Rpmb").Dependencies({ Core.get() }); cfg.AddTest("Ta").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Rpmb/main.cpp b/tests/Rpmb/main.cpp new file mode 100644 index 0000000..461227c --- /dev/null +++ b/tests/Rpmb/main.cpp @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Rpmb unit tests. + +Two things here are not ordinary parsing bugs. + +The key-programming guard protects against an IRREVERSIBLE action: the RPMB +authentication key is one-time programmable in the UFS device, and relaying a +frame that reprograms it destroys that part's RPMB permanently, with no reflash +recovering it. It is tested for coverage over every frame in a request, not +just the first, and for refusing a malformed request rather than reading past +the buffer. + +The out-parameter at +0x08 is the field that made every RPMB transaction fail +for a week. QTEE compares it against what it expected to be transferred and +rejects the whole transaction on a mismatch, so a read and a write do not +report the same thing. +*/ +import std; +import Fingerprintd; + +using namespace fingerprintd::rpmb; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + std::uint32_t Get32(std::span b, std::size_t off) { + std::uint32_t v = 0; + for (std::size_t i = 0; i < 4; i++) + v |= static_cast(std::to_integer(b[off + i])) << (8 * i); + return v; + } + void Put32(std::span b, std::size_t off, std::uint32_t v) { + for (std::size_t i = 0; i < 4; i++) + b[off + i] = static_cast((v >> (8 * i)) & 0xFF); + } + void PutBe16(std::span b, std::size_t off, std::uint16_t v) { + b[off] = static_cast((v >> 8) & 0xFF); + b[off + 1] = static_cast(v & 0xFF); + } + + // A request buffer holding `n` frames at dataOff 0x18, each with the given + // req_resp — the shape QTEE actually sends. + std::vector MakeRequest(Op op, std::uint32_t n, std::uint16_t reqResp, + std::uint32_t blocksPerOp = 1) { + std::vector buf(SharedBufferSize); + Put32(buf, OpOff, static_cast(op)); + Put32(buf, NblocksOff, n); + Put32(buf, FrameSizeOff, static_cast(FrameSize)); + Put32(buf, DataOffOff, 0x18); + Put32(buf, BlocksPerOpOff, blocksPerOp); + for (std::uint32_t k = 0; k < n; k++) + PutBe16(buf, 0x18 + static_cast(k) * FrameSize + FrameReqRespOff, reqResp); + return buf; + } +} + +int main() { + // ---- The guard against an irreversible action + { + // A single key-programming frame is refused. + auto buf = MakeRequest(Op::Write, 1, + static_cast(ReqResp::AuthKeyProgram)); + auto r = ParseRequest(buf); + Check(r.has_value(), "key-program request parses"); + Check(r && AnyKeyProgramming(buf, *r), "a key-programming frame is caught"); + + // Hidden behind five legitimate frames it is still caught — the guard + // scans every frame, not just the first. + auto many = MakeRequest(Op::Write, 6, + static_cast(ReqResp::AuthDataWrite)); + auto rm = ParseRequest(many); + Check(rm && !AnyKeyProgramming(many, *rm), "six clean write frames pass"); + PutBe16(many, 0x18 + 5 * FrameSize + FrameReqRespOff, + static_cast(ReqResp::AuthKeyProgram)); + Check(rm && AnyKeyProgramming(many, *rm), "a key-programming frame in the LAST slot is caught"); + + // An ordinary authenticated write and read are not mistaken for it. + auto w = MakeRequest(Op::Write, 1, static_cast(ReqResp::AuthDataWrite)); + auto rw = ParseRequest(w); + Check(rw && !AnyKeyProgramming(w, *rw), "a normal write is not refused"); + auto rd = MakeRequest(Op::Read, 6, static_cast(ReqResp::AuthDataRead)); + auto rr = ParseRequest(rd); + Check(rr && !AnyKeyProgramming(rd, *rr), "a normal read is not refused"); + + // A request claiming more frames than the buffer holds is refused + // rather than read past. + auto bad = MakeRequest(Op::Write, 1, static_cast(ReqResp::AuthDataWrite)); + Put32(bad, NblocksOff, 100000); + auto rb = ParseRequest(bad); + Check(rb && AnyKeyProgramming(bad, *rb), "a malformed request is refused, not read past"); + Check(rb && !FramesInBounds(bad, *rb), "and it fails the bounds check"); + } + + // ---- The out-parameter that QTEE checks + { + // A read posts one request frame however large nblocks is; a write + // posts nblocks * 512 and the reference reports a flat 512. + Check(BytesTransferred(Op::Read, 6) == 6 * FrameSize, "read reports the total"); + Check(BytesTransferred(Op::Write, 6) == FrameSize, "write reports one frame"); + Check(BytesTransferred(Op::Read, 6) != BytesTransferred(Op::Write, 6), + "the two directions do NOT report the same thing"); + + auto buf = MakeRequest(Op::Read, 6, static_cast(ReqResp::AuthDataRead)); + auto r = ParseRequest(buf); + Check(r && r->frameSize == FrameSize, "frame size arrives as 512"); + WriteReply(buf, StatusOk, BytesTransferred(Op::Read, r->nblocks)); + Check(static_cast(Get32(buf, StatusOff)) == 0, "status written"); + Check(Get32(buf, TransferredOff) == 6 * FrameSize, "bytes transferred, not frame size"); + Check(Get32(buf, TransferredOff) != FrameSize, + "leaving the request's 512 there is what failed every transaction"); + + // The data offset is left exactly as the request supplied it. QTEE + // looks for the frames at req + req[0x0c]; librpmb's hardcoded 20 + // points four bytes early. + Check(Get32(buf, DataOffOff) == 0x18, "data offset preserved"); + Check(Get32(buf, DataOffOff) != LibrpmbHardcodedDataOff, "not overwritten with 20"); + } + + // ---- Request decoding + { + auto buf = MakeRequest(Op::Read, 6, static_cast(ReqResp::AuthDataRead)); + auto r = ParseRequest(buf); + Check(r && r->op == Op::Read, "op 0x102 = read"); + Check(r && r->nblocks == 6, "nblocks"); + Check(r && r->dataOff == 0x18, "data offset"); + Check(r && FramesInBounds(buf, *r), "frames in bounds"); + + std::vector stub(8); + Check(!ParseRequest(stub).has_value(), "short request rejected"); + + auto zero = MakeRequest(Op::Read, 0, 0); + auto rz = ParseRequest(zero); + Check(rz && !FramesInBounds(zero, *rz), "zero blocks is not in bounds"); + } + + // ---- Chunking: a remainder must not commit a partial transaction + { + Check(PlanChunks(6, 6).exact && PlanChunks(6, 6).chunks == 1, "6/6 = one chunk"); + Check(PlanChunks(12, 6).exact && PlanChunks(12, 6).chunks == 2, "12/6 = two chunks"); + Check(!PlanChunks(7, 6).exact, "7 blocks in 6-block chunks is refused"); + Check(!PlanChunks(3, 6).exact, "fewer blocks than a chunk is refused"); + Check(!PlanChunks(6, 0).exact, "a zero chunk size is refused, not divided by"); + Check(!PlanChunks(0, 6).exact, "zero blocks is refused"); + } + + // ---- The Result Read Request constant + { + std::vector rrq(FrameSize); + BuildResultReadRequest(rrq); + Check(ReqRespOf(rrq) == static_cast(ReqResp::ResultRead), + "req_resp = 0x0005"); + std::size_t nonZero = 0; + for (std::byte b : rrq) + if (b != std::byte{0}) nonZero++; + Check(nonZero == 1, "exactly one non-zero byte, as in librpmb's .data"); + } + + // ---- Frame fields are big endian + { + std::vector f(FrameSize); + PutBe16(f, FrameResultOff, 0x0000); + PutBe16(f, FrameReqRespOff, 0x0300); + Check(ResultOf(f) == ResultOk, "result OK"); + Check(ReqRespOf(f) == 0x0300, "req_resp big endian"); + f[FrameWriteCounterOff] = std::byte{0x00}; + f[FrameWriteCounterOff + 1] = std::byte{0x00}; + f[FrameWriteCounterOff + 2] = std::byte{0x24}; + f[FrameWriteCounterOff + 3] = std::byte{0x05}; + Check(WriteCounterOf(f) == 0x2405, "write counter big endian"); + } + + // ---- Result strings, including the expiry bit + Check(ResultString(0x0000) == "OK", "0"); + Check(ResultString(0x0002) == "authentication failure", "2"); + Check(ResultString(0x0007) == "key not yet programmed", "7"); + Check(ResultString(0x0080 | 0x0003) == "counter failure", + "bit 7 is the expiry flag, not part of the code"); + Check((ResultCounterExpired & 0x007F) == 0, "the expiry bit is outside the code"); + + // ---- Transport constants + Check(BsgDevice == "/dev/bsg/0:0:0:49476", "the RPMB WLUN node, not ufs-bsg0"); + Check(RpmbWlun == 49476, "UPIU 0xC4 -> SCSI WLUN 0xC144"); + Check(SecurityProtocolUfs == 0xEC, "JEDEC UFS security protocol"); + Check(FrameSize == 512, "JEDEC frame size"); + + if (Failures == 0) std::println("Rpmb: all tests passed"); + return Failures; +}