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

@ -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");
}