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.
This commit is contained in:
Jorijn van der Graaf 2026-09-05 05:12:41 +02:00
commit 928fe1482e
10 changed files with 582 additions and 6 deletions

View file

@ -53,7 +53,9 @@ extern "C" {
#include <pwd.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <poll.h>
#include <unistd.h>
#include <errno.h>
@ -66,7 +68,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.3";
constexpr const char* Version = "0.2.0";
bool g_verbose = false;
// 500 ms was the research harness's pace, chosen so a human could read the
@ -172,6 +174,9 @@ int g_learnMaxFrames = 1;
std::string g_groupPath{fingerprintd::ta::GroupNamespacePath};
std::string g_taPath = "/lib/firmware/focal64.mbn";
std::string g_cfgPath = "/lib/firmware/fingerprintd.json";
// Per-finger actions. Absent by default, which is the feature being off.
std::string g_actionsPath = "/etc/fingerprintd/actions.conf";
std::vector<fingerprintd::actions::Rule> g_actions;
qcomtee_object* g_root = QCOMTEE_OBJECT_NULL;
@ -2121,9 +2126,90 @@ private:
// =============================================================================
constexpr const char* BusName = "net.reactivated.Fprint";
constexpr const char* ManagerPath = "/net/reactivated/Fprint/Manager";
// ---- Per-finger actions ----------------------------------------------------
//
// Loaded once at startup and never reloaded on the fly: the file decides what
// root executes, and re-reading it at match time would widen the window in
// which a file that passed its permission check is not the file that runs.
// Changing it means restarting the unit, which is also the moment an
// administrator gets to see the parse errors.
void LoadActions() {
namespace ac = fingerprintd::actions;
struct stat st{};
if (::stat(g_actionsPath.c_str(), &st) != 0) return; // absent = off
// The shell owns the stat because the module has no filesystem. Root must
// own it, and no one else may write it -- group included, since a group
// is a set of people and this is a root shell.
bool rootOnly = (st.st_uid == 0) && ((st.st_mode & (S_IWGRP | S_IWOTH)) == 0);
std::ifstream f(g_actionsPath, std::ios::binary);
std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
ac::Parsed p = ac::Parse(text, rootOnly);
if (!p.Ok()) {
// Loud and total. A rejected file leaves NO rules, so the daemon
// behaves exactly as it did before the file existed -- a finger still
// unlocks, nothing runs.
if (p.line)
std::println(std::cerr, "{}:{}: {} -- NO actions loaded",
g_actionsPath, p.line, ac::Describe(p.error));
else
std::println(std::cerr, "{}: {} -- NO actions loaded",
g_actionsPath, ac::Describe(p.error));
return;
}
g_actions = std::move(p.rules);
for (const ac::Rule& r : g_actions)
std::println("action: {} -> {}{}", fingerprintd::store::NameOf(r.finger),
r.where == ac::Where::Session ? "the user's session" : r.command,
r.verdict == ac::Verdict::NoMatch ? " (reported as no-match)" : "");
}
// Run a system action. Double-forked so the grandchild is reparented to init
// and this process never has to wait for it: an action may well outlive the
// daemon (a reboot) or block for a long time, and neither may stall the
// worker thread that is the only thread allowed to touch the trustlet.
//
// Deliberately NOT via system(): that would hand the string to a shell, and
// the shell's word splitting and expansion are extra semantics in a string
// root executes. /bin/sh is still the interpreter here -- the config format
// takes a command line, not an argv -- but it is exec'd directly with a fixed
// argv and a scrubbed environment.
void RunSystemAction(const std::string& command, const std::string& finger) {
pid_t first = ::fork();
if (first < 0) { std::println(std::cerr, "action: fork failed"); return; }
if (first == 0) {
if (::fork() == 0) {
::setsid();
// No inherited stdio: the daemon's stdout is the journal, and an
// action that writes to it would interleave with the frame log.
int devnull = ::open("/dev/null", O_RDWR);
if (devnull >= 0) {
::dup2(devnull, 0); ::dup2(devnull, 1); ::dup2(devnull, 2);
if (devnull > 2) ::close(devnull);
}
const char* env[] = {
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
nullptr, nullptr
};
std::string fingerEnv = std::format("FINGERPRINTD_FINGER={}", finger);
env[1] = fingerEnv.c_str();
const char* argv[] = { "/bin/sh", "-c", command.c_str(), nullptr };
::execve("/bin/sh", const_cast<char* const*>(argv), const_cast<char* const*>(env));
::_exit(127);
}
::_exit(0);
}
int status = 0;
::waitpid(first, &status, 0); // the intermediate child only
}
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";
// Ours, not fprintd's: a signal fprintd has no concept of. Emitted on the
// same object so a session agent needs no second bus name to watch.
constexpr const char* ActionIface = "net.catcrafts.Fingerprintd1";
constexpr const char* DeviceName = "FocalTech FT9391 (QTEE)";
constexpr const char* IntrospectionXml = R"xml(
@ -2341,13 +2427,51 @@ void PostEvent(std::unique_ptr<Event> ev) {
}
}
break;
case Event::Kind::VerifyStatus:
if (!ev->status.empty())
EmitDevice("VerifyStatus", g_variant_new("(sb)", ev->status.c_str(), ev->done ? TRUE : FALSE));
case Event::Kind::VerifyStatus: {
namespace ac = fingerprintd::actions;
namespace store = fingerprintd::store;
std::string status = ev->status;
const ac::Rule* rule = nullptr;
std::optional<store::Finger> matched;
if (ev->done && status == "verify-match" && ev->fid != 0) {
matched = g_claim.fingers.Lookup(ev->fid);
if (matched) rule = ac::Find(g_actions, *matched);
}
// The verdict override happens BEFORE the client is told, because
// it is the whole point of a duress rule: the phone must look like
// it did not recognise the finger. Everything else happens after.
if (rule && rule->verdict == ac::Verdict::NoMatch) {
std::println("action: {} is configured no-match; reporting a rejection",
store::NameOf(*matched));
status = "verify-no-match";
}
if (!status.empty())
EmitDevice("VerifyStatus", g_variant_new("(sb)", 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);
// Told to the session AFTER the verdict, on the same principle
// that keeps the harvest and the save off the unlock path: an
// agent that is slow, or absent, must not delay an unlock.
//
// The signal carries the finger name and nothing else. What a
// finger should DO in a session is the user's business, decided
// by the user's own agent from the user's own configuration --
// root has no session bus, no display and no business launching
// someone's applications.
if (matched && g_conn) {
g_dbus_connection_emit_signal(
g_conn, nullptr, DevicePath, ActionIface, "FingerMatched",
g_variant_new("(su)", std::string(store::NameOf(*matched)).c_str(),
static_cast<guint32>(g_claim.uid)),
nullptr);
}
if (rule && rule->where == ac::Where::System) {
std::println("action: {} -> running {}", store::NameOf(*matched), rule->command);
RunSystemAction(rule->command, std::string(store::NameOf(*matched)));
}
break;
}
}
return G_SOURCE_REMOVE;
}, ev.release());
}
@ -2600,6 +2724,7 @@ int RunDaemon() {
std::println(std::cerr, "fingerprintd: must run as root (/dev/tee0, gpio, RPMB)");
return 1;
}
LoadActions();
Worker worker;
g_worker = &worker;
g_loop = g_main_loop_new(nullptr, FALSE);
@ -2786,6 +2911,7 @@ int main(int argc, char** argv) {
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.starts_with("--actions=")) g_actionsPath = a.substr(10);
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.
@ -2837,6 +2963,8 @@ int main(int argc, char** argv) {
" --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"
" --actions=FILE per-finger actions (default\n"
" /etc/fingerprintd/actions.conf, absent = off)\n"
" --sfs-root=DIR --sfs-writable --rpmb-write storage policy",
Version);
return 1;