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.
This commit is contained in:
Jorijn van der Graaf 2026-09-02 19:16:50 +02:00
commit afb7496ef6
2 changed files with 45 additions and 1 deletions

View file

@ -57,6 +57,7 @@ bool g_auth = false;
int g_frames = 40; int g_frames = 40;
int g_frameGapMs = 500; int g_frameGapMs = 500;
std::string g_logDir = "/var/log/fingerprintd"; 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::uint32_t g_gid = 0;
std::string g_taPath = "/lib/firmware/focal64.mbn"; std::string g_taPath = "/lib/firmware/focal64.mbn";
std::string g_cfgPath = "/lib/firmware/fingerprintd.json"; std::string g_cfgPath = "/lib/firmware/fingerprintd.json";
@ -944,6 +945,29 @@ int Probe() {
} }
std::string json((std::istreambuf_iterator<char>(cf)), std::string json((std::istreambuf_iterator<char>(cf)),
std::istreambuf_iterator<char>()); std::istreambuf_iterator<char>());
// 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. // The trustlet wants the terminating NUL counted.
std::vector<std::byte> cfg(json.size() + 1, std::byte{0}); std::vector<std::byte> cfg(json.size() + 1, std::byte{0});
for (std::size_t i = 0; i < json.size(); i++) 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 // buffer cannot tell "the matcher never ran" from "the matcher
// ran and rejected the finger" -- the failure path writes zero // ran and rejected the finger" -- the failure path writes zero
// there too, so zero is ambiguous and 0xAAAAAAAA is not. // 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); auto r = SendCommand(app, ta::Cmd::ReportEvent, evbuf);
if (!r.invoked) continue; 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 == "--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("--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("--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("--sfs-root=")) g_sfsRoot = a.substr(11);
if (a.starts_with("--gid=")) g_gid = static_cast<std::uint32_t>( if (a.starts_with("--gid=")) g_gid = static_cast<std::uint32_t>(
std::stoul(std::string(a.substr(6)))); std::stoul(std::string(a.substr(6))));

View file

@ -276,6 +276,19 @@ int main() {
Check(Get32(payload, RespFidOff) == FidPoison, "poison written at +0x10"); Check(Get32(payload, RespFidOff) == FidPoison, "poison written at +0x10");
Check(Classify(0, Get32(payload, RespFidOff)) == Verdict::MatcherNeverRan, Check(Classify(0, Get32(payload, RespFidOff)) == Verdict::MatcherNeverRan,
"an untouched poisoned payload classifies as never-ran"); "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<std::byte> 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 // ---- Init chain