gid 60 was never a decision -- it was the ENROLL token's timeout field being read as a gid, then made self-consistent. Keeping it readable alongside gid = uid would be a second code path, a second group to reason about, and a migration to eventually retire, all to avoid re-enrolling one finger on one dev phone. Re-enrol it instead.
174 lines
7.2 KiB
C++
174 lines
7.2 KiB
C++
// 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<std::pair<Finger, std::string_view>, 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<Finger> 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<Finger> 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<Entry>& 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<Entry> 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);
|
|
}
|
|
}
|