Drop two fields nobody needed: session was a no-op, and no-unlock is the finger

Jorijn caught both.

`session` declared nothing. The FingerMatched signal is emitted for every
matched finger unconditionally -- it never consulted the config -- so a
`session` line was a rule the format invited you to write that did exactly
nothing. Announcing every finger is the right default anyway: a session agent
should not need a root-owned file to declare its interest in a signal it is
free to ignore. The column is gone.

Which leaves the config for the two things that really do need the daemon, and
with `session` gone the verdict column had no partner left to vary against. It
read as a property of the finger while being a property of the attempt, so it
is now written as what it is:

    <finger>  [no-unlock]  [absolute command...]

no-unlock says the finger never unlocks; a command is what root runs. At least
one is required, because a finger listed alone says nothing the signal does not
already say -- and that is a parse error rather than a silently useless line.

The example config now also states plainly what no-unlock is not. It is a panic
button, not deniability: the rejection it fabricates comes back in milliseconds
where a real one takes about three seconds, the journal records that the finger
actually matched, the file names the finger in plain text, and the finger still
shows as enrolled. Both of those weaknesses are real and neither is fixed here.
This commit is contained in:
Jorijn van der Graaf 2026-09-05 05:23:40 +02:00
commit 93d7f96a63
5 changed files with 180 additions and 197 deletions

View file

@ -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.
<finger> [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.
<finger> <where> <verdict> <command...>
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<std::string_view> Fields(std::string_view line, std::size_t upto) {
std::vector<std::string_view> 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);