239 lines
10 KiB
Text
239 lines
10 KiB
Text
|
|
// 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<Event> Observe(bool finger, Mode mode) {
|
||
|
|
std::vector<Event> 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<ta::Cmd, 2> 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; }
|
||
|
|
}
|