// 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; } // Whether the daemon may remove a template of this group that the map // does not name. Group 0 is left alone: it is root's, which no session // user is, and the group a stock Android install's primary user also // writes. Every path that removes an unnamed template, and the name drop // that precedes one, asks this and nothing else. inline bool GroupIsManaged(Gid gid) { return gid != 0; } // 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; } // Removing a finger takes its entry and every shadowed line for it: // a line the decoder understood but could not make the finger's // entry (a repeated name, a sixth finger) must not come back as the // finger's name once the entry is gone. The fids all go to the // caller, whose delete removes their templates. bool Remove(Finger f) { auto n = std::erase_if(entries_, [&](const Entry& e) { return e.finger == f; }); n += std::erase_if(shadowed_, [&](const Entry& e) { return e.finger == f; }); return n > 0; } // "Every finger" includes the ones in lines the daemon could not // read, so this is the one call that drops those lines too. The // templates go with the same delete, named or not: that removal is // on the user's word, not on this map's. void Clear() { entries_.clear(); shadowed_.clear(); unparsed_.clear(); } // Every fid a finger's lines carry: its entry's and its shadowed // ones'. What a delete of that finger removes from the trustlet. std::vector FidsNamed(Finger f) const { std::vector out; for (const Entry& e : entries_) if (e.finger == f) out.push_back(e.fid); for (const Entry& e : shadowed_) if (e.finger == f) out.push_back(e.fid); return out; } std::vector AllFids() const { std::vector out; for (const Entry& e : entries_) out.push_back(e.fid); for (const Entry& e : shadowed_) out.push_back(e.fid); return out; } // 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, because // this file can be rebuilt by hand: the claim log prints the fids the // trustlet holds ("templates loaded: N (fid ...)"). It is worth // rebuilding promptly, and a rebuild counts from the next enrolment // on, a held claim's included (the map is re-read at every // EnrollStart). The map decides which of a group's templates an // enrolment or a delete-all keeps -- a template no line names is // removed by the next one, in a managed group -- so losing the map // costs the names at once and the unnamed templates at that point. // // The lines Decode could not use are written back unchanged, after // the entries: a save must not turn a map the daemon did not // understand into one it does, because the next load would trust // it and the next enrolment remove what those lines named. std::string Encode() const { std::string out; for (const Entry& e : entries_) out += std::format("{} {}\n", NameOf(e.finger), e.fid); for (const Entry& e : shadowed_) out += std::format("{} {}\n", NameOf(e.finger), e.fid); for (const std::string& l : unparsed_) out += l + '\n'; return out; } // A non-empty line that does not become its own entry is kept and // counted, not dropped and not fatal: a damaged map must degrade to // "fewer names known", never to a daemon that will not start and // therefore a phone that cannot be unlocked. A line the decoder // cannot read -- no space, an unknown finger name, a bad number, // fid 0 -- is kept as text; one it can read but cannot seat -- a // finger named twice, a sixth finger -- is kept as the finger and // fid it names, so a delete of that finger finds it. The kept lines // are what keep "fewer names" from becoming "fewer templates": a map // with a line it could not use is not trusted to say which // templates are unnamed (Trusted()), and Encode carries the lines // along so the next load is not either. A file that could not be // read at all is Unreadable(); only an absent file means "no // fingers". 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; if (!m.Parse(line)) m.unparsed_.emplace_back(line); } return m; } // The lines Decode could not use: the unreadable ones verbatim, the // unseatable ones as entries. Either non-empty means the map on disk // says more than this object's entries do. std::size_t Skipped() const { return unparsed_.size() + shadowed_.size(); } const std::vector& Unparsed() const { return unparsed_; } const std::vector& Shadowed() const { return shadowed_; } // The empty map that stands for a file the daemon could not open or // read for a reason other than its absence. It is never saved: a // file the daemon could not see is not a file it may replace. static Map Unreadable() { Map m; m.readable_ = false; return m; } bool Readable() const { return readable_; } // Whether this object says everything the map on disk says: read in // full, every line its own entry. Only then may it decide which of // the trustlet's templates are unnamed; nothing is removed on the // word of a map that is not. bool Trusted() const { return readable_ && unparsed_.empty() && shadowed_.empty(); } private: // One line into one entry, or false for a line the decoder cannot // read. A line it can read but cannot seat -- the finger is already // named, or the map is full -- is shadowed: the earlier line's fid // would otherwise be lost in memory while it still stands on disk. bool Parse(std::string_view line) { std::size_t sp = line.find(' '); if (sp == std::string_view::npos) return false; auto f = FingerFromName(line.substr(0, sp)); if (!f) return false; 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() || fid == 0) return false; if (Has(*f) || Full()) { shadowed_.push_back({ *f, fid }); return true; } return Add(*f, fid); } std::vector entries_; std::vector shadowed_; std::vector unparsed_; bool readable_ = true; }; // 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); } }