Port the finger name map, the last of the core modules
Fingerprintd:Store holds the correspondence between two vocabularies that know
nothing about each other: the trustlet identifies a finger by an opaque 32-bit
id it chose, and fprintd speaks users and names like "right-index-finger".
Nothing else can hold it -- the trustlet has no field for a name.
No biometric data passes through here. A template is a ~252 KB container the
trustlet encrypts and QTEE anti-rollback protects; this is a table of {name ->
the id the trustlet reported}, worth about as much as a username. It is stored
as one "name fid" per line, deliberately boring and greppable, because losing
it costs names rather than templates and it should be repairable by hand.
Two properties are load-bearing. fid 0 is never 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. And a damaged map degrades to
"fewer names known" rather than to a daemon that will not start -- unknown
names, missing ids, partly-numeric ids and zero fids are all skipped, since the
daemon that fails to start is the one that unlocks the phone.
The gid is the caller's Linux uid. SET_ACTIVE_GROUP and AUTHENTICATE only have
to agree with each other, so the value is ours to choose, and the uid makes the
mapping total with no allocation table. The dev phone's gid 60 is recorded as a
legacy group -- it was never a decision, just the ENROLL token's timeout field
read as a gid and then made self-consistent.
Verified by mutation: storing fid 0 and accepting a partly-numeric id both fail
the suite. A third mutation did not: Lookup's own zero guard is unreachable
because Add is the only way an entry is created and it already refuses 0. The
guard stays as defence in depth for a future writer, and the test now asserts
the invariant that makes it unreachable -- no entry holds fid 0 however it was
created -- rather than leaving a branch that no test can reach.
This commit is contained in:
parent
dff77110ef
commit
80ff09b4ce
4 changed files with 340 additions and 1 deletions
175
interfaces/Fingerprintd-Store.cppm
Normal file
175
interfaces/Fingerprintd-Store.cppm
Normal file
|
|
@ -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<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 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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,4 @@ export import :Sfs;
|
|||
export import :Rpmb;
|
||||
export import :Ta;
|
||||
export import :Engine;
|
||||
export import :Store;
|
||||
|
|
|
|||
Loading…
Reference in a new issue