// 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 research harness used gid 60, which was never a decision -- it was // the ENROLL token's timeout field being read as the gid, then made // self-consistent. There is no compatibility path for it: the finger it // enrolled gets re-enrolled under the uid. Nothing is worth carrying a // second group id for. using Gid = std::uint32_t; 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); } }