Package the daemon, so a fingerprint survives a reflash

An aport, the units, and everything a phone needs to come up with a working
sensor without a single command being run by hand. Verified on the dev phone
across two reboots: modules-load.d loads qcomtee, tmpfiles builds the SFS
root, the mount unit brings up persist, and the daemon is ready 51 seconds
into the boot, owning net.reactivated.Fprint with the enrolled finger
visible.

The packaging shape is the one imsd uses for 81voltd. A versioned
provides="fprintd=..." satisfies plasma-workspace -- its Users KCM is the
enrolment UI and speaks exactly this bus name -- and excludes the real
fprintd, which is not tidiness: fprintd is D-Bus-activatable, so a client
call would otherwise start it and fight us for the name. The cost is the
fprintd-* CLIs, which go with the package.

fprintd-pam is an install_if subpackage pinned to the exact fprintd version,
so the provides breaks its condition and apk purges it -- taking pam_fprintd,
which is the entire point of the daemon, with it. Depending on it explicitly
is what keeps it, and it has no dependency on fprintd itself.

Two things the packaging exposed in the daemon:

The transcript is for experiments, not for a shipped daemon. A file per start
in an unrotated directory, recording the time of every unlock, to say what
the journal already has. It is now opt-in behind --log-dir, which is what
deploy-dev.sh passes since fplearn.sh reads it.

Taking it off the daemon path also took away the setvbuf it was doing as a
side effect of dup2'ing fd 1, and under systemd stdout is a pipe, which means
full buffering: the daemon started, worked, answered D-Bus calls, and printed
nothing. A working daemon that looks hung. stdout is now line-buffered from
the first line of main.

The config ships as generated by fp6fpcfg.py --daemon --verbose, sha256
b205c756914a66f1, because that is the file every accuracy number was measured
on. The quieter variant is untested and switching is a measurement.

The trustlet is not here and never will be: focal64.mbn is a proprietary
OEM-signed blob, and the unit's ConditionPathExists is what keeps the package
inert without it -- as it does on a kernel with no CONFIG_QCOMTEE.
This commit is contained in:
Jorijn van der Graaf 2026-09-05 02:52:01 +02:00
commit 905e261d63
15 changed files with 518 additions and 8 deletions

View file

