fingerprintd/interfaces/Fingerprintd.cppm

22 lines
754 B
Text
Raw Permalink Normal View History

Initial commit: the gpfile wire format, pinned by two real containers fingerprintd will own the FP6's fingerprint sensor: the rail, the QTEE session, the storage callbacks QTEE makes back into the normal world, and net.reactivated.Fprint so pam_fprintd and the desktop need no changes. None of that runs yet. What is here is the first core module and the machinery around it. Fingerprintd:Sfs is the gpfile listener's frame -- the callback that carries 47 of 66 storage requests during an enrolment. It is parse, reply and root mapping only: no file I/O, no TEE, no allocation of the shared buffer. The daemon shell supplies those, which is what lets every byte-level decision be tested on a dev box with no phone. The module exists mainly to hold one fact. READ answers at req+0x00c and WRITE reads its payload from req+0x110, because the frame is a union: a WRITE still needs its path while the payload is copied out, so it sits past the 256-byte path field, while a READ has consumed the path and packs its reply over it. Conflating them is wrong in both directions with the same symptom -- the container does not round-trip, QTEE's HMAC check fails, and the file is unlinked as tampered on the next session. So the tests do not assert the constants against themselves. They load two real containers off the phone -- one written correctly, one written with the offsets conflated -- and re-derive the bug: the broken one opens with ASCII path text rather than a binary HMAC, that text is the group name from character 8 because the read offset is 8 bytes into the path field, and the real container sits exactly 0x104 further in. Then a write-store-read round trip must be the identity, and the same round trip through a single offset must not be. O_TRUNC gets a static_assert of its own. 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; it unlinks a file it means to shorten rather than relying on the opener. Verified by mutation: conflating the offsets, making DataOffset return the read offset for writes, and setting O_TRUNC each fail the suite.
2026-09-02 16:02:46 +02:00
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
/*
fingerprintd — fingerprint daemon for the Fairphone 6 on mainline Linux.
The sensor is a FocalTech FT9391 on a TrustZone-owned SPI bus: the normal world
cannot reach it, and raw frames never leave the TEE. All capture, enrolment and
matching happen inside the focal64 trustlet. This daemon owns the sensor rail,
holds the QTEE session open, serves the storage callbacks QTEE makes back into
the normal world, and reports the matched finger id.
*/
export module Fingerprintd;
export import :Sfs;
Port the RPMB listener's wire format, guard included Fingerprintd:Rpmb is the other half of QTEE's storage. Where gpfile moves the container bytes, RPMB is the anti-rollback: an authenticated, monotonically counted area of the UFS device that lets QTEE tell a genuine store from an old one replayed back at it. Framing and policy only; the SCSI transport stays in the daemon shell. The guard is the reason this module has tests rather than just constants. req_resp 0x0001 is Authentication Key Programming, and the RPMB key is one-time programmable in the UFS device -- relaying such a frame destroys that part's RPMB permanently and no reflash recovers it. QTEE has no legitimate reason to send one, so it is refused unconditionally, whatever the write policy says. It is tested for scanning every frame rather than the first, and for refusing a request that claims more frames than the buffer holds instead of reading past the end. The out-parameter at +0x08 is the field that failed every RPMB transaction for a week. librpmb passes it by address, so it reports bytes transferred, and QTEE compares it against what it expected and rejects the transaction on a mismatch. A read posts one request frame however large nblocks is while a write posts nblocks * 512, so the two directions genuinely do not report the same thing -- tested as such, because leaving the request's frame size there is the bug. +0x0c is kept exactly as the request supplied it. QTEE looks for the response frames at req + req[0x0c] and the request arrives with 0x18; librpmb's hardcoded 20 points four bytes early. Chunking refuses a remainder rather than following the reference, which silently drops one -- a partial authenticated write leaves the store inconsistent with a counter that cannot be moved back. Verified by mutation: checking only the first frame for key programming, reporting a flat frame size as bytes transferred, and admitting a remainder each fail the suite.
2026-09-02 16:48:57 +02:00
export import :Rpmb;
Port the trustlet command surface, and pin the counting rule to recorded runs Fingerprintd:Ta is the second core module: request payloads, response fields, the error table, and the rule that decides what a frame meant. Payload building and response reading only -- no TEE, no transport. Very little of this is guessable, so each constant carries where it came from. Three were found only because QTEE recorded a fault naming the instruction that read them: * the event context's scan-slot count at +712, which do_enroll branches on to skip the entire slot loop -- an all-zero payload logged "groups->, results->" and read exactly like a gate failing deep in the trustlet, when it was zero iterations; * CAPTURE_IMAGE's flags at payload+0x18, without which preprocessing, the classifier and the enrol grouper never run at all, whatever is on the sensor; * SYNC_STATISTICS, whose absence leaves g_statistics NULL so the first enrol frame that gets far enough takes a data abort and every later command answers -90. The verdict rule gets the most attention because it was mislabelled three times before the comparison producing it was read. A frame is one of three things and only the third is a verdict: the poison intact means the matcher never ran, rc=-11 means not identified yet with attempts remaining, and only rc=0 carries a match or a rejection. The poison exists because a zero-initialised buffer cannot tell a released finger from a rejected one. The tests are in two halves that cannot prop each other up. Explicit wire conditions pin the classifier; three recorded runs pin the counting policy, which is what actually went wrong. In the stock-budget run 31 of 48 frames answered "not identified yet" and every frame that carried an image matched -- counting those 31 as attempts turns 8-for-8 into 8-of-39 and reads as a flaky sensor. The wrong-finger control pins zero false accepts. Fixtures are verdict-line excerpts, not the 40 KB transcripts, which are thick with the device's SFS container names the test has no use for. Verified by mutation: classifying -11 as a rejection, dropping SYNC_STATISTICS from the init chain, and forgetting the +0x10 response payload offset each fail the suite.
2026-09-02 16:46:00 +02:00
export import :Ta;
Port the capture loop, and make the per-press rate re-derivable Fingerprintd:Engine is the policy the trustlet cannot supply. It never polls for a finger: the normal world captures a frame, decides whether a finger is there, and tells it. So finger detection, edge reporting and the accounting all live out here, and they are the parts most easily got wrong in a way that reads as bad hardware. Baseline refuses to be a fixed threshold. The capture metric is per frame, so it scales with how many frames a capture asks for, and it drifts upward while idle -- an early session read "18 -> 24 with a finger" as weak detection when the values were climbing regardless of what was on the sensor. The floor is the maximum of the idle samples, and an uncalibrated Baseline calls nothing a finger rather than inventing a threshold. TouchTracker keeps the two modes apart. Enrolment reports touch on the rising edge and release on the falling one and nothing while held, mirroring stock, whose entire enrolment trace contains no image-ready event; emitting one per held frame feeds the algorithm near-duplicate images from a single press. Authentication does want it, because event 7 reaches the matcher unconditionally. AuthTally exists to stop two counting mistakes. Only a terminal verdict is an attempt -- counting rescan frames as rejections is what turned an 8-for-8 run into an apparent 8-of-39. And a press that ran out of frames without reaching a verdict is UNDECIDED, not failed; treating it as a failure is the same error one level up, which is why decided presses are counted separately. The tests replay the three recorded runs in order rather than as totals, because press structure only exists in the order. That makes the per-press claim re-derivable here instead of quoted: the enrolled finger matched on all five presses although five of its twenty frames did not, the wrong-finger control matched nothing, and on the stock-budget run five of ten presses reached a verdict and all five matched. Verified by mutation: counting undecided presses as decided, using a baseline before calibration, emitting image-ready during enrolment, and taking the first idle sample as the floor each fail the suite.
2026-09-02 17:19:45 +02:00
export import :Engine;
Port the finger name map, the last of the core modules Fingerprintd:Store holds the correspondence between two vocabularies that know nothing about each other: the trustlet identifies a finger by an opaque 32-bit id it chose, and fprintd speaks users and names like "right-index-finger". Nothing else can hold it -- the trustlet has no field for a name. No biometric data passes through here. A template is a ~252 KB container the trustlet encrypts and QTEE anti-rollback protects; this is a table of {name -> the id the trustlet reported}, worth about as much as a username. It is stored as one "name fid" per line, deliberately boring and greppable, because losing it costs names rather than templates and it should be repairable by hand. Two properties are load-bearing. fid 0 is never storable or matchable: 0 is what the trustlet writes into the fid field when authentication FAILS, so a stored 0 would turn every rejection into a match. And a damaged map degrades to "fewer names known" rather than to a daemon that will not start -- unknown names, missing ids, partly-numeric ids and zero fids are all skipped, since the daemon that fails to start is the one that unlocks the phone. The gid is the caller's Linux uid. SET_ACTIVE_GROUP and AUTHENTICATE only have to agree with each other, so the value is ours to choose, and the uid makes the mapping total with no allocation table. The dev phone's gid 60 is recorded as a legacy group -- it was never a decision, just the ENROLL token's timeout field read as a gid and then made self-consistent. Verified by mutation: storing fid 0 and accepting a partly-numeric id both fail the suite. A third mutation did not: Lookup's own zero guard is unreachable because Add is the only way an entry is created and it already refuses 0. The guard stays as defence in depth for a future writer, and the test now asserts the invariant that makes it unreachable -- no entry holds fid 0 however it was created -- rather than leaving a branch that no test can reach.
2026-09-02 17:22:27 +02:00
export import :Store;
Reach QTEE: credentials, client env and the app loader, with no QCBOR fingerprintd's own code now talks to QTEE. On the phone: root object on /dev/tee0 client env obtained (uid 0, 13-byte credentials) QSEECOM-compat app loader (UID 122) opened The credentials object is ours rather than libqcomtee's. Upstream's exists only to build a thirteen-byte CBOR map and drags in QCBOR to do it, so packaging/make-libqcomtee.sh compiles the two sources that matter and drops credentials_obj.c entirely -- nothing else references it, and the library then has no dependency beyond libc. The map is built in Fingerprintd:Tee where it is pinned byte-for-byte against the string verified on-device, and the object's two-op read protocol is served here. Three interop details, all of which cost a build cycle: * libqcomtee's headers carry no extern "C" guard, having only ever been consumed from C, so everything came out C++-mangled. They also pull in <stdatomic.h> and <stdio.h>, which under libc++ drag in templates that may not appear inside extern "C" -- so those are included first. * tee_call_t's second parameter is unsigned long on glibc and int on musl. The native build is glibc and the phone is musl; both forms are compiled. * On the callback path a UBUF_OUTPUT param arrives with addr = NULL. The dispatcher supplies the buffer, so a handler POINTS the param at its own storage rather than writing through the incoming address. Doing the latter is a null dereference that takes the supplicant thread with it, which is how the first run against real QTEE ended -- with the correct behaviour already spelled out in the module comment above the code that ignored it. That comment now says so in as many words.
2026-09-02 18:02:28 +02:00
export import :Tee;
Own the sensor rail, and run the init chain against it The daemon now powers the sensor and initialises the trustlet against it. On the phone, every step of the chain returning rc=0: gpiochip 'f100000.pinctrl' is /dev/gpiochip5 (168 lines) sensor powered, reset released, irq=1 CMD 0x1006 INIT_SPI rc=0 CMD 0x100a PROBE_DEVICE rc=0 CMD 0x100b INIT_DEVICE rc=0 CMD 0x1004 TA_INIT rc=0 CMD 0x1020 WORK_MODE rc=0 CMD 0x100e SYNC_STATISTICS rc=0 GPIO v2 chardev ioctls directly rather than libgpiod, which is on neither the phone nor the sysroot and would be a dependency for three lines. The chip is found by label, and the label is not what the device tree calls it: the node is pinctrl@f100000 so the chardev advertises "f100000.pinctrl", while every DT reference says "tlmm". Matching on "tlmm" finds nothing, which is how the first run failed. There is a second check on the line count, because this SoC has another pinctrl with 23 lines and driving line 75 of the wrong controller is not something you recover from over ssh. The XPU guard is enforced where the line is actually opened, not only asserted in the core. gpio8-11 are the fingerprint SPI pads and touching one is an immediate SError with the phone rebooting where it stands, so a refusal has to sit in front of the ioctl. Owning the rail is what makes the session recoverable at all: one reset buys exactly one trustlet init and a second answers -205, so a failed session needs the rail cycled rather than the chain retried. The harness split these across two processes and every run began by restarting the one holding the rail. CAPTURE_IMAGE answers -201 here and that is correct, not a regression: it needs a shared memory region whose address QTEE patches into the payload, and none is supplied yet. That is the next piece.
2026-09-02 18:24:12 +02:00
export import :Sensor;
Give a finger a meaning beyond "it was you" The trustlet has always reported WHICH finger matched and the daemon only ever used it to answer yes. A table in /etc/fingerprintd/actions.conf now gives each finger a meaning: run a command as root, tell the user's session, or report no-match while doing one of those anyway -- which is duress, where the phone should look like it simply did not recognise the finger. Two rules shaped the design. Root does not launch applications. The daemon has no session bus, no display and no user environment, so a `session` rule carries no command at all: the daemon emits net.catcrafts.Fingerprintd1.FingerMatched(finger, uid) and an agent in the user's own session decides what that means from the user's own configuration. The only commands in the file are ones root is meant to run. Which makes the file a root shell, and the parser treats it as one. It is refused outright unless root owns it and nobody else can write it, group included. A malformed line rejects the WHOLE file rather than being skipped: applying the prefix would leave a policy nobody wrote, and the missing half could be the one that mattered. That property is tested, and the test caught it being false the first time -- rules accumulated before the bad line survived the rejection. A system command must be an absolute path, because resolving a bare name through PATH makes what root runs depend on an environment this daemon does not control. It is double-forked with a scrubbed environment so an action may outlive the daemon (a reboot) without ever stalling the worker thread that is the only thread allowed to touch the trustlet. Ordering is deliberate: the verdict override happens before the client is told, because that is the point of duress; the session signal and the root command happen after, on the same principle that keeps the harvest and the save off the unlock path. No actions.conf ships. An example goes to /usr/share/doc, because shipping a root shell nobody asked for is not a default. Not yet exercised on hardware.
2026-09-05 05:12:41 +02:00
export import :Actions;