189 lines
8.2 KiB
Text
189 lines
8.2 KiB
Text
|
|
// 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> <where> <verdict> <command...>
|
||
|
|
|
||
|
|
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<Rule> 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<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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// `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<Rule>& rules, store::Finger f) {
|
||
|
|
for (const Rule& r : rules)
|
||
|
|
if (r.finger == f) return &r;
|
||
|
|
return nullptr;
|
||
|
|
}
|
||
|
|
}
|