@ -0,0 +1,66 @@
name: package
# Builds the fingerprintd apk for aarch64 from the pushed commit and publishes
# it to the Forgejo Alpine registry — the repo installed phones already point
# at (via catcrafts-fp6-repo), so a release reaches users through plain
# 'apk upgrade' without an fp6-img image run.
#
# Build: crafter-build cross-compiles against an Alpine aarch64 sysroot
# (packaging/build-package.sh — the README's "Cross-compiling" flow), the test
# suite runs natively, and packaging/APKBUILD wraps the result. This needs no
# privileged runner: it runs in an alpine:edge container on the ordinary
# arch-latest runner.
#
# Release gating is the version: pkgver comes from implementations/main.cpp,
# the registry answers 409 for an already-published version, and the publish
# step treats that as "nothing to do" — so pushes only release when the
# Version constant bumps.
#
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
# org-level secret on Catcrafts, shared with imsd and fp6-img; without it the
# build still runs and the publish step skips quietly.
on:
workflow_dispatch:
push:
branches: [main]
jobs:
package:
runs-on: arch-latest
container:
image: alpine:edge
timeout-minutes: 90
steps:
# actions/checkout is a Node action; bare alpine has no node
- name: Provision job container
run: apk add -q nodejs git curl
- name: Checkout
uses: actions/checkout@v4
- name: Build and package
run: ./packaging/build-package.sh
- name: Publish to the apk registry
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
if [ -z "$PACKAGE_TOKEN" ]; then
echo "no PACKAGE_TOKEN secret configured; skipping package publish"
exit 0
fi
found=0
for f in /home/build/.local/share/abuild/*/aarch64/fingerprintd*.apk; do
[ -e "$f" ] || continue
found=1
code=$(curl -s -o /dev/null -w '%{http_code}' \
--user "catbot:$PACKAGE_TOKEN" --upload-file "$f" \
"https://forgejo.catcrafts.net/api/packages/Catcrafts/alpine/edge/fp6")
case "$code" in
201) echo "published: $(basename "$f")" ;;
409) echo "already published: $(basename "$f")" ;;
*) echo "FAILED ($code): $(basename "$f")"; exit 1 ;;
esac
done
[ "$found" = 1 ] || { echo "no packages found to publish"; exit 1; }

View file

@ -66,7 +66,7 @@ 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";
constexpr const char* Version = "0.1.1";
bool g_verbose = false;
// 500 ms was the research harness's pace, chosen so a human could read the
@ -87,6 +87,9 @@ 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";
// Only an explicit --log-dir turns the transcript on for the daemon; see
// StartTranscript.
bool g_logDirExplicit = false;
std::string g_stateDir = "/var/lib/fingerprintd";
int g_rescan = -1; // -1 = leave the config's value alone
@ -172,11 +175,16 @@ 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.
// Every PROBE 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.
//
// The daemon is the exception, and takes one only when --log-dir says so. That
// reasoning is about experiments; a packaged daemon that ran it would leave a
// file per start in an unrotated directory, and record the time of every
// unlock, to say what the journal already has.
//
// 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
@ -2623,6 +2631,12 @@ int RunProbeTaLoad(const std::string& path) {
}
int main(int argc, char** argv) {
// Under systemd stdout is a pipe, and a pipe means FULL buffering: the
// daemon's lines would sit in the buffer rather than reach the journal,
// which is how a working daemon looks like a hung one. StartTranscript
// used to set this as a side effect of taking over fd 1, and the daemon
// does not run it.
::setvbuf(stdout, nullptr, _IOLBF, 0);
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;
@ -2652,7 +2666,7 @@ int main(int argc, char** argv) {
// 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("--log-dir=")) { g_logDir = a.substr(10); g_logDirExplicit = true; }
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("--reject-budget=")) g_pressRejectBudget = std::stoi(std::string(a.substr(16)));
@ -2664,7 +2678,7 @@ int main(int argc, char** argv) {
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 (!daemon || g_logDirExplicit) StartTranscript(g_logDir);
if (!probeTa.empty()) return RunProbeTaLoad(probeTa);
if (daemon) return RunDaemon();
if (probe) return RunProbe(doAuth, doEnrol, doCalSave, doLearnProbe, gid, frames);

View file

@ -0,0 +1 @@
enable fingerprintd.service

99
packaging/APKBUILD Normal file
View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
# Binary packaging: wraps a crafter-build binary cross-compiled per the
# README's "Cross-compiling" section into a proper apk — used by this repo's
# package CI (packaging/build-package.sh, which seds pkgver from
# implementations/main.cpp) and runnable by hand. The source tarball is
# produced by packaging/make-bin-tarball.sh. There is deliberately no
# source-building aport: the build driver is crafter-build, which is not in
# Alpine, so an APKBUILD that compiled from source could not be built by
# anyone but us either.
pkgname=fingerprintd
pkgver=0.1.1
pkgrel=0
pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)"
url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd"
arch="aarch64"
license="GPL-3.0-only"
# GLib for the D-Bus interface; libc++ because the binary is a clang/libc++
# C++26 modules build linked dynamically against the phone's own runtime.
#
# fprintd-pam is pam_fprintd, which is the point of the whole daemon: it is
# what turns a matched finger into a login. It is an install_if subpackage
# conditioned on the EXACT version fprintd-pam was built against
# (i:fprintd=1.94.5-r1), so the provides below breaks that condition and apk
# would purge it as no-longer-needed. Depending on it explicitly is what keeps
# it. It has no dependency on fprintd itself, so nothing is being forced.
depends="dbus glib libc++ fprintd-pam"
# The versioned provides both satisfies plasma-workspace's fprintd dependency
# — its Users KCM is the fingerprint enrolment UI and speaks exactly this bus
# name — and EXCLUDES the real package, which is required rather than tidy:
# fprintd is D-Bus-activatable, so a client call would otherwise start the
# real daemon and fight for net.reactivated.Fprint. fprintd-pam is a separate
# package that does not depend on fprintd, so PAM keeps working.
#
# The cost, which is real: the fprintd-enroll/-list/-verify/-delete CLIs go
# away with the package. Enrolment then goes through Plasma's Users KCM.
provides="fprintd=$pkgver-r$pkgrel"
# The unit is the deliverable — a daemon holding QTEE's listener table open for
# the life of the boot is not something to start by hand — but abuild wants
# systemd files in their own package, and install_if puts them back on any
# system that has systemd. No OpenRC service: nothing here has ever been run
# under one, and the unit's conditions (the vendor blob, /dev/tee0) are what
# keep the package inert on a phone that cannot use it.
subpackages="$pkgname-systemd"
# nothing is compiled here, and the aarch64 ELF's NEEDED entries must not be
# traced against an x86_64 build host
options="!check !tracedeps"
source="fingerprintd-$pkgver.tar.gz"
package() {
cd "$srcdir/fingerprintd-$pkgver"
install -Dm755 fingerprintd "$pkgdir"/usr/bin/fingerprintd
install -Dm644 fingerprintd.service \
"$pkgdir"/usr/lib/systemd/system/fingerprintd.service
install -Dm644 mnt-persist.mount \
"$pkgdir"/usr/lib/systemd/system/mnt-persist.mount
# enabled by preset, and by an explicit .wants link so a fingerprint
# surviving a reboot never depends on a manual systemctl enable. The
# mount needs neither: fingerprintd.service pulls it in with
# RequiresMountsFor.
install -Dm644 80-fingerprintd.preset \
"$pkgdir"/usr/lib/systemd/system-preset/80-fingerprintd.preset
mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants
ln -s /usr/lib/systemd/system/fingerprintd.service \
"$pkgdir"/etc/systemd/system/multi-user.target.wants/fingerprintd.service
# who may own and call the bus name
install -Dm644 net.reactivated.Fprint.conf \
"$pkgdir"/usr/share/dbus-1/system.d/net.reactivated.Fprint.conf
# replaces fprintd's activation file, which points at /usr/libexec/fprintd
install -Dm644 net.reactivated.Fprint.service \
"$pkgdir"/usr/share/dbus-1/system-services/net.reactivated.Fprint.service
# the action ids fprintd defined; see the file for what enforces them
install -Dm644 net.reactivated.fprint.device.policy \
"$pkgdir"/usr/share/polkit-1/actions/net.reactivated.fprint.device.policy
# the SFS root's directories and its two symlinks into the persist mount
install -Dm644 fingerprintd.tmpfiles.conf \
"$pkgdir"/usr/lib/tmpfiles.d/fingerprintd.conf
# qcomtee -> /dev/tee0
install -Dm644 fingerprintd.modules-load.conf \
"$pkgdir"/usr/lib/modules-load.d/fingerprintd.conf
# the trustlet's configuration, generated by fp6fpcfg.py from the captured
# stock dump — see packaging/README.config.md. Not a user config file:
# the TA discards a file whose configuration_uuid does not match, and the
# two policy keys in it were each forced by a measurement.
install -Dm644 fingerprintd.json \
"$pkgdir"/usr/lib/firmware/fingerprintd.json
}
systemd() {
install_if="$pkgname=$pkgver-r$pkgrel systemd"
amove usr/lib/systemd
amove etc/systemd
}

View file

@ -0,0 +1,37 @@
# `fingerprintd.json` — where it comes from, and why this exact file
The trustlet gates its config file on `common.configuration_uuid` and silently
falls back to built-in defaults on a mismatch, so the file is not decorative:
`SYNC_CONFIG` returning 0 is what makes the whole init chain run.
This copy is generated, not hand-written. Its source is `fp6fpcfg.py` in the
fp6 bring-up repo, which builds it from the captured stock configuration dump
(`journal/fingerprint/captures/2026-08-25-focal64-effective-config-from-stock.txt`):
```sh
utilities/fp6fpcfg.py --daemon --verbose > packaging/fingerprintd.json
```
sha256 begins `b205c756914a66f1`.
## Why the `--verbose` variant
`--verbose` here is the *trustlet's* own log level, not the daemon's. It is
shipped because it is the file every accuracy number was measured on — 30/30
held presses, zero false accepts, 36-330 ms to a verdict — and the TA's log
level cannot be raised again by a runtime `SYNC_CONFIG`, so a session that
needs the matcher's own lines has to have started with it.
`--daemon` alone produces the same file with trustlet logging off (sha256
`6c4503e628406424`, four `diagnosis.*` keys differ and nothing else). It is
plausibly the better shipping default and it is **untested**: no rate in the
journal was measured on it. Switching is a measurement, not an edit.
## The two policy keys
* `common.max_authentication_rescan_times: 0` — at the stock budget a wrong
finger never yields a terminal frame, so a PAM client waits forever for the
`verify-no-match` it needs.
* `trustlet.enable_trusted_enrollment: false` — skips the challenge compare
and the `hw_auth_token` HMAC verify. pmOS has no Gatekeeper to issue a token
and nothing on pmOS verifies one.

114
packaging/build-package.sh Executable file
View file

@ -0,0 +1,114 @@
#!/bin/sh -eu
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# CI package build: cross-compile fingerprintd for aarch64 with crafter-build
# (the README's "Cross-compiling" flow), run the test suite natively, and
# package the result via packaging/APKBUILD. Expects an x86_64 Alpine
# environment with root — the workflow runs it in an alpine:edge job
# container on an ordinary runner. Root only installs packages and hands off
# to a scratch user: the sysroot is built with apk.static --usermode (which
# refuses root) and abuild wants a user too.
#
# Built packages land in /home/build/.local/share/abuild/*/aarch64/fingerprintd*.apk;
# the workflow's publish step uploads them to the Forgejo Alpine registry.
set -eu
# The musl build of crafter-build (Crafter.Build CI's release-musl job): this
# container is Alpine, and the glibc launcher cannot run on musl. v2 = SSE4.2
# baseline: the CI box is an Intel N5105 (no AVX). Overridable for local
# rehearsals (file:// works).
CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz}
SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
# clang cross-targets aarch64 natively and the target's libc++/glib come from
# the sysroot; llvm-runtimes/libc++-dev/glib-dev here serve the NATIVE
# test-suite run. build-base = Alpine's standard build environment (the one
# abuild implies): binutils' ld/ar for clang's default link driver, gcc's
# libgcc_s/crt objects the musl clang driver links against.
if [ "$(id -u)" = 0 ]; then
apk add -q git curl tar clang lld llvm llvm-runtimes libc++-dev llvm-libunwind-dev glib-dev \
build-base abuild sudo
id build >/dev/null 2>&1 || adduser -D build
addgroup build abuild 2>/dev/null || true
echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build
# abuild in cross mode strips with $CHOST-strip; llvm-strip handles any
# ELF arch, so give it that name
ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip
# the CI checkout arrives root-owned; crafter-build writes bin/ into it
chown -R build "$SRC"
# -l: a login shell, so HOME really is /home/build (abuild keys + output);
# it scrubs the environment, so carry the one knob that matters across
exec su -l build -c "CRAFTER_URL='${CRAFTER_URL:-}' sh -eu '$SRC/packaging/build-package.sh'"
fi
retry() { # retry <description> <cmd...>
_desc=$1; shift
for _i in 1 2 3; do
"$@" && return 0
echo "$_desc failed (attempt $_i/3), retrying in 15s..." >&2
sleep 15
done
echo "$_desc failed after 3 attempts" >&2
return 1
}
# implementations/main.cpp is the version's single source of truth (same
# derivation as make-bin-tarball.sh)
VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp")
[ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; }
echo ">> packaging fingerprintd $VER"
# --- crafter-build: static launcher from the rolling release
mkdir -p "$HOME/crafter-build"
retry "fetch crafter-build" \
sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'"
PATH="$HOME/crafter-build/bin:$PATH"
export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build"
# --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode)
SYSROOT="$HOME/.cache/fingerprintd/sysroot-aarch64-alpine"
retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT"
# --- libqcomtee: Qualcomm's BSD-3 QTEE client (quic-teec, pinned commit),
# which is not in Alpine and is not vendored here. crafter-build looks for it
# under ~/.cache/fingerprintd/libqcomtee-<target>, which is this script's
# default output dir. Needs git, installed above.
retry "make libqcomtee" "$SRC/packaging/make-libqcomtee.sh" \
--target=aarch64-alpine-linux-musl --sysroot="$SYSROOT" --march=armv8-a
# --- cross-compile the daemon; run the suites natively. The core is portable
# by construction (no GLib, no libqcomtee, no system headers), which is what
# lets the wire formats and state machines be tested on the build host at all.
cd "$SRC"
XTARGET="--target=aarch64-alpine-linux-musl --sysroot=$SYSROOT --march=armv8-a --mtune=generic"
crafter-build -- $XTARGET
crafter-build test
# --- bundle + package
./packaging/make-bin-tarball.sh "$VER"
PKG="$HOME/pkg"
rm -rf "$PKG"
mkdir -p "$PKG"
cp "$SRC/packaging/APKBUILD" "$PKG/APKBUILD"
mv "fingerprintd-$VER.tar.gz" "$PKG/"
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
# a throwaway signing key: phones trust the registry-signed APKINDEX, not
# per-package keys (same situation as fp6-img's pmbootstrap-built packages).
# abuild >= 3.18 keeps keys under ~/.config/abuild and output under
# ~/.local/share/abuild (REPODEST default).
abuild-keygen -a -n >/dev/null 2>&1
sudo cp "$HOME"/.config/abuild/*.rsa.pub /etc/apk/keys/
# CHOST puts abuild in cross mode so arch="aarch64" packages on this x86_64
# host. -d skips dependency handling entirely: nothing compiles under abuild
# (with -r, cross mode would try to install a nonexistent build-base-aarch64
# plus the runtime depends); !tracedeps in the APKBUILD keeps abuild from
# resolving the aarch64 ELF NEEDED entries against this x86_64 host.
cd "$PKG"
abuild checksum
CHOST=aarch64 abuild -d
echo "=== built packages ==="
ls -la "$HOME"/.local/share/abuild/*/aarch64/fingerprintd*.apk

