a template no name accounts for is removed, not kept in the way

The templates live on the persist partition and survive a reflash; the
finger-name map lives on the rootfs and does not. A reflashed phone comes
back with the trustlet holding every template it had and the daemon
knowing none of them by name -- and with five of them the group is full, so
every enrolment from Settings answered -207 "Up to the limit" (fennek182,
2026-10-13). No D-Bus client could get out of that: the list answered from
the map, delete removed only the fids the map knew, and ENUMERATE was sent
with an empty payload and read for its count alone.

A finger re-enrolled under its old name produced the same state one
template at a time: the map took the new fid and the old template stayed
in its slot.

ENUMERATE now asks with room for every slot the group can hold, and the
reply names the templates: one 8-byte entry per loaded one, its fid in the
second word. Verified live: the three fids the map names came back among
the four listed. With the list in hand:

  - An enrolment removes the templates no name accounts for before ENROLL.
    They can match nothing (a verify filters on the map's fids, standby
    arms from them), and each is a slot the enrolment may need or the
    duplicate the trustlet would refuse it for.
  - Deleting every finger deletes them too, and the job is posted even
    when the map is empty, which after a reflash it is.
  - Re-enrolling a finger drops its name at EnrollStart, so the old
    template is unnamed and goes under the first rule. The trustlet forces
    the order: with duplicate checking on, a re-enrolment of a finger it
    still holds does not complete (2026-09-04: one attempt 0 accepted of 7
    presses, another stalled at sample 8 of 20).
  - The claim log counts them: "4 template(s) loaded, 1 of them unnamed".

The map now decides what an enrolment keeps, so the Store module says so,
and three rules keep that from turning a damaged map into lost templates.
A line the decoder cannot use -- a typo in a hand rebuild, a finger named
twice -- makes the map untrusted, and no enrolment removes anything on its
account. A line it cannot read at all is written back, word for word, by
every save but a delete of every finger, so the distrust outlasts the next
enrolment; a line it can read but not seat, the repeated name or a sixth
finger, is kept as the finger and fid it names and goes with that finger's
delete, template included, instead of coming back as the finger's name;
and the finger named twice now answers to its FIRST line, where before
the last line's fid replaced the earlier one's. A file that will not open
or read, for any reason but its absence, refuses
enrolment and deletion outright and is never written over. And the map is
written with GLib's g_file_set_contents_full under its consistent and
durable flags, and a save that did not land is reported: the old map
stays, a re-enrolment whose name drop did not land is refused with the
name put back, a delete whose save did not land removes nothing, and an
enrolment whose name did not land is reported to the client as failed,
not completed. Of the daemon's own paths, deleting every finger is the
one removal of unnamed templates on the user's word rather than the
map's: in a managed group it takes every template, named or not. A delete is refused while the
claimant's enrolment or verify for that user is in progress, because the
running job's keep-list came from the claim's map; a standby verify for
that user is stopped and the delete goes ahead. Group 0 is never tidied:
every daemon path that removes a template the map does not name, and the
name drop before a re-enrolment, asks one predicate, and it says no for
group 0 -- root's,
which no session user is, and the group a stock Android install's primary
user also writes. Root re-enrolling a finger behaves as it did before.
The --probe-remove diagnostic removes the fid it is given in any group,
with no map involved, as before.

A result belongs to the operation that asked for it. EnrollStart and
VerifyStart number the operation, the job and every event it posts carry
the number, and a Stop or the claim's end clears it: a job still queued
under a cleared number exits at dequeue instead of running an operation
nobody wants, and a result still in flight is neither named nor reported
-- its template, unnamed, goes with the group's next enrolment. Without
this, a stop followed by a new start could name a late result under the
new finger and remove the just-saved template as unnamed. An enrolment is
also refused before anything changes when the store is read-only, and the
claim's map is re-read from disk at every EnrollStart, so a file rebuilt
by hand under a held claim counts.

The probe passes the loaded list as the named one. A diagnostic does not
tidy the group it is probing. fplearn.sh's header no longer says that
re-enrolment adds a template with no trustlet-side remove, and its `enrol`
refusal gives the protocol's own reason (a template with no history)
instead of the trustlet's duplicate refusal.

Tested on the dev phone. An earlier build of this change, before the
shared predicate and the damaged-map rule, removed the phone's one stray
template at an EnrollStart for a finger not in the map (fid 1313616972,
8 -> 6 containers), reloaded the group as the three named ones, got rc=0
from ENROLL and cancelled cleanly. A build of this series then ran the
same claim and EnrollStart on the group it left behind: three loaded, none
unnamed, nothing removed, ENROLL rc=0, cancelled cleanly. The build of this
series' tip then enrolled a fourth finger to completion (20 samples
accepted, one refused), which the map saved and listed, and deleted it
again under the same claim: its template removed, the group and the map
back to the three they started with.
This commit is contained in:
Jorijn van der Graaf 2026-10-13 21:55:15 +02:00
commit bae21dc7a8
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 553 additions and 79 deletions

View file

@ -79,6 +79,13 @@ export namespace fingerprintd::store {
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;
@ -104,11 +111,36 @@ export namespace fingerprintd::store {
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;
}
void Clear() { entries_.clear(); }
// "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<std::uint32_t> FidsNamed(Finger f) const {
std::vector<std::uint32_t> 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<std::uint32_t> AllFids() const {
std::vector<std::uint32_t> 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<Finger> Lookup(std::uint32_t fid) const {
@ -127,19 +159,45 @@ export namespace fingerprintd::store {
// ---- 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.
// 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;
}
// 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.
// 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;
@ -149,21 +207,52 @@ export namespace fingerprintd::store {
? 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);
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<std::string>& Unparsed() const { return unparsed_; }
const std::vector<Entry>& 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<Entry> entries_;
std::vector<Entry> shadowed_;
std::vector<std::string> unparsed_;
bool readable_ = true;
};
// Per-user file under the daemon's state directory. Root-only: it is not