diff --git a/implementations/main.cpp b/implementations/main.cpp index 67f4062..c493a94 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -53,7 +53,9 @@ extern "C" { #include #include #include +#include #include +#include #include #include #include @@ -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 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(f)), std::istreambuf_iterator()); + 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(argv), const_cast(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 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 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(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; diff --git a/interfaces/Fingerprintd-Actions.cppm b/interfaces/Fingerprintd-Actions.cppm new file mode 100644 index 0000000..5ad688a --- /dev/null +++ b/interfaces/Fingerprintd-Actions.cppm @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Actions — what a finger means, beyond "it was you". + +The trustlet reports WHICH finger matched, and until now the daemon only used +that to answer yes. This module holds the table that gives each finger a +meaning: run a command as root, tell the user's session, or answer no-match +while doing one of those anyway -- which is the duress case, where the phone +should look like it simply did not recognise the finger. + +Two rules shape the whole design. + +ROOT DOES NOT LAUNCH APPS. The daemon runs as root with no session bus, no +Wayland display and no user environment, so it cannot meaningfully start a +user's application, and trying would either fail or run the user's software as +root. So a session action is not a command here at all: the daemon emits a +signal naming the finger, and an agent in the user's own session decides what +that means from the user's own configuration. The only commands in this file +are ones root is supposed to run. + +WHICH MAKES THIS FILE A ROOT-EXECUTION SURFACE. Anything that can write it +gets root at the next press of a finger. The parser therefore refuses a file +that is not owned by root or that anyone else can write, and refuses it +WHOLESALE rather than skipping the offending line -- a half-applied security +policy is worse than none. Ownership is checked by the shell, which has the +stat; this module states the rule and holds the verdict. + +Format, one rule per line, four fields: + + + + finger an fprintd finger name, e.g. right-index-finger + where system -- root runs the command + session -- the user's agent is told; no command is run here + verdict match -- the client is told the finger matched (normal) + no-match -- the client is told it did not, whatever really + happened. The duress case. + command required for system, and must be absent for session + +'#' comments and blank lines are ignored. A finger with no rule behaves +exactly as before, which is what makes the feature absent until configured. +*/ + +export module Fingerprintd:Actions; +import std; +import :Store; + +export namespace fingerprintd::actions { + + enum class Where { System, Session }; + enum class Verdict { Match, NoMatch }; + + struct Rule { + store::Finger finger{}; + Where where = Where::System; + Verdict verdict = Verdict::Match; + std::string command; // empty for Session + }; + + // Why a file was rejected. Rejection is total: no rule from a file that + // failed to parse is ever applied. + enum class Error { + None, + NotWritableOnlyByRoot, // the shell's stat says someone else can write it + UnknownFinger, + UnknownWhere, + UnknownVerdict, + MissingCommand, // system without a command + UnexpectedCommand, // session with one + DuplicateFinger, // two rules for one finger: ambiguous, not merged + RelativeCommand, // a command that is not an absolute path + }; + + inline constexpr std::string_view Describe(Error e) { + switch (e) { + case Error::None: return "ok"; + case Error::NotWritableOnlyByRoot: return "the file must be owned by root and writable by no one else"; + case Error::UnknownFinger: return "not an fprintd finger name"; + case Error::UnknownWhere: return "expected 'system' or 'session'"; + case Error::UnknownVerdict: return "expected 'match' or 'no-match'"; + case Error::MissingCommand: return "a system rule needs a command"; + case Error::UnexpectedCommand: return "a session rule runs no command here; the user's agent decides"; + case Error::DuplicateFinger: return "two rules for the same finger"; + case Error::RelativeCommand: return "the command must be an absolute path"; + } + return "unknown"; + } + + struct Parsed { + std::vector rules; + Error error = Error::None; + int line = 0; // 1-based, 0 when the error is the file itself + bool Ok() const { return error == Error::None; } + }; + + // Split on runs of spaces and tabs, keeping the tail intact from `upto` + // fields onward so a command may contain spaces. + inline std::vector Fields(std::string_view line, std::size_t upto) { + std::vector out; + std::size_t i = 0; + while (i < line.size()) { + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) i++; + if (i >= line.size()) break; + if (out.size() == upto) { out.push_back(line.substr(i)); break; } + std::size_t j = i; + while (j < line.size() && line[j] != ' ' && line[j] != '\t') j++; + out.push_back(line.substr(i, j - i)); + i = j; + } + return out; + } + + // `rootOnlyWritable` is the shell's answer about the file's mode and + // owner. Passing false rejects the file without looking at a single rule: + // a config root will execute is not worth parsing if someone else can + // rewrite it between the parse and the press. + inline Parsed Parse(std::string_view text, bool rootOnlyWritable) { + Parsed p; + // Every rejection discards the rules gathered so far. A file that + // stops being valid halfway is not "valid up to there": applying the + // prefix would leave a policy nobody wrote, and the missing half + // could be the one that mattered. + auto reject = [&p](Error e, int line) -> Parsed& { + p.error = e; + p.line = line; + p.rules.clear(); + return p; + }; + if (!rootOnlyWritable) return reject(Error::NotWritableOnlyByRoot, 0); + + int lineNo = 0; + for (const auto part : std::views::split(text, '\n')) { + lineNo++; + std::string_view line(part.begin(), part.end()); + if (!line.empty() && line.back() == '\r') line.remove_suffix(1); + // Trim leading blanks so a comment may be indented. + std::size_t s = line.find_first_not_of(" \t"); + if (s == std::string_view::npos) continue; + line.remove_prefix(s); + if (line.front() == '#') continue; + + auto f = Fields(line, 3); + if (f.size() < 3) return reject(Error::UnknownWhere, lineNo); + + Rule r; + auto fin = store::FingerFromName(std::string(f[0])); + if (!fin) return reject(Error::UnknownFinger, lineNo); + r.finger = *fin; + + if (f[1] == "system") r.where = Where::System; + else if (f[1] == "session") r.where = Where::Session; + else return reject(Error::UnknownWhere, lineNo); + + if (f[2] == "match") r.verdict = Verdict::Match; + else if (f[2] == "no-match") r.verdict = Verdict::NoMatch; + else return reject(Error::UnknownVerdict, lineNo); + + if (f.size() > 3) { + std::string_view cmd = f[3]; + while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.remove_suffix(1); + r.command = std::string(cmd); + } + + if (r.where == Where::System && r.command.empty()) + return reject(Error::MissingCommand, lineNo); + if (r.where == Where::Session && !r.command.empty()) + return reject(Error::UnexpectedCommand, lineNo); + // An absolute path only. Resolving a bare name through PATH would + // make what root executes depend on an environment this daemon + // does not control. + if (r.where == Where::System && !r.command.starts_with('/')) + return reject(Error::RelativeCommand, lineNo); + for (const Rule& e : p.rules) { + if (e.finger == r.finger) return reject(Error::DuplicateFinger, lineNo); + } + p.rules.push_back(std::move(r)); + } + return p; + } + + inline const Rule* Find(const std::vector& rules, store::Finger f) { + for (const Rule& r : rules) + if (r.finger == f) return &r; + return nullptr; + } +} diff --git a/interfaces/Fingerprintd.cppm b/interfaces/Fingerprintd.cppm index 1d5d63e..5a50511 100644 --- a/interfaces/Fingerprintd.cppm +++ b/interfaces/Fingerprintd.cppm @@ -19,3 +19,4 @@ export import :Engine; export import :Store; export import :Tee; export import :Sensor; +export import :Actions; diff --git a/packaging/APKBUILD b/packaging/APKBUILD index 3eac6b7..741d5fd 100644 --- a/packaging/APKBUILD +++ b/packaging/APKBUILD @@ -10,7 +10,7 @@ # Alpine, so an APKBUILD that compiled from source could not be built by # anyone but us either. pkgname=fingerprintd -pkgver=0.1.3 +pkgver=0.2.0 pkgrel=0 pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)" url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd" @@ -101,6 +101,17 @@ package() { # same mechanism soc-fairphone-fp6-audio uses for the amp config. install -Dm644 20-focal64.manifest \ "$pkgdir"/usr/share/fp6-vendor-blobs/manifest.d/20-focal64.manifest + + # The PAM service kscreenlocker substacks and Alpine does not provide. + # Vendor directory, so /etc/pam.d still overrides it. + install -Dm644 fingerprint-auth.pam \ + "$pkgdir"/usr/lib/pam.d/fingerprint-auth + + # Documentation, not configuration: shipping an /etc/fingerprintd/ + # actions.conf would be shipping a root shell nobody asked for. The + # feature is off until an administrator installs one. + install -Dm644 actions.conf.example \ + "$pkgdir"/usr/share/doc/$pkgname/actions.conf.example } systemd() { diff --git a/packaging/actions.conf.example b/packaging/actions.conf.example new file mode 100644 index 0000000..1ed2537 --- /dev/null +++ b/packaging/actions.conf.example @@ -0,0 +1,61 @@ +# fingerprintd — per-finger actions. +# +# Install as /etc/fingerprintd/actions.conf. With no such file, a finger does +# exactly what it always did: it unlocks, and nothing else happens. +# +# THIS FILE IS A ROOT SHELL. Every `system` line is a command root runs when +# that finger touches the sensor, so anything able to write this file owns the +# machine at the next press. fingerprintd refuses the whole file — not just the +# offending line — unless root owns it and no one else can write it: +# +# sudo install -Dm644 -o root -g root actions.conf.example \ +# /etc/fingerprintd/actions.conf +# +# It is read once, at startup. Editing it means restarting the unit, which is +# also when you get to see the parse errors. +# +# Format, four fields: +# +# +# +# finger an fprintd finger name: left-thumb, left-index-finger, +# left-middle-finger, left-ring-finger, left-little-finger, and +# the right-* equivalents. +# +# where system root runs the command below. +# session no command here. The daemon emits +# net.catcrafts.Fingerprintd1.FingerMatched(finger, uid) +# and an agent in your session decides what it means. +# This is how you launch an application: root has no +# session bus and no display, and running your software +# as root to get one would be a poor trade. +# +# verdict match the client is told the finger matched. Normal. +# no-match the client is told it did NOT, whatever really +# happened, while the action runs anyway. +# +# command an ABSOLUTE path, required for system, forbidden for session. +# It is passed to /bin/sh -c with a fixed environment plus +# FINGERPRINTD_FINGER. It is double-forked, so it may outlive the +# daemon and will never delay an unlock. +# +# A finger with no line here is untouched. + +# --- Launching things in your session ----------------------------------- +# The daemon only announces the finger; your agent maps it to an app. +#right-ring-finger session match + +# --- A duress finger ------------------------------------------------------ +# The phone reports that it did not recognise this finger, and runs the +# script anyway. Think carefully before making that script destructive: +# +# * a false accept that opens a camera is a shrug; one that wipes is not, +# * and anyone who can compel one unlock can usually compel a second, so +# this is a panic button, not protection for data at rest. Only +# encryption is that, and by unlock time your session is already +# decrypted in RAM. +# +#left-little-finger system no-match /etc/fingerprintd/panic.sh + +# --- Something harmless to try it with ------------------------------------ +#left-thumb system match /usr/bin/logger -t fingerprintd "thumb" diff --git a/packaging/fingerprint-auth.pam b/packaging/fingerprint-auth.pam new file mode 100644 index 0000000..091f8bd --- /dev/null +++ b/packaging/fingerprint-auth.pam @@ -0,0 +1,32 @@ +#%PAM-1.0 +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# +# The service kscreenlocker's /etc/pam.d/kde-fingerprint substacks, and which +# nothing on Alpine provides -- so on a stock pmOS image every fingerprint +# unlock fails before it reaches any daemon, with PAM unable to open the +# substack rather than anything about fingerprints. kscreenlocker ships +# kde-fingerprint (auth/account/password/session all `include fingerprint-auth`) +# and Alpine ships pam_fprintd, and the file joining them is simply absent. +# +# It lives in the vendor directory /usr/lib/pam.d, next to Alpine's own +# base-auth, so an administrator can still override it in /etc/pam.d. +# +# fingerprintd ships it because fingerprintd is what makes it mean anything: +# this package provides fprintd, so it owns the bus name pam_fprintd talks to. + +# pam_fprintd asks the daemon to verify, prompting through the PAM +# 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. +auth required pam_env.so +auth sufficient pam_fprintd.so +auth required pam_deny.so + +account include base-account + +# A fingerprint cannot set a password, and kde-fingerprint includes this +# service for `password` as well. +password required pam_deny.so + +session include base-session diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh index 5f1ff77..d7098d3 100755 --- a/packaging/make-bin-tarball.sh +++ b/packaging/make-bin-tarball.sh @@ -25,6 +25,8 @@ cp packaging/fingerprintd.service \ packaging/fingerprintd.modules-load.conf \ packaging/fingerprintd.json \ packaging/20-focal64.manifest \ + packaging/fingerprint-auth.pam \ + packaging/actions.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/packaging/net.reactivated.Fprint.conf b/packaging/net.reactivated.Fprint.conf index 028ba7a..cf11dc8 100644 --- a/packaging/net.reactivated.Fprint.conf +++ b/packaging/net.reactivated.Fprint.conf @@ -18,4 +18,12 @@ + diff --git a/project.cpp b/project.cpp index eb7702d..a5b2b03 100644 --- a/project.cpp +++ b/project.cpp @@ -90,7 +90,7 @@ extern "C" Configuration CrafterBuildProject(std::span a ApplyStandardArgs(*Core, args); Core->type = ConfigurationType::LibraryStatic; { - std::array ifaces = { + std::array ifaces = { "interfaces/Fingerprintd", "interfaces/Fingerprintd-Sfs", "interfaces/Fingerprintd-Rpmb", @@ -99,6 +99,7 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Fingerprintd-Store", "interfaces/Fingerprintd-Tee", "interfaces/Fingerprintd-Sensor", + "interfaces/Fingerprintd-Actions", }; std::array impls = {}; Core->GetInterfacesAndImplementations(ifaces, impls); @@ -130,6 +131,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Store").Dependencies({ Core.get() }); cfg.AddTest("Tee").Dependencies({ Core.get() }); cfg.AddTest("Sensor").Dependencies({ Core.get() }); + cfg.AddTest("Actions").Dependencies({ Core.get() }); ProjectLint::AddProjectLintRules(cfg); diff --git a/tests/Actions/main.cpp b/tests/Actions/main.cpp new file mode 100644 index 0000000..1587763 --- /dev/null +++ b/tests/Actions/main.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types +/* +Fingerprintd:Actions unit tests. + +This table decides what root executes when a finger touches the sensor, so the +tests here are mostly about REFUSAL. The load-bearing properties: + + * a file anyone but root can write is rejected before a single rule is read, + * a malformed rule rejects the WHOLE file rather than being skipped -- a + half-applied policy is the dangerous outcome, not the safe one, + * a system command must be an absolute path, because resolving a bare name + through PATH would make what root runs depend on an environment this + daemon does not control, + * and no rule at all means no behaviour change, which is what keeps the + feature absent until someone configures it. +*/ +import std; +import Fingerprintd; + +using namespace fingerprintd::actions; +using fingerprintd::store::Finger; + +namespace { + int Failures = 0; + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + Parsed P(std::string_view t, bool rootOnly = true) { return Parse(t, rootOnly); } +} + +int main() { + // ---- the file's own permissions are checked before its contents + { + Parsed p = P("right-index-finger system match /bin/true", /*rootOnly*/ false); + Check(!p.Ok(), "a file others can write is refused"); + Check(p.error == Error::NotWritableOnlyByRoot, "and refused for that reason"); + Check(p.line == 0, "the file is the fault, not a line"); + Check(p.rules.empty(), "nothing is parsed out of it"); + } + + // ---- no config is not an error; it is the feature being off + { + Parsed p = P(""); + Check(p.Ok() && p.rules.empty(), "an empty file yields no rules"); + Parsed c = P("# nothing but a comment\n\n # indented\n"); + Check(c.Ok() && c.rules.empty(), "comments and blank lines are ignored"); + } + + // ---- a well-formed table + { + Parsed p = P("# finger where verdict command\n" + "right-index-finger session match\n" + "left-little-finger system no-match /etc/fingerprintd/panic.sh\n"); + Check(p.Ok(), "a valid file parses"); + Check(p.rules.size() == 2, "both rules"); + + const Rule* idx = Find(p.rules, Finger::RightIndex); + Check(idx != nullptr, "the index finger has a rule"); + Check(idx && idx->where == Where::Session, "session"); + Check(idx && idx->verdict == Verdict::Match, "and it still unlocks"); + Check(idx && idx->command.empty(), "a session rule carries no command"); + + const Rule* pin = Find(p.rules, Finger::LeftLittle); + Check(pin != nullptr, "the duress finger has a rule"); + Check(pin && pin->where == Where::System, "root runs it"); + Check(pin && pin->verdict == Verdict::NoMatch, + "and the client is told it did NOT match -- the whole point of duress"); + Check(pin && pin->command == "/etc/fingerprintd/panic.sh", "the command"); + + Check(Find(p.rules, Finger::RightThumb) == nullptr, + "a finger with no rule has no rule"); + } + + // ---- a command may contain spaces; the tail is not re-split + { + Parsed p = P("right-ring-finger system match /usr/bin/env FOO=1 /usr/local/bin/x -v\n"); + Check(p.Ok(), "a command with arguments parses"); + const Rule* r = Find(p.rules, Finger::RightRing); + Check(r && r->command == "/usr/bin/env FOO=1 /usr/local/bin/x -v", + "and arrives whole"); + } + + // ---- every rejection rejects the whole file + { + struct Case { std::string_view text; Error want; std::string_view why; }; + const Case cases[] = { + { "not-a-finger system match /bin/true\n", Error::UnknownFinger, + "an unknown finger name" }, + { "right-index-finger elsewhere match /bin/true\n", Error::UnknownWhere, + "an unknown 'where'" }, + { "right-index-finger system maybe /bin/true\n", Error::UnknownVerdict, + "an unknown verdict" }, + { "right-index-finger system match\n", Error::MissingCommand, + "a system rule with no command" }, + { "right-index-finger session match /bin/true\n", Error::UnexpectedCommand, + "a session rule with a command" }, + { "right-index-finger system match reboot\n", Error::RelativeCommand, + "a command that is not an absolute path" }, + { "right-index-finger system match\n", Error::MissingCommand, + "a truncated line" }, + { "right-index-finger session match\nright-index-finger system match /bin/true\n", + Error::DuplicateFinger, "two rules for one finger" }, + }; + for (const Case& c : cases) { + Parsed p = P(c.text); + Check(!p.Ok(), std::format("rejected: {}", c.why)); + Check(p.error == c.want, std::format("for the right reason: {}", c.why)); + Check(p.rules.empty(), + std::format("and yields NO rules at all: {}", c.why)); + } + } + + // ---- a valid rule before a bad one is discarded with it + { + Parsed p = P("right-index-finger session match\n" + "left-thumb system match reboot\n"); + Check(!p.Ok(), "the file fails"); + Check(p.line == 2, "on the offending line"); + Check(p.rules.empty(), + "and the GOOD rule above it is discarded too -- a half-applied " + "policy is the dangerous outcome"); + } + + // ---- every Error has a description; a switch that forgets one shows up here + { + const Error all[] = { Error::None, Error::NotWritableOnlyByRoot, Error::UnknownFinger, + Error::UnknownWhere, Error::UnknownVerdict, Error::MissingCommand, + Error::UnexpectedCommand, Error::DuplicateFinger, + Error::RelativeCommand }; + for (Error e : all) + Check(Describe(e) != "unknown", "every error describes itself"); + } + + if (Failures == 0) std::println("Actions: all checks passed"); + return Failures == 0 ? 0 : 1; +}