View file

@ -92,8 +92,11 @@ mount | grep " /mnt/persist " | grep -q "rw," || { echo "persist not rw" >&2; ex
echo ">> persist mounted rw"
sudo systemctl stop fingerprintd-test 2>/dev/null || true
sleep 1
# --log-dir is explicit because the daemon writes no transcript without it
# (a packaged daemon must not), and fplearn.sh reads the newest one.
sudo systemd-run --unit=fingerprintd-test --collect \
/tmp/fingerprintd --daemon --verbose --edge-wake \
--log-dir=/var/log/fingerprintd \
--sfs-root=/var/lib/fingerprintd/sfs --sfs-writable --rpmb-write $EXTRA >/dev/null
[ -z "$EXTRA" ] || echo ">> extra daemon flags: $EXTRA"
printf ">> daemon starting"

View file

@ -0,0 +1 @@
{"algorithm":{"enrolling_overlap_intervals":"0x54A0","min_enrolling_quality_threshold":20,"min_enrolling_coverage_threshold":70,"enroll_overlap_compare_cnts":30,"enroll_overlap_min_area":60,"enroll_overlap_max_area":80,"enroll_overlap_ang":0,"enable_duplicated_finger_checking":true,"max_extral_enroll_low_quality":16,"max_extral_enroll_too_slow":10,"enable_template_learning":true,"template_update_threshold":50,"max_sub_template_num":96},"driver":{"spi_bus_num":2,"spi_c_s_num":0},"device":{"spi_default_bps":4800000,"spi_mode":0,"preferred_device_id":"0x9391"},"trustlet":{"enable_trusted_enrollment":false},"common":{"max_enrolling_fingers":5,"max_enrolling_samples":20,"max_authentication_rescan_times":0},"diagnosis":{"enable_logcat_trustlet":true,"enable_trustlet_native_log":true,"enable_algorithm_log":true,"framework_log_level":6,"firmware_log_level":6,"algorithm_log_level":2}}

View file

@ -0,0 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# /dev/tee0, which the daemon opens to reach QTEE. Needs CONFIG_QCOMTEE=m in
# the kernel package; on a kernel without it this line is a no-op and
# fingerprintd.service stays inert on its ConditionPathExists.
qcomtee

View file

@ -0,0 +1,46 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
[Unit]
Description=Fingerprint daemon (FocalTech FT9391 behind QTEE)
Documentation=https://forgejo.catcrafts.net/Catcrafts/fingerprintd
# The trustlet does the matching and it is a proprietary OEM-signed blob
# extracted from the stock vendor partition, so it is not in this package.
# Without it there is no sensor, and the unit stays out of the way instead of
# restart-looping.
ConditionPathExists=/usr/lib/firmware/focal64.mbn
# /dev/tee0 is the qcomtee driver, which the pmOS kernel does not build yet
# (CONFIG_QCOMTEE). modules-load.d asks for the module; this condition is what
# makes the package harmless on a kernel that has none.
ConditionPathExists=/dev/tee0
# The templates are QTEE containers on the Android persist partition, reached
# through the SFS root's persist-data/root3 symlinks.
RequiresMountsFor=/mnt/persist
Requires=dbus.service
After=dbus.service
[Service]
Type=simple
# --edge-wake wait on the sensor IRQ instead of polling for a finger
# --sfs-writable QTEE must be able to WRITE the template store, or an
# enrolment cannot be saved. It can also unlink a container it
# rejects, which is why it is opt-in rather than the default.
# --rpmb-write the anti-rollback counter lives in RPMB; a save that cannot
# write it does not commit.
# No --verbose: it prints the frame-by-frame state machine, which on a phone
# is both journal noise and a record of when its owner unlocked it.
ExecStart=/usr/bin/fingerprintd --daemon --edge-wake \
--sfs-root=/var/lib/fingerprintd/sfs --sfs-writable --rpmb-write
# Root is required and not reducible: the daemon drives the sensor rails over
# gpiochip, holds /dev/tee0, and serves QTEE's RPMB transactions against the
# raw UFS RPMB device. No sandboxing is declared here rather than declaring
# some that was never tested against those three.
Restart=on-failure
RestartSec=5
# SIGTERM: the worker finishes the invoke it is inside before the session goes
# down; QTEE's listener table is global to the boot and a half-torn session
# leaves it holding ours.
KillMode=mixed
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,19 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# The SFS root. QTEE asks the normal world for absolute Android paths; the
# daemon maps each prefix onto one name below and refuses anything that
# escapes the root. The names are the mapping and cannot be renamed:
#
# /mnt/vendor/persist/data/ -> persist-data the templates
# /persist/data/ -> root3 the same directory
# /data/vendor/tzstorage/ -> tzstorage
# /data/misc/qsee/ -> misc
#
# persist-data and root3 are the same place on purpose: QTEE reaches the
# template store under both prefixes.
d /var/lib/fingerprintd 0700 root root -
d /var/lib/fingerprintd/sfs 0700 root root -
d /var/lib/fingerprintd/sfs/tzstorage 0700 root root -
d /var/lib/fingerprintd/sfs/misc 0700 root root -
L /var/lib/fingerprintd/sfs/persist-data - - - - /mnt/persist/data
L /var/lib/fingerprintd/sfs/root3 - - - - /mnt/persist/data

29
packaging/make-bin-tarball.sh Executable file
View file

@ -0,0 +1,29 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# make-bin-tarball.sh — bundle the cross-compiled fingerprintd binary and its
# runtime files into the source tarball APKBUILD consumes. Run from the repo
# root after a cross build; output lands in the current directory.
set -eu
VER="${1:-$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' implementations/main.cpp)}"
[ -n "$VER" ] || { echo "could not determine version — pass it as \$1" >&2; exit 1; }
BIN=$(ls -t bin/fingerprintd-aarch64-*/fingerprintd 2>/dev/null | head -n1)
[ -n "$BIN" ] || { echo "no aarch64 fingerprintd build found — cross-compile first" >&2; exit 1; }
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir "$stage/fingerprintd-$VER"
cp "$BIN" "$stage/fingerprintd-$VER/fingerprintd"
cp packaging/fingerprintd.service \
packaging/mnt-persist.mount \
packaging/80-fingerprintd.preset \
packaging/net.reactivated.Fprint.conf \
packaging/net.reactivated.Fprint.service \
packaging/net.reactivated.fprint.device.policy \
packaging/fingerprintd.tmpfiles.conf \
packaging/fingerprintd.modules-load.conf \
packaging/fingerprintd.json \
"$stage/fingerprintd-$VER/"
tar -C "$stage" -czf "fingerprintd-$VER.tar.gz" "fingerprintd-$VER"
echo "wrote fingerprintd-$VER.tar.gz ($(du -h "fingerprintd-$VER.tar.gz" | cut -f1))"

View file

@ -0,0 +1,17 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# The Android persist partition. QTEE's own storage paths land here through
# the SFS root's persist-data/root3 symlinks, and the enrolled templates are
# the containers under data/ — the same place stock Android keeps them, which
# is not a choice: every container is sealed to a hardware anti-rollback
# counter, so a template cannot be relocated, copied or restored.
[Unit]
Description=Android persist partition
Documentation=https://forgejo.catcrafts.net/Catcrafts/fingerprintd
ConditionPathExists=/dev/disk/by-partlabel/persist
[Mount]
What=/dev/disk/by-partlabel/persist
Where=/mnt/persist
Type=ext4
Options=rw,nosuid,nodev,noexec

View file

@ -0,0 +1,5 @@
[D-BUS Service]
Name=net.reactivated.Fprint
Exec=/usr/bin/fingerprintd --daemon
User=root
SystemdService=fingerprintd.service

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<!-- SPDX-License-Identifier: GPL-3.0-only
SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
fingerprintd owns net.reactivated.Fprint, which excludes the fprintd
package and the actions file it ships. These are the same three action
ids with the same defaults, so a client that consults them — or a site
that has already written rules against them — sees no change.
NOT YET ENFORCED. The daemon currently authorizes by caller uid: a
client may act on its own prints and no other user's. That is stricter
than allow_active=yes for verify and LOOSER than auth_self_keep for
enroll, which asks the user to re-authenticate first. Enforcing these
needs a polkit agent to be reachable, and the FP6 does not have a
working session UI yet, so it would today block enrolment on the one
device that can test it. Tracked in the fp6 journal's fingerprint lane.
Install: /usr/share/polkit-1/actions/ -->
<policyconfig>
<vendor>Catcrafts</vendor>
<vendor_url>https://forgejo.catcrafts.net/Catcrafts/fingerprintd</vendor_url>
<icon_name>fingerprint</icon_name>
<action id="net.reactivated.fprint.device.verify">
<description>Verify a fingerprint</description>
<message>Privileges are required to verify fingerprints.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
</action>
<action id="net.reactivated.fprint.device.enroll">
<description>Enroll new fingerprints</description>
<message>Privileges are required to enroll new fingerprints.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
<action id="net.reactivated.fprint.device.setusername">
<description>Select a user to enroll</description>
<message>Privileges are required to select a user to enroll.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>