diff --git a/implementations/main.cpp b/implementations/main.cpp index c493a94..12a7cd0 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -68,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.2.0"; +constexpr const char* Version = "0.2.1"; bool g_verbose = false; // 500 ms was the research harness's pace, chosen so a human could read the @@ -2160,9 +2160,9 @@ void LoadActions() { } 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)" : ""); + std::println("action: {}{}{}", fingerprintd::store::NameOf(r.finger), + r.unlocks ? "" : " does NOT unlock", + r.command.empty() ? "" : std::format(" runs {}", r.command)); } // Run a system action. Double-forked so the grandchild is reparented to init @@ -2440,8 +2440,8 @@ void PostEvent(std::unique_ptr ev) { // 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", + if (rule && !rule->unlocks) { + std::println("action: {} is configured no-unlock; reporting a rejection", store::NameOf(*matched)); status = "verify-no-match"; } @@ -2449,15 +2449,17 @@ void PostEvent(std::unique_ptr ev) { 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. + // UNCONDITIONAL, and that is the design: every matched finger is + // announced, so a session agent needs nothing declared in the + // root-owned config to hear about one. 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. // - // 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. + // 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. if (matched && g_conn) { g_dbus_connection_emit_signal( g_conn, nullptr, DevicePath, ActionIface, "FingerMatched", @@ -2465,7 +2467,7 @@ void PostEvent(std::unique_ptr ev) { static_cast(g_claim.uid)), nullptr); } - if (rule && rule->where == ac::Where::System) { + if (rule && !rule->command.empty()) { std::println("action: {} -> running {}", store::NameOf(*matched), rule->command); RunSystemAction(rule->command, std::string(store::NameOf(*matched))); } diff --git a/interfaces/Fingerprintd-Actions.cppm b/interfaces/Fingerprintd-Actions.cppm index 5ad688a..0cf1700 100644 --- a/interfaces/Fingerprintd-Actions.cppm +++ b/interfaces/Fingerprintd-Actions.cppm @@ -5,43 +5,42 @@ /* 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. +The trustlet reports WHICH finger matched, and the daemon used to spend that +only on answering yes. Two things can now hang off it. -Two rules shape the whole design. +The first needs no configuration at all: every matched finger is announced on +the bus as net.catcrafts.Fingerprintd1.FingerMatched(finger, uid), always. That +is how a finger launches an application — an agent in the user's own session +hears it and decides what it means, from the user's own configuration. Root has +no session bus, no display and no business starting someone's applications, so +the daemon deliberately does not try; and since the signal is unconditional, +there is nothing to declare here to receive it. -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. +What is left for this file is the two things that DO need the daemon: -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. + [no-unlock] [absolute command...] -Format, one rule per line, four fields: + finger an fprintd finger name, e.g. right-index-finger + no-unlock this finger never unlocks. The client is told the finger did + not match, whatever really happened. The duress case. + command run by root when this finger matches. - +At least one of the two must be present; a line with neither says nothing the +signal above does not already say, and is more likely a mistake than an +intention. '#' comments and blank lines are ignored, and a finger with no line +behaves exactly as it always did. - 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 +THIS FILE IS A ROOT-EXECUTION SURFACE. Anything that can write it gets root at +the next press of a finger. The parser 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. -'#' comments and blank lines are ignored. A finger with no rule behaves -exactly as before, which is what makes the feature absent until configured. +WHAT no-unlock IS NOT. It is a panic button, not deniability. The rejection it +fabricates is far faster than a real one, this file names the finger in plain +text, and the finger still shows as enrolled. It reliably runs your script; it +does not reliably hide that it did. */ export module Fingerprintd:Actions; @@ -50,14 +49,13 @@ 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 + // False for a duress finger: it matches, and the client is told it did + // not. Named for what the administrator wants rather than for the + // fprintd status it produces. + bool unlocks = true; + std::string command; // empty = announce only }; // Why a file was rejected. Rejection is total: no rule from a file that @@ -66,12 +64,9 @@ export namespace fingerprintd::actions { 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 + NothingToDo, // neither no-unlock nor a command + DuplicateFinger, // two rules for one finger: ambiguous, not merged }; inline constexpr std::string_view Describe(Error e) { @@ -79,12 +74,9 @@ export namespace fingerprintd::actions { 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"; + case Error::NothingToDo: return "expected 'no-unlock', a command, or both"; + case Error::DuplicateFinger: return "two rules for the same finger"; } return "unknown"; } @@ -96,21 +88,12 @@ export namespace fingerprintd::actions { 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; + inline constexpr std::string_view NoUnlockKeyword = "no-unlock"; + + inline std::string_view TrimBlanks(std::string_view v) { + while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.remove_prefix(1); + while (!v.empty() && (v.back() == ' ' || v.back() == '\t')) v.remove_suffix(1); + return v; } // `rootOnlyWritable` is the shell's answer about the file's mode and @@ -136,42 +119,36 @@ export namespace fingerprintd::actions { 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; + line = TrimBlanks(line); + if (line.empty() || line.front() == '#') continue; - auto f = Fields(line, 3); - if (f.size() < 3) return reject(Error::UnknownWhere, lineNo); + std::size_t sp = line.find_first_of(" \t"); + std::string_view name = line.substr(0, sp); + std::string_view rest = sp == std::string_view::npos + ? std::string_view{} : TrimBlanks(line.substr(sp)); Rule r; - auto fin = store::FingerFromName(std::string(f[0])); + auto fin = store::FingerFromName(std::string(name)); 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); + // The keyword is optional and, when present, leads. + if (rest == NoUnlockKeyword) { + r.unlocks = false; + rest = {}; + } else if (rest.starts_with(NoUnlockKeyword) + && (rest[NoUnlockKeyword.size()] == ' ' + || rest[NoUnlockKeyword.size()] == '\t')) { + r.unlocks = false; + rest = TrimBlanks(rest.substr(NoUnlockKeyword.size())); } + r.command = std::string(rest); - 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); + if (r.unlocks && r.command.empty()) return reject(Error::NothingToDo, 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('/')) + if (!r.command.empty() && !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); diff --git a/packaging/APKBUILD b/packaging/APKBUILD index 741d5fd..dcca2b0 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.2.0 +pkgver=0.2.1 pkgrel=0 pkgdesc="Fingerprint daemon for the Fairphone 6 (FocalTech FT9391 behind QTEE)" url="https://forgejo.catcrafts.net/Catcrafts/fingerprintd" diff --git a/packaging/actions.conf.example b/packaging/actions.conf.example index 1ed2537..bfb6ff9 100644 --- a/packaging/actions.conf.example +++ b/packaging/actions.conf.example @@ -1,12 +1,25 @@ # 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. +# exactly what it always did: it unlocks. # -# 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: +# YOU PROBABLY DO NOT NEED THIS FILE. Every matched finger is already +# announced on the system bus, unconditionally and with nothing configured: +# +# net.catcrafts.Fingerprintd1.FingerMatched(finger, uid) +# on /net/reactivated/Fprint/Device/0 +# +# That is how a finger launches an application. An agent in your own session +# hears the signal and decides what the finger means, from your own +# configuration, running as you with your bus and your display. The daemon is +# root and deliberately does not try to do that for you. +# +# This file is for the two things that do need the daemon. +# +# THIS FILE IS A ROOT SHELL. Every command here is run by root when that +# finger touches the sensor, so anything able to write it 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 @@ -14,48 +27,43 @@ # 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: +# Format: # -# +# [no-unlock] [absolute command...] # -# finger an fprintd finger name: left-thumb, left-index-finger, -# left-middle-finger, left-ring-finger, left-little-finger, and -# the right-* equivalents. +# 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. +# no-unlock this finger never unlocks. The client is told it did not match, +# whatever really happened. # -# 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, passed to /bin/sh -c with a fixed environment +# plus FINGERPRINTD_FINGER. Double-forked, so it may outlive the +# daemon and can never delay an unlock. # -# 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. +# At least one of the two is required. A finger listed on its own says nothing +# the signal above does not already say. -# --- Launching things in your session ----------------------------------- -# The daemon only announces the finger; your agent maps it to an app. -#right-ring-finger session match +# --- A finger that also does something, as root ----------------------------- +#right-ring-finger /usr/local/bin/toggle-something -# --- 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 finger that does not unlock ------------------------------------------ +#left-thumb no-unlock + +# --- A duress finger: rejected, and the script runs 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. +# * and anyone who can compel one unlock can usually compel a second. # -#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" +# AND KNOW WHAT THIS IS NOT. It is a panic button, not deniability. The +# rejection it fabricates is far faster than a real one — a finger the sensor +# genuinely does not know takes about three seconds to be refused, this takes +# milliseconds — the daemon's journal records that the finger really matched, +# this file names it in plain text, and the finger still shows as enrolled in +# fprintd-list. It reliably runs your script. It does not reliably hide that +# it did. +# +#left-little-finger no-unlock /etc/fingerprintd/panic.sh diff --git a/tests/Actions/main.cpp b/tests/Actions/main.cpp index 1587763..4cee7e7 100644 --- a/tests/Actions/main.cpp +++ b/tests/Actions/main.cpp @@ -11,9 +11,9 @@ 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, + * a 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. */ @@ -37,7 +37,7 @@ namespace { int main() { // ---- the file's own permissions are checked before its contents { - Parsed p = P("right-index-finger system match /bin/true", /*rootOnly*/ false); + Parsed p = P("right-index-finger /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"); @@ -52,59 +52,56 @@ int main() { Check(c.Ok() && c.rules.empty(), "comments and blank lines are ignored"); } - // ---- a well-formed table + // ---- the three shapes a line can take { - 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"); + Parsed p = P("# finger what\n" + "right-ring-finger /usr/bin/logger -t fp ring\n" + "left-little-finger no-unlock /etc/fingerprintd/panic.sh\n" + "left-thumb no-unlock\n"); Check(p.Ok(), "a valid file parses"); - Check(p.rules.size() == 2, "both rules"); + Check(p.rules.size() == 3, "all three 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* ring = Find(p.rules, Finger::RightRing); + Check(ring && ring->unlocks, "a command-only finger still unlocks"); + Check(ring && ring->command == "/usr/bin/logger -t fp ring", + "and the command arrives whole, spaces and all"); - 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"); + const Rule* duress = Find(p.rules, Finger::LeftLittle); + Check(duress && !duress->unlocks, "no-unlock is recorded"); + Check(duress && duress->command == "/etc/fingerprintd/panic.sh", + "alongside its command -- the duress case needs both"); - Check(Find(p.rules, Finger::RightThumb) == nullptr, + const Rule* thumb = Find(p.rules, Finger::LeftThumb); + Check(thumb && !thumb->unlocks, "no-unlock alone is a complete rule"); + Check(thumb && thumb->command.empty(), "with no command"); + + Check(Find(p.rules, Finger::RightIndex) == nullptr, "a finger with no rule has no rule"); } - // ---- a command may contain spaces; the tail is not re-split + // ---- a command that merely STARTS like the keyword is a command { - 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"); + Parsed p = P("left-index-finger /usr/local/bin/no-unlock-helper\n"); + Check(p.Ok(), "parses"); + const Rule* r = Find(p.rules, Finger::LeftIndex); + Check(r && r->unlocks, "the finger still unlocks"); + Check(r && r->command == "/usr/local/bin/no-unlock-helper", + "and the path was not mistaken for the keyword"); } // ---- 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, + { "not-a-finger /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, + { "right-index-finger\n", Error::NothingToDo, + "a finger on its own, which the signal already covers" }, + { "right-index-finger 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", + { "right-index-finger no-unlock reboot\n", Error::RelativeCommand, + "a relative command after the keyword" }, + { "left-thumb no-unlock\nleft-thumb /bin/true\n", Error::DuplicateFinger, "two rules for one finger" }, }; for (const Case& c : cases) { @@ -118,8 +115,8 @@ int main() { // ---- 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"); + Parsed p = P("right-index-finger no-unlock\n" + "left-thumb reboot\n"); Check(!p.Ok(), "the file fails"); Check(p.line == 2, "on the offending line"); Check(p.rules.empty(), @@ -130,9 +127,8 @@ int main() { // ---- 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 }; + Error::RelativeCommand, Error::NothingToDo, + Error::DuplicateFinger }; for (Error e : all) Check(Describe(e) != "unknown", "every error describes itself"); }