A three-tap enrolment declared itself complete. The transcript says why:
frame 2: metric=308 FINGER ev5 rem=10
frame 3: metric=187 ev6 rem=0
samples: 10 of 10 accepted
The release event never enters do_enroll, so its response leaves
samples-remaining untouched at 0 -- which is indistinguishable from "none
remaining, you are finished". The session believed it, stopped after one press,
and called SAVE_DATA on an algorithm holding no template. That answered -1 and
wrote nothing, so the store was undamaged, but only by luck: the guard meant to
prevent a partial save was itself satisfied by the bogus count.
A reading is only meaningful when it came from the event that runs the enrol
path, and nothing about the value says so -- the caller has to. Observe now
takes that as an argument. Two further guards: a FIRST reading of 0 is an
unpopulated field rather than a finished enrolment, and the count only ever
falls, so an increase is noise.
Verified by mutation: trusting the release event's count, and accepting a
leading zero, each fail the suite.
260 lines
11 KiB
C++
260 lines
11 KiB
C++
// 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.
|
|
//
|
|
// "The common path" means do_enroll's common path -- and a finger-RELEASE
|
|
// event never enters do_enroll at all. Its response leaves the field
|
|
// untouched, so it reads 0, which is indistinguishable from "no samples
|
|
// remaining, you are finished". Taking that at face value ends an
|
|
// enrolment after one press and then calls SAVE_DATA on an algorithm
|
|
// holding no template, which answers -1.
|
|
//
|
|
// So a reading is only meaningful when it came from the event that runs
|
|
// the enrol path. The caller has to say so; there is no way to tell from
|
|
// the value.
|
|
class EnrolSession {
|
|
public:
|
|
void Observe(std::int32_t remaining, bool fromEnrolPath) {
|
|
if (!fromEnrolPath) return; // a release reports nothing
|
|
if (remaining < 0) return; // not populated at all
|
|
if (!started_) {
|
|
// A first reading of 0 is an unpopulated field, not a finished
|
|
// enrolment: the count starts at max_enrolling_samples.
|
|
if (remaining == 0) return;
|
|
total_ = remaining;
|
|
remaining_ = remaining;
|
|
started_ = true;
|
|
return;
|
|
}
|
|
if (remaining > remaining_) return; // the count only ever falls
|
|
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_ = -1;
|
|
};
|
|
|
|
// ---- 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; }
|
|
}
|