Skip to main content

sysknife_daemon/actions/
ssh.rs

1use super::{command_mechanism, ActionMechanism, ActionSpec};
2use sysknife_types::RiskLevel;
3
4pub fn specs() -> Vec<ActionSpec> {
5    vec![
6        get_authorized_keys("alice"),
7        add_authorized_key("alice", "ssh-ed25519 AAAA..."),
8        remove_authorized_key("alice", "ssh-ed25519 AAAA..."),
9        set_sshd_option("PermitRootLogin", "prohibit-password"),
10    ]
11}
12
13/// Installed path of the privileged sshd-option helper script.
14/// See `packaging/sysknife-sshd-option-edit` and the matching NOPASSWD grant in
15/// `packaging/sysknife-sudoers`.
16const SSHD_OPTION_HELPER: &str = "/usr/lib/sysknife/sshd-option-edit";
17
18/// Set an allowlisted sshd option via a drop-in under
19/// `/etc/ssh/sshd_config.d/`, validated with `sshd -t` and applied by reloading
20/// the ssh service.
21///
22/// Risk: High. A misconfigured sshd can lock out remote access; the helper
23/// gates every change on `sshd -t` and rolls back on failure. `option` and
24/// `value` are checked against a fixed allowlist by both the daemon and the
25/// helper — this is deliberately NOT an arbitrary `sshd_config` editor.
26pub fn set_sshd_option(option: &str, value: &str) -> ActionSpec {
27    ActionSpec {
28        action_name: "SetSshdOption",
29        mechanism: command_mechanism(
30            "sudo",
31            [SSHD_OPTION_HELPER, "--option", option, "--value", value],
32        ),
33        risk_level: RiskLevel::High,
34        reboot_required: false,
35        rollback_available: false,
36    }
37}
38
39pub fn get_authorized_keys(username: &str) -> ActionSpec {
40    ActionSpec {
41        action_name: "GetAuthorizedKeys",
42        mechanism: command_mechanism("cat", [&format!("/home/{username}/.ssh/authorized_keys")]),
43        risk_level: RiskLevel::Low,
44        reboot_required: false,
45        rollback_available: false,
46    }
47}
48
49/// Shell body for `AddAuthorizedKey`.
50///
51/// The key and path arrive as positional arguments (`$1`, `$2`) rather than
52/// being interpolated into the script text, so no value the caller supplies is
53/// ever parsed as shell syntax. `printf '%s\n'` is used instead of `echo`
54/// because `echo` mangles values beginning with `-` and interprets backslash
55/// escapes on some shells.
56const ADD_KEY_SCRIPT: &str =
57    "key=$1; path=$2; grep -Fxq -- \"$key\" \"$path\" 2>/dev/null || printf '%s\\n' \"$key\" >> \"$path\"";
58
59/// `sudo runuser -u <username> -- sh -c <script> sh <key> <path>`.
60///
61/// The write runs **as the target user**, not as root. This is the fix for the
62/// symlink-follow escalation: the daemon runs as root only long enough to drop
63/// to `<username>`, so `>> "$path"` following a symlink the attacker planted at
64/// `~/.ssh/authorized_keys` can only reach files that user can already write.
65/// A link to `/etc/passwd` or another account's file fails with `EACCES` instead
66/// of letting root append there. It also keeps the file owned by the user, which
67/// is what `authorized_keys` must be, without a `chown` dance.
68///
69/// The `runuser -u <user> -- <argv>` form bypasses the shell for the outer
70/// command, exactly as `flatpak_as` documents, so `<username>` cannot act as a
71/// flag or a shell metacharacter. The inner `sh -c <script>` still takes the key
72/// and path as positional `$1`/`$2`, never interpolated.
73fn keys_edit_as_user(
74    action_name: &'static str,
75    username: &str,
76    script: &str,
77    public_key: &str,
78) -> ActionSpec {
79    let keys_path = format!("/home/{username}/.ssh/authorized_keys");
80    ActionSpec {
81        action_name,
82        mechanism: ActionMechanism::Command {
83            program: "sudo",
84            args: vec![
85                "runuser".to_string(),
86                "-u".to_string(),
87                username.to_string(),
88                "--".to_string(),
89                "sh".to_string(),
90                "-c".to_string(),
91                script.to_string(),
92                "sh".to_string(),
93                public_key.to_string(),
94                keys_path,
95            ],
96        },
97        risk_level: RiskLevel::High,
98        reboot_required: false,
99        rollback_available: false,
100    }
101}
102
103pub fn add_authorized_key(username: &str, public_key: &str) -> ActionSpec {
104    keys_edit_as_user("AddAuthorizedKey", username, ADD_KEY_SCRIPT, public_key)
105}
106
107/// Shell body for `RemoveAuthorizedKey`.
108///
109/// **The key must never reach a regex.** This action previously deleted the
110/// line with `sed -i '\|^KEY$|d'`, which made the caller-supplied key a *basic
111/// regular expression*: a value like `ssh-ed25519 .*` passes every check in
112/// `validated_public_key` (allowed prefix, printable ASCII, no shell
113/// metacharacters) and then matched — and deleted — every ed25519 key in the
114/// file. `sed` exits 0, so the job was recorded `Succeeded` and the signed
115/// audit summary read as a routine single-key removal: the executed effect
116/// silently diverged from the approved preview on a lockout-capable action.
117///
118/// Blocklisting regex metacharacters is NOT a viable fix: `.` is legal inside
119/// a key comment (`alice@example.com`), so rejecting it would refuse valid
120/// keys. The fix is structural — `grep -Fxv` compares fixed whole lines, so no
121/// metacharacter can widen the match, and the key travels as `$1` rather than
122/// being interpolated into the script.
123///
124/// The result is streamed to a temp file and copied back with `cat >` rather
125/// than moved: that preserves the original inode, owner, and mode, which
126/// matters because `authorized_keys` must stay owned by the target user.
127/// `grep` exits 1 when it selects no lines (the file is now empty) — that is a
128/// success here, so only an exit status above 1 is treated as an error.
129const REMOVE_KEY_SCRIPT: &str = "key=$1; path=$2; \
130tmp=$(mktemp) || exit 1; \
131grep -Fxv -- \"$key\" \"$path\" > \"$tmp\"; rc=$?; \
132if [ $rc -gt 1 ]; then rm -f \"$tmp\"; exit $rc; fi; \
133cat \"$tmp\" > \"$path\"; rm -f \"$tmp\"";
134
135pub fn remove_authorized_key(username: &str, public_key: &str) -> ActionSpec {
136    // Runs as the target user, same as the add path: the `cat "$tmp" > "$path"`
137    // must not follow a planted symlink out of the user's own home either.
138    // Revoking a key is lockout-capable and cannot be rolled back, so High,
139    // symmetric with AddAuthorizedKey.
140    keys_edit_as_user(
141        "RemoveAuthorizedKey",
142        username,
143        REMOVE_KEY_SCRIPT,
144        public_key,
145    )
146}