From e2033ed26d2c43c69ae8603041c963326d5c1e34 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 3 Sep 2026 17:45:58 +0200 Subject: [PATCH] Learn from a matched press, as stock does Template learning, the half of the algorithm this daemon never ran. Every match rate measured against this device -- 1/10, 2/10, 3/10, 7/10 -- was measured against a day-zero template that no stock user lives with, because a stock template is rewritten on every successful press and ours never moved a byte. HarvestTemplate mirrors stock's post-match loop: while the finger is still on the sensor, capture and fold, sending no event so the matcher cannot re-run and revise a verdict the client already has. A quick tap pays almost nothing, since the finger is gone by the time a verdict lands and the first capture reads the idle floor; a held press contributes the frames it was actually held for. That last property is why this subsumes the enrolment-tuning thread. The working hypothesis after the strict-threshold and 30-sample failures was that enrolment conditions have to resemble verification conditions. Learning is exactly that, done by the algorithm from real unlock presses instead of by coaching a user into positions they never use. FlushTemplate issues the SAVE_DATA, and the worker calls it after the verdict is posted rather than before. Persisting a learned template is roughly 350 ms of gpfile and RPMB traffic and it does not belong on an unlock path; stock defers it the same way, with a lazy-updater timer. --learn=0 turns the whole thing off, which is the only way the comparison is single-variable: learning is cumulative, so an A/B needs one binary and one template lineage rather than two builds. --learn-frames bounds what one press may contribute, because the template body moves as a single gpfile op against a 516084-byte listener buffer and a 30-sample body already measured 386402. --probe-learn sends one UPDATE_TEMPLATE with no finger and then ENUMERATEs to prove the trustlet survived it, which is the part a bare return code cannot say. --- implementations/main.cpp | 151 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/implementations/main.cpp b/implementations/main.cpp index 17e09a8..4a3e13e 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -101,6 +101,19 @@ bool g_undecidedIsNoMatch = false; bool g_irqObserve = false; bool g_edgeWake = false; +// TEMPLATE LEARNING: fold the frames of a successful press back into the +// stored template, as stock does. On by default because stock does it and +// because every match rate this project has measured was taken against a +// day-zero template; `--learn=0` turns it off so the two can be compared on +// one binary without a rebuild, which is the only way the comparison is +// single-variable. +bool g_learn = true; +// How many frames one press may contribute. Stock has no explicit bound -- it +// harvests until the finger lifts -- but a finger left resting on the sensor +// should not grow the template without limit, and the template body has a hard +// ceiling: it moves as a SINGLE gpfile op against a 516084-byte listener +// buffer, and a 30-sample body already measured 386402. +int g_learnMaxFrames = 8; // The namespace key the trustlet hashes into the SFS group's directory name. // It defaults to Android's because that is what this device's existing store @@ -1453,9 +1466,95 @@ public: SendCommand(app_, ta::Cmd::Cancel, {}); out.cancelled = true; } + // A press that matched is the only material template learning gets. + if (out.matched && g_learn) HarvestTemplate(g_learnMaxFrames); return out; } + // ---- Template learning + // + // Stock's post-match harvest, and the whole reason a stock template grows + // over its life: while the finger is STILL on the sensor after a match, + // keep capturing and fold each frame into the loaded template. Stock's + // loop is {QUERY_FINGER_STATUS, CAPTURE_IMAGE, UPDATE_TEMPLATE} and it + // sends NO event -- so the matcher does not run again and this cannot + // revise the verdict the client has already been given. + // + // A quick tap pays almost nothing: the finger is gone by the time a + // verdict lands, so the first capture reads the idle floor and the loop + // stops. A held press contributes the frames it was actually held for. + // Which means the frames learned from are exactly the frames a real + // unlock produces -- the "enrolment must resemble verification" problem, + // solved by the algorithm instead of by coaching the user. + int HarvestTemplate(int maxFrames) { + namespace ta = fingerprintd::ta; + int folded = 0; + for (int i = 0; i < maxFrames; i++) { + std::vector cap(ta::CaptureDeclaredLen); + ta::BuildCapturePayload(cap); + auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap); + if (!c.invoked || c.result != 0) break; + if (!baseline_.IsFinger(c.metric)) { + if (g_verbose) + std::println(" learn: finger gone (metric={}), {} frame(s) folded", + c.metric, folded); + break; + } + std::vector up(ta::UpdateTemplatePayloadSize); + // Bit 6 on the first frame, mirroring stock: it marks the frame + // whose reported event was FingerTouched, and it selects which of + // the algorithm's two update entries runs. + ta::BuildUpdateTemplate(up, static_cast(folded), folded == 0); + auto u = SendCommand(app_, ta::Cmd::UpdateTemplate, up); + if (!u.Ok()) { + std::println(" learn: UPDATE_TEMPLATE rc={} ({}) result={} -- stopping", + u.rc, ta::StrError(u.rc), static_cast(u.result)); + break; + } + folded++; + if (g_verbose) + std::println(" learn: frame {} folded in (metric={})", folded, c.metric); + } + if (folded > 0) templateDirty_ = true; + std::println(" learn: {} frame(s) folded into the template{}", folded, + templateDirty_ ? ", save pending" : ""); + return folded; + } + + // Persist what the harvest folded in. Stock does this lazily -- it arms a + // timer on the match ("lazy data updater activated") and the SAVE_DATA + // lands at the next authenticate, taking 356 ms and rewriting the template + // plus the share template plus the chip calibration. Deferring it matters + // for the same reason it does on stock: the client already has its answer, + // and a third of a second of RPMB traffic does not belong on the unlock + // path. Here the worker calls this after it has posted the verdict. + bool FlushTemplate() { + namespace ta = fingerprintd::ta; + if (!templateDirty_) return true; + if (g_sfsReadOnly || !g_rpmbWrite) { + std::println(" learn: learned frames DISCARDED -- the store is read-only"); + templateDirty_ = false; + return false; + } + std::vector sd(0x10, std::byte{0}); + for (std::size_t k = 0; k < 4; k++) + sd[k] = static_cast((ta::SaveMaskTemplate >> (8 * k)) & 0xFF); + auto t0 = std::chrono::steady_clock::now(); + auto sv = SendCommand(app_, ta::Cmd::SaveData, sd); + Report(ta::Cmd::SaveData, sv); + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + templateDirty_ = false; + if (!sv.Ok()) { + std::println(" learn: the learned template did NOT persist (rc={})", sv.rc); + return false; + } + std::println(" learn: template saved in {} ms", ms); + return true; + } + + bool TemplateDirty() const { return templateDirty_; } + bool FingerPresent() const { return fingerPresent_.load(); } // Before SyncConfig has run there is no answer yet; stock's value is the // only honest stand-in, and a client asking this early gets it. @@ -1556,6 +1655,8 @@ private: Sensor sensor_; fingerprintd::engine::Baseline baseline_; std::uint32_t gid_ = 0xFFFFFFFF; + // Set when a harvest folded at least one frame in; cleared by the save. + bool templateDirty_ = false; std::atomic fingerPresent_{false}; std::thread irqThread_; std::atomic irqQuit_{false}; @@ -1699,6 +1800,11 @@ private: else if (o.matched) ev->status = "verify-match"; else ev->status = "verify-no-match"; PostEvent(std::move(ev)); + // AFTER the verdict is on its way to the client, never before: + // persisting a learned template is ~350 ms of gpfile and RPMB + // traffic and it must not sit on the unlock path. Stock defers + // it the same way, with a timer. + session_.FlushTemplate(); break; } } @@ -2207,13 +2313,43 @@ int RunDaemon() { // ----------------------------------------------------------------------------- // Diagnostic modes: the probe flow, kept for a phone with no bus client at hand. // ----------------------------------------------------------------------------- -int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, std::uint32_t gid, int frames) { +int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, bool doLearnProbe, + std::uint32_t gid, int frames) { namespace ta = fingerprintd::ta; Session s; if (!s.Start()) return 1; int n = s.SetActiveGroup(gid); (void)n; + // Does the trustlet accept UPDATE_TEMPLATE at all? NO FINGER NEEDED, and + // that is the point: this project's record says "DO NOT RETRY 0x1015 until + // a template is loaded" because every earlier shape answered -90, i.e. the + // app was gone. One template is loaded now, so this asks the question for + // the cost of one command -- and ENUMERATE afterwards proves whether the + // trustlet survived it, which is the part a bare rc cannot tell you. + if (doLearnProbe) { + if (n <= 0) + std::println("\n*** no template loaded (ENUMERATE={}) -- 0x1015 reads the " + "template AFTER the update call and faults without one. " + "Refusing to probe. ***", n); + else { + std::println("\n=== UPDATE_TEMPLATE (no finger; {} template(s) loaded) ===", n); + std::vector up(ta::UpdateTemplatePayloadSize); + ta::BuildUpdateTemplate(up, 0, /*touchFrame*/ true); + Report(ta::Cmd::UpdateTemplate, + SendCommand(s.App(), ta::Cmd::UpdateTemplate, up)); + std::println(" and again as a held frame (bit 6 clear, the x_update path):"); + ta::BuildUpdateTemplate(up, 1, /*touchFrame*/ false); + Report(ta::Cmd::UpdateTemplate, + SendCommand(s.App(), ta::Cmd::UpdateTemplate, up)); + // The liveness probe. -90 here means the trustlet took a fault. + auto e = SendCommand(s.App(), ta::Cmd::Enumerate, {}); + Report(ta::Cmd::Enumerate, e); + std::println(" trustlet {} (ENUMERATE rc={})", + e.invoked && e.rc >= 0 ? "SURVIVED" : "IS GONE", e.rc); + } + } + if (doCalSave) { if (g_sfsReadOnly) { std::println(std::cerr, "a calibration save writes; pass --sfs-writable"); return 1; } std::vector sd(0x10, std::byte{0}); @@ -2235,6 +2371,10 @@ int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, std::uint32_t gid, int f for (int c = 3; c > 0; c--) { std::println("*** press your finger in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); } auto o = s.Verify(gid, cancel, frames, {}); // probe: any template counts std::println("verify: decided={} matched={} fid={}", o.decided, o.matched, o.fid); + // The probe is the cheapest end-to-end test of template learning: + // Verify harvests, this persists, and the container on disk should + // come back larger than it went in. + s.FlushTemplate(); } s.Stop(); return 0; @@ -2287,6 +2427,7 @@ int RunProbeTaLoad(const std::string& path) { int main(int argc, char** argv) { std::span args(argv, static_cast(argc)); bool probe = false, daemon = false, doAuth = false, doEnrol = false, doCalSave = false; + bool doLearnProbe = false; std::string probeTa; std::uint32_t gid = 0; int frames = 120; @@ -2305,6 +2446,7 @@ int main(int argc, char** argv) { if (a == "--auth") { doAuth = true; probe = true; } if (a == "--enrol") { doEnrol = true; probe = true; } if (a == "--cal-save") { doCalSave = true; probe = true; g_verbose = true; } + if (a == "--probe-learn") { doLearnProbe = true; probe = true; g_verbose = true; } if (a.starts_with("--frames=")) frames = std::stoi(std::string(a.substr(9))); if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12))); if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true; @@ -2317,13 +2459,15 @@ int main(int argc, char** argv) { if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9))); if (a.starts_with("--group-path=")) g_groupPath = a.substr(13); if (a.starts_with("--samples=")) { g_samples = std::stoi(std::string(a.substr(10))); g_samplesForced = true; } + if (a.starts_with("--learn=")) g_learn = a.substr(8) != "0"; + if (a.starts_with("--learn-frames=")) g_learnMaxFrames = std::stoi(std::string(a.substr(15))); if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11); if (a.starts_with("--gid=")) gid = static_cast(std::stoul(std::string(a.substr(6)))); } StartTranscript(g_logDir); if (!probeTa.empty()) return RunProbeTaLoad(probeTa); if (daemon) return RunDaemon(); - if (probe) return RunProbe(doAuth, doEnrol, doCalSave, gid, frames); + if (probe) return RunProbe(doAuth, doEnrol, doCalSave, doLearnProbe, gid, frames); std::println(std::cerr, "fingerprintd {}\n" @@ -2331,6 +2475,9 @@ int main(int argc, char** argv) { " --probe-tee [--gid=N] bring the session up and report\n" " --probe-ta-load=PATH load one TA image and report the loader result\n" " --auth | --enrol | --cal-save diagnostic loops (see README)\n" + " --probe-learn send one UPDATE_TEMPLATE, no finger needed\n" + " --learn=0|1 [--learn-frames=N] fold a matched press back into the\n" + " template, as stock does (default on, 8)\n" " --sfs-root=DIR --sfs-writable --rpmb-write storage policy", Version); return 1;