Compare commits

..
13 changed files with 94 additions and 614 deletions

View file

@ -11,7 +11,7 @@ name: package
# privileged runner: it runs in an alpine:edge container on the ordinary # privileged runner: it runs in an alpine:edge container on the ordinary
# arch-latest runner. # arch-latest runner.
# #
# Release gating is the version: pkgver comes from interfaces/Fingerprintd.cppm, # Release gating is the version: pkgver comes from implementations/main.cpp,
# the registry answers 409 for an already-published version, and the publish # the registry answers 409 for an already-published version, and the publish
# step treats that as "nothing to do" — so pushes only release when the # step treats that as "nothing to do" — so pushes only release when the
# Version constant bumps. # Version constant bumps.

View file

@ -62,45 +62,6 @@ The `focal64` trustlet is proprietary and is not in this repo. It is
extracted from the device's own stock Android partition on first boot by the extracted from the device's own stock Android partition on first boot by the
`fp6-vendor-blobs` mechanism, the same way other firmware in fp6-img is. `fp6-vendor-blobs` mechanism, the same way other firmware in fp6-img is.
## Releases
Every push to `main` is built and tested by CI, which then publishes the
apks to the Forgejo Alpine registry that installed phones already point at.
CI treats the registry's "already published" answer as nothing to do, so a
push publishes only a package version new to the registry. The package
version is `pkgver-rpkgrel`: `pkgver` is the `Version` constant in
`interfaces/Fingerprintd.cppm`, `pkgrel` is the APKBUILD's and stays at 0.
An installed phone picks a published version up with plain `apk upgrade`.
From 0.2.6 on, a release is a range of commits ending in a bump commit
titled `fingerprintd X.Y.Z` that changes only the version: the constant and
the APKBUILD's `pkgver`. Each release is tagged `vX.Y.Z` on its bump commit
(signed, annotated) with the release notes as the tag message, and
published as a Forgejo release of the same name and text. No version
before 0.2.6 has a tag or release notes. The notes are written for the
people whose phones will upgrade:
```
fingerprintd X.Y.Z
## New
- **What a user can now do.** One bullet per change, the most important
first, with anything the user has to do inside the bullet.
## Fixes
- **What went wrong, in the user's terms.** What they saw, what changes,
what to do.
```
A section with nothing in it is left out, and so is behaviour the release
does not change. Versioning, from 0.2.6 on, is `MAJOR.MINOR.PATCH`: the
patch number counts releases, fixes and features alike; the minor number
moves when a release changes what a user can rely on the daemon for; the
major number moves when a release changes what an installed phone does
without being asked.
## License ## License
GPL-3.0-only, see LICENSE. GPL-3.0-only, see LICENSE.

View file

