From afb7496ef676533317a4c5912c7560efecee4f70 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Wed, 2 Sep 2026 19:16:50 +0200 Subject: [PATCH] Fix the poison offset: a released finger was reading as a rejection PoisonFid takes the payload and offsets to the fid field internally. It was being handed a span already offset by the payload offset, so the poison landed at payload+0x20 and the real fid field stayed zero. A frame where the matcher never ran then looks exactly like a frame where it ran and rejected -- which is the specific failure this project has recorded three times and is precisely what the poison exists to prevent. Visible in a real run: the frames marked REJECTED were 138, 138, 133, 137, 134 against a floor of 136, i.e. every one of them was a finger-RELEASE frame with nothing on the sensor. Five rejections that never happened. The two offsets are numerically equal, which is why double-applying is silent, so the test now pins both directions: poisoning the payload marks the fid field, and poisoning an already-offset span leaves it zero and misclassifies. Also adds --rescan=N, which patches common.max_authentication_rescan_times into the config. The stock budget lets a whole run end with no terminal verdict -- correct for shipping, useless as a measurement, because a wrong-finger control that never reaches a verdict has not demonstrated a rejection. Forcing 0 makes every frame terminal. It prints MEASUREMENT ONLY because a rate taken that way is a per-frame figure with the retry mechanism disabled, and is not a shipping reject rate. --- implementations/main.cpp | 33 ++++++++++++++++++++++++++++++++- tests/Ta/main.cpp | 13 +++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/implementations/main.cpp b/implementations/main.cpp index f8df4b6..4671f44 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -57,6 +57,7 @@ bool g_auth = false; int g_frames = 40; int g_frameGapMs = 500; std::string g_logDir = "/var/log/fingerprintd"; +int g_rescan = -1; // -1 = leave the config's value alone std::uint32_t g_gid = 0; std::string g_taPath = "/lib/firmware/focal64.mbn"; std::string g_cfgPath = "/lib/firmware/fingerprintd.json"; @@ -944,6 +945,29 @@ int Probe() { } std::string json((std::istreambuf_iterator(cf)), std::istreambuf_iterator()); + + // common.max_authentication_rescan_times bounds how many frames the + // matcher may answer "not identified yet" before it has to produce a + // verdict. At the stock default a whole run can end undecided, which is + // the right shipping behaviour and useless as a measurement: a + // wrong-finger control that never reaches a verdict has not demonstrated + // a rejection. Setting it to 0 forces every frame terminal. + // + // MEASUREMENT ONLY. A rate measured this way is a per-frame figure taken + // with the retry mechanism disabled and is not a shipping reject rate. + if (g_rescan >= 0) { + auto at = json.find("\"common\":{"); + if (at == std::string::npos) at = json.find("\"common\": {"); + if (at == std::string::npos) { + std::println(std::cerr, "config has no \"common\" object to patch"); + return 1; + } + auto brace = json.find('{', at); + json.insert(brace + 1, + std::format("\"max_authentication_rescan_times\":{},", g_rescan)); + std::println("forcing max_authentication_rescan_times={} (MEASUREMENT ONLY)", + g_rescan); + } // The trustlet wants the terminating NUL counted. std::vector cfg(json.size() + 1, std::byte{0}); for (std::size_t i = 0; i < json.size(); i++) @@ -1100,7 +1124,13 @@ int Probe() { // buffer cannot tell "the matcher never ran" from "the matcher // ran and rejected the finger" -- the failure path writes zero // there too, so zero is ambiguous and 0xAAAAAAAA is not. - ta::PoisonFid(std::span(evbuf).subspan(ta::ResponsePayloadOff)); + // PoisonFid already writes at RespFidOff within the PAYLOAD. + // Handing it a span that is itself already offset by the + // payload offset double-counts and poisons payload+0x20, so + // the real fid field stays zero -- and a released finger then + // classifies as a REJECTION, inventing failures that never + // happened. + ta::PoisonFid(evbuf); auto r = SendCommand(app, ta::Cmd::ReportEvent, evbuf); if (!r.invoked) continue; @@ -1162,6 +1192,7 @@ int main(int argc, char** argv) { if (a == "--auth") { g_auth = true; g_listeners = true; } if (a.starts_with("--frames=")) g_frames = std::stoi(std::string(a.substr(9))); if (a.starts_with("--log-dir=")) g_logDir = a.substr(10); + if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9))); if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11); if (a.starts_with("--gid=")) g_gid = static_cast( std::stoul(std::string(a.substr(6)))); diff --git a/tests/Ta/main.cpp b/tests/Ta/main.cpp index 4914f6e..65dae76 100644 --- a/tests/Ta/main.cpp +++ b/tests/Ta/main.cpp @@ -276,6 +276,19 @@ int main() { Check(Get32(payload, RespFidOff) == FidPoison, "poison written at +0x10"); Check(Classify(0, Get32(payload, RespFidOff)) == Verdict::MatcherNeverRan, "an untouched poisoned payload classifies as never-ran"); + + // PoisonFid takes the PAYLOAD and offsets internally. Handing it a + // span already offset by ResponsePayloadOff double-counts and poisons + // payload+0x20, leaving the real fid field zero -- which makes every + // released finger read as a rejection. That shipped once. + Check(RespFidOff == ResponsePayloadOff, + "the two offsets are equal, which is exactly why double-applying is silent"); + std::vector wrong(64); + PoisonFid(std::span(wrong).subspan(ResponsePayloadOff)); + Check(Get32(wrong, RespFidOff) != FidPoison, + "double-offsetting leaves the fid field unpoisoned"); + Check(Classify(0, Get32(wrong, RespFidOff)) == Verdict::Rejected, + "and an unpoisoned release is then misread as a rejection"); } // ---- Init chain