diff --git a/interfaces/Fingerprintd-Store.cppm b/interfaces/Fingerprintd-Store.cppm new file mode 100644 index 0000000..6439dab --- /dev/null +++ b/interfaces/Fingerprintd-Store.cppm @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Store — the name map between two vocabularies. + +The trustlet stores templates itself, in QTEE-encrypted containers under a +group id, and identifies a finger by an opaque 32-bit id it chose. fprintd +speaks users and finger names ("right-index-finger"). Neither knows about the +other, so something has to hold the correspondence, and it cannot be the +trustlet — it has no field for a name. + +What this module is NOT: template storage. No biometric data passes through +here. A template is a ~252 KB container the trustlet encrypts and QTEE +anti-rollback protects; this side holds a small table of {finger name -> the id +the trustlet reported}, which is worth no more than a username. + +Encode and decode only — the daemon shell owns the file. +*/ + +export module Fingerprintd:Store; +import std; + +export namespace fingerprintd::store { + + // ---- Fingers ---------------------------------------------------------- + // + // fprintd's vocabulary, which is libfprint's. "any" is not a finger: it is + // what a client passes to VerifyStart to mean "identify against everything + // enrolled", and it must never be stored as one. + enum class Finger { + LeftThumb, LeftIndex, LeftMiddle, LeftRing, LeftLittle, + RightThumb, RightIndex, RightMiddle, RightRing, RightLittle, + }; + + inline constexpr std::array, 10> FingerNames = {{ + { Finger::LeftThumb, "left-thumb" }, + { Finger::LeftIndex, "left-index-finger" }, + { Finger::LeftMiddle, "left-middle-finger" }, + { Finger::LeftRing, "left-ring-finger" }, + { Finger::LeftLittle, "left-little-finger" }, + { Finger::RightThumb, "right-thumb" }, + { Finger::RightIndex, "right-index-finger" }, + { Finger::RightMiddle, "right-middle-finger" }, + { Finger::RightRing, "right-ring-finger" }, + { Finger::RightLittle, "right-little-finger" }, + }}; + + inline constexpr std::string_view AnyFinger = "any"; + + inline constexpr std::string_view NameOf(Finger f) { + for (auto& [k, v] : FingerNames) + if (k == f) return v; + return {}; + } + + inline std::optional FingerFromName(std::string_view name) { + for (auto& [k, v] : FingerNames) + if (v == name) return k; + return std::nullopt; + } + + // ---- Groups ----------------------------------------------------------- + // + // The trustlet's group id is Android's per-user id. SET_ACTIVE_GROUP + // writes it to device+0x30 and AUTHENTICATE compares its own argument + // against that field, returning -200 on a mismatch — so the two only have + // to agree with each other and the value itself is ours to choose. + // + // We choose the caller's Linux uid, which makes the mapping total and + // needs no allocation table. + // + // The dev phone's existing template sits under gid 60, which was never a + // decision: it came from the ENROLL token's timeout field being read as + // the gid, and was then made self-consistent. It is kept readable as a + // legacy group so an enrolled finger is not stranded, but nothing new is + // written there. + using Gid = std::uint32_t; + inline constexpr Gid LegacyGid = 60; + inline Gid GidForUid(std::uint32_t uid) { return uid; } + + // common.max_enrolling_fingers, from the trustlet config we ship. + inline constexpr std::size_t MaxFingersPerGroup = 5; + + // ---- The map ---------------------------------------------------------- + + struct Entry { + Finger finger; + std::uint32_t fid = 0; // what the trustlet reported at enrolment + }; + + class Map { + public: + // Enrolling the same finger twice replaces the old id rather than + // accumulating: the trustlet allocated a new template and the old id + // is no longer what a match will report. + bool Add(Finger f, std::uint32_t fid) { + if (fid == 0) return false; // 0 is the trustlet's "no match" + for (Entry& e : entries_) { + if (e.finger == f) { e.fid = fid; return true; } + } + if (entries_.size() >= MaxFingersPerGroup) return false; + entries_.push_back({ f, fid }); + return true; + } + + bool Remove(Finger f) { + auto n = std::erase_if(entries_, [&](const Entry& e) { return e.finger == f; }); + return n > 0; + } + void Clear() { entries_.clear(); } + + // A match reports an id; this turns it back into a name. + std::optional Lookup(std::uint32_t fid) const { + if (fid == 0) return std::nullopt; + for (const Entry& e : entries_) + if (e.fid == fid) return e.finger; + return std::nullopt; + } + + bool Has(Finger f) const { + return std::ranges::any_of(entries_, [&](const Entry& e) { return e.finger == f; }); + } + std::size_t Size() const { return entries_.size(); } + bool Full() const { return entries_.size() >= MaxFingersPerGroup; } + const std::vector& Entries() const { return entries_; } + + // ---- Serialisation + // + // One "name fid" per line. Deliberately boring and greppable: this + // file is recoverable by hand if it is ever lost, because losing it + // costs names, not templates. + std::string Encode() const { + std::string out; + for (const Entry& e : entries_) + out += std::format("{} {}\n", NameOf(e.finger), e.fid); + return out; + } + + // Unknown finger names and malformed lines are skipped, not fatal: a + // corrupt map must degrade to "fewer names known", never to a daemon + // that will not start and therefore a phone that cannot be unlocked. + static Map Decode(std::string_view text) { + Map m; + std::size_t pos = 0; + while (pos <= text.size()) { + std::size_t nl = text.find('\n', pos); + std::string_view line = text.substr(pos, nl == std::string_view::npos + ? std::string_view::npos : nl - pos); + pos = (nl == std::string_view::npos) ? text.size() + 1 : nl + 1; + if (line.empty()) continue; + std::size_t sp = line.find(' '); + if (sp == std::string_view::npos) continue; + auto f = FingerFromName(line.substr(0, sp)); + if (!f) continue; + std::uint32_t fid = 0; + auto num = line.substr(sp + 1); + auto [ptr, ec] = std::from_chars(num.data(), num.data() + num.size(), fid); + if (ec != std::errc{} || ptr != num.data() + num.size()) continue; + m.Add(*f, fid); + } + return m; + } + + private: + std::vector entries_; + }; + + // Per-user file under the daemon's state directory. Root-only: it is not + // secret, but it decides which name an authentication reports. + inline std::string PathForUid(std::string_view stateDir, std::uint32_t uid) { + return std::format("{}/fingers-{}.map", stateDir, uid); + } +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index 0b9e3a6..28c188b 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -16,3 +16,4 @@ export import :Sfs; export import :Rpmb; export import :Ta; export import :Engine; +export import :Store; diff --git a/project.cpp b/project.cpp index 49ae107..056ba0c 100644 --- a/project.cpp +++ b/project.cpp @@ -21,12 +21,13 @@ 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", "interfaces/Fingerprintd-Engine", + "interfaces/Fingerprintd-Store", }; std::array impls = {}; Core->GetInterfacesAndImplementations(ifaces, impls); @@ -51,6 +52,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Rpmb").Dependencies({ Core.get() }); cfg.AddTest("Ta").Dependencies({ Core.get() }); cfg.AddTest("Engine").Dependencies({ Core.get() }); + cfg.AddTest("Store").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Store/main.cpp b/tests/Store/main.cpp new file mode 100644 index 0000000..311300f --- /dev/null +++ b/tests/Store/main.cpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Store unit tests. + +The map is small, but two of its properties are load-bearing. A corrupt or +partly-unreadable map must degrade to "fewer names known" rather than to a +daemon that refuses to start, because that daemon is what unlocks the phone. +And fid 0 must never be storable or matchable: 0 is what the trustlet writes +into the fid field when authentication FAILS, so a stored 0 would turn every +rejection into a match. +*/ +import std; +import Fingerprintd; + +using namespace fingerprintd::store; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } +} + +int main() { + // ---- fid 0 is the failure sentinel, never an identity + { + Map m; + Check(!m.Add(Finger::RightIndex, 0), "fid 0 is refused"); + Check(m.Size() == 0, "and nothing was stored"); + Check(!m.Lookup(0).has_value(), "a zero fid never resolves to a finger"); + m.Add(Finger::RightIndex, 1296911490); + Check(!m.Lookup(0).has_value(), "still not, with entries present"); + + // Lookup's own zero guard is unreachable by construction: Add is the + // only way an entry is created and it refuses 0, so the guard is + // defence in depth against a future writer, not a live branch. What + // IS testable is the invariant that makes it unreachable -- assert + // that, including through the decoder, which is the other way entries + // arrive. + Map decoded = Map::Decode("left-thumb 0\nright-thumb 5\nleft-index-finger 0\n"); + bool anyZero = false; + for (const Entry& e : decoded.Entries()) + if (e.fid == 0) anyZero = true; + Check(!anyZero, "no entry ever holds fid 0, however it was created"); + Check(decoded.Size() == 1, "the zero-fid lines were dropped by the decoder"); + } + + // ---- Names round-trip through fprintd's vocabulary + { + for (auto& [f, name] : FingerNames) { + Check(NameOf(f) == name, std::format("name of {}", name)); + auto back = FingerFromName(name); + Check(back && *back == f, std::format("{} round-trips", name)); + } + Check(FingerNames.size() == 10, "ten fingers"); + Check(!FingerFromName(AnyFinger).has_value(), + "'any' is a verify argument, not a storable finger"); + Check(!FingerFromName("").has_value(), "empty name"); + Check(!FingerFromName("nose").has_value(), "unknown name"); + } + + // ---- Add, replace, remove + { + Map m; + Check(m.Add(Finger::RightIndex, 1296911490), "add"); + Check(m.Size() == 1 && m.Has(Finger::RightIndex), "stored"); + auto f = m.Lookup(1296911490); + Check(f && *f == Finger::RightIndex, "a match resolves to the finger"); + + // Re-enrolling replaces: the trustlet allocated a new template and the + // old id is not what a match will report any more. + Check(m.Add(Finger::RightIndex, 42), "re-enrol"); + Check(m.Size() == 1, "no duplicate entry"); + Check(!m.Lookup(1296911490).has_value(), "the stale id no longer resolves"); + auto g = m.Lookup(42); + Check(g && *g == Finger::RightIndex, "the new id does"); + + Check(m.Remove(Finger::RightIndex), "remove"); + Check(m.Size() == 0 && !m.Has(Finger::RightIndex), "removed"); + Check(!m.Remove(Finger::RightIndex), "removing twice is not an error the second time"); + } + + // ---- The group limit + { + Map m; + Finger fingers[] = { Finger::LeftThumb, Finger::LeftIndex, Finger::LeftMiddle, + Finger::LeftRing, Finger::LeftLittle }; + for (std::size_t i = 0; i < 5; i++) + Check(m.Add(fingers[i], static_cast(100 + i)), "fill the group"); + Check(m.Full() && m.Size() == MaxFingersPerGroup, "five is the limit"); + Check(!m.Add(Finger::RightThumb, 200), "a sixth finger is refused"); + // But replacing one already present still works when full. + Check(m.Add(Finger::LeftThumb, 999), "replacing while full is allowed"); + Check(m.Size() == 5, "still five"); + } + + // ---- Serialisation round-trip + { + Map m; + m.Add(Finger::RightIndex, 1296911490); + m.Add(Finger::LeftThumb, 7); + std::string text = m.Encode(); + Check(text.contains("right-index-finger 1296911490"), "encodes the name and id"); + Check(text.contains("left-thumb 7"), "encodes the second entry"); + + Map back = Map::Decode(text); + Check(back.Size() == 2, "decodes both"); + auto f = back.Lookup(1296911490); + Check(f && *f == Finger::RightIndex, "round-trips the mapping"); + Check(back.Encode() == text, "re-encoding is stable"); + } + + // ---- A damaged map degrades, it does not fail + { + std::string damaged = + "right-index-finger 1296911490\n" // good + "\n" // blank + "left-thumb\n" // no id + "nose 5\n" // unknown finger + "left-index-finger notanumber\n" // unparsable id + "left-middle-finger 12x\n" // trailing junk + "left-ring-finger 0\n" // the failure sentinel + "right-thumb 8\n"; // good + Map m = Map::Decode(damaged); + Check(m.Size() == 2, "only the two good lines survive"); + auto a = m.Lookup(1296911490); + auto b = m.Lookup(8); + Check(a && *a == Finger::RightIndex, "first good entry kept"); + Check(b && *b == Finger::RightThumb, "last good entry kept"); + Check(!m.Has(Finger::LeftRing), "the zero-fid line was dropped"); + Check(!m.Has(Finger::LeftMiddle), "trailing junk rejected, not truncated to 12"); + + // Total garbage yields an empty map, not a throw. + Map junk = Map::Decode("\0\0\xff nonsense \n\n\n"); + Check(junk.Size() == 0, "garbage decodes to empty"); + Map empty = Map::Decode(""); + Check(empty.Size() == 0, "empty input decodes to empty"); + // A final line without a newline is still read. + Map noNl = Map::Decode("right-thumb 3"); + Check(noNl.Size() == 1, "a missing trailing newline is not a lost entry"); + } + + // ---- Groups + { + Check(GidForUid(1000) == 1000, "gid is the uid"); + Check(GidForUid(0) == 0, "root maps too"); + Check(LegacyGid == 60, "the dev phone's accidental group is remembered"); + Check(GidForUid(1000) != LegacyGid, "and a real uid does not collide with it"); + Check(PathForUid("/var/lib/fingerprintd", 1000) + == "/var/lib/fingerprintd/fingers-1000.map", "state path"); + } + + if (Failures == 0) std::println("Store: all tests passed"); + return Failures; +}