diff --git a/interfaces/Fingerprintd-Engine.cppm b/interfaces/Fingerprintd-Engine.cppm new file mode 100644 index 0000000..2f1d087 --- /dev/null +++ b/interfaces/Fingerprintd-Engine.cppm @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Engine — the capture loop as a state machine. + +The trustlet does not poll for a finger. The normal world captures a frame, +decides whether a finger is on the sensor, and *tells* it what happened; the +trustlet's enrolment and matching advance only on those reports. So the policy +that decides "finger down", "finger lifted" and "this press matched" lives out +here, and it is the part most easily got wrong in a way that looks like bad +hardware. + +Pure logic: frames in, events and tallies out. No TEE, no sensor, no clock. +*/ + +export module Fingerprintd:Engine; +import std; +import :Ta; + +export namespace fingerprintd::engine { + + using ta::Event; + using ta::Verdict; + + // ---- Finger detection ------------------------------------------------- + // + // CAPTURE_IMAGE returns a metric in the response header (reqOut+0x0c — a + // header field, not payload+12). It tracks the finger reproducibly: an + // idle floor around 132 against 345-366 with a finger. + // + // The floor is NOT a constant and must never be one. It is per frame, so + // it scales with how many frames a capture requests, and it drifts: an + // early session read "18 -> 24 with a finger" as weak detection when the + // values were drifting upward regardless of what was on the sensor. That + // was a clean negative misread as a positive because the floor was sampled + // once and then trusted. + // + // So a Baseline is not usable until it has been calibrated, and asking it + // about a frame before that is a programming error rather than a guess. + class Baseline { + public: + static constexpr std::size_t DefaultSamples = 5; + // A finger reads roughly 2.6x the floor; 2x separates them with margin + // on both measured runs. + static constexpr std::int32_t Multiplier = 2; + + explicit Baseline(std::size_t samples = DefaultSamples) : want_(samples) {} + + // Feed an idle capture. The floor is the MAX of the idle samples, not + // the mean: a floor that under-reads turns drift into false fingers. + void Observe(std::int32_t metric) { + if (seen_ < want_) { + floor_ = std::max(floor_, metric); + ++seen_; + } + } + + bool Ready() const { return seen_ >= want_ && floor_ > 0; } + std::int32_t Floor() const { return floor_; } + std::int32_t Threshold() const { return floor_ * Multiplier; } + + // Nothing is a finger until the floor is known. An uncalibrated + // Baseline reports false for everything rather than inventing a + // threshold. + bool IsFinger(std::int32_t metric) const { + return Ready() && metric >= Threshold(); + } + + private: + std::size_t want_; + std::size_t seen_ = 0; + std::int32_t floor_ = 0; + }; + + // ---- What to report to the trustlet ----------------------------------- + + enum class Mode { Enrol, Authenticate }; + + // Edge detection over the finger-present signal. Which events a frame + // produces depends on the mode, and the difference is not cosmetic. + // + // Enrolment mirrors stock: event 5 on the rising edge, event 6 on the + // falling one, and nothing in between. Every sample in the Android + // reference is "got finger touched" -> "enrolling group S" -> "got finger + // released", and "got image ready" never appears in the whole enrolment + // trace. Sending event 7 on every held frame instead feeds the algorithm + // near-duplicate images from a single press. + // + // Authentication does want event 7, which reaches the matcher + // unconditionally; event 5 only reaches it when device+0x10a8 is 1 or 2. + class TouchTracker { + public: + // Returns the events to report for this frame, in order. + std::vector Observe(bool finger, Mode mode) { + std::vector out; + bool rising = finger && !prev_; + bool falling = !finger && prev_; + if (rising) + out.push_back(Event::FingerTouched); + if (finger && mode == Mode::Authenticate) + out.push_back(Event::ImageReady); + if (falling) + out.push_back(Event::FingerReleased); + prev_ = finger; + return out; + } + bool FingerDown() const { return prev_; } + void Reset() { prev_ = false; } + + private: + bool prev_ = false; + }; + + // ---- Enrolment progress ----------------------------------------------- + // + // Read from the response, not the trustlet's log: do_enroll copies samples + // remaining into the response payload on the common path whether or not + // the sample was accepted, and the log starves exactly when a frame is + // accepted. `rem` counting down is the only reliable progress signal. + class EnrolSession { + public: + void Observe(std::int32_t remaining) { + if (remaining < 0) return; // not populated by this command + if (!started_) { total_ = remaining; started_ = true; } + remaining_ = remaining; + } + bool Started() const { return started_; } + std::int32_t Remaining() const { return remaining_; } + std::int32_t Total() const { return total_; } + std::int32_t Accepted() const { return started_ ? total_ - remaining_ : 0; } + bool Complete() const { return started_ && remaining_ == 0; } + + // fprintd wants a stage count up front. It is the trustlet's + // common.max_enrolling_samples, which the first response reveals. + std::int32_t Stages() const { return total_; } + + private: + bool started_ = false; + std::int32_t total_ = 0; + std::int32_t remaining_ = 0; + }; + + // ---- Authentication accounting ---------------------------------------- + // + // The rule this exists to enforce: only a terminal verdict is an attempt. + // A frame the matcher never saw, and a frame answering "not identified + // yet, attempts remain", are neither accepts nor rejects. Counting them as + // rejects is what turned an 8-for-8 run into an apparent 8-of-39. + // + // And the rate that means anything to a user is PER PRESS, not per frame. + // In the forced-terminal measurement 15 of 20 frames matched, but the five + // that did not fell inside presses that also matched, so every press + // succeeded. Quoting the frame rate describes the sensor before the retry + // mechanism built to absorb exactly those frames. + // + // A press can also end without a verdict at all: at the stock rescan + // budget a press whose frames all answered "not identified yet" simply ran + // out of frames. It is UNDECIDED, not failed, and counting it as a failure + // repeats the -11 mistake one level up — so presses are counted separately + // from presses that reached a verdict. + class AuthTally { + public: + void Observe(Verdict v, bool fingerPresent) { + switch (v) { + case Verdict::Match: ++match_; break; + case Verdict::Rejected: ++rejected_; break; + case Verdict::NotIdentifiedYet: ++notYet_; break; + case Verdict::MatcherNeverRan: ++neverRan_; break; + } + // A press is a contiguous run of frames with a finger present. + if (fingerPresent) { + if (!inPress_) { + inPress_ = true; + ++presses_; + pressMatched_ = false; + pressDecided_ = false; + } + if (ta::IsTerminal(v) && !pressDecided_) { + pressDecided_ = true; + ++pressesDecided_; + } + if (v == Verdict::Match && !pressMatched_) { + pressMatched_ = true; + ++pressesMatched_; + } + } else { + inPress_ = false; + } + } + + int Matches() const { return match_; } + int Rejections() const { return rejected_; } + int NotIdentifiedYet() const { return notYet_; } + int NeverRan() const { return neverRan_; } + + // The denominator. Anything else over-counts attempts. + int TerminalFrames() const { return match_ + rejected_; } + + int Presses() const { return presses_; } + // Presses that reached a verdict. This is the denominator for a + // per-press rate; Presses() includes ones that ran out of frames. + int PressesDecided() const { return pressesDecided_; } + int PressesMatched() const { return pressesMatched_; } + int PressesUndecided() const { return presses_ - pressesDecided_; } + + // A session succeeded if any frame identified the finger. + bool Identified() const { return match_ > 0; } + + private: + int match_ = 0, rejected_ = 0, notYet_ = 0, neverRan_ = 0; + int presses_ = 0, pressesDecided_ = 0, pressesMatched_ = 0; + bool inPress_ = false, pressMatched_ = false, pressDecided_ = false; + }; + + // ---- Session sequencing ----------------------------------------------- + // + // The reference loop per frame, from the Android trace: + // QUERY_EVENT_STATUS, CAPTURE_IMAGE, REPORT_EVENT x n, QUERY_EVENT_STATUS + // Without REPORT_EVENT the trustlet never advances its state machine at + // all, which is what made SAVE_DATA return "Internal error" for weeks. + inline constexpr std::array FramePrologue = { + ta::Cmd::CaptureImage, ta::Cmd::ReportEvent, + }; + + // One sensor reset buys exactly one trustlet init: a second init in the + // same power cycle answers -205. So recovering a failed session means + // power-cycling the rail, not retrying the init. + enum class SessionState { + Cold, // sensor unpowered + Powered, // rail up, reset released, not yet initialised + Ready, // init chain done, templates loadable + Failed, // needs a power cycle, not a retry + }; + + inline bool NeedsPowerCycle(SessionState s) { return s == SessionState::Failed; } + inline bool CanInit(SessionState s) { return s == SessionState::Powered; } +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index b7f73f1..0b9e3a6 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -15,3 +15,4 @@ export module Fingerprintd; export import :Sfs; export import :Rpmb; export import :Ta; +export import :Engine; diff --git a/project.cpp b/project.cpp index b7a2feb..49ae107 100644 --- a/project.cpp +++ b/project.cpp @@ -21,11 +21,12 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Fingerprintd", "interfaces/Fingerprintd-Sfs", "interfaces/Fingerprintd-Rpmb", "interfaces/Fingerprintd-Ta", + "interfaces/Fingerprintd-Engine", }; std::array impls = {}; Core->GetInterfacesAndImplementations(ifaces, impls); @@ -49,6 +50,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Sfs").Dependencies({ Core.get() }); cfg.AddTest("Rpmb").Dependencies({ Core.get() }); cfg.AddTest("Ta").Dependencies({ Core.get() }); + cfg.AddTest("Engine").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp new file mode 100644 index 0000000..2ba0743 --- /dev/null +++ b/tests/Engine/main.cpp @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Engine unit tests. + +The accounting half replays the three recorded authentication runs as ordered +sequences, not just as totals, because press structure only exists in the +order. That is what lets the per-press claim be re-derived here rather than +taken from the journal: the enrolled finger matched on every press even though +five of its twenty frames did not. + +It also pins the distinction one level up from the -11 mistake. At the stock +rescan budget a press whose frames all answered "not identified yet" ran out of +frames without reaching a verdict. It is undecided, not failed, and lumping it +in with failures is the same error in a new place. +*/ +import std; +import Fingerprintd; + +using namespace fingerprintd::engine; +using fingerprintd::ta::Verdict; +using fingerprintd::ta::Event; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + // A recorded run, in order. The fixtures live with the Ta suite; these are + // the same three files. + std::vector LoadRun(std::string_view name) { + std::vector out; + std::string path = std::format("tests/Ta/fixtures/{}", name); + std::ifstream f(path); + if (!f) { + std::println(std::cerr, "FAIL: cannot open fixture {}", path); + ++Failures; + return out; + } + std::string line; + while (std::getline(f, line)) { + if (line.starts_with("#") || !line.contains("AUTH ")) continue; + if (line.contains("*** MATCH ***")) out.push_back(Verdict::Match); + else if (line.contains("matcher never ran")) out.push_back(Verdict::MatcherNeverRan); + else if (line.contains("REJECTED")) out.push_back(Verdict::Rejected); + else if (line.contains("no match")) out.push_back(Verdict::NotIdentifiedYet); + } + return out; + } + + // The matcher never running is exactly the released-finger frame, so it is + // also the finger-present signal for press accounting. + AuthTally Replay(const std::vector& run) { + AuthTally t; + for (Verdict v : run) + t.Observe(v, v != Verdict::MatcherNeverRan); + return t; + } +} + +int main() { + // ---- Baseline: never a fixed threshold + { + Baseline b; + Check(!b.Ready(), "uncalibrated"); + Check(!b.IsFinger(1000000), "an uncalibrated baseline calls nothing a finger"); + + for (int i = 0; i < 5; i++) b.Observe(132); + Check(b.Ready(), "calibrated after five idle samples"); + Check(b.Floor() == 132, "floor"); + Check(b.Threshold() == 264, "threshold is 2x the floor"); + + // The real measurement: idle 132-133, finger 345-366. + Check(!b.IsFinger(133), "an idle frame is not a finger"); + Check(b.IsFinger(345) && b.IsFinger(366), "a pressed frame is"); + + // The floor is the max of the idle samples. A drifting idle must not + // become a false finger. + Baseline drift; + for (std::int32_t m : {18, 20, 22, 24, 26}) drift.Observe(m); + Check(drift.Floor() == 26, "floor takes the maximum, not the first sample"); + Check(!drift.IsFinger(24), "drift within the idle range is not a finger"); + + // Extra samples after calibration do not move it. + Baseline fixed; + for (int i = 0; i < 5; i++) fixed.Observe(100); + fixed.Observe(9999); + Check(fixed.Floor() == 100, "calibration closes after its sample count"); + + // A per-frame metric scales with the frame count, so a threshold from + // one configuration is meaningless in another. Two baselines, same + // sensor, different capture counts: + Baseline one, four; + for (int i = 0; i < 5; i++) { one.Observe(18); four.Observe(68); } + Check(one.Threshold() != four.Threshold(), "the threshold is not portable between configs"); + } + + // ---- Edge detection + { + TouchTracker t; + // Enrolment: touch on the rising edge, release on the falling one, + // and nothing at all while held. + auto e1 = t.Observe(true, Mode::Enrol); + Check(e1.size() == 1 && e1[0] == Event::FingerTouched, "enrol: rising edge -> touched"); + auto e2 = t.Observe(true, Mode::Enrol); + Check(e2.empty(), "enrol: a held frame reports nothing"); + auto e3 = t.Observe(false, Mode::Enrol); + Check(e3.size() == 1 && e3[0] == Event::FingerReleased, "enrol: falling edge -> released"); + auto e4 = t.Observe(false, Mode::Enrol); + Check(e4.empty(), "enrol: idle reports nothing"); + + // Authentication: every frame with a finger reaches the matcher. + TouchTracker a; + auto a1 = a.Observe(true, Mode::Authenticate); + Check(a1.size() == 2 && a1[0] == Event::FingerTouched && a1[1] == Event::ImageReady, + "auth: rising edge reports touched then image-ready"); + auto a2 = a.Observe(true, Mode::Authenticate); + Check(a2.size() == 1 && a2[0] == Event::ImageReady, "auth: a held frame still reports image-ready"); + auto a3 = a.Observe(false, Mode::Authenticate); + Check(a3.size() == 1 && a3[0] == Event::FingerReleased, "auth: release"); + + // The modes genuinely differ: enrolling every held frame is what feeds + // the algorithm near-duplicate images from one press. + TouchTracker x, y; + x.Observe(true, Mode::Enrol); + y.Observe(true, Mode::Authenticate); + Check(x.Observe(true, Mode::Enrol).empty(), "enrol emits nothing while held"); + Check(!y.Observe(true, Mode::Authenticate).empty(), "auth emits while held"); + + Check(a.FingerDown() == false, "tracker reports lifted"); + } + + // ---- Enrolment progress, read from the response + { + EnrolSession e; + Check(!e.Started(), "not started"); + e.Observe(-1); + Check(!e.Started(), "an unpopulated field does not start the session"); + e.Observe(10); + Check(e.Started() && e.Total() == 10 && e.Stages() == 10, "first response sets the total"); + Check(e.Accepted() == 0 && !e.Complete(), "nothing accepted yet"); + e.Observe(9); + Check(e.Accepted() == 1, "rem 10 -> 9 is one accepted sample"); + for (std::int32_t r : {8, 7, 6, 5, 4, 3, 2, 1}) e.Observe(r); + Check(!e.Complete() && e.Remaining() == 1, "not complete at one remaining"); + e.Observe(0); + Check(e.Complete() && e.Accepted() == 10, "complete at zero"); + } + + // ---- The three recorded runs, replayed in order + { + auto enrolled = LoadRun("auth-enrolled-finger.txt"); + auto wrong = LoadRun("auth-wrong-finger.txt"); + auto stock = LoadRun("auth-stock-budget.txt"); + Check(enrolled.size() == 25 && wrong.size() == 21 && stock.size() == 48, + "all three runs loaded in order"); + + // The enrolled finger, forced-terminal. 15 of 20 frames matched... + AuthTally e = Replay(enrolled); + Check(e.Matches() == 15 && e.Rejections() == 5, "enrolled: 15 match / 5 reject"); + Check(e.TerminalFrames() == 20, "enrolled: 20 terminal frames"); + // ...but every press did, which is the number a user experiences. + Check(e.Presses() == 5, "enrolled: five presses"); + Check(e.PressesMatched() == 5, "enrolled: every press matched"); + Check(e.PressesDecided() == 5, "enrolled: every press reached a verdict"); + Check(e.Identified(), "enrolled: the finger was identified"); + // The distinction the journal insists on. + Check(e.Matches() != e.TerminalFrames(), "the frame rate is not 100%"); + Check(e.PressesMatched() == e.PressesDecided(), "the press rate is"); + + // The control. This is the claim that matters most about the device. + AuthTally w = Replay(wrong); + Check(w.Matches() == 0, "wrong finger: zero false accepts"); + Check(w.Rejections() == 19, "wrong finger: 19 rejections"); + Check(w.PressesMatched() == 0, "wrong finger: no press matched"); + Check(w.PressesDecided() == 2 && w.Presses() == 2, "wrong finger: both presses decided"); + Check(!w.Identified(), "wrong finger: not identified"); + + // The stock-budget run: most frames are "not identified yet". + AuthTally s = Replay(stock); + Check(s.Matches() == 8, "stock budget: 8 matches"); + Check(s.NotIdentifiedYet() == 31, "stock budget: 31 rescan frames"); + Check(s.Rejections() == 0, "stock budget: not one real rejection"); + Check(s.TerminalFrames() == 8, "stock budget: 8 terminal frames, all matches"); + + // Five of its ten presses ran out of frames without a verdict. They + // are undecided, not failures -- and every press that DID reach a + // verdict matched. + Check(s.Presses() == 10, "stock budget: ten presses"); + Check(s.PressesDecided() == 5, "stock budget: five reached a verdict"); + Check(s.PressesUndecided() == 5, "stock budget: five ran out of frames"); + Check(s.PressesMatched() == 5, "stock budget: every decided press matched"); + Check(s.PressesMatched() == s.PressesDecided(), + "stock budget: the decided-press rate is 5/5, not 5/10"); + } + + // ---- Undecided presses must not be counted as failures + { + AuthTally t; + // One press, all rescan frames, then a lift. + for (int i = 0; i < 4; i++) t.Observe(Verdict::NotIdentifiedYet, true); + t.Observe(Verdict::MatcherNeverRan, false); + Check(t.Presses() == 1, "one press"); + Check(t.PressesDecided() == 0, "it reached no verdict"); + Check(t.PressesMatched() == 0, "and matched nothing"); + Check(t.Rejections() == 0, "but it produced no rejection either"); + Check(t.TerminalFrames() == 0, "and no terminal frame"); + } + + // ---- Session state: a failure needs a power cycle, not a retry + { + Check(CanInit(SessionState::Powered), "init from powered"); + Check(!CanInit(SessionState::Ready), "no second init on a live session"); + Check(!CanInit(SessionState::Cold), "no init before power"); + Check(!CanInit(SessionState::Failed), "a failed session may not simply re-init"); + Check(NeedsPowerCycle(SessionState::Failed), "it needs the rail cycled"); + Check(!NeedsPowerCycle(SessionState::Ready), "a healthy session does not"); + } + + if (Failures == 0) std::println("Engine: all tests passed"); + return Failures; +}