fingerprintd/tests/Store/main.cpp

221 lines
11 KiB
C++
Raw Normal View History

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.
2026-09-02 17:22:27 +02:00
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Fingerprintd:Store unit tests.
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.
2026-10-13 21:55:15 +02:00
The map is small, but three of its properties are load-bearing. A corrupt or
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.
2026-09-02 17:22:27 +02:00
partly-unreadable map must degrade to "fewer names known" rather than to a
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.
2026-10-13 21:55:15 +02:00
daemon that refuses to start, because that daemon is what unlocks the phone --
and it must say so, because the map now decides which templates an enrolment
removes, and a map with lines it could not read must not decide that. Fid 0
must never be 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 group 0 is never managed.
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.
2026-09-02 17:22:27 +02:00
*/
import std;
import Fingerprintd;
using namespace fingerprintd::store;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
}
int main() {
// ---- fid 0 is the failure sentinel, never an identity
{
Map m;
Check(!m.Add(Finger::RightIndex, 0), "fid 0 is refused");
Check(m.Size() == 0, "and nothing was stored");
Check(!m.Lookup(0).has_value(), "a zero fid never resolves to a finger");
m.Add(Finger::RightIndex, 1296911490);
Check(!m.Lookup(0).has_value(), "still not, with entries present");
// Lookup's own zero guard is unreachable by construction: Add is the
// only way an entry is created and it refuses 0, so the guard is
// defence in depth against a future writer, not a live branch. What
// IS testable is the invariant that makes it unreachable -- assert
// that, including through the decoder, which is the other way entries
// arrive.
Map decoded = Map::Decode("left-thumb 0\nright-thumb 5\nleft-index-finger 0\n");
bool anyZero = false;
for (const Entry& e : decoded.Entries())
if (e.fid == 0) anyZero = true;
Check(!anyZero, "no entry ever holds fid 0, however it was created");
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.
2026-10-13 21:55:15 +02:00
Check(decoded.Size() == 1, "the zero-fid lines make no entry");
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.
2026-09-02 17:22:27 +02:00
}
// ---- Names round-trip through fprintd's vocabulary
{
for (auto& [f, name] : FingerNames) {
Check(NameOf(f) == name, std::format("name of {}", name));
auto back = FingerFromName(name);
Check(back && *back == f, std::format("{} round-trips", name));
}
Check(FingerNames.size() == 10, "ten fingers");
Check(!FingerFromName(AnyFinger).has_value(),
"'any' is a verify argument, not a storable finger");
Check(!FingerFromName("").has_value(), "empty name");
Check(!FingerFromName("nose").has_value(), "unknown name");
}
// ---- Add, replace, remove
{
Map m;
Check(m.Add(Finger::RightIndex, 1296911490), "add");
Check(m.Size() == 1 && m.Has(Finger::RightIndex), "stored");
auto f = m.Lookup(1296911490);
Check(f && *f == Finger::RightIndex, "a match resolves to the finger");
// Re-enrolling replaces: the trustlet allocated a new template and the
// old id is not what a match will report any more.
Check(m.Add(Finger::RightIndex, 42), "re-enrol");
Check(m.Size() == 1, "no duplicate entry");
Check(!m.Lookup(1296911490).has_value(), "the stale id no longer resolves");
auto g = m.Lookup(42);
Check(g && *g == Finger::RightIndex, "the new id does");
Check(m.Remove(Finger::RightIndex), "remove");
Check(m.Size() == 0 && !m.Has(Finger::RightIndex), "removed");
Check(!m.Remove(Finger::RightIndex), "removing twice is not an error the second time");
}
// ---- The group limit
{
Map m;
Finger fingers[] = { Finger::LeftThumb, Finger::LeftIndex, Finger::LeftMiddle,
Finger::LeftRing, Finger::LeftLittle };
for (std::size_t i = 0; i < 5; i++)
Check(m.Add(fingers[i], static_cast<std::uint32_t>(100 + i)), "fill the group");
Check(m.Full() && m.Size() == MaxFingersPerGroup, "five is the limit");
Check(!m.Add(Finger::RightThumb, 200), "a sixth finger is refused");
// But replacing one already present still works when full.
Check(m.Add(Finger::LeftThumb, 999), "replacing while full is allowed");
Check(m.Size() == 5, "still five");
}
// ---- Serialisation round-trip
{
Map m;
m.Add(Finger::RightIndex, 1296911490);
m.Add(Finger::LeftThumb, 7);
std::string text = m.Encode();
Check(text.contains("right-index-finger 1296911490"), "encodes the name and id");
Check(text.contains("left-thumb 7"), "encodes the second entry");
Map back = Map::Decode(text);
Check(back.Size() == 2, "decodes both");
auto f = back.Lookup(1296911490);
Check(f && *f == Finger::RightIndex, "round-trips the mapping");
Check(back.Encode() == text, "re-encoding is stable");
}
// ---- A damaged map degrades, it does not fail
{
std::string damaged =
"right-index-finger 1296911490\n" // good
"\n" // blank
"left-thumb\n" // no id
"nose 5\n" // unknown finger
"left-index-finger notanumber\n" // unparsable id
"left-middle-finger 12x\n" // trailing junk
"left-ring-finger 0\n" // the failure sentinel
"right-thumb 8\n"; // good
Map m = Map::Decode(damaged);
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.
2026-10-13 21:55:15 +02:00
Check(m.Size() == 2, "only the two good lines become entries");
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.
2026-09-02 17:22:27 +02:00
auto a = m.Lookup(1296911490);
auto b = m.Lookup(8);
Check(a && *a == Finger::RightIndex, "first good entry kept");
Check(b && *b == Finger::RightThumb, "last good entry kept");
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.
2026-10-13 21:55:15 +02:00
Check(!m.Has(Finger::LeftRing), "the zero-fid line made no entry");
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.
2026-09-02 17:22:27 +02:00
Check(!m.Has(Finger::LeftMiddle), "trailing junk rejected, not truncated to 12");
// Total garbage yields an empty map, not a throw.
Map junk = Map::Decode("\0\0\xff nonsense \n\n\n");
Check(junk.Size() == 0, "garbage decodes to empty");
Map empty = Map::Decode("");
Check(empty.Size() == 0, "empty input decodes to empty");
// A final line without a newline is still read.
Map noNl = Map::Decode("right-thumb 3");
Check(noNl.Size() == 1, "a missing trailing newline is not a lost entry");
}
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.
2026-10-13 21:55:15 +02:00
// ---- A damaged map is not trusted, and stays that way through a save
{
const char* damaged = "right-thumb 1819675103\nleft-index-finger notanumber\nnonsense\n"
"right-index-finger 0\nright-index 123\n";
Map m = Map::Decode(damaged);
Check(m.Size() == 1, "the one good line is kept");
Check(m.Skipped() == 4 && m.Unparsed().size() == 4,
"the bad number, the line without a space, the fid 0 and the unknown name are kept as text");
Check(Map::Decode("right-thumb 7\n\n").Skipped() == 0, "empty lines are not damage");
Check(Map::Decode("").Skipped() == 0, "an empty map is a clean map");
Check(!m.Trusted(), "a damaged map is not trusted");
Check(Map::Decode("").Trusted(), "an absent or empty map is: no fingers is a true answer");
// The round trip: a save after an enrolment must not launder the damage.
m.Add(Finger::LeftThumb, 42);
Map again = Map::Decode(m.Encode());
Check(!again.Trusted(), "still not trusted after Add + Encode + Decode");
Check(again.Size() == 2 && again.Has(Finger::LeftThumb), "and the new finger was saved");
Check(again.Unparsed().size() == 4 && again.Unparsed()[3] == "right-index 123",
"the lines it could not use come back verbatim");
Check(Map::Decode(Map::Decode(damaged).Encode()).Encode() == Map::Decode(damaged).Encode(),
"a second round trip changes nothing");
// A finger named twice: the second line parses, but the first line's
// fid would be lost in memory while it still stands on disk. It is
// kept as data, so a delete of that finger finds it.
Map twice = Map::Decode("right-thumb 111\nright-thumb 222\n");
Check(twice.Size() == 1 && twice.Lookup(111).has_value(), "the first line wins");
Check(!twice.Trusted() && twice.Shadowed().size() == 1 && twice.Shadowed()[0].fid == 222,
"the second is kept as the finger and fid it names");
Check(twice.Skipped() == 1 && twice.Unparsed().empty(), "and counted, but not as text");
Check(Map::Decode(twice.Encode()).Encode() == "right-thumb 111\nright-thumb 222\n",
"it survives a round trip as the same line");
auto named = twice.FidsNamed(Finger::RightThumb);
Check(named.size() == 2 && named[0] == 111 && named[1] == 222, "a delete of the finger gets both fids");
Check(twice.Remove(Finger::RightThumb), "removing the finger");
Map after = Map::Decode(twice.Encode());
Check(!after.Has(Finger::RightThumb) && after.Trusted() && after.Size() == 0,
"takes the shadowed line with it: nothing comes back under that name");
// A sixth finger shadows the same way, and goes with its finger's delete.
Map six = Map::Decode("left-thumb 1\nleft-index-finger 2\nleft-middle-finger 3\n"
"left-ring-finger 4\nleft-little-finger 5\nright-thumb 6\n");
Check(six.Size() == 5 && six.Full() && !six.Trusted() && six.Shadowed().size() == 1, "the sixth is shadowed");
Check(six.FidsNamed(Finger::RightThumb).size() == 1 && six.FidsNamed(Finger::RightThumb)[0] == 6,
"and its finger's delete gets its fid");
Check(six.AllFids().size() == 6, "every fid the file carries");
// Deleting every finger drops the kept lines too: "every" means every.
m.Clear();
Check(m.Size() == 0 && m.Trusted(), "cleared, and trusted again");
Check(!Map::Unreadable().Trusted(), "a map that could not be read is not trusted");
Check(!Map::Unreadable().Readable() && Map::Decode(damaged).Readable(), "and is the only kind that is not readable");
Check(Map::Unreadable().Size() == 0, "and it names nothing");
}
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.
2026-09-02 17:22:27 +02:00
// ---- Groups
{
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.
2026-10-13 21:55:15 +02:00
Check(!GroupIsManaged(0), "group 0 is never tidied");
Check(GroupIsManaged(10000), "a session user's group is");
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.
2026-09-02 17:22:27 +02:00
Check(GidForUid(1000) == 1000, "gid is the uid");
Check(GidForUid(0) == 0, "root maps too");
Check(GidForUid(4294967295u) == 4294967295u, "the whole uid range maps");
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.
2026-09-02 17:22:27 +02:00
Check(PathForUid("/var/lib/fingerprintd", 1000)
== "/var/lib/fingerprintd/fingers-1000.map", "state path");
}
if (Failures == 0) std::println("Store: all tests passed");
return Failures;
}