Skip to main content

sysknife_daemon/
auth.rs

1use sysknife_types::CallerRole;
2
3pub const OBSERVER_GROUP: &str = "sysknife-observer";
4pub const DEV_GROUP: &str = "sysknife-dev";
5pub const ADMIN_GROUP: &str = "sysknife-admin";
6pub const BOOT_GROUP: &str = "sysknife-boot";
7pub const WHEEL_GROUP: &str = "wheel";
8
9pub fn highest_role_from_groups<I, S>(groups: I) -> CallerRole
10where
11    I: IntoIterator<Item = S>,
12    S: AsRef<str>,
13{
14    groups
15        .into_iter()
16        .map(|group| role_for_group(group.as_ref()))
17        .fold(CallerRole::Observer, higher_role)
18}
19
20fn role_for_group(group: &str) -> CallerRole {
21    match group {
22        BOOT_GROUP => CallerRole::Boot,
23        ADMIN_GROUP | WHEEL_GROUP => CallerRole::Admin,
24        DEV_GROUP => CallerRole::Dev,
25        OBSERVER_GROUP => CallerRole::Observer,
26        _ => CallerRole::Observer,
27    }
28}
29
30fn higher_role(current: CallerRole, candidate: CallerRole) -> CallerRole {
31    current.max(candidate)
32}
33
34/// The group that grants `role`, i.e. the inverse of the private
35/// `role_for_group` mapping above.
36///
37/// Exists so a refusal can name the group to join. `wheel` also grants Admin,
38/// but the SysKnife group is the one to recommend: it is what `make install`
39/// creates, and it scopes access to this daemon rather than to sudo at large.
40pub fn group_for_role(role: CallerRole) -> &'static str {
41    match role {
42        CallerRole::Boot => BOOT_GROUP,
43        CallerRole::Admin => ADMIN_GROUP,
44        CallerRole::Dev => DEV_GROUP,
45        CallerRole::Observer => OBSERVER_GROUP,
46    }
47}
48
49/// The message a caller sees when their role is too low for an action.
50///
51/// Names the action, both roles, the group that would grant the needed role,
52/// and the command to join it — including the part people lose an afternoon to,
53/// that a new group only takes effect in a new login session.
54pub fn denial_message(action: &str, caller: CallerRole, required: CallerRole) -> String {
55    format!(
56        "action '{action}' requires the {required:?} role, but you have {caller:?}. \
57         Join the group that grants it: sudo usermod -aG {group} $USER, \
58         then log out and back in (group membership only applies to a new login).",
59        group = group_for_role(required),
60    )
61}
62
63// ---------------------------------------------------------------------------
64// Token authentication (vsock connections)
65// ---------------------------------------------------------------------------
66
67/// Validate `presented_token` against the token stored in `token_path`.
68///
69/// Returns the role the token holder is granted (read from the
70/// `SYSKNIFE_TOKEN_ROLE` env var, defaulting to `Dev`) on success, or `None`
71/// if the token file is absent, unreadable, or the token does not match.
72///
73/// Whitespace (including trailing newlines) is stripped from the stored token
74/// before comparison. Provision the file with `install -m 600` — a token file
75/// readable by group or other is rejected outright (see below).
76///
77/// # Permissions
78///
79/// The file must not be group- or world-accessible (`mode & 0o077 == 0`),
80/// mirroring the check `AuditKey::load_or_generate` applies to the Ed25519
81/// signing key. Without it, a token written under a default `umask 022` lands
82/// at `0644`, and any local user who can read it can authenticate over vsock
83/// and receive `token_role()` — bypassing the `SO_PEERCRED` → group →
84/// `CallerRole` mechanism that is the documented authorization model for the
85/// Unix path.
86pub fn validate_token_against_file(
87    presented_token: &str,
88    token_path: &std::path::Path,
89) -> Option<CallerRole> {
90    if presented_token.is_empty() {
91        return None;
92    }
93    match token_file_permissions_ok(token_path) {
94        Ok(true) => {}
95        Ok(false) => return None,
96        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
97            eprintln!(
98                "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
99                token_path.display()
100            );
101            return None;
102        }
103        Err(e) => {
104            eprintln!(
105                "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
106                token_path.display()
107            );
108            return None;
109        }
110    }
111    // Absence is already reported by the permission check above; anything
112    // reaching here is a genuine read failure (or a file removed in between).
113    let stored = match std::fs::read_to_string(token_path) {
114        Ok(s) => s,
115        Err(e) => {
116            eprintln!(
117                "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
118                token_path.display()
119            );
120            return None;
121        }
122    };
123    let stored = stored.trim();
124    if stored.is_empty() {
125        return None;
126    }
127    // Constant-time comparison to prevent timing oracles on credentials.
128    // Using `==` here would allow an attacker to learn the stored token
129    // byte-by-byte from response-time differences. `subtle::ConstantTimeEq`
130    // returns a `Choice` that takes the same time regardless of how many
131    // leading bytes match. Length mismatch short-circuits — that is fine,
132    // the secret is the bytes of the token, not its length class.
133    if stored.len() != presented_token.len() {
134        return None;
135    }
136    use subtle::ConstantTimeEq;
137    if stored
138        .as_bytes()
139        .ct_eq(presented_token.as_bytes())
140        .unwrap_u8()
141        != 1
142    {
143        return None;
144    }
145    Some(token_role())
146}
147
148/// Is the token file protected from other local users?
149///
150/// Returns `Ok(false)` (after warning) when the file is group- or
151/// world-accessible. The token is a bearer credential: anything that can read
152/// it can present it.
153fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
154    use std::os::unix::fs::PermissionsExt;
155
156    let mode = std::fs::metadata(token_path)?.permissions().mode();
157    if mode & 0o077 != 0 {
158        eprintln!(
159            "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
160             readable by group or other (any local user could authenticate with it). \
161             Rejecting vsock auth — fix with: chmod 600 {}",
162            token_path.display(),
163            mode & 0o7777,
164            token_path.display()
165        );
166        return Ok(false);
167    }
168    Ok(true)
169}
170
171/// Return the `CallerRole` granted to token-authenticated vsock connections.
172///
173/// Reads `SYSKNIFE_TOKEN_ROLE` env var (surrounding whitespace is trimmed).
174/// When unset or empty it defaults to `Dev` — the documented default for
175/// token-authenticated vsock guests. An **unrecognized** value fails *closed*
176/// to `Observer` (read-only) with a warning, rather than silently granting the
177/// mutating `Dev` tier on an operator typo.
178pub fn token_role() -> CallerRole {
179    let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
180    match raw.trim().to_ascii_lowercase().as_str() {
181        "observer" => CallerRole::Observer,
182        "admin" => CallerRole::Admin,
183        "boot" => CallerRole::Boot,
184        "dev" | "" => CallerRole::Dev,
185        other => {
186            eprintln!(
187                "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
188                 failing closed to Observer (read-only)"
189            );
190            CallerRole::Observer
191        }
192    }
193}
194
195/// Default path for the daemon token file.
196pub fn default_token_path() -> std::path::PathBuf {
197    sysknife_core::config::prefs_path()
198        .parent()
199        .unwrap_or_else(|| {
200            eprintln!(
201                "[sysknife-daemon] WARNING: prefs_path() has no parent; \
202                 falling back to /tmp for token file — this is a misconfiguration"
203            );
204            std::path::Path::new("/tmp")
205        })
206        .join("token")
207}
208
209// ---------------------------------------------------------------------------
210// Tests
211// ---------------------------------------------------------------------------
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    /// Provision a token file the way the daemon requires (`install -m 600`).
218    /// A default-umask write lands at 0644, which is refused: a token any
219    /// local user can read is not a credential.
220    fn secure(path: &std::path::Path) {
221        use std::os::unix::fs::PermissionsExt;
222        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
223    }
224
225    fn role(groups: &[&str]) -> CallerRole {
226        highest_role_from_groups(groups.iter().copied())
227    }
228
229    // -----------------------------------------------------------------
230    // Telling a denied caller how to stop being denied
231    //
232    // Denials named the action and the caller's role and stopped there:
233    // "action 'AptInstall' is not allowed for Observer role". Accurate,
234    // and a dead end — the group that grants the role is not discoverable
235    // from that sentence, and unlike every other well-written message in
236    // this codebase it offered no command to run.
237    // -----------------------------------------------------------------
238
239    #[test]
240    fn every_role_maps_back_to_the_group_that_grants_it() {
241        assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
242        assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
243        assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
244        assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
245    }
246
247    #[test]
248    fn the_group_a_role_maps_to_actually_grants_that_role() {
249        // Guards against the two mappings drifting apart: whatever group we
250        // tell the user to join must resolve back to the role they need.
251        for wanted in [
252            CallerRole::Observer,
253            CallerRole::Dev,
254            CallerRole::Admin,
255            CallerRole::Boot,
256        ] {
257            let group = group_for_role(wanted);
258            assert_eq!(
259                role(&[group]),
260                wanted,
261                "joining {group} must grant {wanted:?}"
262            );
263        }
264    }
265
266    #[test]
267    fn a_denial_says_which_group_to_join_and_how() {
268        let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
269        assert!(msg.contains("AptInstall"), "names the action: {msg}");
270        assert!(msg.contains("Observer"), "names the current role: {msg}");
271        assert!(msg.contains("Dev"), "names the required role: {msg}");
272        assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
273        assert!(msg.contains("usermod"), "gives the command: {msg}");
274        assert!(
275            msg.contains("log out") || msg.contains("new login"),
276            "group membership needs a fresh session; say so: {msg}"
277        );
278    }
279
280    #[test]
281    fn empty_groups_resolves_to_observer() {
282        assert_eq!(role(&[]), CallerRole::Observer);
283    }
284
285    #[test]
286    fn unknown_group_resolves_to_observer() {
287        assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
288    }
289
290    #[test]
291    fn lacs_observer_group_resolves_to_observer() {
292        assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
293    }
294
295    #[test]
296    fn lacs_dev_group_resolves_to_dev() {
297        assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
298    }
299
300    #[test]
301    fn lacs_admin_group_resolves_to_admin() {
302        assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
303    }
304
305    #[test]
306    fn wheel_group_resolves_to_admin() {
307        assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
308    }
309
310    #[test]
311    fn lacs_boot_group_resolves_to_boot() {
312        assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
313    }
314
315    #[test]
316    fn highest_role_wins_when_multiple_groups_present() {
317        // A user in both sysknife-dev and wheel gets Admin (wheel > Dev).
318        assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
319    }
320
321    #[test]
322    fn boot_role_beats_admin_and_wheel() {
323        assert_eq!(
324            role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
325            CallerRole::Boot
326        );
327    }
328
329    #[test]
330    fn mixed_known_and_unknown_groups_returns_highest_known() {
331        assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
332    }
333
334    // --- token auth ---
335
336    #[test]
337    fn valid_token_matches_and_returns_dev_by_default() {
338        let dir = tempfile::tempdir().unwrap();
339        let path = dir.path().join("token");
340        std::fs::write(&path, "secret123").unwrap();
341        secure(&path);
342        assert_eq!(
343            validate_token_against_file("secret123", &path),
344            Some(CallerRole::Dev)
345        );
346    }
347
348    #[test]
349    fn token_file_with_trailing_newline_still_matches() {
350        // `echo TOKEN > file` appends a newline — must still work.
351        let dir = tempfile::tempdir().unwrap();
352        let path = dir.path().join("token");
353        std::fs::write(&path, "secret123\n").unwrap();
354        secure(&path);
355        assert_eq!(
356            validate_token_against_file("secret123", &path),
357            Some(CallerRole::Dev)
358        );
359    }
360
361    #[test]
362    fn wrong_token_returns_none() {
363        let dir = tempfile::tempdir().unwrap();
364        let path = dir.path().join("token");
365        std::fs::write(&path, "correct\n").unwrap();
366        secure(&path);
367        assert_eq!(validate_token_against_file("wrong", &path), None);
368    }
369
370    #[test]
371    fn absent_token_file_returns_none() {
372        let dir = tempfile::tempdir().unwrap();
373        let path = dir.path().join("nonexistent");
374        assert_eq!(validate_token_against_file("any", &path), None);
375    }
376
377    #[test]
378    fn empty_presented_token_is_always_rejected() {
379        let dir = tempfile::tempdir().unwrap();
380        let path = dir.path().join("token");
381        std::fs::write(&path, "").unwrap();
382        secure(&path);
383        assert_eq!(validate_token_against_file("", &path), None);
384    }
385
386    #[test]
387    fn empty_stored_token_is_rejected_even_with_matching_presented() {
388        let dir = tempfile::tempdir().unwrap();
389        let path = dir.path().join("token");
390        std::fs::write(&path, "\n").unwrap();
391        secure(&path);
392        assert_eq!(validate_token_against_file("", &path), None);
393    }
394
395    /// Regression test for the constant-time comparison path.
396    ///
397    /// We can't measure timing variance reliably in CI, so this just
398    /// documents intent: the comparator must return the same boolean
399    /// answer for an exact match, a wrong-prefix-same-length token, and a
400    /// wrong-suffix-same-length token. Equal-length non-matching inputs
401    /// are the path that would leak timing under a non-constant-time
402    /// compare; this test exercises that path.
403    #[test]
404    fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
405        let dir = tempfile::tempdir().unwrap();
406        let path = dir.path().join("token");
407        // Stored token is exactly 9 bytes; both candidates below are also
408        // exactly 9 bytes, so the length-mismatch shortcut does not apply
409        // and we genuinely traverse the constant-time `ct_eq` path.
410        std::fs::write(&path, "abcdefghi").unwrap();
411        secure(&path);
412
413        // Exact match → accepted.
414        assert_eq!(
415            validate_token_against_file("abcdefghi", &path),
416            Some(CallerRole::Dev)
417        );
418        // Equal-length, wrong first byte → rejected.
419        assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
420        // Equal-length, wrong last byte → rejected.
421        assert_eq!(validate_token_against_file("abcdefghX", &path), None);
422        // Equal-length, completely different → rejected.
423        assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
424    }
425
426    // --- token_role() ---
427
428    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
429
430    fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
431        let _guard = ENV_LOCK.lock().unwrap();
432        match val {
433            Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
434            None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
435        }
436        f();
437        std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
438    }
439
440    #[test]
441    fn token_role_defaults_to_dev_when_unset() {
442        with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
443    }
444
445    #[test]
446    fn token_role_explicit_dev() {
447        with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
448    }
449
450    #[test]
451    fn token_role_observer() {
452        with_role_env(Some("observer"), || {
453            assert_eq!(token_role(), CallerRole::Observer)
454        });
455    }
456
457    #[test]
458    fn token_role_admin() {
459        with_role_env(Some("admin"), || {
460            assert_eq!(token_role(), CallerRole::Admin)
461        });
462    }
463
464    #[test]
465    fn token_role_boot() {
466        with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
467    }
468
469    #[test]
470    fn token_role_unknown_value_fails_closed_to_observer() {
471        // An unrecognized value is a misconfiguration; fail closed to the
472        // least-privileged (read-only) role rather than granting Dev.
473        with_role_env(Some("superuser"), || {
474            assert_eq!(token_role(), CallerRole::Observer)
475        });
476    }
477
478    #[test]
479    fn token_role_is_case_insensitive() {
480        with_role_env(Some("ADMIN"), || {
481            assert_eq!(token_role(), CallerRole::Admin)
482        });
483    }
484
485    #[test]
486    fn token_role_trims_surrounding_whitespace() {
487        // Env values sourced from files often carry a trailing newline; it must
488        // not turn a valid role into an unknown value.
489        with_role_env(Some("  admin\n"), || {
490            assert_eq!(token_role(), CallerRole::Admin)
491        });
492    }
493}