diff --git a/implementations/agent.cpp b/implementations/agent.cpp new file mode 100644 index 0000000..4e65265 --- /dev/null +++ b/implementations/agent.cpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file no-char-pointer +/* +fingerprintd-agent — run something in YOUR session when a finger matches. + +The daemon is root. It has no session bus, no display and no business starting +your applications, so it does not try: it announces every matched finger on the +system bus and stops there. + + net.catcrafts.Fingerprintd1.FingerMatched(s finger, u uid) + on /net/reactivated/Fprint/Device/0 + +This is the other half. It runs as you, inside your session, with your +environment, and maps fingers to commands from a file you own: + + ~/.config/fingerprintd/fingers.conf + right-ring-finger kde-open camera + left-index-finger plasma-settings + +Different trust domain from /etc/fingerprintd/actions.conf, deliberately. That +file is a root shell and is guarded like one. This one runs your commands as +you, so it is an ordinary dotfile: no ownership check, relative commands fine, +re-read when it changes so editing it does not need a restart. +*/ +// stdio.h before the module import, for the same reason main.cpp does it: +// setvbuf and its constants come from the C header, and the std module does +// not re-export the macro. +#include +#include +#include + +import std; +import Fingerprintd; + +namespace { + + constexpr const char* BusName = "net.reactivated.Fprint"; + constexpr const char* DevicePath = "/net/reactivated/Fprint/Device/0"; + constexpr const char* Iface = "net.catcrafts.Fingerprintd1"; + constexpr const char* Signal = "FingerMatched"; + + std::string ConfigPath() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + if (xdg && *xdg) return std::string(xdg) + "/fingerprintd/fingers.conf"; + const char* home = std::getenv("HOME"); + return std::string(home ? home : ".") + "/.config/fingerprintd/fingers.conf"; + } + + struct Config { + std::filesystem::file_time_type stamp{}; + std::map byFinger; + + // Re-read when the file's mtime moves, so editing the map takes effect + // without restarting the agent. Cheap: one stat per matched finger, + // and a finger match is a human-scale event. + void Refresh(const std::string& path) { + std::error_code ec; + auto now = std::filesystem::last_write_time(path, ec); + if (ec) { byFinger.clear(); return; } + if (now == stamp && !byFinger.empty()) return; + stamp = now; + byFinger.clear(); + + std::ifstream f(path); + std::string line; + while (std::getline(f, line)) { + std::string_view v(line); + while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.remove_prefix(1); + while (!v.empty() && (v.back() == ' ' || v.back() == '\t' || v.back() == '\r')) + v.remove_suffix(1); + if (v.empty() || v.front() == '#') continue; + std::size_t sp = v.find_first_of(" \t"); + if (sp == std::string_view::npos) continue; + std::string name(v.substr(0, sp)); + std::string_view cmd = v.substr(sp); + while (!cmd.empty() && (cmd.front() == ' ' || cmd.front() == '\t')) + cmd.remove_prefix(1); + if (cmd.empty()) continue; + // Validated against the same vocabulary the daemon uses, so a + // typo is reported here rather than silently never firing. + if (!fingerprintd::store::FingerFromName(name)) { + std::println(std::cerr, "{}: not a finger name: {}", path, name); + continue; + } + byFinger[name] = std::string(cmd); + } + } + }; + + Config g_config; + std::string g_configPath; + std::uint32_t g_me = 0; + + void OnFingerMatched(GDBusConnection*, const gchar*, const gchar*, const gchar*, + const gchar*, GVariant* params, gpointer) { + const gchar* finger = nullptr; + guint32 uid = 0; + g_variant_get(params, "(&su)", &finger, &uid); + if (!finger) return; + + // The signal is visible to every local user. Someone else's finger is + // not your trigger. + if (uid != g_me) return; + + g_config.Refresh(g_configPath); + auto it = g_config.byFinger.find(finger); + if (it == g_config.byFinger.end()) return; + + std::println("{} -> {}", finger, it->second); + GError* err = nullptr; + // Async: a slow application must not wedge the agent, and the agent is + // not the thing that decides whether the unlock succeeded -- that has + // already happened by the time this signal arrives. + if (!g_spawn_command_line_async(it->second.c_str(), &err)) { + std::println(std::cerr, " failed: {}", err ? err->message : "unknown"); + if (err) g_error_free(err); + } + } +} + +int main(int argc, char** argv) { + ::setvbuf(stdout, nullptr, _IOLBF, 0); + std::span args(argv, static_cast(argc)); + for (std::string_view a : args.subspan(1)) { + if (a == "--version") { std::println("fingerprintd-agent 0.2.2"); return 0; } + if (a.starts_with("--config=")) g_configPath = a.substr(9); + } + // Root has a session bus about as often as it has a display. Refusing is + // clearer than starting and never firing. + if (::geteuid() == 0) { + std::println(std::cerr, "fingerprintd-agent: run as your user, not root"); + return 1; + } + if (g_configPath.empty()) g_configPath = ConfigPath(); + g_me = static_cast(::getuid()); + + GError* err = nullptr; + GDBusConnection* conn = g_bus_get_sync(G_BUS_TYPE_SYSTEM, nullptr, &err); + if (!conn) { + std::println(std::cerr, "system bus: {}", err ? err->message : "unavailable"); + return 1; + } + g_dbus_connection_signal_subscribe( + conn, BusName, Iface, Signal, DevicePath, nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, OnFingerMatched, nullptr, nullptr); + + g_config.Refresh(g_configPath); + std::println("fingerprintd-agent: uid {}, {} finger(s) mapped from {}", + g_me, g_config.byFinger.size(), g_configPath); + + GMainLoop* loop = g_main_loop_new(nullptr, FALSE); + g_main_loop_run(loop); + return 0; +} diff --git a/packaging/APKBUILD b/packaging/APKBUILD index c783055..fec4800 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -47,7 +47,7 @@ provides="fprintd=$pkgver-r$pkgrel" # 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" +subpackages="$pkgname-agent $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" @@ -114,6 +114,25 @@ package() { # feature is off until an administrator installs one. install -Dm644 actions.conf.example \ "$pkgdir"/usr/share/doc/$pkgname/actions.conf.example + + # The session half; split out below. + install -Dm755 fingerprintd-agent "$pkgdir"/usr/bin/fingerprintd-agent + install -Dm644 fingerprintd-agent.service \ + "$pkgdir"/usr/lib/systemd/user/fingerprintd-agent.service + install -Dm644 fingers.conf.example \ + "$pkgdir"/usr/share/doc/$pkgname/fingers.conf.example +} + +agent() { + pkgdesc="Run things in your session when a fingerprint matches" + # Useful only against a daemon that emits the signal, but it is a + # SEPARATE package because it is a separate trust domain: it runs as the + # user, reads a config the user owns, and starts the user's software. + depends="$pkgname=$pkgver-r$pkgrel" + + amove usr/bin/fingerprintd-agent + amove usr/lib/systemd/user/fingerprintd-agent.service + amove usr/share/doc/$pkgname/fingers.conf.example } systemd() { diff --git a/packaging/build-package.sh b/packaging/build-package.sh index d08d9de..52a7c35 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -84,6 +84,7 @@ retry "make libqcomtee" "$SRC/packaging/make-libqcomtee.sh" \ cd "$SRC" XTARGET="--target=aarch64-alpine-linux-musl --sysroot=$SYSROOT --march=armv8-a --mtune=generic" crafter-build -- $XTARGET +crafter-build -- --product=agent $XTARGET crafter-build test # --- bundle + package diff --git a/packaging/fingerprint-auth.pam b/packaging/fingerprint-auth.pam index 091f8bd..7ccc58c 100644 --- a/packaging/fingerprint-auth.pam +++ b/packaging/fingerprint-auth.pam @@ -19,8 +19,16 @@ # conversation; sufficient, so a match ends the stack successfully and a # failure falls through to pam_deny rather than to a password -- the caller # (kde-fingerprint) is the one that decides whether to offer a password next. +# timeout: pam_fprintd defaults to 30 seconds and the lock screen arms +# fingerprint exactly ONCE when it appears, so on a stock setup the sensor is +# live for half a minute and then silently is not -- a press after that reaches +# nothing at all, which reads as a broken sensor rather than an expired window. +# 60 s is a compromise, not a fix: every second of it is our verify loop +# polling the trustlet at ~5 Hz, so the honest ceiling on this number is set by +# the idle-IRQ work (fp6 journal/fingerprint, "SENSOR POWER"). Raise it once an +# idle verify costs nothing. auth required pam_env.so -auth sufficient pam_fprintd.so +auth sufficient pam_fprintd.so timeout=60 auth required pam_deny.so account include base-account diff --git a/packaging/fingerprintd-agent.service b/packaging/fingerprintd-agent.service new file mode 100644 index 0000000..2ea2965 --- /dev/null +++ b/packaging/fingerprintd-agent.service @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# A USER unit: it runs as you, in your session, which is the whole point. +# `systemctl --user enable --now fingerprintd-agent` +[Unit] +Description=Run things in your session when a fingerprint matches +Documentation=https://forgejo.catcrafts.net/Catcrafts/fingerprintd +# Nothing to do without a map. Absent file, absent feature. +ConditionPathExists=%h/.config/fingerprintd/fingers.conf + +[Service] +Type=simple +ExecStart=/usr/bin/fingerprintd-agent +# It only listens on the system bus and spawns your commands; if it dies, +# restarting costs nothing and losing it silently would be confusing. +Restart=on-failure +RestartSec=5 + +[Install] +# default.target rather than graphical-session.target: the agent needs your +# session bus, not a display, and Plasma Mobile does not reliably reach +# graphical-session.target on this device. +WantedBy=default.target diff --git a/packaging/fingers.conf.example b/packaging/fingers.conf.example new file mode 100644 index 0000000..7274bb0 --- /dev/null +++ b/packaging/fingers.conf.example @@ -0,0 +1,28 @@ +# fingerprintd-agent — what a finger does in YOUR session. +# +# Install as ~/.config/fingerprintd/fingers.conf, then: +# systemctl --user enable --now fingerprintd-agent +# +# One finger per line: +# +# +# +# The command runs as you, with your session's environment, so it can start +# applications — which is exactly what the daemon cannot do, being root with +# no session bus and no display. +# +# This file is yours. It is re-read whenever it changes, so editing it needs no +# restart, and unlike /etc/fingerprintd/actions.conf it is not a root shell: +# relative commands are fine and nothing checks its ownership. +# +# Fingers: left-thumb, left-index-finger, left-middle-finger, left-ring-finger, +# left-little-finger, and the right-* equivalents. A name that is not one of +# those is reported at startup rather than silently never firing. +# +# NOTE: a finger fires this every time it MATCHES, which includes the press +# that unlocks the phone. If you map your unlock finger, the app opens on +# every unlock. + +#right-ring-finger kde-open camera +#left-index-finger plasma-settings +#left-thumb sh -c 'notify-send "hello from a finger"' diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 47d3eb2..da5a053 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -10,11 +10,14 @@ VER="${1:-$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' implementations/main.c [ -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; } +AGENT=$(ls -t bin/fingerprintd-agent-aarch64-*/fingerprintd-agent 2>/dev/null | head -n1) +[ -n "$AGENT" ] || { echo "no aarch64 agent build — cross-compile with --product=agent" >&2; exit 1; } stage=$(mktemp -d) trap 'rm -rf "$stage"' EXIT mkdir "$stage/fingerprintd-$VER" cp "$BIN" "$stage/fingerprintd-$VER/fingerprintd" +cp "$AGENT" "$stage/fingerprintd-$VER/fingerprintd-agent" cp packaging/fingerprintd.service \ packaging/mnt-persist.mount \ packaging/80-fingerprintd.preset \ @@ -28,6 +31,8 @@ cp packaging/fingerprintd.service \ packaging/fingerprint-auth.pam \ packaging/postlogin.pam \ packaging/actions.conf.example \ + packaging/fingerprintd-agent.service \ + packaging/fingers.conf.example \ "$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))" diff --git a/project.cpp b/project.cpp index a5b2b03..d83c4a3 100644 --- a/project.cpp +++ b/project.cpp @@ -105,6 +105,28 @@ extern "C" Configuration CrafterBuildProject(std::span a Core->GetInterfacesAndImplementations(ifaces, impls); } + // fingerprintd-agent — the session half. Built with --product=agent. + // It is a separate binary and not a mode of the daemon because it is a + // different user, a different trust domain and a different lifetime: the + // daemon is root and boot-long, this is you and session-long. It links + // the core only for the finger-name vocabulary, and never touches the TEE. + for (std::string_view a : args) { + if (a != "--product=agent") continue; + Configuration agent; + agent.path = "./"; + agent.name = "fingerprintd-agent"; + agent.outputName = "fingerprintd-agent"; + ApplyStandardArgs(agent, args); + agent.type = ConfigurationType::Executable; + agent.dependencies = { Core.get() }; + std::array noIfaces = {}; + std::array agentImpls = { "implementations/agent" }; + agent.GetInterfacesAndImplementations(noIfaces, agentImpls); + ApplyGioFlags(agent); + ProjectLint::AddProjectLintRules(agent); + return agent; + } + // fingerprintd — the daemon: sensor rail, QTEE session, the gpfile and // RPMB listeners, and the bus surface, wrapped around the core. Configuration cfg;