Jorijn worked out the technique and it changes what every number in this project means: "press and LIFT (quick tap) is wrong, holding the sensor until it gives the result is a 100% success rate." The logs agree, on a properly controlled comparison. Same template, same session, learning off for all four blocks, only the technique differing: tapped 4/15 and 3/15, held 15/15 and 15/15. The frame data says why. Over every frame this project has a verdict for, split at 2.5x the idle floor: full contact, interrupt settled 78/175 = 45% match full contact, interrupt asserted 28/142 = 20% partial, interrupt settled 1/9 = 11% partial, interrupt asserted 0/53 = 0% A tap is caught while the finger is still arriving or already leaving. Such a frame is not a hard verdict waiting to happen, it is a wasted one: with the rescan budget at 0 every frame is terminal, so its rejection ends the press. 62 partial frames produced exactly one match between them. So the tracker becomes a Schmitt trigger. A press now STARTS on settled contact and ENDS on the finger leaving, which means a frame taken mid-landing produces no event at all rather than a false rejection. A press that never settles simply yields no verdict and the loop waits for the next one, which is an honest try again. Enrolment is untouched: it passes one threshold for both and keeps its own sample-quality gate inside the trustlet. fptrial.sh now says hold, and defaults to fifteen presses. Instructing a tap for its whole life is what quietly made every rate this project has quoted a worst case, and a tap is not a case the product has -- nobody taps a phone sensor and walks away, they rest a finger until it unlocks.
2572 lines
115 KiB
C++
2572 lines
115 KiB
C++
// SPDX-License-Identifier: GPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// lint-disable-file fixed-width-types
|
|
// lint-disable-file no-char-pointer
|
|
/*
|
|
fingerprintd — the daemon shell.
|
|
|
|
Everything that touches hardware lives here; the decisions live in
|
|
fingerprintd-core, which is tested without a phone.
|
|
|
|
Three threads:
|
|
|
|
* the SUPPLICANT services QTEE's callbacks — the storage listeners and the
|
|
credentials object. Nothing QTEE asks of us happens without it.
|
|
* the WORKER owns the sensor rail, the QTEE session and the trustlet, and is
|
|
the ONLY thread that invokes the trustlet. Every enrolment and every
|
|
authentication runs here, serialised by construction.
|
|
* the MAIN thread runs the GLib loop and speaks net.reactivated.Fprint. It
|
|
never touches the trustlet; it posts jobs to the worker and receives
|
|
results back through g_idle_add.
|
|
|
|
Why one long-lived process: a listener registration is held for the life of
|
|
the process and QTEE's listener table is global to the boot; one sensor reset
|
|
buys exactly one trustlet init; and fprintd's clients expect a device that is
|
|
already open when they ask. So the process that powers the sensor is the
|
|
process that holds the session and owns the bus name.
|
|
*/
|
|
// libqcomtee is a C library and its headers carry no extern "C" guard -- it
|
|
// has only ever been consumed from C. Without one every symbol would be
|
|
// C++-mangled and none would link.
|
|
//
|
|
// The headers pull in <stdarg.h>, <stdatomic.h> and <stdio.h>, and under
|
|
// libc++ those drag in C++ templates, which may not appear inside an
|
|
// extern "C" block. Including them first makes the nested includes no-ops.
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <stdatomic.h>
|
|
extern "C" {
|
|
#include <qcomtee_object.h>
|
|
#include <qcomtee_object_types.h>
|
|
#include <qcomtee_errno.h>
|
|
}
|
|
|
|
#include <gio/gio.h>
|
|
#include <glib-unix.h>
|
|
|
|
#include <linux/gpio.h>
|
|
#include <linux/bsg.h>
|
|
#include <scsi/sg.h>
|
|
#include <pthread.h>
|
|
#include <fcntl.h>
|
|
#include <pwd.h>
|
|
#include <signal.h>
|
|
#include <sys/ioctl.h>
|
|
#include <sys/time.h>
|
|
#include <poll.h>
|
|
#include <unistd.h>
|
|
#include <errno.h>
|
|
#include <string.h>
|
|
|
|
import std;
|
|
import Fingerprintd;
|
|
|
|
namespace {
|
|
|
|
// Bumping this is what publishes a package: the registry answers 409 for a
|
|
// version it already has, which a build treats as a no-op.
|
|
constexpr const char* Version = "0.1.0";
|
|
|
|
bool g_verbose = false;
|
|
// 500 ms was the research harness's pace, chosen so a human could read the
|
|
// transcript scroll by. It is not a design. The matcher rejects the early
|
|
// frames of a correct press and matches several frames in (frames 3 and 8 in
|
|
// the acceptance run), so frames-per-press is what decides a press -- and a
|
|
// lift is only noticed on the NEXT frame, so it is also the latency a user
|
|
// feels. A frame costs four QTEE round trips regardless; the gap on top is
|
|
// pure delay.
|
|
int g_frameGapMs = 40;
|
|
// The enrolment sample count lives in ONE place: common.max_enrolling_samples
|
|
// in the trustlet config, read by SyncConfig(). It is not duplicated here,
|
|
// because the trustlet enforces that value and the daemon only counts against
|
|
// it -- if the two disagree the progress reporting is silently wrong, which is
|
|
// how a 30-sample enrolment came to advertise 20 stages. -1 means "not read
|
|
// yet"; --samples= overrides for a deliberate experiment.
|
|
int g_samples = -1;
|
|
constexpr int SamplesFallback = 20; // stock's value, if the config lacks the key
|
|
bool g_samplesForced = false;
|
|
std::string g_logDir = "/var/log/fingerprintd";
|
|
std::string g_stateDir = "/var/lib/fingerprintd";
|
|
int g_rescan = -1; // -1 = leave the config's value alone
|
|
|
|
// What a press that ends with no terminal verdict means. At the stock rescan
|
|
// budget a wrong finger answers "not identified yet" on every frame and never
|
|
// yields a terminal frame, so its press is undecided at lift -- and the only
|
|
// way a client ever hears verify-no-match is to treat that as one. The cost is
|
|
// on the correct finger: a press that ran out of frames before the matcher
|
|
// reached a verdict is also reported as no-match. Under rescan=0 this cannot
|
|
// arise (every frame is terminal), so the knob only matters with a budget.
|
|
bool g_undecidedIsNoMatch = false;
|
|
bool g_irqObserve = false;
|
|
bool g_edgeWake = false;
|
|
|
|
// TEMPLATE LEARNING: fold the frames of a successful press back into the
|
|
// stored template, as stock does. On by default because stock does it and
|
|
// because every match rate this project has measured was taken against a
|
|
// day-zero template; `--learn=0` turns it off so the two can be compared on
|
|
// one binary without a rebuild, which is the only way the comparison is
|
|
// single-variable.
|
|
bool g_learn = true;
|
|
// Dump the trustlet's own log out of the response buffer after every command.
|
|
// See DumpTaLog: on pmOS this is the ONLY way to read it.
|
|
bool g_taLog = false;
|
|
// How many frames one press may contribute. Stock has no explicit bound -- it
|
|
// harvests until the finger lifts -- but a finger left resting on the sensor
|
|
// should not grow the template without limit, and the template body has a hard
|
|
// ceiling: it moves as a SINGLE gpfile op against a 516084-byte listener
|
|
// buffer, and a 30-sample body already measured 386402.
|
|
int g_learnMaxFrames = 8;
|
|
|
|
// The namespace key the trustlet hashes into the SFS group's directory name.
|
|
// It defaults to Android's because that is what this device's existing store
|
|
// was written under. It does NOT isolate anything -- SET_ACTIVE_GROUP's path
|
|
// selects the group to read, but SAVE_DATA writes into the Android group
|
|
// regardless. Isolation comes from the SFS root, not from this.
|
|
std::string g_groupPath{fingerprintd::ta::GroupNamespacePath};
|
|
std::string g_taPath = "/lib/firmware/focal64.mbn";
|
|
std::string g_cfgPath = "/lib/firmware/fingerprintd.json";
|
|
|
|
qcomtee_object* g_root = QCOMTEE_OBJECT_NULL;
|
|
|
|
// Every run writes its own timestamped transcript. Not a convenience: a run
|
|
// whose result nobody recorded is a run that has to be repeated on a human's
|
|
// finger. And a SINGLE shared log path is worse than none -- the next run,
|
|
// including a quick control, destroys the interesting one, which is how the
|
|
// first successful authentication in this project was very nearly lost.
|
|
//
|
|
// Done at the file-descriptor level rather than by wrapping a stream, because
|
|
// std::println writes to stdout through C stdio: an ostream wrapper would
|
|
// capture nothing. Routing fd 1 through tee catches every line including the
|
|
// ones libqcomtee prints.
|
|
bool StartTranscript(const std::string& dir) {
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(dir, ec);
|
|
auto now = std::chrono::system_clock::now();
|
|
std::string path = std::format("{}/{:%Y%m%d-%H%M%S}.log", dir,
|
|
std::chrono::floor<std::chrono::seconds>(now));
|
|
FILE* t = ::popen(std::format("tee {}", path).c_str(), "w");
|
|
if (!t) return false;
|
|
::dup2(::fileno(t), 1);
|
|
::setvbuf(stdout, nullptr, _IOLBF, 0);
|
|
std::println("transcript: {}", path);
|
|
return true;
|
|
}
|
|
|
|
// tee_call_t's second parameter is `unsigned long` on glibc and `int` on musl
|
|
// (qcomtee_object.h keys it off __GLIBC__), so the signature has to match or
|
|
// the function pointer will not convert. The native build is glibc and the
|
|
// phone is musl, so both forms are compiled here.
|
|
#ifdef __GLIBC__
|
|
int TeeCall(int fd, unsigned long op, ...) {
|
|
#else
|
|
int TeeCall(int fd, int op, ...) {
|
|
#endif
|
|
va_list ap;
|
|
va_start(ap, op);
|
|
void* arg = va_arg(ap, void*);
|
|
va_end(ap);
|
|
pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, nullptr);
|
|
int ret = ::ioctl(fd, static_cast<unsigned long>(op), arg);
|
|
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, nullptr);
|
|
return ret;
|
|
}
|
|
|
|
// QTEE's callbacks are serviced here. Nothing QTEE asks of us happens without
|
|
// this running.
|
|
void* Supplicant(void*) {
|
|
for (;;) {
|
|
pthread_testcancel();
|
|
if (qcomtee_object_process_one(g_root))
|
|
break;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
std::uint64_t NowMs() {
|
|
timeval tv{};
|
|
::gettimeofday(&tv, nullptr);
|
|
return static_cast<std::uint64_t>(tv.tv_sec) * 1000
|
|
+ static_cast<std::uint64_t>(tv.tv_usec) / 1000;
|
|
}
|
|
|
|
// ---- The credentials object
|
|
//
|
|
// QTEE will not take the credentials blob directly on the Register path: it
|
|
// takes an object and calls back into it, twice, while our invoke is still in
|
|
// flight. Two ops, GET_LENGTH then READ_AT_OFFSET.
|
|
//
|
|
// libqcomtee ships one of these, but only by pulling in QCBOR to build the
|
|
// map. The map is thirteen bytes and lives in Fingerprintd:Tee under test, so
|
|
// this serves it and the library needs no dependency beyond libc.
|
|
struct CredentialsObject {
|
|
qcomtee_object object; // must be first: we cast between them
|
|
std::vector<std::byte> blob;
|
|
std::uint64_t lenStorage = 0; // op 0's answer, pointed at not copied
|
|
};
|
|
|
|
void CredentialsRelease(qcomtee_object* object) {
|
|
delete reinterpret_cast<CredentialsObject*>(object);
|
|
}
|
|
|
|
qcomtee_result_t CredentialsDispatch(qcomtee_object* object, qcomtee_op_t op,
|
|
qcomtee_param* params, int num) {
|
|
auto* self = reinterpret_cast<CredentialsObject*>(object);
|
|
|
|
// On the CALLBACK path a QCOMTEE_UBUF_OUTPUT param arrives with
|
|
// addr = NULL and size = the capacity QTEE will accept: the dispatcher
|
|
// supplies the buffer, so the handler POINTS the param at storage of its
|
|
// own and lets the framework marshal it. Writing through the incoming addr
|
|
// is a null dereference, which is exactly how this crashed the first time
|
|
// it ran against real QTEE.
|
|
if (op == static_cast<qcomtee_op_t>(fingerprintd::tee::CredOp::GetLength)) {
|
|
if (num != 1 || params[0].attr != QCOMTEE_UBUF_OUTPUT)
|
|
return QCOMTEE_ERROR_INVALID;
|
|
if (params[0].ubuf.size < fingerprintd::tee::CredLengthReplySize)
|
|
return QCOMTEE_ERROR_INVALID;
|
|
self->lenStorage = static_cast<std::uint64_t>(self->blob.size());
|
|
params[0].ubuf.addr = &self->lenStorage;
|
|
params[0].ubuf.size = sizeof(self->lenStorage);
|
|
return QCOMTEE_OK;
|
|
}
|
|
|
|
if (op == static_cast<qcomtee_op_t>(fingerprintd::tee::CredOp::ReadAtOffset)) {
|
|
if (num != 2 || params[0].attr != QCOMTEE_UBUF_INPUT
|
|
|| params[1].attr != QCOMTEE_UBUF_OUTPUT)
|
|
return QCOMTEE_ERROR_INVALID;
|
|
// An INPUT param does carry a real address; only outputs arrive NULL.
|
|
if (params[0].ubuf.size < sizeof(std::uint64_t) || !params[0].ubuf.addr)
|
|
return QCOMTEE_ERROR_INVALID;
|
|
std::uint64_t offset = 0;
|
|
::memcpy(&offset, params[0].ubuf.addr, sizeof(offset));
|
|
|
|
auto plan = fingerprintd::tee::PlanRead(self->blob.size(), offset,
|
|
params[1].ubuf.size);
|
|
if (!plan.valid)
|
|
return QCOMTEE_ERROR_INVALID;
|
|
// Same again: point at the blob, do not copy into QTEE's buffer. The
|
|
// storage has to outlive the dispatch, which the object owns.
|
|
params[1].ubuf.addr = self->blob.data() + plan.offset;
|
|
params[1].ubuf.size = plan.count;
|
|
return QCOMTEE_OK;
|
|
}
|
|
|
|
return QCOMTEE_ERROR_INVALID;
|
|
}
|
|
|
|
qcomtee_object_ops g_credOps = {
|
|
/* release */ CredentialsRelease,
|
|
/* dispatch */ CredentialsDispatch,
|
|
/* error */ nullptr,
|
|
/* supported */ nullptr,
|
|
};
|
|
|
|
qcomtee_object* MakeCredentials(std::uint32_t uid) {
|
|
auto* c = new CredentialsObject{};
|
|
c->blob = fingerprintd::tee::BuildCredentials(uid, NowMs());
|
|
if (qcomtee_object_cb_init(&c->object, &g_credOps, g_root)) {
|
|
delete c;
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
return &c->object;
|
|
}
|
|
|
|
// ROOT op 2: hand QTEE a live credentials object and get a client env back.
|
|
// QTEE calls into the object while this invoke is outstanding, which is why
|
|
// the supplicant has to be running first.
|
|
qcomtee_object* GetClientEnv(std::uint32_t uid) {
|
|
qcomtee_object* creds = MakeCredentials(uid);
|
|
if (creds == QCOMTEE_OBJECT_NULL) {
|
|
std::println(std::cerr, "credentials object init failed");
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
qcomtee_param p[2] = {};
|
|
p[0].attr = QCOMTEE_OBJREF_INPUT;
|
|
p[0].object = creds;
|
|
p[1].attr = QCOMTEE_OBJREF_OUTPUT;
|
|
qcomtee_result_t result = 0;
|
|
if (qcomtee_object_invoke(g_root,
|
|
static_cast<qcomtee_op_t>(fingerprintd::tee::ClientEnvOp),
|
|
p, 2, &result) || result) {
|
|
std::println(std::cerr, "ROOT op {} failed, result={}",
|
|
static_cast<unsigned>(fingerprintd::tee::ClientEnvOp),
|
|
static_cast<int>(result));
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
return p[1].object;
|
|
}
|
|
|
|
// IClientEnv op 0: open a service by UID on the env.
|
|
qcomtee_object* OpenService(qcomtee_object* env, std::uint32_t uid) {
|
|
qcomtee_param p[2] = {};
|
|
p[0].attr = QCOMTEE_UBUF_INPUT;
|
|
p[0].ubuf.addr = &uid;
|
|
p[0].ubuf.size = sizeof(uid);
|
|
p[1].attr = QCOMTEE_OBJREF_OUTPUT;
|
|
qcomtee_result_t result = 0;
|
|
if (qcomtee_object_invoke(env, 0, p, 2, &result) || result) {
|
|
std::println(std::cerr, "IClientEnv.open({}) failed, result={}", uid,
|
|
static_cast<int>(result));
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
return p[1].object;
|
|
}
|
|
|
|
// ---- The storage listeners
|
|
//
|
|
// QTEE cannot reach a filesystem, so it calls back into the normal world for
|
|
// every template read and write. This serves those callbacks. The framing is
|
|
// Fingerprintd:Sfs; what lives here is the file I/O and the registration.
|
|
//
|
|
// READ-ONLY MODE EXISTS FOR A REASON. QTEE deletes a container whose keyed
|
|
// integrity tag does not verify, so a listener that serves bytes at the wrong
|
|
// offset does not merely fail -- it makes QTEE unlink an enrolled template.
|
|
// That is unrecoverable. Until a build has been shown to round-trip a
|
|
// container, it should serve read-only, where an unlink is refused with EROFS
|
|
// and the store cannot be damaged.
|
|
bool g_sfsReadOnly = true;
|
|
std::string g_sfsRoot = "/var/lib/fingerprintd/sfs";
|
|
|
|
struct ListenerObject {
|
|
qcomtee_object object; // must be first
|
|
std::uint32_t id = 0;
|
|
qcomtee_object* shared = QCOMTEE_OBJECT_NULL;
|
|
std::array<std::array<std::byte, 64>, 8> outBufs{};
|
|
};
|
|
|
|
void ListenerRelease(qcomtee_object* object) {
|
|
delete reinterpret_cast<ListenerObject*>(object);
|
|
}
|
|
|
|
// Serve one gpfile request out of the shared buffer, in place.
|
|
void ServeGpFile(std::span<std::byte> sb) {
|
|
namespace sfs = fingerprintd::sfs;
|
|
|
|
auto req = sfs::ParseRequest(sb);
|
|
if (!req) {
|
|
std::println(" gpfile: undecodable request");
|
|
sfs::WriteReply(sb, EINVAL, 0);
|
|
return;
|
|
}
|
|
if (req->op == sfs::OpConfigPathInit) {
|
|
// Asked first, with an empty frame. The answer is LATCHED for the
|
|
// whole boot, so an experiment on its value needs a fresh boot.
|
|
if (g_verbose)
|
|
std::println(" gpfile op 12 (path init) -> {}", sfs::ConfigPathInitReply);
|
|
sfs::WriteConfigPathInitReply(sb);
|
|
return;
|
|
}
|
|
|
|
auto full = sfs::ResolvePath(g_sfsRoot, req->root, req->path);
|
|
if (!full) {
|
|
std::println(" gpfile: refusing path '{}' under root {}", req->path, req->root);
|
|
sfs::WriteReply(sb, EINVAL, 0);
|
|
return;
|
|
}
|
|
|
|
switch (req->action) {
|
|
case sfs::Action::Read: {
|
|
if (g_verbose)
|
|
std::println(" gpfile READ {} off={} len={}", *full, req->offset, req->length);
|
|
std::ifstream f(*full, std::ios::binary);
|
|
if (!f) { sfs::WriteReply(sb, ENOENT, 0); return; }
|
|
if (req->offset > 0) f.seekg(req->offset);
|
|
std::size_t want = std::min<std::size_t>(req->length,
|
|
sfs::Capacity(sb, sfs::Action::Read));
|
|
f.read(reinterpret_cast<char*>(sb.data() + sfs::ReadDataOff),
|
|
static_cast<std::streamsize>(want));
|
|
auto got = static_cast<std::uint32_t>(f.gcount());
|
|
if (g_verbose)
|
|
std::println(" -> errno=0 count={} (asked {}, capacity {})", got,
|
|
req->length, sfs::Capacity(sb, sfs::Action::Read));
|
|
sfs::WriteReply(sb, 0, got);
|
|
return;
|
|
}
|
|
case sfs::Action::Write: {
|
|
std::println(" gpfile WRITE {} off={} len={}", *full, req->offset, req->length);
|
|
if (g_sfsReadOnly) {
|
|
std::println(" REFUSED: read-only");
|
|
sfs::WriteReply(sb, EROFS, 0);
|
|
return;
|
|
}
|
|
// The group directory may not exist yet -- a store with no enrolments
|
|
// has no group at all, and open(O_CREAT) creates the file, never its
|
|
// parent. Without this a first enrolment into a fresh store fails with
|
|
// ENOENT, which QTEE reports as an I/O error indistinguishable from a
|
|
// real storage fault.
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(
|
|
std::filesystem::path(*full).parent_path(), ec);
|
|
|
|
// O_RDWR | O_CREAT | O_SYNC and never O_TRUNC: QTEE writes a container
|
|
// as write(0,4096), write(4096,N), write(0,4096), so truncating on open
|
|
// leaves 4096 bytes where a 258850-byte template belongs.
|
|
int fd = ::open(full->c_str(), O_RDWR | O_CREAT | O_SYNC, 0600);
|
|
if (fd < 0) { sfs::WriteReply(sb, errno, 0); return; }
|
|
if (req->offset > 0 && ::lseek(fd, req->offset, SEEK_SET) < 0) {
|
|
int e = errno; ::close(fd); sfs::WriteReply(sb, e, 0); return;
|
|
}
|
|
std::size_t want = std::min<std::size_t>(req->length,
|
|
sfs::Capacity(sb, sfs::Action::Write));
|
|
std::size_t done = 0;
|
|
int werr = 0;
|
|
while (done < want) { // short writes are real; the reference loops
|
|
ssize_t n = ::write(fd, sb.data() + sfs::WriteDataOff + done, want - done);
|
|
if (n < 0) { werr = errno; break; }
|
|
if (n == 0) break;
|
|
done += static_cast<std::size_t>(n);
|
|
}
|
|
::fsync(fd);
|
|
::close(fd);
|
|
std::println(" -> errno={} count={}", werr, done);
|
|
sfs::WriteReply(sb, static_cast<std::uint32_t>(werr),
|
|
static_cast<std::uint32_t>(done));
|
|
return;
|
|
}
|
|
case sfs::Action::Unlink:
|
|
std::println(" gpfile UNLINK {}", *full);
|
|
if (g_sfsReadOnly) {
|
|
std::println(" REFUSED: read-only (this is what protects an enrolled template)");
|
|
sfs::WriteReply(sb, EROFS, 0);
|
|
return;
|
|
}
|
|
sfs::WriteReply(sb, ::unlink(full->c_str()) ? errno : 0, 0);
|
|
return;
|
|
case sfs::Action::Rename: {
|
|
auto to = sfs::ResolvePath(g_sfsRoot, req->root, req->path2);
|
|
std::println(" gpfile RENAME {} -> {}", *full, to ? *to : std::string("?"));
|
|
if (g_sfsReadOnly || !to) { sfs::WriteReply(sb, EROFS, 0); return; }
|
|
sfs::WriteReply(sb, ::rename(full->c_str(), to->c_str()) ? errno : 0, 0);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- RPMB
|
|
//
|
|
// The anti-rollback half. QTEE will not trust a container until it has read
|
|
// its counter record out of the UFS device's replay-protected area, and it
|
|
// cannot reach the device itself. This serves that read, and the write.
|
|
//
|
|
// A WRITE advances a monotonic counter that can never be moved back, so it is
|
|
// refused unless explicitly enabled. Key programming is refused ALWAYS -- the
|
|
// RPMB key is one-time programmable and relaying such a frame destroys this
|
|
// part's RPMB permanently.
|
|
bool g_rpmbWrite = false;
|
|
|
|
// SECURITY PROTOCOL IN/OUT against the RPMB well-known LUN. Returns 0 on
|
|
// success, 1 on unit attention (retryable), -1 on error.
|
|
int SecurityProtocol(int fd, bool isIn, std::byte* buf, std::uint32_t len) {
|
|
namespace rp = fingerprintd::rpmb;
|
|
std::array<unsigned char, 12> cdb{};
|
|
std::array<unsigned char, 64> sense{};
|
|
|
|
cdb[0] = isIn ? 0xA2 : 0xB5;
|
|
cdb[1] = rp::SecurityProtocolUfs;
|
|
cdb[2] = (rp::SecurityProtocolSpecific >> 8) & 0xFF;
|
|
cdb[3] = rp::SecurityProtocolSpecific & 0xFF;
|
|
cdb[4] = 0; // INC_512 = 0: the length is in bytes
|
|
cdb[6] = (len >> 24) & 0xFF;
|
|
cdb[7] = (len >> 16) & 0xFF;
|
|
cdb[8] = (len >> 8) & 0xFF;
|
|
cdb[9] = len & 0xFF;
|
|
|
|
sg_io_v4 io{};
|
|
io.guard = 'Q';
|
|
io.protocol = BSG_PROTOCOL_SCSI;
|
|
io.subprotocol = BSG_SUB_PROTOCOL_SCSI_CMD;
|
|
io.request_len = cdb.size();
|
|
io.request = reinterpret_cast<std::uintptr_t>(cdb.data());
|
|
io.max_response_len = sense.size();
|
|
io.response = reinterpret_cast<std::uintptr_t>(sense.data());
|
|
io.timeout = 15000;
|
|
if (isIn) {
|
|
io.din_xfer_len = len;
|
|
io.din_xferp = reinterpret_cast<std::uintptr_t>(buf);
|
|
} else {
|
|
io.dout_xfer_len = len;
|
|
io.dout_xferp = reinterpret_cast<std::uintptr_t>(buf);
|
|
}
|
|
|
|
if (::ioctl(fd, SG_IO, &io) < 0) {
|
|
std::println(" SP{} ioctl failed: {}", isIn ? "I" : "O", ::strerror(errno));
|
|
return -1;
|
|
}
|
|
if (io.driver_status || io.transport_status || io.device_status) {
|
|
unsigned key = sense[2] & 0x0F;
|
|
if (g_verbose)
|
|
std::println(" SP{} status drv={} trans={} dev={} sense key={} asc=0x{:02x}/{:02x}",
|
|
isIn ? "I" : "O", io.driver_status, io.transport_status,
|
|
io.device_status, key, sense[12], sense[13]);
|
|
// The RPMB LUN raises UNIT ATTENTION on the first command after a
|
|
// reset and clears it by reporting it once. Retryable, not an error.
|
|
return key == fingerprintd::rpmb::SenseKeyUnitAttention ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int SecurityProtocolRetry(int fd, bool isIn, std::byte* buf, std::uint32_t len) {
|
|
for (int t = 0; t < 4; t++) {
|
|
int rc = SecurityProtocol(fd, isIn, buf, len);
|
|
if (rc != 1) return rc;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
void ServeRpmb(std::span<std::byte> sb) {
|
|
namespace rp = fingerprintd::rpmb;
|
|
|
|
auto req = rp::ParseRequest(sb);
|
|
if (!req) { rp::WriteReply(sb, rp::StatusRefused, 0); return; }
|
|
if (g_verbose)
|
|
std::println(" rpmb op=0x{:x} nblocks={} framesz={} dataoff=0x{:x}",
|
|
static_cast<unsigned>(req->op), req->nblocks, req->frameSize,
|
|
req->dataOff);
|
|
|
|
if (!rp::FramesInBounds(sb, *req)) {
|
|
std::println(" rpmb: frames out of bounds, refusing");
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
|
|
// NEVER RELAYED, whatever the write policy says. The RPMB authentication
|
|
// key is one-time programmable: reprogramming it destroys this part's RPMB
|
|
// permanently and no reflash recovers it. QTEE has no legitimate reason to
|
|
// send one.
|
|
if (rp::AnyKeyProgramming(sb, *req)) {
|
|
std::println(" *** REFUSED: RPMB KEY PROGRAMMING frame. Irreversible. ***");
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
|
|
if (req->op == rp::Op::Write && !g_rpmbWrite) {
|
|
std::println(" rpmb WRITE refused (advances an irreversible counter)");
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
if (req->op != rp::Op::Read && req->op != rp::Op::Write) {
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
|
|
int fd = ::open(std::string(rp::BsgDevice).c_str(), O_RDWR);
|
|
if (fd < 0) {
|
|
std::println(" rpmb: open {}: {}", rp::BsgDevice, ::strerror(errno));
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
std::byte* frames = sb.data() + req->dataOff;
|
|
std::uint32_t total = req->nblocks * static_cast<std::uint32_t>(rp::FrameSize);
|
|
|
|
int rc = -1;
|
|
if (req->op == rp::Op::Read) {
|
|
// A read posts ONE request frame however large nblocks is, then
|
|
// collects nblocks * 512 back.
|
|
if (SecurityProtocolRetry(fd, false, frames, rp::FrameSize) == 0)
|
|
rc = SecurityProtocolRetry(fd, true, frames, total);
|
|
} else {
|
|
// The authenticated write sequence, per chunk: the data frames out, a
|
|
// Result Read Request out, the result frame back.
|
|
//
|
|
// req+0x14 is the chunk size, but it is not always usable: the
|
|
// reference falls back to the whole block count when it is zero or
|
|
// larger than nblocks. A remainder is refused rather than partially
|
|
// committed -- a partial authenticated write leaves the store
|
|
// inconsistent with a counter that cannot be moved back.
|
|
std::uint32_t bpo = req->blocksPerOp;
|
|
if (bpo == 0 || bpo > req->nblocks) bpo = req->nblocks;
|
|
auto plan = rp::PlanChunks(req->nblocks, bpo);
|
|
if (!plan.exact) {
|
|
std::println(" rpmb: {} blocks is not a whole number of {}-block chunks"
|
|
" -- refusing", req->nblocks, bpo);
|
|
} else {
|
|
std::array<std::byte, rp::FrameSize> rrq{};
|
|
rp::BuildResultReadRequest(rrq);
|
|
rc = 0;
|
|
for (std::uint32_t k = 0; k < plan.chunks && rc == 0; k++) {
|
|
std::byte* chunk = frames + static_cast<std::size_t>(k)
|
|
* bpo * rp::FrameSize;
|
|
std::uint32_t bytes = bpo * static_cast<std::uint32_t>(rp::FrameSize);
|
|
// The RESULT FRAME GOES BACK INTO THE SHARED BUFFER, at the
|
|
// data offset -- QTEE reads it at req + req[0x0c], which is
|
|
// exactly where the request frames were. Collecting it into a
|
|
// local means QTEE never sees the device's answer and fails
|
|
// the whole transaction with an I/O error, having already
|
|
// committed the counter.
|
|
std::byte* result = frames;
|
|
if (SecurityProtocolRetry(fd, false, chunk, bytes) != 0 ||
|
|
SecurityProtocolRetry(fd, false, rrq.data(), rp::FrameSize) != 0 ||
|
|
SecurityProtocolRetry(fd, true, result, rp::FrameSize) != 0) {
|
|
rc = -1;
|
|
break;
|
|
}
|
|
std::span<const std::byte> rf(result, rp::FrameSize);
|
|
std::uint16_t res = rp::ResultOf(rf);
|
|
std::println(" rpmb write chunk {}/{}: result=0x{:04x} ({}) counter={}",
|
|
k + 1, plan.chunks, res, rp::ResultString(res),
|
|
rp::WriteCounterOf(rf));
|
|
// Anything non-zero aborts rather than continuing into further
|
|
// chunks: the device rejected the frame and the counter state
|
|
// is not what we think it is.
|
|
if (res != rp::ResultOk) rc = -1;
|
|
}
|
|
}
|
|
}
|
|
::close(fd);
|
|
|
|
if (rc != 0) {
|
|
rp::WriteReply(sb, rp::StatusRefused, 0);
|
|
return;
|
|
}
|
|
// +0x08 is an OUT parameter QTEE checks against what it expected to be
|
|
// transferred; leaving the request's frame size there fails every
|
|
// transaction. +0x0c is left exactly as the request supplied it.
|
|
rp::WriteReply(sb, rp::StatusOk, rp::BytesTransferred(req->op, req->nblocks));
|
|
}
|
|
|
|
qcomtee_result_t ListenerDispatch(qcomtee_object* object, qcomtee_op_t op,
|
|
qcomtee_param* params, int num) {
|
|
auto* self = reinterpret_cast<ListenerObject*>(object);
|
|
(void)op;
|
|
|
|
for (int i = 0; i < num; i++) {
|
|
switch (params[i].attr) {
|
|
case QCOMTEE_UBUF_OUTPUT: {
|
|
// addr arrives NULL on the callback path; point it at our own
|
|
// storage. Zeros are the answer QTEE expects here.
|
|
std::size_t want = std::min<std::size_t>(params[i].ubuf.size,
|
|
self->outBufs[0].size());
|
|
if (i < 8) {
|
|
self->outBufs[i].fill(std::byte{0});
|
|
params[i].ubuf.addr = self->outBufs[i].data();
|
|
params[i].ubuf.size = want;
|
|
}
|
|
break;
|
|
}
|
|
case QCOMTEE_OBJREF_OUTPUT:
|
|
// MUST be set. cb_marshal_in leaves .object uninitialised and
|
|
// marshal_out then calls typeof() on stack garbage -- a SIGSEGV in
|
|
// the supplicant the moment QTEE first dispatches.
|
|
params[i].object = QCOMTEE_OBJECT_NULL;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The request itself rides in the registered shared buffer, not in params.
|
|
void* addr = qcomtee_memory_object_addr(self->shared);
|
|
std::size_t size = qcomtee_memory_object_size(self->shared);
|
|
if (addr) {
|
|
std::span<std::byte> sb(static_cast<std::byte*>(addr), size);
|
|
if (self->id == 0x7000)
|
|
ServeGpFile(sb);
|
|
else if (self->id == 0x2000)
|
|
ServeRpmb(sb);
|
|
}
|
|
return QCOMTEE_OK;
|
|
}
|
|
|
|
qcomtee_object_ops g_listenerOps = {
|
|
/* release */ ListenerRelease,
|
|
/* dispatch */ ListenerDispatch,
|
|
/* error */ nullptr,
|
|
/* supported */ nullptr,
|
|
};
|
|
|
|
// One callback object PER registration. Sharing one across registrations
|
|
// overwrites its id and buffer, and every multi-listener result taken that way
|
|
// is void -- six sessions of hypotheses rested on exactly that bug.
|
|
bool RegisterListener(qcomtee_object* env, std::uint32_t id, std::size_t bufSize) {
|
|
qcomtee_object* svc = OpenService(env, fingerprintd::tee::UidListenerCbo);
|
|
if (svc == QCOMTEE_OBJECT_NULL) return false;
|
|
|
|
qcomtee_object* shared = QCOMTEE_OBJECT_NULL;
|
|
if (qcomtee_memory_object_alloc(bufSize, g_root, &shared)) {
|
|
std::println(std::cerr, "listener 0x{:x}: shared buffer alloc failed", id);
|
|
return false;
|
|
}
|
|
auto* lo = new ListenerObject{};
|
|
lo->id = id;
|
|
lo->shared = shared;
|
|
if (qcomtee_object_cb_init(&lo->object, &g_listenerOps, g_root)) {
|
|
delete lo;
|
|
return false;
|
|
}
|
|
|
|
std::uint32_t lid = id;
|
|
qcomtee_param p[3] = {};
|
|
p[0].attr = QCOMTEE_UBUF_INPUT; p[0].ubuf.addr = &lid; p[0].ubuf.size = sizeof(lid);
|
|
p[1].attr = QCOMTEE_OBJREF_INPUT; p[1].object = &lo->object;
|
|
p[2].attr = QCOMTEE_OBJREF_INPUT; p[2].object = shared;
|
|
qcomtee_result_t result = 0;
|
|
if (qcomtee_object_invoke(svc, 0, p, 3, &result)) {
|
|
std::println(std::cerr, "listener 0x{:x}: invoke failed", id);
|
|
return false;
|
|
}
|
|
std::println("listener 0x{:<5x} sb={:<7} -> result={}{}", id, bufSize,
|
|
static_cast<int>(result),
|
|
result == 0 ? " REGISTERED"
|
|
: static_cast<int>(result) == fingerprintd::tee::ResultIdAlreadyTaken
|
|
? " (id already taken)" : "");
|
|
return result == 0;
|
|
}
|
|
|
|
// ---- The sensor rail
|
|
//
|
|
// GPIO v2 chardev ioctls directly: libgpiod is not on the phone and this is
|
|
// three lines. The chip is found by LABEL, never by index -- /dev/gpiochipN
|
|
// ordering is not stable and driving the wrong controller's pins is the kind
|
|
// of mistake that is not recoverable over ssh.
|
|
class Sensor {
|
|
public:
|
|
~Sensor() { PowerOff(); }
|
|
|
|
bool Open() {
|
|
namespace sn = fingerprintd::sensor;
|
|
chip_ = FindChip(sn::ChipLabel);
|
|
if (chip_ < 0) {
|
|
std::println(std::cerr, "no gpiochip labelled '{}'", sn::ChipLabel);
|
|
return false;
|
|
}
|
|
power_ = RequestLine(sn::PowerLine, GPIO_V2_LINE_FLAG_OUTPUT, "fpd-pwr");
|
|
reset_ = RequestLine(sn::ResetLine, GPIO_V2_LINE_FLAG_OUTPUT, "fpd-rst");
|
|
// Both edges, not just the rising one the DT declares: the line is a
|
|
// ~1 ms pulse, so a level read catches it only by luck, and knowing
|
|
// the pulse WIDTH distinguishes a touch pulse from a heartbeat.
|
|
irq_ = RequestLine(sn::IrqLine,
|
|
GPIO_V2_LINE_FLAG_INPUT | GPIO_V2_LINE_FLAG_EDGE_RISING
|
|
| GPIO_V2_LINE_FLAG_EDGE_FALLING,
|
|
"fpd-irq");
|
|
return power_ >= 0 && reset_ >= 0 && irq_ >= 0;
|
|
}
|
|
|
|
// The line fd, for poll(): readable when an edge event is queued.
|
|
int IrqFd() const { return irq_; }
|
|
|
|
// Discard anything already queued, so a wait sees only NEW edges. Without
|
|
// this the burst from the previous press wakes the next wait instantly.
|
|
void DrainEdges() { ReadEdges(); }
|
|
|
|
// Drain queued edge events. Each is a gpio_v2_line_event with a kernel
|
|
// timestamp in ns and RISING/FALLING.
|
|
struct Edge { std::uint64_t ns; bool rising; };
|
|
std::vector<Edge> ReadEdges() {
|
|
std::vector<Edge> out;
|
|
if (irq_ < 0) return out;
|
|
gpio_v2_line_event ev[16];
|
|
for (;;) {
|
|
ssize_t n = ::read(irq_, ev, sizeof(ev));
|
|
if (n <= 0) break;
|
|
for (std::size_t i = 0; i < static_cast<std::size_t>(n) / sizeof(ev[0]); i++)
|
|
out.push_back({ ev[i].timestamp_ns, ev[i].id == GPIO_V2_LINE_EVENT_RISING_EDGE });
|
|
if (static_cast<std::size_t>(n) < sizeof(ev)) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Block up to timeoutMs for an edge, then drain. Empty on timeout. This is
|
|
// what turns the capture loop from a fixed-cadence poll into a
|
|
// wake-on-contact: the line is silent at idle and pulses within
|
|
// milliseconds of a finger landing, hundreds of milliseconds before a
|
|
// polled capture notices.
|
|
std::vector<Edge> WaitEdges(int timeoutMs);
|
|
|
|
// Rail up, settle, release reset, settle. Both lines are driven low first
|
|
// so a warm restart starts where a cold one does.
|
|
bool PowerOn() {
|
|
namespace sn = fingerprintd::sensor;
|
|
if (!Set(power_, 0) || !Set(reset_, 0)) return false;
|
|
if (!Set(power_, 1)) return false;
|
|
std::this_thread::sleep_for(sn::PowerSettle);
|
|
if (!Set(reset_, 1)) return false;
|
|
std::this_thread::sleep_for(sn::ResetSettle);
|
|
on_ = true;
|
|
return true;
|
|
}
|
|
|
|
void PowerOff() {
|
|
if (!on_) return;
|
|
Set(reset_, 0);
|
|
Set(power_, 0);
|
|
on_ = false;
|
|
}
|
|
|
|
std::optional<int> ReadIrq() const { return Get(irq_); }
|
|
|
|
private:
|
|
static int FindChip(std::string_view label) {
|
|
for (int i = 0; i < 32; i++) {
|
|
std::string path = std::format("/dev/gpiochip{}", i);
|
|
int fd = ::open(path.c_str(), O_RDWR | O_CLOEXEC);
|
|
if (fd < 0) continue;
|
|
gpiochip_info info{};
|
|
if (::ioctl(fd, GPIO_GET_CHIPINFO_IOCTL, &info) == 0
|
|
&& label == info.label
|
|
&& info.lines >= fingerprintd::sensor::MinChipLines) {
|
|
std::println("gpiochip '{}' is {} ({} lines)", info.label, path,
|
|
info.lines);
|
|
return fd;
|
|
}
|
|
::close(fd);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
int RequestLine(unsigned line, std::uint64_t flags, const char* consumer) {
|
|
// The guard, enforced where the line is actually opened rather than
|
|
// only asserted in the core. gpio8-11 are XPU-protected and touching
|
|
// one is an immediate SError, not an error return.
|
|
if (!fingerprintd::sensor::IsSafeLine(line)) {
|
|
std::println(std::cerr,
|
|
"REFUSING to open gpio{}: XPU-protected fingerprint SPI", line);
|
|
return -1;
|
|
}
|
|
gpio_v2_line_request req{};
|
|
req.offsets[0] = line;
|
|
req.num_lines = 1;
|
|
req.config.flags = flags;
|
|
std::snprintf(req.consumer, sizeof(req.consumer), "%s", consumer);
|
|
if (::ioctl(chip_, GPIO_V2_GET_LINE_IOCTL, &req) < 0) {
|
|
std::println(std::cerr, "gpio{} request failed: {}", line, ::strerror(errno));
|
|
return -1;
|
|
}
|
|
// Edge events are drained with read(); never block the caller on it.
|
|
int fl = ::fcntl(req.fd, F_GETFL);
|
|
if (fl >= 0) ::fcntl(req.fd, F_SETFL, fl | O_NONBLOCK);
|
|
return req.fd;
|
|
}
|
|
|
|
static bool Set(int fd, int v) {
|
|
if (fd < 0) return false;
|
|
gpio_v2_line_values vals{};
|
|
vals.mask = 1;
|
|
vals.bits = v ? 1 : 0;
|
|
return ::ioctl(fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals) == 0;
|
|
}
|
|
|
|
static std::optional<int> Get(int fd) {
|
|
if (fd < 0) return std::nullopt;
|
|
gpio_v2_line_values vals{};
|
|
vals.mask = 1;
|
|
if (::ioctl(fd, GPIO_V2_LINE_GET_VALUES_IOCTL, &vals) < 0) return std::nullopt;
|
|
return static_cast<int>(vals.bits & 1);
|
|
}
|
|
|
|
int chip_ = -1, power_ = -1, reset_ = -1, irq_ = -1;
|
|
bool on_ = false;
|
|
};
|
|
|
|
std::vector<Sensor::Edge> Sensor::WaitEdges(int timeoutMs) {
|
|
if (irq_ < 0) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(timeoutMs));
|
|
return {};
|
|
}
|
|
pollfd pfd{ irq_, POLLIN, 0 };
|
|
int r = ::poll(&pfd, 1, timeoutMs);
|
|
if (r <= 0) return {};
|
|
return ReadEdges();
|
|
}
|
|
|
|
// ---- The trustlet
|
|
//
|
|
// The loader is IQSEEComCompatAppLoader (UID 122): op 1 loadFromBuffer, op 2
|
|
// lookupTA. A stale instance from a crashed run is unloaded first, which is
|
|
// what stops a bad experiment costing a reboot.
|
|
constexpr const char* TaName = "focal64";
|
|
|
|
void UnloadStale(qcomtee_object* loader) {
|
|
qcomtee_param p[3] = {};
|
|
std::array<std::byte, 4> ob{};
|
|
p[0].attr = QCOMTEE_UBUF_INPUT;
|
|
p[0].ubuf.addr = const_cast<char*>(TaName);
|
|
p[0].ubuf.size = std::strlen(TaName);
|
|
p[1].attr = QCOMTEE_UBUF_OUTPUT;
|
|
p[1].ubuf.addr = ob.data();
|
|
p[1].ubuf.size = ob.size();
|
|
p[2].attr = QCOMTEE_OBJREF_OUTPUT;
|
|
qcomtee_result_t result = 0;
|
|
if (qcomtee_object_invoke(loader, 2, p, 3, &result) || result) {
|
|
std::println("lookupTA('{}') -> result={} (nothing to unload)", TaName,
|
|
static_cast<int>(result));
|
|
return;
|
|
}
|
|
if (!qcomtee_object_invoke(p[2].object, 2, nullptr, 0, &result))
|
|
std::println("unloaded a stale '{}' -> result={}", TaName, static_cast<int>(result));
|
|
qcomtee_object_refs_dec(p[2].object);
|
|
}
|
|
|
|
qcomtee_object* LoadTrustlet(qcomtee_object* loader, const std::string& path) {
|
|
UnloadStale(loader);
|
|
|
|
std::ifstream f(path, std::ios::binary);
|
|
if (!f) {
|
|
std::println(std::cerr, "cannot open {}", path);
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
std::vector<char> image((std::istreambuf_iterator<char>(f)),
|
|
std::istreambuf_iterator<char>());
|
|
if (image.empty()) {
|
|
std::println(std::cerr, "{} is empty", path);
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
|
|
std::array<char, 128> distName{};
|
|
qcomtee_param p[4] = {};
|
|
p[0].attr = QCOMTEE_UBUF_INPUT;
|
|
p[0].ubuf.addr = image.data();
|
|
p[0].ubuf.size = image.size();
|
|
p[1].attr = QCOMTEE_UBUF_INPUT;
|
|
p[1].ubuf.addr = const_cast<char*>(TaName);
|
|
p[1].ubuf.size = std::strlen(TaName);
|
|
p[2].attr = QCOMTEE_UBUF_OUTPUT;
|
|
p[2].ubuf.addr = distName.data();
|
|
p[2].ubuf.size = distName.size();
|
|
p[3].attr = QCOMTEE_OBJREF_OUTPUT;
|
|
qcomtee_result_t result = 0;
|
|
if (qcomtee_object_invoke(loader, 1, p, 4, &result) || result) {
|
|
std::println(std::cerr, "loadFromBuffer failed, result={}",
|
|
static_cast<int>(result));
|
|
return QCOMTEE_OBJECT_NULL;
|
|
}
|
|
std::println("trustlet loaded from {} ({} bytes), distName='{}'", path,
|
|
image.size(), distName.data());
|
|
return p[3].object;
|
|
}
|
|
|
|
// sendRequest is op 0 with arity 0x0424: four input buffers, two output, four
|
|
// object slots. The request and response buffers go in and come back out; the
|
|
// trustlet's own return code rides in the returned request's header.
|
|
struct CommandResult {
|
|
bool invoked = false;
|
|
qcomtee_result_t result = 0;
|
|
std::int32_t rc = 0;
|
|
std::int32_t metric = 0;
|
|
// Only meaningful for REPORT_EVENT: the matcher's verdict rides in the
|
|
// returned request's payload.
|
|
std::uint32_t gid = 0;
|
|
std::uint32_t fid = 0;
|
|
std::int32_t samplesRemaining = -1;
|
|
|
|
bool Ok() const { return invoked && result == 0 && rc == 0; }
|
|
};
|
|
|
|
void DumpTaLog(std::span<const std::byte> buf); // defined below, with Report
|
|
|
|
CommandResult SendCommand(qcomtee_object* app, fingerprintd::ta::Cmd cmd,
|
|
std::span<const std::byte> payload) {
|
|
namespace ta = fingerprintd::ta;
|
|
namespace tee = fingerprintd::tee;
|
|
// Only ever called from the worker thread, hence static.
|
|
static std::vector<std::byte> req(8192), rsp(16384), reqOut(8192), rspOut(16384);
|
|
std::ranges::fill(rsp, std::byte{0});
|
|
std::ranges::fill(reqOut, std::byte{0});
|
|
std::ranges::fill(rspOut, std::byte{0});
|
|
ta::BuildRequest(req, cmd, payload);
|
|
|
|
// CAPTURE_IMAGE's flags word sits at payload+0x18, PAST the declared
|
|
// length of 0x14 -- the trustlet range-checks the length to exactly that
|
|
// and reads the flags anyway. Without bit 1 or bit 30 it skips
|
|
// preprocessing, the classifier and the enrol grouper entirely and returns
|
|
// success having done nothing but a raw scan.
|
|
if (cmd == ta::Cmd::CaptureImage) {
|
|
for (std::size_t i = 0; i < 4; i++)
|
|
req[ta::ReqPayloadOff + ta::CaptureFlagsOff + i] =
|
|
static_cast<std::byte>((ta::CaptureFlagsEnrol >> (8 * i)) & 0xFF);
|
|
}
|
|
|
|
std::uint32_t is64 = 1;
|
|
qcomtee_param p[10] = {};
|
|
p[0].attr = QCOMTEE_UBUF_INPUT; p[0].ubuf.addr = req.data(); p[0].ubuf.size = req.size();
|
|
p[1].attr = QCOMTEE_UBUF_INPUT; p[1].ubuf.addr = rsp.data(); p[1].ubuf.size = rsp.size();
|
|
p[2].attr = QCOMTEE_UBUF_INPUT; p[2].ubuf.addr = nullptr; p[2].ubuf.size = 0;
|
|
p[3].attr = QCOMTEE_UBUF_INPUT; p[3].ubuf.addr = &is64; p[3].ubuf.size = sizeof(is64);
|
|
p[4].attr = QCOMTEE_UBUF_OUTPUT; p[4].ubuf.addr = reqOut.data(); p[4].ubuf.size = reqOut.size();
|
|
p[5].attr = QCOMTEE_UBUF_OUTPUT; p[5].ubuf.addr = rspOut.data(); p[5].ubuf.size = rspOut.size();
|
|
for (int i = 6; i < 10; i++) {
|
|
p[i].attr = QCOMTEE_OBJREF_INPUT;
|
|
p[i].object = QCOMTEE_OBJECT_NULL;
|
|
}
|
|
|
|
// A capture needs a real shared memory REGION or the trustlet answers
|
|
// -201: it reads an output-buffer pointer out of payload+0x00, and QTEE
|
|
// only patches an address in there if we name the location in
|
|
// embeddedBufOffsets (IB2) and hand it the region in an object slot.
|
|
//
|
|
// Two traps: the offsets array applies to EVERY command in a run, so it is
|
|
// scoped to this one command -- patching a pointer into SYNC_CONFIG's
|
|
// request breaks it. And the region is allocated fresh per frame and
|
|
// released right after the invoke, success or not. An earlier version
|
|
// released it only on failure, believing "an invoke consumes its input
|
|
// objects" -- that is libqcomtee's rule for CALLBACK objects, not memory
|
|
// objects. Its own ta_load.c hands a memory object in exactly like this
|
|
// and releases it unconditionally afterwards ("QTEE releases its copy").
|
|
// Releasing only on failure leaked one tee_shm fd per capture; a
|
|
// long-lived daemon hit the 1024-fd limit after ~1000 frames, every
|
|
// capture then answered "memory region alloc failed", and an enrolment
|
|
// in progress ran out of frames with nothing on the sensor to blame.
|
|
// Nothing reads the region after the invoke -- the pixels never reach
|
|
// the normal world -- so there is no reason to hold it.
|
|
qcomtee_object* region = QCOMTEE_OBJECT_NULL;
|
|
std::uint32_t offsets = tee::EmbeddedBufOffsetValue;
|
|
if (cmd == static_cast<ta::Cmd>(tee::RegionScopedToCommand)) {
|
|
if (qcomtee_memory_object_alloc(tee::CaptureRegionSize, g_root, ®ion)) {
|
|
std::println(std::cerr, " memory region alloc failed");
|
|
region = QCOMTEE_OBJECT_NULL;
|
|
} else {
|
|
std::memset(qcomtee_memory_object_addr(region), 0,
|
|
qcomtee_memory_object_size(region));
|
|
p[2].ubuf.addr = &offsets;
|
|
p[2].ubuf.size = sizeof(offsets);
|
|
p[6].object = region;
|
|
}
|
|
}
|
|
|
|
CommandResult out;
|
|
int invokeFailed = qcomtee_object_invoke(app, tee::AppSendRequestOp, p, 10, &out.result);
|
|
if (region != QCOMTEE_OBJECT_NULL)
|
|
qcomtee_memory_object_release(region); // closes our fd; QTEE holds its own ref
|
|
if (invokeFailed)
|
|
return out;
|
|
out.invoked = true;
|
|
out.rc = ta::ResultCode(reqOut);
|
|
out.metric = ta::CaptureMetric(reqOut);
|
|
if (cmd == ta::Cmd::ReportEvent) {
|
|
out.gid = ta::MatchedGid(reqOut);
|
|
out.fid = ta::MatchedFid(reqOut);
|
|
out.samplesRemaining = ta::SamplesRemaining(reqOut);
|
|
}
|
|
if (g_taLog) DumpTaLog(rspOut);
|
|
return out;
|
|
}
|
|
|
|
// THE TRUSTLET'S OWN LOG, and on pmOS the only way to read it.
|
|
//
|
|
// focal64 writes its log lines into the RESPONSE buffer, which is how the
|
|
// research harness printed them (`scan_ascii` in utilities/fpta.c). It matters
|
|
// because mainline has no tzdbg: /sys/kernel/debug/tzdbg does not exist on this
|
|
// kernel, so the /proc/tzdbg/qsee_log route that captured the reference log on
|
|
// Android is unavailable here, and the qcomtee `qseelog=1` ring is recorded as
|
|
// wedging TZ. Without this the matcher is a black box that answers only yes or
|
|
// no -- which is exactly why "the matcher saw a full-contact image of the
|
|
// enrolled finger and rejected it" went a whole session with no explanation.
|
|
//
|
|
// What it surfaces, given diagnosis.algorithm_log_level: `auth success
|
|
// score:0x...`, `focal_IdentifyByImage...identify fail! FtVerifyByTemplate() =
|
|
// -2`, and the per-frame `image quality = N, coverage = N, humidity = N`.
|
|
//
|
|
// The ring is ~150 lines per session and is never reset, so it repeats itself
|
|
// across commands. A targeted instrument, not something to leave on.
|
|
void DumpTaLog(std::span<const std::byte> buf) {
|
|
std::size_t i = 0, n = buf.size();
|
|
while (i < n) {
|
|
std::size_t j = i;
|
|
while (j < n) {
|
|
auto c = std::to_integer<unsigned char>(buf[j]);
|
|
if (!(c == '\n' || (c >= 0x20 && c < 0x7f))) break;
|
|
j++;
|
|
}
|
|
if (j - i >= 12)
|
|
std::println(" ta: {}",
|
|
std::string_view(reinterpret_cast<const char*>(buf.data() + i),
|
|
j - i));
|
|
i = (j > i) ? j : i + 1;
|
|
}
|
|
}
|
|
|
|
void Report(fingerprintd::ta::Cmd cmd, const CommandResult& r) {
|
|
namespace ta = fingerprintd::ta;
|
|
if (!r.invoked) {
|
|
std::println(" CMD 0x{:04x} -> INVOKE FAILED", static_cast<unsigned>(cmd));
|
|
return;
|
|
}
|
|
// A POSITIVE rc is not an error code. ENUMERATE returns the template
|
|
// count there, so running it through the error table prints "unknown" for
|
|
// a perfectly good answer.
|
|
if (r.rc > 0)
|
|
std::println(" CMD 0x{:04x} -> result={} rc={}", static_cast<unsigned>(cmd),
|
|
static_cast<int>(r.result), r.rc);
|
|
else
|
|
std::println(" CMD 0x{:04x} -> result={} rc={} ({})", static_cast<unsigned>(cmd),
|
|
static_cast<int>(r.result), r.rc, ta::StrError(r.rc));
|
|
}
|
|
|
|
// =============================================================================
|
|
// The session: everything from a cold /dev/tee0 to a calibrated sensor, and
|
|
// the enrol and verify loops that run against it. WORKER THREAD ONLY.
|
|
// =============================================================================
|
|
class Session {
|
|
public:
|
|
// The whole bring-up. Fails loudly at the first step that does not
|
|
// answer; nothing after a failure is attempted.
|
|
bool Start() {
|
|
namespace tee = fingerprintd::tee;
|
|
namespace ta = fingerprintd::ta;
|
|
|
|
std::string dev(tee::DevTee);
|
|
g_root = qcomtee_object_root_init(dev.c_str(), TeeCall, nullptr, nullptr);
|
|
if (g_root == QCOMTEE_OBJECT_NULL) {
|
|
std::println(std::cerr, "root object on {}: {}", tee::DevTee, ::strerror(errno));
|
|
return false;
|
|
}
|
|
std::println("root object on {}", tee::DevTee);
|
|
|
|
if (pthread_create(&supplicant_, nullptr, Supplicant, nullptr) != 0) {
|
|
std::println(std::cerr, "supplicant thread failed to start");
|
|
return false;
|
|
}
|
|
|
|
std::uint32_t uid = ::getuid();
|
|
env_ = GetClientEnv(uid);
|
|
if (env_ == QCOMTEE_OBJECT_NULL) return false;
|
|
std::println("client env obtained (uid {})", uid);
|
|
|
|
// Register the storage listeners BEFORE loading the trustlet, so any
|
|
// storage QTEE wants during init has somewhere to go.
|
|
for (const auto& l : tee::Listeners) {
|
|
if (l.id == 10) continue; // never called on the fingerprint path
|
|
if (!RegisterListener(env_, l.id, l.bufferSize)) return false;
|
|
}
|
|
std::println("SFS root {} ({})", g_sfsRoot, g_sfsReadOnly ? "READ-ONLY" : "writable");
|
|
|
|
qcomtee_object* loader = OpenService(env_, tee::UidQseecomCompatAppLoader);
|
|
if (loader == QCOMTEE_OBJECT_NULL) return false;
|
|
app_ = LoadTrustlet(loader, g_taPath);
|
|
if (app_ == QCOMTEE_OBJECT_NULL) return false;
|
|
|
|
if (!SyncConfig()) return false;
|
|
|
|
// The sensor, and the init chain that needs it powered.
|
|
if (!sensor_.Open()) {
|
|
std::println(std::cerr, "sensor lines unavailable");
|
|
return false;
|
|
}
|
|
if (!sensor_.PowerOn()) {
|
|
std::println(std::cerr, "sensor power-up failed");
|
|
return false;
|
|
}
|
|
std::println("sensor powered, reset released");
|
|
|
|
// Every step answers rc=0 on a healthy sensor and the last one is not
|
|
// optional: without SYNC_STATISTICS the trustlet's g_statistics stays
|
|
// NULL and the first enrol frame that gets far enough writes through
|
|
// it. One reset buys one init: if this fails the rail has to go down
|
|
// and come back up, re-running the chain answers -205.
|
|
for (ta::Cmd c : ta::InitChain) {
|
|
std::vector<std::byte> payload;
|
|
if (c == ta::Cmd::WorkMode) {
|
|
payload.assign(0x10, std::byte{0});
|
|
payload[0] = static_cast<std::byte>(
|
|
static_cast<std::uint32_t>(ta::WorkMode::WaitTouch));
|
|
} else if (c == ta::Cmd::SyncStatistics) {
|
|
payload.assign(ta::SyncStatisticsPayloadSize, std::byte{0});
|
|
}
|
|
auto r = SendCommand(app_, c, payload);
|
|
Report(c, r);
|
|
if (!r.Ok()) {
|
|
if (r.rc == fingerprintd::sensor::RcDeviceNotFound)
|
|
std::println(std::cerr, " -205: a second init in one power cycle");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// With the sensor initialised and a region supplied, a capture returns
|
|
// a real metric. The idle floor is the only meaningful reference: the
|
|
// metric is per frame and drifts, so a fixed threshold is wrong by
|
|
// construction.
|
|
for (std::size_t i = 0; i < fingerprintd::engine::Baseline::DefaultSamples; i++) {
|
|
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
|
|
ta::BuildCapturePayload(cap);
|
|
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
|
|
if (!c.invoked || c.result != 0) {
|
|
Report(ta::Cmd::CaptureImage, c);
|
|
std::println(std::cerr, "capture failed during calibration");
|
|
return false;
|
|
}
|
|
baseline_.Observe(c.metric);
|
|
}
|
|
if (!baseline_.Ready()) {
|
|
std::println(std::cerr, "baseline did not calibrate (floor stayed 0)");
|
|
return false;
|
|
}
|
|
std::println("idle floor = {}, finger threshold = {}, settled threshold = {}",
|
|
baseline_.Floor(), baseline_.Threshold(),
|
|
baseline_.SettledThreshold());
|
|
return true;
|
|
}
|
|
|
|
void Stop() {
|
|
sensor_.PowerOff();
|
|
if (supplicant_) {
|
|
pthread_cancel(supplicant_);
|
|
pthread_join(supplicant_, nullptr);
|
|
supplicant_ = 0;
|
|
}
|
|
}
|
|
|
|
// Select a group and count what loads. A template reload needs the init
|
|
// chain to have run first -- Start() guarantees that.
|
|
int SetActiveGroup(std::uint32_t gid) {
|
|
namespace ta = fingerprintd::ta;
|
|
auto sag = ta::BuildSetActiveGroup(gid, g_groupPath);
|
|
std::println("SET_ACTIVE_GROUP gid={}", gid);
|
|
auto g = SendCommand(app_, ta::Cmd::SetActiveGroup, sag);
|
|
Report(ta::Cmd::SetActiveGroup, g);
|
|
auto e = SendCommand(app_, ta::Cmd::Enumerate, {});
|
|
Report(ta::Cmd::Enumerate, e);
|
|
std::println(" templates loaded: {}", e.rc);
|
|
gid_ = gid;
|
|
return e.invoked ? e.rc : -1;
|
|
}
|
|
|
|
// ---- Enrolment
|
|
//
|
|
// One sample per PRESS: touch on the rising edge, release on the falling
|
|
// one, nothing in between. `onStage(accepted, total)` fires on each
|
|
// accepted sample; `onRetry()` when a press yielded none.
|
|
struct EnrolOutcome {
|
|
bool completed = false;
|
|
bool cancelled = false;
|
|
bool saved = false;
|
|
std::uint32_t fid = 0;
|
|
std::string why;
|
|
};
|
|
|
|
EnrolOutcome Enrol(std::uint32_t gid, std::atomic<bool>& cancel,
|
|
const std::function<void(int, int)>& onStage,
|
|
const std::function<void()>& onRetry,
|
|
int maxFrames) {
|
|
namespace ta = fingerprintd::ta;
|
|
namespace en = fingerprintd::engine;
|
|
EnrolOutcome out;
|
|
|
|
if (g_sfsReadOnly || !g_rpmbWrite) {
|
|
out.why = "store is read-only or RPMB writes disabled";
|
|
return out;
|
|
}
|
|
if (gid != gid_) SetActiveGroup(gid);
|
|
|
|
// Stock's opening sequence. AUTHENTICATE is what arms the capture
|
|
// session; CANCEL and RESET_LOCKOUT bracket it.
|
|
std::vector<std::byte> au(ta::AuthPayloadSize);
|
|
ta::BuildAuthPayload(au, 1, 0);
|
|
SendCommand(app_, ta::Cmd::Cancel, {});
|
|
SendCommand(app_, ta::Cmd::ResetLockout, {});
|
|
SendCommand(app_, ta::Cmd::Authenticate, au);
|
|
SendCommand(app_, ta::Cmd::Cancel, {});
|
|
SendCommand(app_, ta::Cmd::ResetLockout, {});
|
|
SendCommand(app_, ta::Cmd::PreEnroll, {});
|
|
SendCommand(app_, ta::Cmd::Authenticate, au);
|
|
SendCommand(app_, ta::Cmd::Cancel, {});
|
|
|
|
// The token is all zero: with trustlet.enable_trusted_enrollment false
|
|
// the trustlet skips the version check, the challenge compare and the
|
|
// HMAC verify outright. The u32 at +69 is the GID this enrolment lands
|
|
// under.
|
|
std::vector<std::byte> tok(ta::EnrollPayloadSize);
|
|
ta::BuildEnrollPayload(tok, gid);
|
|
auto er = SendCommand(app_, ta::Cmd::Enroll, tok);
|
|
Report(ta::Cmd::Enroll, er);
|
|
if (!er.Ok()) { out.why = "ENROLL refused"; return out; }
|
|
std::println("enrolling gid={}", gid);
|
|
|
|
en::TouchTracker tracker;
|
|
en::EnrolSession enrol(EnrolStages());
|
|
int lastAccepted = 0;
|
|
bool pressHadTouch = false;
|
|
for (int i = 0; i < maxFrames && !enrol.Complete() && !cancel; i++) {
|
|
std::vector<std::byte> q(0x10, std::byte{0});
|
|
SendCommand(app_, ta::Cmd::QueryEventStatus, q);
|
|
|
|
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
|
|
ta::BuildCapturePayload(cap);
|
|
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
|
|
bool finger = baseline_.IsFinger(c.metric);
|
|
|
|
for (ta::Event ev : tracker.Observe(finger, en::Mode::Enrol)) {
|
|
std::vector<std::byte> evbuf(ta::EventContextSize);
|
|
ta::BuildEventContext(evbuf, { .event = ev });
|
|
// Poisoned so an unwritten fid can be told from a zero one.
|
|
ta::PoisonFid(evbuf);
|
|
auto r = SendCommand(app_, ta::Cmd::ReportEvent, evbuf);
|
|
if (!r.invoked) continue;
|
|
if (ev == ta::Event::FingerTouched) {
|
|
pressHadTouch = true;
|
|
// Only the event that runs the enrol path reports a real
|
|
// count. A release leaves the field at 0, which reads
|
|
// exactly like "finished".
|
|
enrol.Observe(r.samplesRemaining, true);
|
|
if (r.fid != 0 && r.fid != ta::FidPoison) out.fid = r.fid;
|
|
// The rc is the trustlet's own word on the press. A refused
|
|
// sample with rc=0 was seen and turned down by the
|
|
// algorithm; a negative rc is an error it never got past.
|
|
// Without this a run of 22 refusals says nothing about
|
|
// which of the two it was.
|
|
std::println(" touch: rem={} fid={:#x} rc={}{}", r.samplesRemaining,
|
|
r.fid, r.rc, r.rc ? std::format(" ({})", ta::StrError(r.rc)) : "");
|
|
}
|
|
if (ev == ta::Event::FingerReleased && pressHadTouch) {
|
|
// The press is over. Did it move the count?
|
|
if (enrol.Accepted() > lastAccepted) {
|
|
lastAccepted = enrol.Accepted();
|
|
onStage(enrol.Accepted(), enrol.Total());
|
|
} else {
|
|
// The press produced a touch the trustlet did not turn
|
|
// into a sample: too close to the previous position,
|
|
// below the coverage or quality threshold, or outside
|
|
// the overlap band.
|
|
onRetry();
|
|
}
|
|
pressHadTouch = false;
|
|
}
|
|
}
|
|
SendCommand(app_, ta::Cmd::QueryEventStatus, q);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs));
|
|
}
|
|
// A final press that completed the count has no release yet.
|
|
if (enrol.Complete() && enrol.Accepted() > lastAccepted)
|
|
onStage(enrol.Accepted(), enrol.Total());
|
|
|
|
if (cancel) {
|
|
SendCommand(app_, ta::Cmd::Cancel, {});
|
|
out.cancelled = true;
|
|
out.why = "cancelled";
|
|
return out;
|
|
}
|
|
std::println("samples: {} of {} accepted", enrol.Accepted(), enrol.Total());
|
|
if (!enrol.Complete()) {
|
|
out.why = "ran out of frames";
|
|
return out;
|
|
}
|
|
out.completed = true;
|
|
|
|
SendCommand(app_, ta::Cmd::PostEnroll, {});
|
|
std::vector<std::byte> sd(0x10, std::byte{0});
|
|
for (std::size_t k = 0; k < 4; k++)
|
|
sd[k] = static_cast<std::byte>((ta::SaveMaskTemplate >> (8 * k)) & 0xFF);
|
|
auto sv = SendCommand(app_, ta::Cmd::SaveData, sd);
|
|
Report(ta::Cmd::SaveData, sv);
|
|
out.saved = sv.Ok();
|
|
if (!out.saved) out.why = std::format("SAVE_DATA rc={}", sv.rc);
|
|
return out;
|
|
}
|
|
|
|
// ---- Verification
|
|
//
|
|
// The unit of decision is a PRESS, not a frame. A frame is one of three
|
|
// things -- release (poison intact), rescan (rc=-11), match/reject -- and
|
|
// within one press the matcher may reject early frames and match a later
|
|
// one, so a press is judged when the finger LIFTS: any match wins; only
|
|
// rejections means no-match; no terminal frame at all means undecided,
|
|
// and scanning continues into the next press.
|
|
//
|
|
// That last case is why the rescan budget matters here. At the stock
|
|
// budget a wrong finger answers "not identified yet" on every frame and
|
|
// never yields a terminal rejection, so its presses are all undecided and
|
|
// a client waits forever -- fprintd's PAM module needs a verify-no-match
|
|
// to deny or retry. With max_authentication_rescan_times at 0 every frame
|
|
// is terminal and every press decides. A wrong finger stops being a
|
|
// silent wait.
|
|
struct VerifyOutcome {
|
|
bool decided = false;
|
|
bool matched = false;
|
|
bool cancelled = false;
|
|
std::uint32_t fid = 0;
|
|
int presses = 0;
|
|
int frames = 0;
|
|
// Frames that showed a finger arriving but were not settled enough to
|
|
// spend a verdict on. A press made only of these is a "try again", not
|
|
// a rejection.
|
|
int skippedUnsettled = 0;
|
|
};
|
|
|
|
// `accept` is the set of fids that count as a match for THIS request. The
|
|
// trustlet identifies against every template loaded in the group, and a
|
|
// group accumulates them: re-enrolling a finger does NOT replace its
|
|
// template, it adds one, because FF_CMD_TA_REMOVE is not implemented. So
|
|
// without this filter a verify answers for fingers the caller did not ask
|
|
// about, and for stale templates no longer named by anything -- which is
|
|
// both wrong by fprintd's contract and quietly ruins any measurement.
|
|
VerifyOutcome Verify(std::uint32_t gid, std::atomic<bool>& cancel, int maxFrames,
|
|
const std::vector<std::uint32_t>& accept) {
|
|
namespace ta = fingerprintd::ta;
|
|
namespace en = fingerprintd::engine;
|
|
VerifyOutcome out;
|
|
|
|
if (gid != gid_) SetActiveGroup(gid);
|
|
|
|
// AUTHENTICATE arms the scan session. Its gid must match the active
|
|
// group or the trustlet answers -200.
|
|
std::vector<std::byte> au(ta::AuthPayloadSize);
|
|
ta::BuildAuthPayload(au, 1, gid);
|
|
auto a = SendCommand(app_, ta::Cmd::Authenticate, au);
|
|
Report(ta::Cmd::Authenticate, a);
|
|
if (!a.Ok()) return out;
|
|
// The previous press's burst is still queued; drop it so the first
|
|
// wait of this session cannot be woken by an old finger.
|
|
if (g_edgeWake) sensor_.DrainEdges();
|
|
|
|
en::TouchTracker tracker;
|
|
harvested_ = 0; // no fold has happened in this session yet
|
|
bool inPress = false, pressMatched = false, pressRejected = false;
|
|
int pressFrames = 0, rescans = 0, skipped = 0;
|
|
std::uint32_t pressFid = 0;
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto msSince = [&](auto t) {
|
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - t).count();
|
|
};
|
|
|
|
// The reference frame loop is {QUERY, CAPTURE, REPORT, QUERY, REPORT}.
|
|
// The trailing query acknowledges the trustlet's event state; without
|
|
// it, after the first verdict every later frame answers "not
|
|
// identified yet" forever.
|
|
for (int i = 0; i < maxFrames && !cancel && !out.decided; i++) {
|
|
std::vector<std::byte> q(0x10, std::byte{0});
|
|
SendCommand(app_, ta::Cmd::QueryEventStatus, q);
|
|
|
|
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
|
|
ta::BuildCapturePayload(cap);
|
|
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
|
|
bool finger = baseline_.IsFinger(c.metric);
|
|
// A press begins only once contact is settled; see TouchTracker.
|
|
bool settled = baseline_.IsSettled(c.metric);
|
|
fingerPresent_.store(finger);
|
|
out.frames++;
|
|
if (finger && !settled && !tracker.FingerDown()) skipped++;
|
|
|
|
std::string note;
|
|
// No recapture on the rising edge. It was tried, on the theory that
|
|
// the detecting frame is the finger landing and a frame 50 ms later
|
|
// would be a settled one. On a quick tap the finger was already
|
|
// gone 50 ms later: the recapture read the idle floor (metric 133,
|
|
// still flagged FINGER from the first capture) and an empty image
|
|
// was reported to the matcher. A guaranteed miss on exactly the
|
|
// case it was meant to fix.
|
|
for (ta::Event ev : tracker.Observe(finger, settled, en::Mode::Authenticate)) {
|
|
if (ev == ta::Event::FingerTouched) {
|
|
inPress = true; pressMatched = false; pressRejected = false;
|
|
pressFrames = 0; rescans = 0; pressFid = 0;
|
|
out.presses++;
|
|
}
|
|
std::vector<std::byte> evbuf(ta::EventContextSize);
|
|
ta::BuildEventContext(evbuf, { .event = ev });
|
|
// A zero-initialised buffer cannot tell "the matcher never
|
|
// ran" from "the matcher ran and rejected" -- the failure path
|
|
// writes zero there too.
|
|
ta::PoisonFid(evbuf);
|
|
auto r = SendCommand(app_, ta::Cmd::ReportEvent, evbuf);
|
|
if (!r.invoked) continue;
|
|
ta::Verdict v = ta::Classify(r.rc, r.fid);
|
|
// A match against a template the caller did not ask for is not
|
|
// a match for this request.
|
|
if (v == ta::Verdict::Match && !accept.empty()
|
|
&& std::ranges::find(accept, r.fid) == accept.end()) {
|
|
std::println(" fid {} matched but is not the requested finger", r.fid);
|
|
v = ta::Verdict::Rejected;
|
|
}
|
|
switch (v) {
|
|
case ta::Verdict::Match:
|
|
pressMatched = true; pressFid = r.fid; note += " MATCH";
|
|
// FOLD THE FRAME THAT JUST MATCHED, HERE, WITHOUT
|
|
// CAPTURING A NEW ONE. This is the only fold a quick tap
|
|
// will ever get, and stock does exactly this: in the
|
|
// reference trace UPDATE_TEMPLATE follows do_authenticate
|
|
// directly -- `auth success score` -> `authenticated
|
|
// result is updated` -> CANCEL -> `checking the
|
|
// template...` -> UPDATE_TEMPLATE -- with NO CAPTURE_IMAGE
|
|
// between them. The trustlet still holds the image it just
|
|
// matched against.
|
|
if (g_learn && FoldFrame(0, ev == ta::Event::FingerTouched)) {
|
|
harvested_ = 1;
|
|
note += " folded";
|
|
}
|
|
break;
|
|
case ta::Verdict::Rejected: pressRejected = true; note += " rej"; break;
|
|
case ta::Verdict::NotIdentifiedYet: rescans++; note += " -11"; break;
|
|
case ta::Verdict::MatcherNeverRan: break;
|
|
}
|
|
if (ev == ta::Event::FingerReleased && inPress) {
|
|
// The press is over: judge it.
|
|
inPress = false;
|
|
if (pressMatched) {
|
|
out.decided = true; out.matched = true; out.fid = pressFid;
|
|
} else if (pressRejected || (g_undecidedIsNoMatch && pressFrames > 0)) {
|
|
out.decided = true; out.matched = false;
|
|
}
|
|
std::println(" press {}: {} frames, {} rescans -> {}", out.presses, pressFrames,
|
|
rescans, pressMatched ? std::format("MATCH fid={}", pressFid)
|
|
: pressRejected ? "NO MATCH"
|
|
: out.decided ? "NO MATCH (undecided at lift)" : "undecided");
|
|
}
|
|
}
|
|
if (finger) pressFrames++;
|
|
// A press that matched is decided the moment it does; do not make
|
|
// the user keep holding for a release.
|
|
if (pressMatched && !out.decided) {
|
|
out.decided = true; out.matched = true; out.fid = pressFid;
|
|
std::println(" press {}: {} frames -> MATCH fid={}", out.presses, pressFrames, pressFid);
|
|
}
|
|
SendCommand(app_, ta::Cmd::QueryEventStatus, q);
|
|
if (g_verbose) {
|
|
auto irq = sensor_.ReadIrq();
|
|
std::println(" frame {:3} @{:5}ms: metric={:<4}{} irq={}{}", i + 1,
|
|
msSince(t0), c.metric, finger ? " FINGER" : " ",
|
|
irq ? std::to_string(*irq) : "?", note);
|
|
}
|
|
// WAKE ON CONTACT. Measured: gpio75 is silent at idle under
|
|
// WAIT_TOUCH (0 edges in 60 s) and bursts within milliseconds of a
|
|
// finger landing -- and the burst arrives HUNDREDS of ms before a
|
|
// fixed-cadence capture notices, which is why a quick tap only
|
|
// ever yielded one frame. Waiting on the edge instead of sleeping
|
|
// means the first capture of a press happens at contact.
|
|
//
|
|
// While a finger is DOWN the sensor keeps pulsing, so the wait
|
|
// returns immediately and the loop runs as fast as QTEE allows --
|
|
// exactly what a press wants. The timeout is the idle fallback, so
|
|
// a release is still noticed promptly.
|
|
if (g_edgeWake) sensor_.WaitEdges(g_frameGapMs);
|
|
else std::this_thread::sleep_for(std::chrono::milliseconds(g_frameGapMs));
|
|
}
|
|
fingerPresent_.store(false);
|
|
std::println(" verify loop: {} frames in {} ms ({} ms/frame incl. {} ms gap){}",
|
|
out.frames, msSince(t0),
|
|
out.frames ? msSince(t0) / out.frames : 0, g_frameGapMs,
|
|
skipped ? std::format(", {} unsettled frame(s) skipped", skipped) : "");
|
|
out.skippedUnsettled = skipped;
|
|
if (cancel) {
|
|
SendCommand(app_, ta::Cmd::Cancel, {});
|
|
out.cancelled = true;
|
|
}
|
|
// The matching frame was already folded, at the moment it matched.
|
|
// This picks up any further frames the finger stayed down for.
|
|
if (out.matched && g_learn) HarvestTemplate(g_learnMaxFrames);
|
|
return out;
|
|
}
|
|
|
|
// ---- Template learning
|
|
//
|
|
// Stock's post-match harvest, and the whole reason a stock template grows
|
|
// over its life: while the finger is STILL on the sensor after a match,
|
|
// keep capturing and fold each frame into the loaded template. Stock's
|
|
// loop is {QUERY_FINGER_STATUS, CAPTURE_IMAGE, UPDATE_TEMPLATE} and it
|
|
// sends NO event -- so the matcher does not run again and this cannot
|
|
// revise the verdict the client has already been given.
|
|
//
|
|
// A quick tap pays almost nothing: the finger is gone by the time a
|
|
// verdict lands, so the first capture reads the idle floor and the loop
|
|
// stops. A held press contributes the frames it was actually held for.
|
|
// Which means the frames learned from are exactly the frames a real
|
|
// unlock produces -- the "enrolment must resemble verification" problem,
|
|
// solved by the algorithm instead of by coaching the user.
|
|
// Fold one frame the trustlet already holds. No capture: the caller has
|
|
// just had a verdict out of it, so the image is the one that produced it.
|
|
bool FoldFrame(int slot, bool touchFrame) {
|
|
namespace ta = fingerprintd::ta;
|
|
std::vector<std::byte> up(ta::UpdateTemplatePayloadSize);
|
|
ta::BuildUpdateTemplate(up, static_cast<std::uint32_t>(slot), touchFrame);
|
|
auto u = SendCommand(app_, ta::Cmd::UpdateTemplate, up);
|
|
if (!u.Ok()) {
|
|
std::println(" learn: UPDATE_TEMPLATE rc={} ({}) result={}", u.rc,
|
|
ta::StrError(u.rc), static_cast<int>(u.result));
|
|
return false;
|
|
}
|
|
templateDirty_ = true;
|
|
return true;
|
|
}
|
|
|
|
int HarvestTemplate(int maxFrames) {
|
|
namespace ta = fingerprintd::ta;
|
|
int folded = harvested_;
|
|
harvested_ = 0;
|
|
for (int i = folded; i < maxFrames; i++) {
|
|
std::vector<std::byte> cap(ta::CaptureDeclaredLen);
|
|
ta::BuildCapturePayload(cap);
|
|
auto c = SendCommand(app_, ta::Cmd::CaptureImage, cap);
|
|
if (!c.invoked || c.result != 0) break;
|
|
if (!baseline_.IsFinger(c.metric)) {
|
|
if (g_verbose)
|
|
std::println(" learn: finger gone (metric={}), {} frame(s) folded",
|
|
c.metric, folded);
|
|
break;
|
|
}
|
|
// Bit 6 only on the very first fold of the press, mirroring
|
|
// stock: it marks the frame whose reported event was
|
|
// FingerTouched, and it selects which of the algorithm's two
|
|
// update entries runs.
|
|
if (!FoldFrame(folded, folded == 0)) break;
|
|
folded++;
|
|
if (g_verbose)
|
|
std::println(" learn: frame {} folded in (metric={})", folded, c.metric);
|
|
}
|
|
std::println(" learn: {} frame(s) folded into the template{}", folded,
|
|
templateDirty_ ? ", save pending" : "");
|
|
return folded;
|
|
}
|
|
|
|
// Persist what the harvest folded in. Stock does this lazily -- it arms a
|
|
// timer on the match ("lazy data updater activated") and the SAVE_DATA
|
|
// lands at the next authenticate, taking 356 ms and rewriting the template
|
|
// plus the share template plus the chip calibration. Deferring it matters
|
|
// for the same reason it does on stock: the client already has its answer,
|
|
// and a third of a second of RPMB traffic does not belong on the unlock
|
|
// path. Here the worker calls this after it has posted the verdict.
|
|
bool FlushTemplate() {
|
|
namespace ta = fingerprintd::ta;
|
|
if (!templateDirty_) return true;
|
|
if (g_sfsReadOnly || !g_rpmbWrite) {
|
|
std::println(" learn: learned frames DISCARDED -- the store is read-only");
|
|
templateDirty_ = false;
|
|
return false;
|
|
}
|
|
std::vector<std::byte> sd(0x10, std::byte{0});
|
|
for (std::size_t k = 0; k < 4; k++)
|
|
sd[k] = static_cast<std::byte>((ta::SaveMaskTemplate >> (8 * k)) & 0xFF);
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto sv = SendCommand(app_, ta::Cmd::SaveData, sd);
|
|
Report(ta::Cmd::SaveData, sv);
|
|
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - t0).count();
|
|
templateDirty_ = false;
|
|
if (!sv.Ok()) {
|
|
std::println(" learn: the learned template did NOT persist (rc={})", sv.rc);
|
|
return false;
|
|
}
|
|
std::println(" learn: template saved in {} ms", ms);
|
|
return true;
|
|
}
|
|
|
|
bool TemplateDirty() const { return templateDirty_; }
|
|
|
|
bool FingerPresent() const { return fingerPresent_.load(); }
|
|
// Before SyncConfig has run there is no answer yet; stock's value is the
|
|
// only honest stand-in, and a client asking this early gets it.
|
|
int EnrolStages() const { return g_samples > 0 ? g_samples : SamplesFallback; }
|
|
qcomtee_object* App() const { return app_; }
|
|
|
|
// IRQ edge observer: poll() the line and log each edge. This is the
|
|
// measurement the IRQ-driven design needs before it is built -- whether
|
|
// edges are observable at all, how wide the pulses are, and whether the
|
|
// line is quiet at idle. If the sensor pulses at rest, a wake-on-edge is
|
|
// useless and the design is dead before it starts.
|
|
void StartIrqObserver() {
|
|
if (sensor_.IrqFd() < 0) return;
|
|
irqThread_ = std::thread([this] {
|
|
std::uint64_t last = 0, lastRise = 0;
|
|
unsigned edges = 0;
|
|
for (;;) {
|
|
pollfd pfd{ sensor_.IrqFd(), POLLIN, 0 };
|
|
int r = ::poll(&pfd, 1, 500);
|
|
if (irqQuit_.load()) break;
|
|
if (r <= 0) continue;
|
|
for (auto& e : sensor_.ReadEdges()) {
|
|
edges++;
|
|
double sinceLast = last ? (e.ns - last) / 1e6 : 0;
|
|
if (e.rising) lastRise = e.ns;
|
|
std::string width = (!e.rising && lastRise)
|
|
? std::format(" pulse={:.2f}ms", (e.ns - lastRise) / 1e6) : "";
|
|
std::println(" irq edge #{}: {} +{:.1f}ms{}", edges,
|
|
e.rising ? "RISE" : "fall", sinceLast, width);
|
|
last = e.ns;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
void StopIrqObserver() {
|
|
irqQuit_.store(true);
|
|
if (irqThread_.joinable()) irqThread_.join();
|
|
}
|
|
|
|
private:
|
|
bool SyncConfig() {
|
|
namespace ta = fingerprintd::ta;
|
|
std::ifstream cf(g_cfgPath);
|
|
if (!cf) {
|
|
std::println(std::cerr, "cannot open config {}", g_cfgPath);
|
|
return false;
|
|
}
|
|
std::string json((std::istreambuf_iterator<char>(cf)), std::istreambuf_iterator<char>());
|
|
|
|
// common.max_authentication_rescan_times bounds how many frames the
|
|
// matcher may answer "not identified yet" before it must produce a
|
|
// verdict. MEASUREMENT ONLY: a rate taken with 0 is a per-frame figure
|
|
// with the retry mechanism disabled, 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) {
|
|
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);
|
|
}
|
|
}
|
|
// Take the sample count from the same JSON the trustlet is about to
|
|
// be given, so the two cannot drift. Deliberately crude: this is the
|
|
// only key the daemon needs back out, and pulling in a JSON parser to
|
|
// read one integer is not worth it.
|
|
if (!g_samplesForced) {
|
|
int found = -1;
|
|
for (std::string_view key : { "\"max_enrolling_samples\":", "\"max_enrolling_samples\" :" }) {
|
|
auto at = json.find(key);
|
|
if (at == std::string::npos) continue;
|
|
auto num = json.find_first_of("0123456789", at + key.size());
|
|
if (num == std::string::npos) continue;
|
|
found = std::atoi(json.c_str() + num);
|
|
break;
|
|
}
|
|
g_samples = (found > 0) ? found : SamplesFallback;
|
|
if (found <= 0)
|
|
std::println("config has no max_enrolling_samples; using {}", g_samples);
|
|
}
|
|
std::println("enrolment samples: {}{}", g_samples,
|
|
g_samplesForced ? " (forced on the command line)" : " (from the config)");
|
|
|
|
// The trustlet wants the terminating NUL counted.
|
|
std::vector<std::byte> cfg(json.size() + 1, std::byte{0});
|
|
for (std::size_t i = 0; i < json.size(); i++)
|
|
cfg[i] = static_cast<std::byte>(json[i]);
|
|
auto r = SendCommand(app_, ta::Cmd::SyncConfig, cfg);
|
|
Report(ta::Cmd::SyncConfig, r);
|
|
return r.Ok();
|
|
}
|
|
|
|
qcomtee_object* env_ = QCOMTEE_OBJECT_NULL;
|
|
qcomtee_object* app_ = QCOMTEE_OBJECT_NULL;
|
|
pthread_t supplicant_ = 0;
|
|
Sensor sensor_;
|
|
fingerprintd::engine::Baseline baseline_;
|
|
std::uint32_t gid_ = 0xFFFFFFFF;
|
|
// Set when a fold succeeded; cleared by the save.
|
|
bool templateDirty_ = false;
|
|
// Frames folded during the verify loop itself, carried into the harvest so
|
|
// the slot index keeps counting up across the two.
|
|
int harvested_ = 0;
|
|
std::atomic<bool> fingerPresent_{false};
|
|
std::thread irqThread_;
|
|
std::atomic<bool> irqQuit_{false};
|
|
};
|
|
|
|
// =============================================================================
|
|
// Worker: the one thread that talks to the trustlet. The main thread posts
|
|
// jobs; results and progress come back through the GLib main loop.
|
|
// =============================================================================
|
|
struct Job {
|
|
enum class Kind { Claim, Enroll, Verify } kind;
|
|
std::uint32_t uid = 0;
|
|
std::string finger;
|
|
std::vector<std::uint32_t> acceptFids; // Verify: which fids count
|
|
GDBusMethodInvocation* invocation = nullptr; // Claim replies asynchronously
|
|
};
|
|
|
|
// Everything the worker sends back to the main thread. Delivered by g_idle_add
|
|
// so the D-Bus emission happens on the thread that owns the connection.
|
|
struct Event {
|
|
enum class Kind { Ready, StartFailed, ClaimDone, EnrollStatus, VerifyStatus } kind;
|
|
bool ok = false;
|
|
bool done = false;
|
|
std::string status;
|
|
std::uint32_t fid = 0;
|
|
int templates = 0;
|
|
GDBusMethodInvocation* invocation = nullptr;
|
|
};
|
|
|
|
void PostEvent(std::unique_ptr<Event> ev); // defined with the D-Bus code
|
|
|
|
class Worker {
|
|
public:
|
|
void Start() {
|
|
// musl's default thread stack is 128 KiB; the session holds request
|
|
// buffers on the stack. 8 MiB, as imsd does.
|
|
pthread_attr_t attr;
|
|
pthread_attr_init(&attr);
|
|
pthread_attr_setstacksize(&attr, 8 * 1024 * 1024);
|
|
pthread_create(&tid_, &attr, [](void* self) -> void* {
|
|
static_cast<Worker*>(self)->Run();
|
|
return nullptr;
|
|
}, this);
|
|
pthread_attr_destroy(&attr);
|
|
}
|
|
|
|
void Post(Job j) {
|
|
std::lock_guard lk(mu_);
|
|
jobs_.push_back(std::move(j));
|
|
cv_.notify_one();
|
|
}
|
|
|
|
// Stops a running enrol/verify at its next frame. The op finishes on the
|
|
// worker and reports OpFinished.
|
|
void CancelOp() { cancel_.store(true); }
|
|
|
|
void Quit() {
|
|
{
|
|
std::lock_guard lk(mu_);
|
|
quit_ = true;
|
|
}
|
|
cancel_.store(true);
|
|
cv_.notify_one();
|
|
if (tid_) pthread_join(tid_, nullptr);
|
|
}
|
|
|
|
Session& TheSession() { return session_; }
|
|
|
|
private:
|
|
void Run() {
|
|
if (!session_.Start()) {
|
|
PostEvent(std::make_unique<Event>(Event{ .kind = Event::Kind::StartFailed }));
|
|
return;
|
|
}
|
|
PostEvent(std::make_unique<Event>(Event{ .kind = Event::Kind::Ready }));
|
|
if (g_irqObserve && !g_edgeWake) session_.StartIrqObserver();
|
|
else if (g_irqObserve) std::println("--irq-observe ignored: --edge-wake owns the line fd");
|
|
|
|
for (;;) {
|
|
Job j;
|
|
{
|
|
std::unique_lock lk(mu_);
|
|
cv_.wait(lk, [&] { return quit_ || !jobs_.empty(); });
|
|
if (quit_) break;
|
|
j = std::move(jobs_.front());
|
|
jobs_.pop_front();
|
|
}
|
|
cancel_.store(false);
|
|
switch (j.kind) {
|
|
case Job::Kind::Claim: {
|
|
int n = session_.SetActiveGroup(j.uid);
|
|
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::ClaimDone });
|
|
ev->ok = n >= 0;
|
|
ev->templates = n;
|
|
ev->invocation = j.invocation;
|
|
PostEvent(std::move(ev));
|
|
break;
|
|
}
|
|
case Job::Kind::Enroll: {
|
|
auto o = session_.Enrol(
|
|
j.uid, cancel_,
|
|
[&](int accepted, int total) {
|
|
// Logged as well as signalled. A run whose refusals
|
|
// exist only as D-Bus traffic cannot be counted from
|
|
// the transcript afterwards -- and a grep for them
|
|
// returning nothing was read once as "the thresholds
|
|
// refused nothing", which was wrong.
|
|
std::println(" enrol: sample ACCEPTED ({}/{})", accepted, total);
|
|
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
|
|
ev->status = "enroll-stage-passed";
|
|
PostEvent(std::move(ev));
|
|
},
|
|
[&] {
|
|
std::println(" enrol: sample REFUSED (retry-scan)");
|
|
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
|
|
ev->status = "enroll-retry-scan";
|
|
PostEvent(std::move(ev));
|
|
},
|
|
/*maxFrames*/ 600);
|
|
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::EnrollStatus });
|
|
ev->done = true;
|
|
ev->ok = o.saved;
|
|
ev->fid = o.fid;
|
|
if (o.cancelled) { ev->status = ""; ev->done = false; }
|
|
else if (o.saved) ev->status = "enroll-completed";
|
|
else ev->status = "enroll-failed";
|
|
if (!o.why.empty()) std::println("enrolment: {}", o.why);
|
|
PostEvent(std::move(ev));
|
|
break;
|
|
}
|
|
case Job::Kind::Verify: {
|
|
auto o = session_.Verify(j.uid, cancel_, /*maxFrames*/ 600, j.acceptFids);
|
|
std::println("verify: {} over {} press(es), {} frame(s)",
|
|
o.cancelled ? "cancelled" : !o.decided ? "undecided"
|
|
: o.matched ? "MATCH" : "NO MATCH", o.presses, o.frames);
|
|
auto ev = std::make_unique<Event>(Event{ .kind = Event::Kind::VerifyStatus });
|
|
ev->done = true;
|
|
ev->fid = o.fid;
|
|
if (o.cancelled) { ev->status = ""; ev->done = false; }
|
|
else if (!o.decided) ev->status = "verify-unknown-error";
|
|
else if (o.matched) ev->status = "verify-match";
|
|
else ev->status = "verify-no-match";
|
|
PostEvent(std::move(ev));
|
|
// AFTER the verdict is on its way to the client, never before:
|
|
// persisting a learned template is ~350 ms of gpfile and RPMB
|
|
// traffic and it must not sit on the unlock path. Stock defers
|
|
// it the same way, with a timer.
|
|
session_.FlushTemplate();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
session_.StopIrqObserver();
|
|
session_.Stop();
|
|
}
|
|
|
|
Session session_;
|
|
pthread_t tid_ = 0;
|
|
std::mutex mu_;
|
|
std::condition_variable cv_;
|
|
std::deque<Job> jobs_;
|
|
bool quit_ = false;
|
|
std::atomic<bool> cancel_{false};
|
|
};
|
|
|
|
// =============================================================================
|
|
// net.reactivated.Fprint -- fprintd's interface, so pam_fprintd, the Plasma
|
|
// KCM and fprintd-enroll work against this daemon unmodified. MAIN THREAD.
|
|
// =============================================================================
|
|
constexpr const char* BusName = "net.reactivated.Fprint";
|
|
constexpr const char* ManagerPath = "/net/reactivated/Fprint/Manager";
|
|
constexpr const char* DevicePath = "/net/reactivated/Fprint/Device/0";
|
|
constexpr const char* ManagerIface = "net.reactivated.Fprint.Manager";
|
|
constexpr const char* DeviceIface = "net.reactivated.Fprint.Device";
|
|
constexpr const char* DeviceName = "FocalTech FT9391 (QTEE)";
|
|
|
|
constexpr const char* IntrospectionXml = R"xml(
|
|
<node>
|
|
<interface name="net.reactivated.Fprint.Manager">
|
|
<method name="GetDevices"><arg type="ao" name="devices" direction="out"/></method>
|
|
<method name="GetDefaultDevice"><arg type="o" name="device" direction="out"/></method>
|
|
</interface>
|
|
<interface name="net.reactivated.Fprint.Device">
|
|
<method name="ListEnrolledFingers">
|
|
<arg type="s" name="username" direction="in"/>
|
|
<arg type="as" name="enrolled_fingers" direction="out"/>
|
|
</method>
|
|
<method name="DeleteEnrolledFingers"><arg type="s" name="username" direction="in"/></method>
|
|
<method name="DeleteEnrolledFingers2"/>
|
|
<method name="DeleteEnrolledFinger"><arg type="s" name="finger_name" direction="in"/></method>
|
|
<method name="Claim"><arg type="s" name="username" direction="in"/></method>
|
|
<method name="Release"/>
|
|
<method name="VerifyStart"><arg type="s" name="finger_name" direction="in"/></method>
|
|
<method name="VerifyStop"/>
|
|
<method name="EnrollStart"><arg type="s" name="finger_name" direction="in"/></method>
|
|
<method name="EnrollStop"/>
|
|
<signal name="VerifyFingerSelected"><arg type="s" name="finger_name"/></signal>
|
|
<signal name="VerifyStatus"><arg type="s" name="result"/><arg type="b" name="done"/></signal>
|
|
<signal name="EnrollStatus"><arg type="s" name="result"/><arg type="b" name="done"/></signal>
|
|
<property name="name" type="s" access="read"/>
|
|
<property name="num-enroll-stages" type="i" access="read"/>
|
|
<property name="scan-type" type="s" access="read"/>
|
|
<property name="finger-present" type="b" access="read"/>
|
|
<property name="finger-needed" type="b" access="read"/>
|
|
</interface>
|
|
</node>
|
|
)xml";
|
|
|
|
GDBusConnection* g_conn = nullptr;
|
|
GMainLoop* g_loop = nullptr;
|
|
Worker* g_worker = nullptr;
|
|
bool g_ready = false;
|
|
|
|
// Claim state. fprintd's model: one client holds the device at a time, on
|
|
// behalf of one user, and every operation is against that user's prints.
|
|
enum class Op { None, Enroll, Verify };
|
|
struct Claim {
|
|
bool held = false;
|
|
std::string sender; // the bus name that holds it
|
|
std::string user;
|
|
std::uint32_t uid = 0;
|
|
Op op = Op::None;
|
|
std::string finger; // for the op in progress
|
|
fingerprintd::store::Map fingers;
|
|
};
|
|
Claim g_claim;
|
|
GDBusMethodInvocation* g_pendingClaim = nullptr;
|
|
guint g_claimWatch = 0;
|
|
|
|
void DropClaim(const char* why) {
|
|
if (g_claimWatch) {
|
|
g_dbus_connection_signal_unsubscribe(g_conn, g_claimWatch);
|
|
g_claimWatch = 0;
|
|
}
|
|
if (g_claim.op != Op::None && g_worker) g_worker->CancelOp();
|
|
if (g_claim.held)
|
|
std::println("claim by {} for {} dropped: {}", g_claim.sender, g_claim.user, why);
|
|
g_claim = {};
|
|
}
|
|
|
|
// A claim is held by a bus connection. If that connection goes away -- the
|
|
// client crashed, was killed, or simply never called Release -- the claim
|
|
// must go with it, or the device is wedged for everyone until the daemon
|
|
// restarts. fprintd watches the claimant's name for exactly this reason. Seen
|
|
// the hard way: a Claim from one busctl invocation, which exits immediately,
|
|
// left the device permanently "AlreadyInUse".
|
|
void WatchClaimant(const std::string& sender) {
|
|
g_claimWatch = g_dbus_connection_signal_subscribe(
|
|
g_conn, "org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
|
|
"/org/freedesktop/DBus", sender.c_str(), G_DBUS_SIGNAL_FLAGS_NONE,
|
|
[](GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar*,
|
|
GVariant* params, gpointer) {
|
|
const gchar* name = nullptr; const gchar* oldOwner = nullptr; const gchar* newOwner = nullptr;
|
|
g_variant_get(params, "(&s&s&s)", &name, &oldOwner, &newOwner);
|
|
if (g_claim.held && name && g_claim.sender == name && newOwner && *newOwner == '\0')
|
|
DropClaim("client left the bus");
|
|
},
|
|
nullptr, nullptr);
|
|
}
|
|
|
|
std::string MapPath(std::uint32_t uid) {
|
|
return fingerprintd::store::PathForUid(g_stateDir, uid);
|
|
}
|
|
|
|
fingerprintd::store::Map LoadMap(std::uint32_t uid) {
|
|
std::string path = MapPath(uid);
|
|
std::ifstream f(path);
|
|
if (!f) {
|
|
std::println("map {}: {}", path, ::strerror(errno));
|
|
return {};
|
|
}
|
|
std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
|
auto m = fingerprintd::store::Map::Decode(text);
|
|
std::println("map {}: {} bytes, {} finger(s)", path, text.size(), m.Size());
|
|
return m;
|
|
}
|
|
|
|
void SaveMap(std::uint32_t uid, const fingerprintd::store::Map& m) {
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(g_stateDir, ec);
|
|
std::string path = MapPath(uid);
|
|
std::string tmp = path + ".tmp";
|
|
{
|
|
std::ofstream f(tmp, std::ios::trunc);
|
|
f << m.Encode();
|
|
}
|
|
::chmod(tmp.c_str(), 0600);
|
|
std::filesystem::rename(tmp, path, ec);
|
|
}
|
|
|
|
void EmitDevice(const char* signal, GVariant* params) {
|
|
if (!g_conn) return;
|
|
g_dbus_connection_emit_signal(g_conn, nullptr, DevicePath, DeviceIface, signal, params, nullptr);
|
|
}
|
|
|
|
void ReturnError(GDBusMethodInvocation* inv, const char* name, const std::string& msg) {
|
|
g_dbus_method_invocation_return_dbus_error(inv, std::format("net.reactivated.Fprint.Error.{}", name).c_str(), msg.c_str());
|
|
}
|
|
|
|
// Who is calling. Used for the one authorisation rule this daemon enforces.
|
|
std::optional<std::uint32_t> CallerUid(GDBusMethodInvocation* inv) {
|
|
const gchar* sender = g_dbus_method_invocation_get_sender(inv);
|
|
GError* err = nullptr;
|
|
GVariant* r = g_dbus_connection_call_sync(
|
|
g_conn, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
|
|
"GetConnectionUnixUser", g_variant_new("(s)", sender), G_VARIANT_TYPE("(u)"),
|
|
G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &err);
|
|
if (!r) { if (err) g_error_free(err); return std::nullopt; }
|
|
guint32 uid = 0;
|
|
g_variant_get(r, "(u)", &uid);
|
|
g_variant_unref(r);
|
|
return uid;
|
|
}
|
|
|
|
std::optional<std::pair<std::string, std::uint32_t>> ResolveUser(const std::string& name,
|
|
GDBusMethodInvocation* inv) {
|
|
if (name.empty()) {
|
|
// fprintd: an empty username means the caller.
|
|
auto uid = CallerUid(inv);
|
|
if (!uid) return std::nullopt;
|
|
passwd* pw = ::getpwuid(*uid);
|
|
return std::make_pair(pw ? std::string(pw->pw_name) : std::to_string(*uid), *uid);
|
|
}
|
|
passwd* pw = ::getpwnam(name.c_str());
|
|
if (!pw) return std::nullopt;
|
|
return std::make_pair(name, static_cast<std::uint32_t>(pw->pw_uid));
|
|
}
|
|
|
|
// The authorisation rule. fprintd uses polkit for this; polkit integration is
|
|
// not in this milestone, so the rule is the obvious conservative one: you may
|
|
// act on your own prints, and root may act on anyone's.
|
|
bool Authorised(GDBusMethodInvocation* inv, std::uint32_t targetUid) {
|
|
auto caller = CallerUid(inv);
|
|
return caller && (*caller == 0 || *caller == targetUid);
|
|
}
|
|
|
|
void PostEvent(std::unique_ptr<Event> ev) {
|
|
g_idle_add([](gpointer data) -> gboolean {
|
|
std::unique_ptr<Event> ev(static_cast<Event*>(data));
|
|
switch (ev->kind) {
|
|
case Event::Kind::Ready:
|
|
g_ready = true;
|
|
std::println("fingerprintd: ready");
|
|
break;
|
|
case Event::Kind::StartFailed:
|
|
std::println(std::cerr, "fingerprintd: session bring-up failed -- exiting for systemd");
|
|
g_main_loop_quit(g_loop);
|
|
break;
|
|
case Event::Kind::ClaimDone:
|
|
if (ev->invocation) {
|
|
if (ev->ok) {
|
|
std::println("claimed by {} for {} (uid {}, {} template(s) loaded)",
|
|
g_claim.sender, g_claim.user, g_claim.uid, ev->templates);
|
|
g_dbus_method_invocation_return_value(ev->invocation, nullptr);
|
|
} else {
|
|
DropClaim("group selection failed");
|
|
ReturnError(ev->invocation, "Internal", "could not select the user's group");
|
|
}
|
|
g_pendingClaim = nullptr;
|
|
}
|
|
break;
|
|
case Event::Kind::EnrollStatus:
|
|
if (!ev->status.empty())
|
|
EmitDevice("EnrollStatus", g_variant_new("(sb)", ev->status.c_str(), ev->done ? TRUE : FALSE));
|
|
if (ev->done && ev->ok) {
|
|
auto f = fingerprintd::store::FingerFromName(g_claim.finger);
|
|
if (f && ev->fid != 0) {
|
|
g_claim.fingers.Add(*f, ev->fid);
|
|
SaveMap(g_claim.uid, g_claim.fingers);
|
|
std::println("enrolled {} for uid {} as fid {}", g_claim.finger, g_claim.uid, ev->fid);
|
|
} else {
|
|
std::println(std::cerr,
|
|
"enrolled, but the trustlet reported no fid -- the finger cannot be "
|
|
"named until it is seen in a verification");
|
|
}
|
|
}
|
|
break;
|
|
case Event::Kind::VerifyStatus:
|
|
if (!ev->status.empty())
|
|
EmitDevice("VerifyStatus", g_variant_new("(sb)", ev->status.c_str(), ev->done ? TRUE : FALSE));
|
|
if (ev->done && ev->status == "verify-match")
|
|
std::println("verified fid {} for uid {}", ev->fid, g_claim.uid);
|
|
break;
|
|
}
|
|
return G_SOURCE_REMOVE;
|
|
}, ev.release());
|
|
}
|
|
|
|
void HandleManager(GDBusMethodInvocation* inv, std::string_view method) {
|
|
if (method == "GetDevices") {
|
|
GVariantBuilder b;
|
|
g_variant_builder_init(&b, G_VARIANT_TYPE("ao"));
|
|
g_variant_builder_add(&b, "o", DevicePath);
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(ao)", &b));
|
|
return;
|
|
}
|
|
if (method == "GetDefaultDevice") {
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(o)", DevicePath));
|
|
return;
|
|
}
|
|
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
|
|
}
|
|
|
|
void HandleDevice(GDBusMethodInvocation* inv, std::string_view method, GVariant* params) {
|
|
namespace store = fingerprintd::store;
|
|
const gchar* sender = g_dbus_method_invocation_get_sender(inv);
|
|
|
|
if (!g_ready) {
|
|
ReturnError(inv, "Internal", "device is still starting");
|
|
return;
|
|
}
|
|
|
|
if (method == "Claim") {
|
|
const gchar* username = nullptr;
|
|
g_variant_get(params, "(&s)", &username);
|
|
if (g_claim.held) {
|
|
ReturnError(inv, g_claim.sender == sender ? "AlreadyInUse" : "AlreadyInUse",
|
|
"device is already claimed");
|
|
return;
|
|
}
|
|
auto who = ResolveUser(username ? username : "", inv);
|
|
if (!who) { ReturnError(inv, "Internal", "no such user"); return; }
|
|
if (!Authorised(inv, who->second)) {
|
|
ReturnError(inv, "PermissionDenied", "not allowed to act for that user");
|
|
return;
|
|
}
|
|
g_claim = {};
|
|
g_claim.held = true;
|
|
g_claim.sender = sender;
|
|
g_claim.user = who->first;
|
|
g_claim.uid = who->second;
|
|
g_claim.fingers = LoadMap(g_claim.uid);
|
|
g_pendingClaim = inv;
|
|
WatchClaimant(g_claim.sender);
|
|
g_worker->Post(Job{ .kind = Job::Kind::Claim, .uid = g_claim.uid, .invocation = inv });
|
|
return;
|
|
}
|
|
if (method == "Release") {
|
|
if (!g_claim.held || g_claim.sender != sender) {
|
|
ReturnError(inv, "ClaimDevice", "device is not claimed by you");
|
|
return;
|
|
}
|
|
DropClaim("released");
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
if (method == "ListEnrolledFingers") {
|
|
const gchar* username = nullptr;
|
|
g_variant_get(params, "(&s)", &username);
|
|
auto who = ResolveUser(username ? username : "", inv);
|
|
if (!who) { ReturnError(inv, "Internal", "no such user"); return; }
|
|
std::println("ListEnrolledFingers('{}') -> {} uid {}", username ? username : "",
|
|
who->first, who->second);
|
|
auto m = LoadMap(who->second);
|
|
if (m.Size() == 0) { ReturnError(inv, "NoEnrolledPrints", "no fingers enrolled"); return; }
|
|
GVariantBuilder b;
|
|
g_variant_builder_init(&b, G_VARIANT_TYPE("as"));
|
|
for (const auto& e : m.Entries())
|
|
g_variant_builder_add(&b, "s", std::string(store::NameOf(e.finger)).c_str());
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(as)", &b));
|
|
return;
|
|
}
|
|
if (method == "DeleteEnrolledFingers" || method == "DeleteEnrolledFingers2"
|
|
|| method == "DeleteEnrolledFinger") {
|
|
std::uint32_t uid = 0;
|
|
std::optional<store::Finger> one;
|
|
if (method == "DeleteEnrolledFingers") {
|
|
const gchar* username = nullptr;
|
|
g_variant_get(params, "(&s)", &username);
|
|
auto who = ResolveUser(username ? username : "", inv);
|
|
if (!who) { ReturnError(inv, "Internal", "no such user"); return; }
|
|
if (!Authorised(inv, who->second)) { ReturnError(inv, "PermissionDenied", "not allowed"); return; }
|
|
uid = who->second;
|
|
} else {
|
|
if (!g_claim.held || g_claim.sender != sender) {
|
|
ReturnError(inv, "ClaimDevice", "device is not claimed by you");
|
|
return;
|
|
}
|
|
uid = g_claim.uid;
|
|
if (method == "DeleteEnrolledFinger") {
|
|
const gchar* fname = nullptr;
|
|
g_variant_get(params, "(&s)", &fname);
|
|
one = store::FingerFromName(fname ? fname : "");
|
|
if (!one) { ReturnError(inv, "InvalidFingername", "unknown finger"); return; }
|
|
}
|
|
}
|
|
auto m = LoadMap(uid);
|
|
if (one) m.Remove(*one); else m.Clear();
|
|
SaveMap(uid, m);
|
|
if (g_claim.held && g_claim.uid == uid) g_claim.fingers = m;
|
|
// The trustlet-side template is NOT removed. FF_CMD_TA_REMOVE (0x2006)
|
|
// exists but its payload has not been reverse-engineered, and guessing
|
|
// at a command that writes to the store is how an index gets
|
|
// invalidated. Until it is, a deleted finger loses its name and stops
|
|
// being offered, but its template still occupies a slot in the group.
|
|
std::println("deleted finger name(s) for uid {} -- trustlet template(s) NOT removed "
|
|
"(FF_CMD_TA_REMOVE not yet implemented)", uid);
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
if (method == "EnrollStart" || method == "VerifyStart") {
|
|
if (!g_claim.held || g_claim.sender != sender) {
|
|
ReturnError(inv, "ClaimDevice", "device is not claimed by you");
|
|
return;
|
|
}
|
|
if (g_claim.op != Op::None) {
|
|
ReturnError(inv, "AlreadyInUse", "an operation is already in progress");
|
|
return;
|
|
}
|
|
const gchar* fname = nullptr;
|
|
g_variant_get(params, "(&s)", &fname);
|
|
std::string finger = fname ? fname : "";
|
|
bool enroll = (method == "EnrollStart");
|
|
if (enroll) {
|
|
if (!store::FingerFromName(finger)) {
|
|
ReturnError(inv, "InvalidFingername", "unknown finger");
|
|
return;
|
|
}
|
|
if (g_claim.fingers.Full() && !g_claim.fingers.Has(*store::FingerFromName(finger))) {
|
|
EmitDevice("EnrollStatus", g_variant_new("(sb)", "enroll-data-full", TRUE));
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
} else {
|
|
if (finger != store::AnyFinger && !store::FingerFromName(finger)) {
|
|
ReturnError(inv, "InvalidFingername", "unknown finger");
|
|
return;
|
|
}
|
|
if (g_claim.fingers.Size() == 0) {
|
|
ReturnError(inv, "NoEnrolledPrints", "no fingers enrolled for this user");
|
|
return;
|
|
}
|
|
}
|
|
g_claim.op = enroll ? Op::Enroll : Op::Verify;
|
|
g_claim.finger = finger;
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
if (!enroll) {
|
|
// The trustlet identifies against every template in the group, so
|
|
// the finger it will pick is whichever matches. Report what the
|
|
// client asked for.
|
|
std::string sel = finger == store::AnyFinger && g_claim.fingers.Size() > 0
|
|
? std::string(store::NameOf(g_claim.fingers.Entries().front().finger)) : finger;
|
|
EmitDevice("VerifyFingerSelected", g_variant_new("(s)", sel.c_str()));
|
|
}
|
|
// Which templates may answer this request: the named finger's fid, or
|
|
// every named finger's for "any". A fid that no name maps to -- a
|
|
// template left behind by an earlier enrolment -- answers for nothing.
|
|
std::vector<std::uint32_t> accept;
|
|
if (!enroll) {
|
|
for (const auto& e : g_claim.fingers.Entries())
|
|
if (finger == store::AnyFinger || store::NameOf(e.finger) == finger)
|
|
accept.push_back(e.fid);
|
|
if (accept.empty()) {
|
|
EmitDevice("VerifyStatus", g_variant_new("(sb)", "verify-no-match", TRUE));
|
|
return;
|
|
}
|
|
}
|
|
g_worker->Post(Job{ .kind = enroll ? Job::Kind::Enroll : Job::Kind::Verify,
|
|
.uid = g_claim.uid, .finger = finger,
|
|
.acceptFids = std::move(accept) });
|
|
return;
|
|
}
|
|
if (method == "EnrollStop" || method == "VerifyStop") {
|
|
if (!g_claim.held || g_claim.sender != sender) {
|
|
ReturnError(inv, "ClaimDevice", "device is not claimed by you");
|
|
return;
|
|
}
|
|
Op want = (method == "EnrollStop") ? Op::Enroll : Op::Verify;
|
|
if (g_claim.op != want) {
|
|
ReturnError(inv, "NoActionInProgress", "no such operation in progress");
|
|
return;
|
|
}
|
|
// The operation is over when the CLIENT says so. A `done` status only
|
|
// means no more status is coming; fprintd's clients call Stop after
|
|
// it, and clearing the op ourselves on `done` made every one of them
|
|
// fail with NoActionInProgress. Cancelling a loop that already ended
|
|
// is harmless.
|
|
g_worker->CancelOp();
|
|
g_claim.op = Op::None;
|
|
g_claim.finger.clear();
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
|
|
}
|
|
|
|
void OnMethodCall(GDBusConnection*, const gchar*, const gchar* path, const gchar*,
|
|
const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
|
|
if (std::string_view(path) == ManagerPath) HandleManager(inv, method);
|
|
else HandleDevice(inv, method, params);
|
|
}
|
|
|
|
GVariant* OnGetProperty(GDBusConnection*, const gchar*, const gchar*, const gchar*,
|
|
const gchar* prop, GError**, gpointer) {
|
|
std::string_view p = prop;
|
|
if (p == "name") return g_variant_new_string(DeviceName);
|
|
if (p == "num-enroll-stages") return g_variant_new_int32(g_worker ? g_worker->TheSession().EnrolStages() : SamplesFallback);
|
|
if (p == "scan-type") return g_variant_new_string("press");
|
|
if (p == "finger-present") return g_variant_new_boolean(g_worker && g_worker->TheSession().FingerPresent());
|
|
if (p == "finger-needed") return g_variant_new_boolean(g_claim.op != Op::None);
|
|
return nullptr;
|
|
}
|
|
|
|
const GDBusInterfaceVTable g_vtable = { OnMethodCall, OnGetProperty, nullptr, {} };
|
|
|
|
void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) {
|
|
g_conn = conn;
|
|
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(IntrospectionXml, nullptr);
|
|
g_dbus_connection_register_object(conn, ManagerPath, node->interfaces[0], &g_vtable, nullptr, nullptr, nullptr);
|
|
g_dbus_connection_register_object(conn, DevicePath, node->interfaces[1], &g_vtable, nullptr, nullptr, nullptr);
|
|
g_dbus_node_info_unref(node);
|
|
std::println("objects registered at {} and {}", ManagerPath, DevicePath);
|
|
}
|
|
|
|
gboolean OnTerm(gpointer) {
|
|
std::println("fingerprintd: stopping");
|
|
g_main_loop_quit(g_loop);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
int RunDaemon() {
|
|
if (::geteuid() != 0) {
|
|
std::println(std::cerr, "fingerprintd: must run as root (/dev/tee0, gpio, RPMB)");
|
|
return 1;
|
|
}
|
|
Worker worker;
|
|
g_worker = &worker;
|
|
g_loop = g_main_loop_new(nullptr, FALSE);
|
|
|
|
guint owner = g_bus_own_name(
|
|
G_BUS_TYPE_SYSTEM, BusName, G_BUS_NAME_OWNER_FLAGS_NONE, OnBusAcquired,
|
|
[](GDBusConnection*, const gchar* name, gpointer) { std::println("owning {}", name); },
|
|
[](GDBusConnection*, const gchar* name, gpointer) {
|
|
std::println(std::cerr, "lost {} -- is fprintd running? exiting", name);
|
|
g_main_loop_quit(g_loop);
|
|
},
|
|
nullptr, nullptr);
|
|
|
|
g_unix_signal_add(SIGTERM, OnTerm, nullptr);
|
|
g_unix_signal_add(SIGINT, OnTerm, nullptr);
|
|
|
|
// The session comes up on the worker; the bus answers "still starting"
|
|
// until it posts Ready.
|
|
worker.Start();
|
|
std::println("fingerprintd {} starting on the system bus", Version);
|
|
g_main_loop_run(g_loop);
|
|
|
|
worker.Quit();
|
|
g_bus_unown_name(owner);
|
|
g_main_loop_unref(g_loop);
|
|
return 0;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Diagnostic modes: the probe flow, kept for a phone with no bus client at hand.
|
|
// -----------------------------------------------------------------------------
|
|
int RunProbe(bool doAuth, bool doEnrol, bool doCalSave, bool doLearnProbe,
|
|
std::uint32_t gid, int frames) {
|
|
namespace ta = fingerprintd::ta;
|
|
Session s;
|
|
if (!s.Start()) return 1;
|
|
int n = s.SetActiveGroup(gid);
|
|
(void)n;
|
|
|
|
// Does the trustlet accept UPDATE_TEMPLATE at all? NO FINGER NEEDED, and
|
|
// that is the point: this project's record says "DO NOT RETRY 0x1015 until
|
|
// a template is loaded" because every earlier shape answered -90, i.e. the
|
|
// app was gone. One template is loaded now, so this asks the question for
|
|
// the cost of one command -- and ENUMERATE afterwards proves whether the
|
|
// trustlet survived it, which is the part a bare rc cannot tell you.
|
|
if (doLearnProbe) {
|
|
if (n <= 0)
|
|
std::println("\n*** no template loaded (ENUMERATE={}) -- 0x1015 reads the "
|
|
"template AFTER the update call and faults without one. "
|
|
"Refusing to probe. ***", n);
|
|
else {
|
|
std::println("\n=== UPDATE_TEMPLATE (no finger; {} template(s) loaded) ===", n);
|
|
std::vector<std::byte> up(ta::UpdateTemplatePayloadSize);
|
|
ta::BuildUpdateTemplate(up, 0, /*touchFrame*/ true);
|
|
Report(ta::Cmd::UpdateTemplate,
|
|
SendCommand(s.App(), ta::Cmd::UpdateTemplate, up));
|
|
std::println(" and again as a held frame (bit 6 clear, the x_update path):");
|
|
ta::BuildUpdateTemplate(up, 1, /*touchFrame*/ false);
|
|
Report(ta::Cmd::UpdateTemplate,
|
|
SendCommand(s.App(), ta::Cmd::UpdateTemplate, up));
|
|
// The liveness probe. -90 here means the trustlet took a fault.
|
|
auto e = SendCommand(s.App(), ta::Cmd::Enumerate, {});
|
|
Report(ta::Cmd::Enumerate, e);
|
|
std::println(" trustlet {} (ENUMERATE rc={})",
|
|
e.invoked && e.rc >= 0 ? "SURVIVED" : "IS GONE", e.rc);
|
|
}
|
|
}
|
|
|
|
if (doCalSave) {
|
|
if (g_sfsReadOnly) { std::println(std::cerr, "a calibration save writes; pass --sfs-writable"); return 1; }
|
|
std::vector<std::byte> sd(0x10, std::byte{0});
|
|
for (std::size_t k = 0; k < 4; k++)
|
|
sd[k] = static_cast<std::byte>((ta::SaveMaskCalibration >> (8 * k)) & 0xFF);
|
|
std::println("\n=== SAVE_DATA (calibration, no finger needed) ===");
|
|
Report(ta::Cmd::SaveData, SendCommand(s.App(), ta::Cmd::SaveData, sd));
|
|
}
|
|
std::atomic<bool> cancel{false};
|
|
if (doEnrol) {
|
|
for (int c = 3; c > 0; c--) { std::println("*** press and LIFT, repeatedly, in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); }
|
|
auto o = s.Enrol(gid, cancel,
|
|
[](int a, int t) { std::println(" [{}/{}] accepted -- LIFT, then press again", a, t); },
|
|
[] { std::println(" press rejected -- LIFT, shift the finger, press again"); },
|
|
frames);
|
|
std::println("enrol: completed={} saved={} fid={} {}", o.completed, o.saved, o.fid, o.why);
|
|
}
|
|
if (doAuth) {
|
|
for (int c = 3; c > 0; c--) { std::println("*** press your finger in {}... ***", c); std::this_thread::sleep_for(std::chrono::seconds(1)); }
|
|
auto o = s.Verify(gid, cancel, frames, {}); // probe: any template counts
|
|
std::println("verify: decided={} matched={} fid={}", o.decided, o.matched, o.fid);
|
|
// The probe is the cheapest end-to-end test of template learning:
|
|
// Verify harvests, this persists, and the container on disk should
|
|
// come back larger than it went in.
|
|
s.FlushTemplate();
|
|
}
|
|
s.Stop();
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// A bounded experiment: does the loader that accepts the OEM-signed focal64
|
|
// reject the SAME image with one code byte changed? This brings up ONLY what a
|
|
// load needs -- root, the supplicant (credentials is a callback object), the
|
|
// client env, and the compat loader -- then hands the image to loadFromBuffer
|
|
// and reports the loader's raw result. No listeners, no sensor, no bus. It
|
|
// UnloadStale()s first (inside LoadTrustlet) so a resident copy cannot mask
|
|
// the answer with "already loaded", and unloads a successful load so it leaves
|
|
// nothing resident. Refusal is inert: QTEE simply does not run the image.
|
|
int RunProbeTaLoad(const std::string& path) {
|
|
namespace tee = fingerprintd::tee;
|
|
std::string dev(tee::DevTee);
|
|
g_root = qcomtee_object_root_init(dev.c_str(), TeeCall, nullptr, nullptr);
|
|
if (g_root == QCOMTEE_OBJECT_NULL) {
|
|
std::println(std::cerr, "root object on {}: {}", tee::DevTee, ::strerror(errno));
|
|
return 1;
|
|
}
|
|
pthread_t sup = 0;
|
|
if (pthread_create(&sup, nullptr, Supplicant, nullptr) != 0) {
|
|
std::println(std::cerr, "supplicant thread failed to start");
|
|
return 1;
|
|
}
|
|
std::uint32_t uid = ::getuid();
|
|
qcomtee_object* env = GetClientEnv(uid);
|
|
if (env == QCOMTEE_OBJECT_NULL) return 1;
|
|
std::println("client env obtained (uid {})", uid);
|
|
qcomtee_object* loader = OpenService(env, tee::UidQseecomCompatAppLoader);
|
|
if (loader == QCOMTEE_OBJECT_NULL) return 1;
|
|
|
|
std::println("=== probe: loadFromBuffer('{}') ===", path);
|
|
qcomtee_object* app = LoadTrustlet(loader, path);
|
|
if (app == QCOMTEE_OBJECT_NULL) {
|
|
std::println("PROBE RESULT: loader REFUSED the image (see result= above)");
|
|
return 2;
|
|
}
|
|
qcomtee_result_t result = 0;
|
|
qcomtee_object_invoke(app, 2, nullptr, 0, &result); // op 2 = unload
|
|
std::println("PROBE RESULT: loader ACCEPTED the image; unloaded -> result={}",
|
|
static_cast<int>(result));
|
|
qcomtee_object_refs_dec(app);
|
|
return 0;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
std::span<char*> args(argv, static_cast<std::size_t>(argc));
|
|
bool probe = false, daemon = false, doAuth = false, doEnrol = false, doCalSave = false;
|
|
bool doLearnProbe = false;
|
|
std::string probeTa;
|
|
std::uint32_t gid = 0;
|
|
int frames = 120;
|
|
for (std::string_view a : args.subspan(1)) {
|
|
if (a == "--version") { std::println("fingerprintd {}", Version); return 0; }
|
|
if (a == "--daemon") daemon = true;
|
|
if (a == "--probe-tee") probe = true;
|
|
if (a.starts_with("--ta=")) g_taPath = a.substr(5);
|
|
if (a.starts_with("--probe-ta-load=")) probeTa = a.substr(16);
|
|
if (a.starts_with("--config=")) g_cfgPath = a.substr(9);
|
|
if (a == "--verbose") g_verbose = true;
|
|
// Serving the store writable lets QTEE UNLINK a container it rejects,
|
|
// which destroys an enrolled template. Opt in explicitly.
|
|
if (a == "--sfs-writable") g_sfsReadOnly = false;
|
|
if (a == "--rpmb-write") g_rpmbWrite = true;
|
|
if (a == "--auth") { doAuth = true; probe = true; }
|
|
if (a == "--enrol") { doEnrol = true; probe = true; }
|
|
if (a == "--cal-save") { doCalSave = true; probe = true; g_verbose = true; }
|
|
if (a == "--probe-learn") { doLearnProbe = true; probe = true; g_verbose = true; }
|
|
if (a.starts_with("--frames=")) frames = std::stoi(std::string(a.substr(9)));
|
|
if (a.starts_with("--frame-gap=")) g_frameGapMs = std::stoi(std::string(a.substr(12)));
|
|
if (a == "--undecided=nomatch") g_undecidedIsNoMatch = true;
|
|
if (a == "--irq-observe") g_irqObserve = true;
|
|
// The observer thread and the loop would both drain the same fd, so
|
|
// the diagnostic and the wake are mutually exclusive.
|
|
if (a == "--edge-wake") g_edgeWake = true;
|
|
if (a.starts_with("--log-dir=")) g_logDir = a.substr(10);
|
|
if (a.starts_with("--state-dir=")) g_stateDir = a.substr(12);
|
|
if (a.starts_with("--rescan=")) g_rescan = std::stoi(std::string(a.substr(9)));
|
|
if (a.starts_with("--group-path=")) g_groupPath = a.substr(13);
|
|
if (a.starts_with("--samples=")) { g_samples = std::stoi(std::string(a.substr(10))); g_samplesForced = true; }
|
|
if (a.starts_with("--learn=")) g_learn = a.substr(8) != "0";
|
|
if (a == "--ta-log") g_taLog = true;
|
|
if (a.starts_with("--learn-frames=")) g_learnMaxFrames = std::stoi(std::string(a.substr(15)));
|
|
if (a.starts_with("--sfs-root=")) g_sfsRoot = a.substr(11);
|
|
if (a.starts_with("--gid=")) gid = static_cast<std::uint32_t>(std::stoul(std::string(a.substr(6))));
|
|
}
|
|
StartTranscript(g_logDir);
|
|
if (!probeTa.empty()) return RunProbeTaLoad(probeTa);
|
|
if (daemon) return RunDaemon();
|
|
if (probe) return RunProbe(doAuth, doEnrol, doCalSave, doLearnProbe, gid, frames);
|
|
|
|
std::println(std::cerr,
|
|
"fingerprintd {}\n"
|
|
" --daemon own net.reactivated.Fprint on the system bus\n"
|
|
" --probe-tee [--gid=N] bring the session up and report\n"
|
|
" --probe-ta-load=PATH load one TA image and report the loader result\n"
|
|
" --auth | --enrol | --cal-save diagnostic loops (see README)\n"
|
|
" --probe-learn send one UPDATE_TEMPLATE, no finger needed\n"
|
|
" --ta-log print the trustlet's own log lines\n"
|
|
" --learn=0|1 [--learn-frames=N] fold a matched press back into the\n"
|
|
" template, as stock does (default on, 8)\n"
|
|
" --sfs-root=DIR --sfs-writable --rpmb-write storage policy",
|
|
Version);
|
|
return 1;
|
|
}
|