@ -124,7 +124,7 @@ int main(int argc, char** argv) {
::setvbuf(stdout, nullptr, _IOLBF, 0); ::setvbuf(stdout, nullptr, _IOLBF, 0);
std::span<char*> args(argv, static_cast<std::size_t>(argc)); std::span<char*> args(argv, static_cast<std::size_t>(argc));
for (std::string_view a : args.subspan(1)) { for (std::string_view a : args.subspan(1)) {
if (a == "--version") { std::println("fingerprintd-agent {}", fingerprintd::Version); return 0; } if (a == "--version") { std::println("fingerprintd-agent 0.2.2"); return 0; }
if (a.starts_with("--config=")) g_configPath = a.substr(9); if (a.starts_with("--config=")) g_configPath = a.substr(9);
} }
// Root has a session bus about as often as it has a display. Refusing is // Root has a session bus about as often as it has a display. Refusing is

View file

@ -66,6 +66,10 @@ import Fingerprintd;
namespace { namespace {
// Bumping this is what publishes a package: the registry answers 409 for a
// version it already has, which a build treats as a no-op.
constexpr const char* Version = "0.2.5";
bool g_verbose = false; bool g_verbose = false;
// 500 ms was the research harness's pace, chosen so a human could read the // 500 ms was the research harness's pace, chosen so a human could read the
// transcript scroll by. It is not a design. The matcher rejects the early // transcript scroll by. It is not a design. The matcher rejects the early
@ -1017,8 +1021,6 @@ struct CommandResult {
std::uint32_t gid = 0; std::uint32_t gid = 0;
std::uint32_t fid = 0; std::uint32_t fid = 0;
std::int32_t samplesRemaining = -1; std::int32_t samplesRemaining = -1;
// Only for ENUMERATE: the fids of the templates the group holds.
std::vector<std::uint32_t> fids;
bool Ok() const { return invoked && result == 0 && rc == 0; } bool Ok() const { return invoked && result == 0 && rc == 0; }
}; };
@ -1108,8 +1110,6 @@ CommandResult SendCommand(qcomtee_object* app, fingerprintd::ta::Cmd cmd,
out.fid = ta::MatchedFid(reqOut); out.fid = ta::MatchedFid(reqOut);
out.samplesRemaining = ta::SamplesRemaining(reqOut); out.samplesRemaining = ta::SamplesRemaining(reqOut);
} }
if (cmd == ta::Cmd::Enumerate)
out.fids = ta::EnumeratedFids(reqOut, out.rc);
if (g_taLog) DumpTaLog(rspOut); if (g_taLog) DumpTaLog(rspOut);
return out; return out;
} }
@ -1294,10 +1294,8 @@ public:
// store: an enrolment, or a template removed underneath us. // store: an enrolment, or a template removed underneath us.
// Remove templates from the TRUSTLET, which is what makes a delete a // Remove templates from the TRUSTLET, which is what makes a delete a
// delete. Dropping the name from the map only stops the finger being // delete. Dropping the name from the map only stops the finger being
// offered; the template keeps its slot until the group's next enrolment // offered; the template keeps its slot, and with
// or delete-all removes it as unnamed -- and in group 0, or under a map // enable_duplicated_finger_checking on, that slot is what refuses the
// the daemon does not trust, for good, where with
// enable_duplicated_finger_checking on that slot refuses the
// re-enrolment of the same finger. // re-enrolment of the same finger.
// //
// The group must be active first: the trustlet compares the gid against // The group must be active first: the trustlet compares the gid against
@ -1350,46 +1348,14 @@ public:
std::println("SET_ACTIVE_GROUP gid={}", gid); std::println("SET_ACTIVE_GROUP gid={}", gid);
auto g = SendCommand(app_, ta::Cmd::SetActiveGroup, sag); auto g = SendCommand(app_, ta::Cmd::SetActiveGroup, sag);
Report(ta::Cmd::SetActiveGroup, g); Report(ta::Cmd::SetActiveGroup, g);
// With room for every slot the group can hold, so the reply names the auto e = SendCommand(app_, ta::Cmd::Enumerate, {});
// templates and not only how many there are.
std::vector<std::byte> en(ta::EnumeratePayloadSize);
ta::BuildEnumeratePayload(en, fingerprintd::store::MaxFingersPerGroup);
auto e = SendCommand(app_, ta::Cmd::Enumerate, en);
Report(ta::Cmd::Enumerate, e); Report(ta::Cmd::Enumerate, e);
std::println(" templates loaded: {}{}", e.rc, FidList(e.fids)); std::println(" templates loaded: {}", e.rc);
gid_ = gid; gid_ = gid;
templatesLoaded_ = e.invoked ? e.rc : -1; templatesLoaded_ = e.invoked ? e.rc : -1;
loadedFids_ = e.invoked ? e.fids : std::vector<std::uint32_t>{};
return templatesLoaded_; return templatesLoaded_;
} }
// The loaded templates that none of `named` accounts for. A template
// without a name is offered for nothing -- a verify filters on the map's
// fids, standby arms from them -- but it still holds one of the group's
// slots and, with duplicate-finger checking on, refuses the re-enrolment
// of the finger it was taken from. How they arise: the map lives on the
// rootfs and the templates on the persist partition, so a reflash keeps
// the templates and loses every name (fennek182, 2026-10-13: five of
// them, ENROLL answering -207); and a finger re-enrolled under its old
// name left its old template behind until this release.
std::vector<std::uint32_t> Unnamed(const std::vector<std::uint32_t>& named) const {
std::vector<std::uint32_t> out;
for (std::uint32_t fid : loadedFids_)
if (fid != 0 && !std::ranges::contains(named, fid) && !std::ranges::contains(out, fid))
out.push_back(fid);
return out;
}
const std::vector<std::uint32_t>& LoadedFids() const { return loadedFids_; }
static std::string FidList(const std::vector<std::uint32_t>& fids) {
if (fids.empty()) return {};
std::string s = " (fid";
for (std::size_t i = 0; i < fids.size(); i++)
s += std::format("{}{}", i ? ", " : " ", fids[i]);
return s + ")";
}
// ---- Enrolment // ---- Enrolment
// //
// One sample per PRESS: touch on the rising edge, release on the falling // One sample per PRESS: touch on the rising edge, release on the falling
@ -1403,8 +1369,7 @@ public:
std::string why; std::string why;
}; };
EnrolOutcome Enrol(std::uint32_t gid, const std::vector<std::uint32_t>& named, bool tidy, EnrolOutcome Enrol(std::uint32_t gid, std::atomic<bool>& cancel,
std::atomic<bool>& cancel,
const std::function<void(int, int)>& onStage, const std::function<void(int, int)>& onStage,
const std::function<void()>& onRetry, const std::function<void()>& onRetry,
int maxFrames) { int maxFrames) {
@ -1418,23 +1383,6 @@ public:
} }
// An enrolment adds a template, so this one reloads for real. // An enrolment adds a template, so this one reloads for real.
SetActiveGroup(gid, /*force*/ true); SetActiveGroup(gid, /*force*/ true);
// Templates no name accounts for go before the new one comes: they
// can match nothing, and each one is a slot this enrolment may need
// or the duplicate the trustlet would refuse it for. Not in group 0
// (store::GroupIsManaged), and not on the word of a map the daemon
// could not read in full (`tidy`): a removed template is gone for
// good.
auto stray = Unnamed(named);
if (!stray.empty() && fingerprintd::store::GroupIsManaged(gid)) {
if (tidy) {
std::println("enrol: {} template(s) loaded that no finger name accounts for; "
"removing them first", stray.size());
RemoveTemplates(gid, stray);
} else {
std::println("enrol: {} template(s) loaded that no finger name accounts for, "
"left in place: the map could not be read in full", stray.size());
}
}
// Stock's opening sequence. AUTHENTICATE is what arms the capture // Stock's opening sequence. AUTHENTICATE is what arms the capture
// session; CANCEL and RESET_LOCKOUT bracket it. // session; CANCEL and RESET_LOCKOUT bracket it.
@ -1575,11 +1523,12 @@ public:
}; };
// `accept` is the set of fids that count as a match for THIS request. The // `accept` is the set of fids that count as a match for THIS request. The
// trustlet identifies against every template loaded in the group, so // trustlet identifies against every template loaded in the group, and a
// group accumulates them: re-enrolling a finger does NOT replace its
// template, it adds one, because FF_CMD_TA_REMOVE is not implemented. So
// without this filter a verify answers for fingers the caller did not ask // without this filter a verify answers for fingers the caller did not ask
// about, and for templates no name accounts for (one an enrolment has not // about, and for stale templates no longer named by anything -- which is
// yet removed, or one in group 0) -- which is both wrong by fprintd's // both wrong by fprintd's contract and quietly ruins any measurement.
// contract and quietly ruins any measurement.
VerifyOutcome Verify(std::uint32_t gid, std::atomic<bool>& cancel, int maxFrames, VerifyOutcome Verify(std::uint32_t gid, std::atomic<bool>& cancel, int maxFrames,
const std::vector<std::uint32_t>& accept) { const std::vector<std::uint32_t>& accept) {
namespace ta = fingerprintd::ta; namespace ta = fingerprintd::ta;
@ -1979,7 +1928,6 @@ private:
// What the last real SET_ACTIVE_GROUP reported, so a repeat claim can be // What the last real SET_ACTIVE_GROUP reported, so a repeat claim can be
// answered without making the trustlet re-read the store. -1 = unknown. // answered without making the trustlet re-read the store. -1 = unknown.
int templatesLoaded_ = -1; int templatesLoaded_ = -1;
std::vector<std::uint32_t> loadedFids_;
// Set when a fold succeeded; cleared by the save. // Set when a fold succeeded; cleared by the save.
bool templateDirty_ = false; bool templateDirty_ = false;
// Frames folded during the verify loop itself, carried into the harvest so // Frames folded during the verify loop itself, carried into the harvest so
@ -2000,12 +1948,7 @@ struct Job {
std::string finger; std::string finger;
std::vector<std::uint32_t> acceptFids; // Verify: which fids count std::vector<std::uint32_t> acceptFids; // Verify: which fids count
// Remove: which fids to drop // Remove: which fids to drop
// Claim/Enroll: the fids the map names
GDBusMethodInvocation* invocation = nullptr; // Claim/Remove reply asynchronously GDBusMethodInvocation* invocation = nullptr; // Claim/Remove reply asynchronously
bool everything = false; // Remove: the unnamed templates too
bool tidy = false; // Enroll: the map is trusted (read in
// full), so it may say what is unnamed
std::uint64_t seq = 0; // Enroll/Verify: the operation this job runs
}; };
// Everything the worker sends back to the main thread. Delivered by g_idle_add // Everything the worker sends back to the main thread. Delivered by g_idle_add
@ -2018,8 +1961,6 @@ struct Event {
std::string status; std::string status;
std::uint32_t fid = 0; std::uint32_t fid = 0;
int templates = 0; int templates = 0;
int unnamed = 0; // ClaimDone: of those, how many the map has no name for
std::uint64_t seq = 0; // EnrollStatus/VerifyStatus: the operation it reports on
GDBusMethodInvocation* invocation = nullptr; GDBusMethodInvocation* invocation = nullptr;
}; };
@ -2033,25 +1974,6 @@ void PostEvent(std::unique_ptr<Event> ev); // defined with the D-Bus code
std::atomic<bool> g_standbyWanted{false}; std::atomic<bool> g_standbyWanted{false};
std::atomic<bool> g_standbyRunning{false}; std::atomic<bool> g_standbyRunning{false};
// Which client operation the worker's jobs and events belong to. EnrollStart
// and VerifyStart give the claim a fresh number, the job and every event it
// posts carry it, 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 (the cancel flag is reset at dequeue, so it alone
// would not stop it), and a result that arrives under one is not the
// current operation's: it is neither named nor reported. 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. The main thread
// owns the counter; the worker reads the live number from the atomic.
//
// The same handshake as standby's `wanted`/`running`: a stop clears the
// number BEFORE it cancels, and the worker resets its cancel flag BEFORE it
// reads the number, so whichever side goes second sees the other -- a job
// dequeued around a stop either reads the cleared number and exits, or
// keeps the cancel that comes after its reset.
std::uint64_t g_opSeqCounter = 0;
std::atomic<std::uint64_t> g_liveOpSeq{0};
class Worker { class Worker {
public: public:
void Start() { void Start() {
@ -2115,36 +2037,14 @@ private:
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::ClaimDone }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::ClaimDone });
ev->ok = n >= 0; ev->ok = n >= 0;
ev->templates = n; ev->templates = n;
ev->unnamed = static_cast<int>(session_.Unnamed(j.acceptFids).size());
ev->invocation = j.invocation; ev->invocation = j.invocation;
PostEvent(std::move(ev)); PostEvent(std::move(ev));
break; break;
} }
case Job::Kind::Remove: { case Job::Kind::Remove: {
std::vector<std::uint32_t> fids = j.acceptFids; int n = session_.RemoveTemplates(j.uid, j.acceptFids);
// "Every finger" is every template in the group, the ones no
// name accounts for included: after a reflash the map is empty
// and they are all this user has to delete. On the user's
// word, not the map's trust -- nothing is being kept on the
// map's account here. Group 0 is left alone, as in Enrol.
// Reloaded, not cached: the cached list can lack a template
// enrolled since, and "every finger" means the group as it is.
// A group that would not reload is a failure, not a delete
// of nothing: the client must not hear "done" while the
// templates stay.
bool loaded = true;
if (j.everything && fingerprintd::store::GroupIsManaged(j.uid)) {
if (session_.SetActiveGroup(j.uid, /*force*/ true) >= 0)
for (std::uint32_t fid : session_.Unnamed(fids)) fids.push_back(fid);
else
loaded = false;
}
int n = loaded ? session_.RemoveTemplates(j.uid, fids) : -1;
if (loaded)
std::println("remove: {} of {} template(s) removed for uid {}", std::println("remove: {} of {} template(s) removed for uid {}",
n < 0 ? 0 : n, fids.size(), j.uid); n < 0 ? 0 : n, j.acceptFids.size(), j.uid);
else
std::println(std::cerr, "remove: group {} would not load, nothing removed", j.uid);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::RemoveDone }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::RemoveDone });
ev->ok = n >= 0; ev->ok = n >= 0;
ev->templates = n; ev->templates = n;
@ -2153,16 +2053,8 @@ private:
break; break;
} }
case Job::Kind::Enroll: { case Job::Kind::Enroll: {
if (j.seq != g_liveOpSeq.load()) {
// Stopped while it was queued.
std::println("enrol: operation {} was stopped before it started", j.seq);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
ev->seq = j.seq; ev->status = ""; ev->done = false;
PostEvent(std::move(ev));
break;
}
auto o = session_.Enrol( auto o = session_.Enrol(
j.uid, j.acceptFids, j.tidy, cancel_, j.uid, cancel_,
[&](int accepted, int total) { [&](int accepted, int total) {
// Logged as well as signalled. A run whose refusals // Logged as well as signalled. A run whose refusals
// exist only as D-Bus traffic cannot be counted from // exist only as D-Bus traffic cannot be counted from
@ -2171,20 +2063,17 @@ private:
// refused nothing", which was wrong. // refused nothing", which was wrong.
std::println(" enrol: sample ACCEPTED ({}/{})", accepted, total); std::println(" enrol: sample ACCEPTED ({}/{})", accepted, total);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
ev->seq = j.seq;
ev->status = "enroll-stage-passed"; ev->status = "enroll-stage-passed";
PostEvent(std::move(ev)); PostEvent(std::move(ev));
}, },
[&] { [&] {
std::println(" enrol: sample REFUSED (retry-scan)"); std::println(" enrol: sample REFUSED (retry-scan)");
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
ev->seq = j.seq;
ev->status = "enroll-retry-scan"; ev->status = "enroll-retry-scan";
PostEvent(std::move(ev)); PostEvent(std::move(ev));
}, },
/*maxFrames*/ 600); /*maxFrames*/ 600);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
ev->seq = j.seq;
ev->done = true; ev->done = true;
ev->ok = o.saved; ev->ok = o.saved;
ev->fid = o.fid; ev->fid = o.fid;
@ -2247,17 +2136,8 @@ private:
// timeout, and a claimant that vanishes from the bus has its // timeout, and a claimant that vanishes from the bus has its
// claim dropped -- and by VerifyStop, which is what sets // claim dropped -- and by VerifyStop, which is what sets
// cancel_. See the idle-wait note in Verify(). // cancel_. See the idle-wait note in Verify().
if (j.seq != g_liveOpSeq.load()) {
// Stopped while it was queued; without this it would poll
// until someone pressed.
std::println("verify: operation {} was stopped before it started", j.seq);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::VerifyStatus });
ev->seq = j.seq; ev->status = ""; ev->done = false;
PostEvent(std::move(ev));
break;
}
auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids);
while (!o.decided && !o.cancelled && o.presses == 0 && j.seq == g_liveOpSeq.load()) { while (!o.decided && !o.cancelled && o.presses == 0) {
std::println("verify: {} frame(s), sensor never touched -- still waiting", std::println("verify: {} frame(s), sensor never touched -- still waiting",
o.frames); o.frames);
o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids); o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids);
@ -2266,7 +2146,6 @@ private:
o.cancelled ? "cancelled" : !o.decided ? "undecided" o.cancelled ? "cancelled" : !o.decided ? "undecided"
: o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames); : o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames);
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::VerifyStatus }); auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::VerifyStatus });
ev->seq = j.seq;
ev->done = true; ev->done = true;
ev->fid = o.fid; ev->fid = o.fid;
if (o.cancelled) { ev->status = ""; ev->done = false; } if (o.cancelled) { ev->status = ""; ev->done = false; }
@ -2442,7 +2321,6 @@ struct Claim {
std::uint32_t uid = 0; std::uint32_t uid = 0;
Op op = Op::None; Op op = Op::None;
std::string finger; // for the op in progress std::string finger; // for the op in progress
std::uint64_t opSeq = 0; // the op in progress, 0 = none (see g_liveOpSeq)
fingerprintd::store::Map fingers; fingerprintd::store::Map fingers;
}; };
Claim g_claim; Claim g_claim;
@ -2457,7 +2335,6 @@ void DropClaim(const char* why) {
g_dbus_connection_signal_unsubscribe(g_conn, g_claimWatch); g_dbus_connection_signal_unsubscribe(g_conn, g_claimWatch);
g_claimWatch = 0; g_claimWatch = 0;
} }
g_liveOpSeq.store(0); // before the cancel: see g_liveOpSeq
if (g_claim.op != Op::None && g_worker) g_worker->CancelOp(); if (g_claim.op != Op::None && g_worker) g_worker->CancelOp();
if (g_claim.held) if (g_claim.held)
std::println("claim by {} for {} dropped: {}", g_claim.sender, g_claim.user, why); std::println("claim by {} for {} dropped: {}", g_claim.sender, g_claim.user, why);
@ -2491,62 +2368,28 @@ std::string MapPath(std::uint32_t uid) {
fingerprintd::store::Map LoadMap(std::uint32_t uid, bool quiet = false) { fingerprintd::store::Map LoadMap(std::uint32_t uid, bool quiet = false) {
std::string path = MapPath(uid); std::string path = MapPath(uid);
// Absent is "no fingers" and the map that says so is trusted. Any other std::ifstream f(path);
// failure, at the open or during the read, is a map the daemon cannot if (!f) {
// see: it must not decide which templates are unnamed on that account, if (!quiet) std::println("map {}: {}", path, ::strerror(errno));
// and it must never be written over. GLib's read reports a failed read return {};
// as an error, where a stream would hand back what it got so far and
// make an EIO look like a short, clean map; and it tells an absent file
// from every other failure, which is the one distinction this needs.
gchar* contents = nullptr;
gsize len = 0;
GError* err = nullptr;
if (!g_file_get_contents(path.c_str(), &contents, &len, &err)) {
bool absent = g_error_matches(err, G_FILE_ERROR, G_FILE_ERROR_NOENT);
if (!quiet) std::println("map {}: {}", path, err ? err->message : "unknown error");
g_clear_error(&err);
return absent ? fingerprintd::store::Map{} : fingerprintd::store::Map::Unreadable();
} }
std::string text(contents, len); std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
g_free(contents);
auto m = fingerprintd::store::Map::Decode(text); auto m = fingerprintd::store::Map::Decode(text);
if (!quiet) if (!quiet) std::println("map {}: {} bytes, {} finger(s)", path, text.size(), m.Size());
std::println("map {}: {} bytes, {} finger(s){}", path, text.size(), m.Size(),
m.Skipped() ? std::format(", {} line(s) not understood, kept as they are",
m.Skipped())
: std::string());
return m; return m;
} }
// Writes the map and says whether it landed. GLib writes a temp file in the void SaveMap(std::uint32_t uid, const fingerprintd::store::Map& m) {
// same directory, fsyncs it, renames it over the old file and fsyncs the
// directory, so a crash leaves either the old map or the new one, never an
// empty file that would read as a trusted map naming nothing. The map
// decides which templates the next enrolment keeps, so a save that did not
// reach the disk leaves the old map in place rather than replace it with a
// shorter one, and the caller hears about it.
bool SaveMap(std::uint32_t uid, const fingerprintd::store::Map& m) {
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(g_stateDir, ec); std::filesystem::create_directories(g_stateDir, ec);
std::string path = MapPath(uid); std::string path = MapPath(uid);
// A map the daemon could not read is not one it may replace: the file std::string tmp = path + ".tmp";
// may name fingers this object does not. {
if (!m.Readable()) { std::ofstream f(tmp, std::ios::trunc);
std::println(std::cerr, "map {}: not written: it could not be read, and the file stays as it is", path); f << m.Encode();
return false;
} }
std::string enc = m.Encode(); ::chmod(tmp.c_str(), 0600);
GError* err = nullptr; std::filesystem::rename(tmp, path, ec);
if (!g_file_set_contents_full(path.c_str(), enc.data(), static_cast<gssize>(enc.size()),
static_cast<GFileSetContentsFlags>(G_FILE_SET_CONTENTS_CONSISTENT
| G_FILE_SET_CONTENTS_DURABLE),
0600, &err)) {
std::println(std::cerr, "map {}: not written ({}); the old map stays", path,
err ? err->message : "unknown error");
g_clear_error(&err);
return false;
}
return true;
} }
void EmitDevice(const char* signal, GVariant* params) { void EmitDevice(const char* signal, GVariant* params) {
@ -2910,10 +2753,8 @@ void PostEvent(std::unique_ptr<Event> ev) {
case Event::Kind::ClaimDone: case Event::Kind::ClaimDone:
if (ev->invocation) { if (ev->invocation) {
if (ev->ok) { if (ev->ok) {
std::println("claimed by {} for {} (uid {}, {} template(s) loaded{})", std::println("claimed by {} for {} (uid {}, {} template(s) loaded)",
g_claim.sender, g_claim.user, g_claim.uid, ev->templates, g_claim.sender, g_claim.user, g_claim.uid, ev->templates);
ev->unnamed ? std::format(", {} of them unnamed", ev->unnamed)
: std::string());
g_dbus_method_invocation_return_value(ev->invocation, nullptr); g_dbus_method_invocation_return_value(ev->invocation, nullptr);
} else { } else {
DropClaim("group selection failed"); DropClaim("group selection failed");
@ -2932,59 +2773,32 @@ void PostEvent(std::unique_ptr<Event> ev) {
} else { } else {
// The names are already gone from the map by this point. // The names are already gone from the map by this point.
// Saying so is better than a bare failure: the fingers // Saying so is better than a bare failure: the fingers
// will not be offered again, and their templates, now // will not be offered again, but their templates still
// unnamed, go with the group's next enrolment or // hold slots, which is what will refuse a re-enrolment.
// delete-all.
ReturnError(ev->invocation, "Internal", ReturnError(ev->invocation, "Internal",
"names removed, but the trustlet templates were not"); "names removed, but the trustlet templates were not");
} }
} }
break; break;
case Event::Kind::EnrollStatus: { case Event::Kind::EnrollStatus:
if (ev->seq != g_claim.opSeq) { if (!ev->status.empty())
// From an enrolment the client has since stopped, or a claim EmitDevice("EnrollStatus", g_variant_new("(sb)", ev->status.c_str(), ev->done ? TRUE : FALSE));
// since dropped: not this operation's result. A template it
// saved is unnamed and goes with the group's next enrolment.
if (ev->done && ev->ok && ev->fid != 0)
std::println("enrol: a result of a stopped enrolment arrived (fid {}); it stays unnamed",
ev->fid);
break;
}
std::string status = ev->status;
if (ev->done && ev->ok) { if (ev->done && ev->ok) {
auto f = fingerprintd::store::FingerFromName(g_claim.finger); auto f = fingerprintd::store::FingerFromName(g_claim.finger);
if (f && ev->fid != 0) { if (f && ev->fid != 0) {
// The name is saved BEFORE the client hears "completed". A
// save that did not land makes this an enrolment that
// failed, with the claim's map put back -- not a success
// whose finger is missing from the next list and never
// offered.
auto kept = g_claim.fingers;
g_claim.fingers.Add(*f, ev->fid); g_claim.fingers.Add(*f, ev->fid);
if (SaveMap(g_claim.uid, g_claim.fingers)) { SaveMap(g_claim.uid, g_claim.fingers);
std::println("enrolled {} for uid {} as fid {}", g_claim.finger, g_claim.uid, ev->fid); std::println("enrolled {} for uid {} as fid {}", g_claim.finger, g_claim.uid, ev->fid);
} else {
g_claim.fingers = kept;
status = "enroll-failed";
std::println(std::cerr,
"enrolled {} for uid {} as fid {}, but its name could not be saved: "
"reported as failed; the template is unnamed and goes with the group's "
"next enrolment", g_claim.finger, g_claim.uid, ev->fid);
}
} else { } else {
std::println(std::cerr, std::println(std::cerr,
"enrolled, but the trustlet reported no fid -- the finger cannot be " "enrolled, but the trustlet reported no fid -- the finger cannot be "
"named until it is seen in a verification"); "named until it is seen in a verification");
} }
} }
if (!status.empty())
EmitDevice("EnrollStatus", g_variant_new("(sb)", status.c_str(), ev->done ? TRUE : FALSE));
break; break;
}
case Event::Kind::VerifyStatus: { case Event::Kind::VerifyStatus: {
namespace ac = fingerprintd::actions; namespace ac = fingerprintd::actions;
namespace store = fingerprintd::store; namespace store = fingerprintd::store;
if (ev->seq != g_claim.opSeq) break; // a verify the client has since stopped
std::string status = ev->status; std::string status = ev->status;
const ac::Rule* rule = nullptr; const ac::Rule* rule = nullptr;
std::optional<store::Finger> matched; std::optional<store::Finger> matched;
@ -3080,8 +2894,7 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
g_pendingClaim = inv; g_pendingClaim = inv;
WatchClaimant(g_claim.sender); WatchClaimant(g_claim.sender);
StandbyCancel(); // the client's op comes first; standby returns with Release StandbyCancel(); // the client's op comes first; standby returns with Release
g_worker->Post(Job{ .kind = Job::Kind::Claim, .uid = g_claim.uid, g_worker->Post(Job{ .kind = Job::Kind::Claim, .uid = g_claim.uid, .invocation = inv });
.acceptFids = g_claim.fingers.AllFids(), .invocation = inv });
return; return;
} }
if (method == "Release") { if (method == "Release") {
@ -3134,31 +2947,13 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
} }
} }
auto m = LoadMap(uid); auto m = LoadMap(uid);
if (!m.Readable()) {
ReturnError(inv, "Internal", "the finger map could not be read; nothing is removed until it can be");
return;
}
// An operation in progress for this user has the claim's own copy of
// the map and a job on the worker whose keep-list came from it; a
// delete underneath would remove what that job is about to name.
if (g_claim.held && g_claim.uid == uid && g_claim.op != Op::None) {
ReturnError(inv, "AlreadyInUse", "an operation is in progress for this user");
return;
}
// The fids have to be read BEFORE the names go: the map is the only // The fids have to be read BEFORE the names go: the map is the only
// place that remembers which template belongs to which finger. // place that remembers which template belongs to which finger.
std::vector<std::uint32_t> fids = one ? m.FidsNamed(*one) : m.AllFids(); std::vector<std::uint32_t> fids;
// Deleting every finger also deletes the templates the map has no for (const auto& e : m.Entries())
// name for, which is why the job is posted even with no fids: after if (!one || e.finger == *one) fids.push_back(e.fid);
// a reflash the map is empty and the trustlet is full.
bool everything = !one;
if (one) m.Remove(*one); else m.Clear(); if (one) m.Remove(*one); else m.Clear();
if (!SaveMap(uid, m)) { SaveMap(uid, m);
// The disk still names them; removing their templates now would
// leave names that can never match.
ReturnError(inv, "Internal", "the finger map could not be written; nothing is removed");
return;
}
if (g_claim.held && g_claim.uid == uid) g_claim.fingers = m; if (g_claim.held && g_claim.uid == uid) g_claim.fingers = m;
// A standby verify for this user holds the fids it was armed with and // A standby verify for this user holds the fids it was armed with and
// keeps the worker busy; stop it so the removal below runs, and so it // keeps the worker busy; stop it so the removal below runs, and so it
@ -3171,16 +2966,16 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
// purpose: a finger whose template survives a failed removal is a slot // purpose: a finger whose template survives a failed removal is a slot
// leak, while a name that survives a successful one would keep // leak, while a name that survives a successful one would keep
// offering a finger that can no longer match. // offering a finger that can no longer match.
std::println("deleting finger name(s) for uid {}; removing {} named trustlet template(s){}", std::println("deleting finger name(s) for uid {}; removing {} trustlet template(s)",
uid, fids.size(), everything ? " and every unnamed one" : ""); uid, fids.size());
if (fids.empty() && !everything) { if (fids.empty()) {
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
// Only the worker thread ever invokes the trustlet, so the reply waits // Only the worker thread ever invokes the trustlet, so the reply waits
// for it -- fprintd's Delete methods are synchronous to the client. // for it -- fprintd's Delete methods are synchronous to the client.
g_worker->Post(Job{ .kind = Job::Kind::Remove, .uid = uid, g_worker->Post(Job{ .kind = Job::Kind::Remove, .uid = uid,
.acceptFids = fids, .invocation = inv, .everything = everything }); .acceptFids = fids, .invocation = inv });
return; return;
} }
if (method == "EnrollStart" || method == "VerifyStart") { if (method == "EnrollStart" || method == "VerifyStart") {
@ -3201,51 +2996,11 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
ReturnError(inv, "InvalidFingername", "unknown finger"); ReturnError(inv, "InvalidFingername", "unknown finger");
return; return;
} }
// Nothing changes for an enrolment the store cannot take: the
// worker would refuse it after the name below was already gone.
if (g_sfsReadOnly || !g_rpmbWrite) {
ReturnError(inv, "Internal", "the template store is read-only; nothing is enrolled");
return;
}
// The claim's copy is re-read from disk: the file may have been
// rebuilt by hand since the claim, and what it says now is what
// decides this enrolment.
g_claim.fingers = LoadMap(g_claim.uid);
// An enrolment ends in a save, and a map the daemon could not
// read is one it must not write over.
if (!g_claim.fingers.Readable()) {
ReturnError(inv, "Internal", "the finger map could not be read; nothing is enrolled until it can be");
return;
}
if (g_claim.fingers.Full() && !g_claim.fingers.Has(*store::FingerFromName(finger))) { if (g_claim.fingers.Full() && !g_claim.fingers.Has(*store::FingerFromName(finger))) {
EmitDevice("EnrollStatus", g_variant_new("(sb)", "enroll-data-full", TRUE)); EmitDevice("EnrollStatus", g_variant_new("(sb)", "enroll-data-full", TRUE));
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
// Re-enrolling a finger replaces it, and the trustlet makes that
// an order: with duplicate-finger 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
// name goes now and the template follows on the
// worker, as one no name accounts for -- a name kept through a
// cancelled enrolment would offer a finger that can no longer
// match. Both halves under the one rule: a managed group, a map
// the daemon trusts. Otherwise the name stays and the trustlet
// refuses the duplicate, as it did before this release.
auto f = *store::FingerFromName(finger);
bool tidy = g_claim.fingers.Trusted();
if (store::GroupIsManaged(g_claim.uid) && tidy && g_claim.fingers.Has(f)) {
auto kept = g_claim.fingers;
g_claim.fingers.Remove(f);
if (!SaveMap(g_claim.uid, g_claim.fingers)) {
// The drop did not land: the disk still names the template
// the worker would remove. Keep both, and say so.
g_claim.fingers = kept;
ReturnError(inv, "Internal", "the finger map could not be written; nothing is enrolled or removed");
return;
}
std::println("enrol: {} is being re-enrolled; its old template goes first", finger);
}
} else { } else {
if (finger != store::AnyFinger && !store::FingerFromName(finger)) { if (finger != store::AnyFinger && !store::FingerFromName(finger)) {
ReturnError(inv, "InvalidFingername", "unknown finger"); ReturnError(inv, "InvalidFingername", "unknown finger");
@ -3258,8 +3013,6 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
} }
g_claim.op = enroll ? Op::Enroll : Op::Verify; g_claim.op = enroll ? Op::Enroll : Op::Verify;
g_claim.finger = finger; g_claim.finger = finger;
g_claim.opSeq = ++g_opSeqCounter;
g_liveOpSeq.store(g_claim.opSeq);
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
if (!enroll) { if (!enroll) {
// The trustlet identifies against every template in the group, so // The trustlet identifies against every template in the group, so
@ -3272,11 +3025,7 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
// Which templates may answer this request: the named finger's fid, or // Which templates may answer this request: the named finger's fid, or
// every named finger's for "any". A fid that no name maps to -- a // every named finger's for "any". A fid that no name maps to -- a
// template left behind by an earlier enrolment -- answers for nothing. // template left behind by an earlier enrolment -- answers for nothing.
// For an enrolment the same list says which templates to KEEP: every
// fid the map carries, the shadowed lines' included -- a template the
// map names is never "unnamed", whatever the trust gate then decides.
std::vector<std::uint32_t> accept; std::vector<std::uint32_t> accept;
if (enroll) accept = g_claim.fingers.AllFids();
if (!enroll) { if (!enroll) {
for (const auto& e : g_claim.fingers.Entries()) for (const auto& e : g_claim.fingers.Entries())
if (finger == store::AnyFinger || store::NameOf(e.finger) == finger) if (finger == store::AnyFinger || store::NameOf(e.finger) == finger)
@ -3288,8 +3037,7 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
} }
g_worker->Post(Job{ .kind = enroll ? Job::Kind::Enroll : Job::Kind::Verify, g_worker->Post(Job{ .kind = enroll ? Job::Kind::Enroll : Job::Kind::Verify,
.uid = g_claim.uid, .finger = finger, .uid = g_claim.uid, .finger = finger,
.acceptFids = std::move(accept), .acceptFids = std::move(accept) });
.tidy = g_claim.fingers.Trusted(), .seq = g_claim.opSeq });
return; return;
} }
if (method == "EnrollStop" || method == "VerifyStop") { if (method == "EnrollStop" || method == "VerifyStop") {
@ -3307,12 +3055,9 @@ void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant*
// it, and clearing the op ourselves on `done` made every one of them // it, and clearing the op ourselves on `done` made every one of them
// fail with NoActionInProgress. Cancelling a loop that already ended // fail with NoActionInProgress. Cancelling a loop that already ended
// is harmless. // is harmless.
// The number is cleared before the cancel: see g_liveOpSeq. g_worker->CancelOp();
g_claim.op = Op::None; g_claim.op = Op::None;
g_claim.finger.clear(); g_claim.finger.clear();
g_claim.opSeq = 0;
g_liveOpSeq.store(0);
g_worker->CancelOp();
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
@ -3379,7 +3124,7 @@ int RunDaemon() {
// The session comes up on the worker; the bus answers "still starting" // The session comes up on the worker; the bus answers "still starting"
// until it posts Ready. // until it posts Ready.
worker.Start(); worker.Start();
std::println("fingerprintd {} starting on the system bus", fingerprintd::Version); std::println("fingerprintd {} starting on the system bus", Version);
g_main_loop_run(g_loop); g_main_loop_run(g_loop);
worker.Quit(); worker.Quit();
@ -3462,9 +3207,7 @@ int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, bool doLearnProbe,
std::atomic<bool> cancel{false}; std::atomic<bool> cancel{false};
if (doEnrol) { if (doEnrol) {
for (int c = 3; c > 0; c--) { std::println("*** press and LIFT, repeatedly, in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); } for (int c = 3; c > 0; c--) { std::println("*** press and LIFT, repeatedly, in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); }
// The probe has no name map, so every loaded template counts as auto o = s.Enrol(gid, cancel,
// named: a diagnostic does not tidy the group it is probing.
auto o = s.Enrol(gid, s.LoadedFids(), /*tidy*/ false, cancel,
[](int a, int t) { std::println(" [{}/{}] accepted -- LIFT, then press again", a, t); }, [](int a, int t) { std::println(" [{}/{}] accepted -- LIFT, then press again", a, t); },
[] { std::println(" press rejected -- LIFT, shift the finger, press again"); }, [] { std::println(" press rejected -- LIFT, shift the finger, press again"); },
frames); frames);
@ -3542,7 +3285,7 @@ int main(int argc, char** argv) {
std::uint32_t gid = 0; std::uint32_t gid = 0;
int frames = 120; int frames = 120;
for (std::string_view a : args.subspan(1)) { for (std::string_view a : args.subspan(1)) {
if (a == "--version") { std::println("fingerprintd {}", fingerprintd::Version); return 0; } if (a == "--version") { std::println("fingerprintd {}", Version); return 0; }
if (a == "--daemon") daemon = true; if (a == "--daemon") daemon = true;
if (a == "--probe-tee") probe = true; if (a == "--probe-tee") probe = true;
if (a.starts_with("--ta=")) g_taPath = a.substr(5); if (a.starts_with("--ta=")) g_taPath = a.substr(5);
@ -3607,6 +3350,6 @@ int main(int argc, char** argv) {
" --standby-unlock=on|off unlock the locked session in front by finger,\n" " --standby-unlock=on|off unlock the locked session in front by finger,\n"
" via logind (default on)\n" " via logind (default on)\n"
" --sfs-root=DIR --sfs-writable --rpmb-write storage policy", " --sfs-root=DIR --sfs-writable --rpmb-write storage policy",
fingerprintd::Version); Version);
return 1; return 1;
} }

View file

@ -79,13 +79,6 @@ export namespace fingerprintd::store {
using Gid = std::uint32_t; using Gid = std::uint32_t;
inline Gid GidForUid(std::uint32_t uid) { return uid; } 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. // common.max_enrolling_fingers, from the trustlet config we ship.
inline constexpr std::size_t MaxFingersPerGroup = 5; inline constexpr std::size_t MaxFingersPerGroup = 5;
@ -111,36 +104,11 @@ export namespace fingerprintd::store {
return true; 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) { bool Remove(Finger f) {
auto n = std::erase_if(entries_, [&](const Entry& e) { return e.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; return n > 0;
} }
// "Every finger" includes the ones in lines the daemon could not void Clear() { entries_.clear(); }
// 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. // A match reports an id; this turns it back into a name.
std::optional<Finger> Lookup(std::uint32_t fid) const { std::optional<Finger> Lookup(std::uint32_t fid) const {
@ -159,45 +127,19 @@ export namespace fingerprintd::store {
// ---- Serialisation // ---- Serialisation
// //
// One "name fid" per line. Deliberately boring and greppable, because // One "name fid" per line. Deliberately boring and greppable: this
// this file can be rebuilt by hand: the claim log prints the fids the // file is recoverable by hand if it is ever lost, because losing it
// trustlet holds ("templates loaded: N (fid ...)"). It is worth // costs names, not templates.
// 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 Encode() const {
std::string out; std::string out;
for (const Entry& e : entries_) for (const Entry& e : entries_)
out += std::format("{} {}\n", NameOf(e.finger), e.fid); 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; return out;
} }
// A non-empty line that does not become its own entry is kept and // Unknown finger names and malformed lines are skipped, not fatal: a
// counted, not dropped and not fatal: a damaged map must degrade to // corrupt map must degrade to "fewer names known", never to a daemon
// "fewer names known", never to a daemon that will not start and // that will not start and therefore a phone that cannot be unlocked.
// 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) { static Map Decode(std::string_view text) {
Map m; Map m;
std::size_t pos = 0; std::size_t pos = 0;
@ -207,52 +149,21 @@ export namespace fingerprintd::store {
? std::string_view::npos : nl - pos); ? std::string_view::npos : nl - pos);
pos = (nl == std::string_view::npos) ? text.size() + 1 : nl + 1; pos = (nl == std::string_view::npos) ? text.size() + 1 : nl + 1;
if (line.empty()) continue; if (line.empty()) continue;
if (!m.Parse(line)) m.unparsed_.emplace_back(line); 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; 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: 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> 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 // Per-user file under the daemon's state directory. Root-only: it is not

View file

@ -370,25 +370,6 @@ export namespace fingerprintd::ta {
detail::StoreU32(out, RemoveFidOff, fid); detail::StoreU32(out, RemoveFidOff, fid);
} }
// ENUMERATE (0x2005). One u32 at payload+0: how many entries the caller
// has room for, 0 to ask for the count alone -- which is what an empty
// payload asked since 2026-09-02, so until now the daemon knew how many
// templates a group held and never which. The reply's rc is the count,
// and with room given the payload from +4 carries one 8-byte entry per
// loaded template whose second word is the fid: asked with room for 5
// on a group of 4, the three fids the map named came back in it
// (2026-10-13). The first word is not read. Decoded by EnumeratedFids
// below.
inline constexpr std::size_t EnumeratePayloadSize = 0x04;
inline constexpr std::size_t EnumerateEntriesOff = 0x04;
inline constexpr std::size_t EnumerateEntrySize = 0x08;
inline constexpr std::size_t EnumerateEntryFidOff = 0x04;
inline void BuildEnumeratePayload(std::span<std::byte> out, std::uint32_t capacity) {
std::ranges::fill(out.first(EnumeratePayloadSize), std::byte{0});
detail::StoreU32(out, 0, capacity);
}
// SET_ACTIVE_GROUP writes its gid to device+0x30 and AUTHENTICATE compares // SET_ACTIVE_GROUP writes its gid to device+0x30 and AUTHENTICATE compares
// its own against the same field (0xeb08), logging // its own against the same field (0xeb08), logging
// "templates with gid(%u != %u) hasn't been loaded." and returning -200 on // "templates with gid(%u != %u) hasn't been loaded." and returning -200 on
@ -490,21 +471,6 @@ export namespace fingerprintd::ta {
return detail::LoadU32(response, ResponsePayloadOff + RespFidOff); return detail::LoadU32(response, ResponsePayloadOff + RespFidOff);
} }
// The fids an ENUMERATE reply lists, given the count it reported. Bounded
// by the buffer, not by trust in the count; an entry the trustlet did not
// write reads as fid 0, which nothing downstream acts on.
inline std::vector<std::uint32_t> EnumeratedFids(std::span<const std::byte> response,
std::int32_t count) {
std::vector<std::uint32_t> fids;
if (count <= 0) return fids;
for (std::size_t i = 0; i < static_cast<std::size_t>(count); i++) {
std::size_t off = ResponsePayloadOff + EnumerateEntriesOff + i * EnumerateEntrySize;
if (off + EnumerateEntrySize > response.size()) break;
fids.push_back(detail::LoadU32(response, off + EnumerateEntryFidOff));
}
return fids;
}
// ---- The verdict ------------------------------------------------------ // ---- The verdict ------------------------------------------------------
// //
// A frame is one of three things and only the third is a verdict. This was // A frame is one of three things and only the third is a verdict. This was

View file

@ -12,7 +12,6 @@ the normal world, and reports the matched finger id.
*/ */
export module Fingerprintd; export module Fingerprintd;
import std;
export import :Sfs; export import :Sfs;
export import :Rpmb; export import :Rpmb;
export import :Ta; export import :Ta;
@ -21,13 +20,3 @@ export import :Store;
export import :Tee; export import :Tee;
export import :Sensor; export import :Sensor;
export import :Actions; export import :Actions;
export namespace fingerprintd {
// The release version. The daemon and the agent print it for --version,
// and packaging/build-package.sh and packaging/make-bin-tarball.sh read
// this line for pkgver, so a bump changes it here and in the APKBUILD and
// nowhere else. Bumping it is what publishes a package: the registry
// answers 409 for a version it already has, which a build treats as a
// no-op.
inline constexpr std::string_view Version = "0.2.6";
}

View file

@ -4,13 +4,13 @@
# Binary packaging: wraps a crafter-build binary cross-compiled per the # Binary packaging: wraps a crafter-build binary cross-compiled per the
# README's "Cross-compiling" section into a proper apk — used by this repo's # README's "Cross-compiling" section into a proper apk — used by this repo's
# package CI (packaging/build-package.sh, which seds pkgver from # package CI (packaging/build-package.sh, which seds pkgver from
# interfaces/Fingerprintd.cppm) and runnable by hand. The source tarball is # implementations/main.cpp) and runnable by hand. The source tarball is
# produced by packaging/make-bin-tarball.sh. There is deliberately no # produced by packaging/make-bin-tarball.sh. There is deliberately no
# source-building aport: the build driver is crafter-build, which is not in # source-building aport: the build driver is crafter-build, which is not in
# Alpine, so an APKBUILD that compiled from source could not be built by # Alpine, so an APKBUILD that compiled from source could not be built by
# anyone but us either. # anyone but us either.
pkgname=fingerprintd pkgname=fingerprintd
pkgver=0.2.6 pkgver=0.2.5
pkgrel=0 pkgrel=0
pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)" pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)"
url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd" url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd"

View file

@ -54,10 +54,10 @@ retry() { # retry <description> <cmd...>
return 1 return 1
} }
# interfaces/Fingerprintd.cppm is the version's single source of truth (same # implementations/main.cpp is the version's single source of truth (same
# derivation as make-bin-tarball.sh) # derivation as make-bin-tarball.sh)
VER=$(sed -n 's/.*Version = "\(.*\)".*/\1/p' "$SRC/interfaces/Fingerprintd.cppm") VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp")
[ -n "$VER" ] || { echo "cannot read Version from interfaces/Fingerprintd.cppm" >&2; exit 1; } [ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; }
echo ">> packaging fingerprintd $VER" echo ">> packaging fingerprintd $VER"
# --- crafter-build: static launcher from the rolling release # --- crafter-build: static launcher from the rolling release

View file

@ -27,11 +27,9 @@
# WIPE FIRST when re-enrolling a finger that is already in the group. Measured # WIPE FIRST when re-enrolling a finger that is already in the group. Measured
# 2026-09-04: the duplicated-finger check refuses a finger the group already # 2026-09-04: the duplicated-finger check refuses a finger the group already
# holds -- 0 accepted of 7 presses, rc=0 on every one, while a never-enrolled # holds -- 0 accepted of 7 presses, rc=0 on every one, while a never-enrolled
# finger progressed normally. Since 0.2.6 the daemon removes a re-enrolled # finger progressed normally. Re-enrolment ADDS a template and there is no
# finger's old template itself before ENROLL (in a managed group, on a map it # trustlet-side remove, so the old one has to go first. `enrol` refuses a
# read in full). This protocol still starts from a wiped group, so that the # finger name that is already in the map and points here.
# template it measures has no history: `enrol` refuses a finger name that is
# already in the map and points here.
# #
# WHY IT IS SHAPED LIKE THIS. Learning is cumulative: every matched press folds # WHY IT IS SHAPED LIKE THIS. Learning is cumulative: every matched press folds
# frames into the stored template, so a second run is not a repeat of the first # frames into the stored template, so a second run is not a repeat of the first
@ -207,9 +205,9 @@ enrol)
F=${2:-right-middle-finger} F=${2:-right-middle-finger}
if sudo grep -q "^$F " "$MAP" 2>/dev/null; then if sudo grep -q "^$F " "$MAP" 2>/dev/null; then
echo "$F is already enrolled (in $MAP)." echo "$F is already enrolled (in $MAP)."
echo "This protocol measures a template with no history; run" echo "The trustlet REFUSES to re-enrol a finger it already holds; run"
echo " fplearn.sh wipe" echo " fplearn.sh wipe"
echo "first. (The daemon replaces a re-enrolled finger's template by itself since 0.2.6.)" echo "first. (Measured 2026-09-04: 0 accepted of 7 presses, rc=0 each.)"
exit 1 exit 1
fi fi
echo "=== enrol $F ===" echo "=== enrol $F ==="

View file

@ -6,7 +6,7 @@
# root after a cross build; output lands in the current directory. # root after a cross build; output lands in the current directory.
set -eu set -eu
VER="${1:-$(sed -n 's/.*Version = "\(.*\)".*/\1/p' interfaces/Fingerprintd.cppm)}" VER="${1:-$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' implementations/main.cpp)}"
[ -n "$VER" ] || { echo "could not determine version — pass it as \$1" >&2; exit 1; } [ -n "$VER" ] || { echo "could not determine version — pass it as \$1" >&2; exit 1; }
BIN=$(ls -t bin/fingerprintd-aarch64-*/fingerprintd 2>/dev/null | head -n1) BIN=$(ls -t bin/fingerprintd-aarch64-*/fingerprintd 2>/dev/null | head -n1)
[ -n "$BIN" ] || { echo "no aarch64 fingerprintd build found — cross-compile first" >&2; exit 1; } [ -n "$BIN" ] || { echo "no aarch64 fingerprintd build found — cross-compile first" >&2; exit 1; }

View file

@ -5,14 +5,12 @@
/* /*
Fingerprintd:Store unit tests. Fingerprintd:Store unit tests.
The map is small, but three of its properties are load-bearing. A corrupt or The map is small, but two of its properties are load-bearing. A corrupt or
partly-unreadable map must degrade to "fewer names known" rather than to a partly-unreadable map must degrade to "fewer names known" rather than to a
daemon that refuses to start, because that daemon is what unlocks the phone -- 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 And fid 0 must never be storable or matchable: 0 is what the trustlet writes
removes, and a map with lines it could not read must not decide that. Fid 0 into the fid field when authentication FAILS, so a stored 0 would turn every
must never be storable or matchable: 0 is what the trustlet writes into the rejection into a match.
fid field when authentication FAILS, so a stored 0 would turn every rejection
into a match. And group 0 is never managed.
*/ */
import std; import std;
import Fingerprintd; import Fingerprintd;
@ -50,7 +48,7 @@ int main() {
for (const Entry& e : decoded.Entries()) for (const Entry& e : decoded.Entries())
if (e.fid == 0) anyZero = true; if (e.fid == 0) anyZero = true;
Check(!anyZero, "no entry ever holds fid 0, however it was created"); Check(!anyZero, "no entry ever holds fid 0, however it was created");
Check(decoded.Size() == 1, "the zero-fid lines make no entry"); Check(decoded.Size() == 1, "the zero-fid lines were dropped by the decoder");
} }
// ---- Names round-trip through fprintd's vocabulary // ---- Names round-trip through fprintd's vocabulary
@ -130,12 +128,12 @@ int main() {
"left-ring-finger 0\n" // the failure sentinel "left-ring-finger 0\n" // the failure sentinel
"right-thumb 8\n"; // good "right-thumb 8\n"; // good
Map m = Map::Decode(damaged); Map m = Map::Decode(damaged);
Check(m.Size() == 2, "only the two good lines become entries"); Check(m.Size() == 2, "only the two good lines survive");
auto a = m.Lookup(1296911490); auto a = m.Lookup(1296911490);
auto b = m.Lookup(8); auto b = m.Lookup(8);
Check(a && *a == Finger::RightIndex, "first good entry kept"); Check(a && *a == Finger::RightIndex, "first good entry kept");
Check(b && *b == Finger::RightThumb, "last good entry kept"); Check(b && *b == Finger::RightThumb, "last good entry kept");
Check(!m.Has(Finger::LeftRing), "the zero-fid line made no entry"); Check(!m.Has(Finger::LeftRing), "the zero-fid line was dropped");
Check(!m.Has(Finger::LeftMiddle), "trailing junk rejected, not truncated to 12"); Check(!m.Has(Finger::LeftMiddle), "trailing junk rejected, not truncated to 12");
// Total garbage yields an empty map, not a throw. // Total garbage yields an empty map, not a throw.
@ -148,67 +146,8 @@ int main() {
Check(noNl.Size() == 1, "a missing trailing newline is not a lost entry"); Check(noNl.Size() == 1, "a missing trailing newline is not a lost entry");
} }
// ---- 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");
}
// ---- Groups // ---- Groups
{ {
Check(!GroupIsManaged(0), "group 0 is never tidied");
Check(GroupIsManaged(10000), "a session user's group is");
Check(GidForUid(1000) == 1000, "gid is the uid"); Check(GidForUid(1000) == 1000, "gid is the uid");
Check(GidForUid(0) == 0, "root maps too"); Check(GidForUid(0) == 0, "root maps too");
Check(GidForUid(4294967295u) == 4294967295u, "the whole uid range maps"); Check(GidForUid(4294967295u) == 4294967295u, "the whole uid range maps");

View file

@ -306,33 +306,6 @@ int main() {
"reading at the payload offset directly gives the wrong word"); "reading at the payload offset directly gives the wrong word");
} }
// ---- ENUMERATE: room for N entries in, {gid, fid} per template out
{
std::vector<std::byte> en(EnumeratePayloadSize);
BuildEnumeratePayload(en, 5);
Check(en.size() == 4 && Get32(en, 0) == 5, "the capacity is the one word");
std::vector<std::byte> resp(256);
auto put32 = [&](std::size_t off, std::uint32_t v) {
for (std::size_t i = 0; i < 4; i++)
resp[off + i] = static_cast<std::byte>((v >> (8 * i)) & 0xFF);
};
std::size_t e0 = ResponsePayloadOff + EnumerateEntriesOff;
put32(e0 + 0, 10000); put32(e0 + 4, 1376187901);
put32(e0 + 8, 10000); put32(e0 + 12, 1103829000);
put32(e0 + 16, 10000); put32(e0 + 20, 7);
auto fids = EnumeratedFids(resp, 2);
Check(fids.size() == 2, "as many entries as the count says");
Check(fids.size() == 2 && fids[0] == 1376187901 && fids[1] == 1103829000,
"the fid is the second word of each 8-byte entry");
Check(EnumeratedFids(resp, 0).empty(), "a count of 0 lists nothing");
Check(EnumeratedFids(resp, -2).empty(), "an error code lists nothing");
// The count is the trustlet's word, the buffer is ours: a count past
// the buffer stops at the buffer.
Check(EnumeratedFids(resp, 1000).size() == (resp.size() - e0) / EnumerateEntrySize,
"bounded by the reply buffer, never by the count");
}
// ---- The request/response envelope // ---- The request/response envelope
{ {
std::vector<std::byte> req(256); std::vector<std::byte> req(256);