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    if role_rank(&candidate) > role_rank(&current) {
32        candidate
33    } else {
34        current
35    }
36}
37
38pub(crate) fn role_rank(role: &CallerRole) -> u8 {
39    match role {
40        CallerRole::Observer => 0,
41        CallerRole::Dev => 1,
42        CallerRole::Admin => 2,
43        CallerRole::Boot => 3,
44    }
45}
46
47// ---------------------------------------------------------------------------
48// Token authentication (vsock connections)
49// ---------------------------------------------------------------------------
50
51/// Validate `presented_token` against the token stored in `token_path`.
52///
53/// Returns the role the token holder is granted (read from the
54/// `SYSKNIFE_TOKEN_ROLE` env var, defaulting to `Dev`) on success, or `None`
55/// if the token file is absent, unreadable, or the token does not match.
56///
57/// Whitespace (including trailing newlines) is stripped from the stored token
58/// before comparison. Provision the file with `install -m 600` — a token file
59/// readable by group or other is rejected outright (see below).
60///
61/// # Permissions
62///
63/// The file must not be group- or world-accessible (`mode & 0o077 == 0`),
64/// mirroring the check `AuditKey::load_or_generate` applies to the Ed25519
65/// signing key. Without it, a token written under a default `umask 022` lands
66/// at `0644`, and any local user who can read it can authenticate over vsock
67/// and receive `token_role()` — bypassing the `SO_PEERCRED` → group →
68/// `CallerRole` mechanism that is the documented authorization model for the
69/// Unix path.
70pub fn validate_token_against_file(
71    presented_token: &str,
72    token_path: &std::path::Path,
73) -> Option<CallerRole> {
74    if presented_token.is_empty() {
75        return None;
76    }
77    match token_file_permissions_ok(token_path) {
78        Ok(true) => {}
79        Ok(false) => return None,
80        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81            eprintln!(
82                "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
83                token_path.display()
84            );
85            return None;
86        }
87        Err(e) => {
88            eprintln!(
89                "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
90                token_path.display()
91            );
92            return None;
93        }
94    }
95    // Absence is already reported by the permission check above; anything
96    // reaching here is a genuine read failure (or a file removed in between).
97    let stored = match std::fs::read_to_string(token_path) {
98        Ok(s) => s,
99        Err(e) => {
100            eprintln!(
101                "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
102                token_path.display()
103            );
104            return None;
105        }
106    };
107    let stored = stored.trim();
108    if stored.is_empty() {
109        return None;
110    }
111    // Constant-time comparison to prevent timing oracles on credentials.
112    // Using `==` here would allow an attacker to learn the stored token
113    // byte-by-byte from response-time differences. `subtle::ConstantTimeEq`
114    // returns a `Choice` that takes the same time regardless of how many
115    // leading bytes match. Length mismatch short-circuits — that is fine,
116    // the secret is the bytes of the token, not its length class.
117    if stored.len() != presented_token.len() {
118        return None;
119    }
120    use subtle::ConstantTimeEq;
121    if stored
122        .as_bytes()
123        .ct_eq(presented_token.as_bytes())
124        .unwrap_u8()
125        != 1
126    {
127        return None;
128    }
129    Some(token_role())
130}
131
132/// Is the token file protected from other local users?
133///
134/// Returns `Ok(false)` (after warning) when the file is group- or
135/// world-accessible. The token is a bearer credential: anything that can read
136/// it can present it.
137fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
138    use std::os::unix::fs::PermissionsExt;
139
140    let mode = std::fs::metadata(token_path)?.permissions().mode();
141    if mode & 0o077 != 0 {
142        eprintln!(
143            "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
144             readable by group or other (any local user could authenticate with it). \
145             Rejecting vsock auth — fix with: chmod 600 {}",
146            token_path.display(),
147            mode & 0o7777,
148            token_path.display()
149        );
150        return Ok(false);
151    }
152    Ok(true)
153}
154
155/// Return the `CallerRole` granted to token-authenticated vsock connections.
156///
157/// Reads `SYSKNIFE_TOKEN_ROLE` env var (surrounding whitespace is trimmed).
158/// When unset or empty it defaults to `Dev` — the documented default for
159/// token-authenticated vsock guests. An **unrecognized** value fails *closed*
160/// to `Observer` (read-only) with a warning, rather than silently granting the
161/// mutating `Dev` tier on an operator typo.
162pub fn token_role() -> CallerRole {
163    let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
164    match raw.trim().to_ascii_lowercase().as_str() {
165        "observer" => CallerRole::Observer,
166        "admin" => CallerRole::Admin,
167        "boot" => CallerRole::Boot,
168        "dev" | "" => CallerRole::Dev,
169        other => {
170            eprintln!(
171                "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
172                 failing closed to Observer (read-only)"
173            );
174            CallerRole::Observer
175        }
176    }
177}
178
179/// Default path for the daemon token file.
180pub fn default_token_path() -> std::path::PathBuf {
181    sysknife_core::config::prefs_path()
182        .parent()
183        .unwrap_or_else(|| {
184            eprintln!(
185                "[sysknife-daemon] WARNING: prefs_path() has no parent; \
186                 falling back to /tmp for token file — this is a misconfiguration"
187            );
188            std::path::Path::new("/tmp")
189        })
190        .join("token")
191}
192
193// ---------------------------------------------------------------------------
194// Tests
195// ---------------------------------------------------------------------------
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    /// Provision a token file the way the daemon requires (`install -m 600`).
202    /// A default-umask write lands at 0644, which is refused: a token any
203    /// local user can read is not a credential.
204    fn secure(path: &std::path::Path) {
205        use std::os::unix::fs::PermissionsExt;
206        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
207    }
208
209    fn role(groups: &[&str]) -> CallerRole {
210        highest_role_from_groups(groups.iter().copied())
211    }
212
213    #[test]
214    fn empty_groups_resolves_to_observer() {
215        assert_eq!(role(&[]), CallerRole::Observer);
216    }
217
218    #[test]
219    fn unknown_group_resolves_to_observer() {
220        assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
221    }
222
223    #[test]
224    fn lacs_observer_group_resolves_to_observer() {
225        assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
226    }
227
228    #[test]
229    fn lacs_dev_group_resolves_to_dev() {
230        assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
231    }
232
233    #[test]
234    fn lacs_admin_group_resolves_to_admin() {
235        assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
236    }
237
238    #[test]
239    fn wheel_group_resolves_to_admin() {
240        assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
241    }
242
243    #[test]
244    fn lacs_boot_group_resolves_to_boot() {
245        assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
246    }
247
248    #[test]
249    fn highest_role_wins_when_multiple_groups_present() {
250        // A user in both sysknife-dev and wheel gets Admin (wheel > Dev).
251        assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
252    }
253
254    #[test]
255    fn boot_role_beats_admin_and_wheel() {
256        assert_eq!(
257            role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
258            CallerRole::Boot
259        );
260    }
261
262    #[test]
263    fn mixed_known_and_unknown_groups_returns_highest_known() {
264        assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
265    }
266
267    // --- token auth ---
268
269    #[test]
270    fn valid_token_matches_and_returns_dev_by_default() {
271        let dir = tempfile::tempdir().unwrap();
272        let path = dir.path().join("token");
273        std::fs::write(&path, "secret123").unwrap();
274        secure(&path);
275        assert_eq!(
276            validate_token_against_file("secret123", &path),
277            Some(CallerRole::Dev)
278        );
279    }
280
281    #[test]
282    fn token_file_with_trailing_newline_still_matches() {
283        // `echo TOKEN > file` appends a newline — must still work.
284        let dir = tempfile::tempdir().unwrap();
285        let path = dir.path().join("token");
286        std::fs::write(&path, "secret123\n").unwrap();
287        secure(&path);
288        assert_eq!(
289            validate_token_against_file("secret123", &path),
290            Some(CallerRole::Dev)
291        );
292    }
293
294    #[test]
295    fn wrong_token_returns_none() {
296        let dir = tempfile::tempdir().unwrap();
297        let path = dir.path().join("token");
298        std::fs::write(&path, "correct\n").unwrap();
299        secure(&path);
300        assert_eq!(validate_token_against_file("wrong", &path), None);
301    }
302
303    #[test]
304    fn absent_token_file_returns_none() {
305        let dir = tempfile::tempdir().unwrap();
306        let path = dir.path().join("nonexistent");
307        assert_eq!(validate_token_against_file("any", &path), None);
308    }
309
310    #[test]
311    fn empty_presented_token_is_always_rejected() {
312        let dir = tempfile::tempdir().unwrap();
313        let path = dir.path().join("token");
314        std::fs::write(&path, "").unwrap();
315        secure(&path);
316        assert_eq!(validate_token_against_file("", &path), None);
317    }
318
319    #[test]
320    fn empty_stored_token_is_rejected_even_with_matching_presented() {
321        let dir = tempfile::tempdir().unwrap();
322        let path = dir.path().join("token");
323        std::fs::write(&path, "\n").unwrap();
324        secure(&path);
325        assert_eq!(validate_token_against_file("", &path), None);
326    }
327
328    /// Regression test for the constant-time comparison path.
329    ///
330    /// We can't measure timing variance reliably in CI, so this just
331    /// documents intent: the comparator must return the same boolean
332    /// answer for an exact match, a wrong-prefix-same-length token, and a
333    /// wrong-suffix-same-length token. Equal-length non-matching inputs
334    /// are the path that would leak timing under a non-constant-time
335    /// compare; this test exercises that path.
336    #[test]
337    fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
338        let dir = tempfile::tempdir().unwrap();
339        let path = dir.path().join("token");
340        // Stored token is exactly 9 bytes; both candidates below are also
341        // exactly 9 bytes, so the length-mismatch shortcut does not apply
342        // and we genuinely traverse the constant-time `ct_eq` path.
343        std::fs::write(&path, "abcdefghi").unwrap();
344        secure(&path);
345
346        // Exact match → accepted.
347        assert_eq!(
348            validate_token_against_file("abcdefghi", &path),
349            Some(CallerRole::Dev)
350        );
351        // Equal-length, wrong first byte → rejected.
352        assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
353        // Equal-length, wrong last byte → rejected.
354        assert_eq!(validate_token_against_file("abcdefghX", &path), None);
355        // Equal-length, completely different → rejected.
356        assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
357    }
358
359    // --- token_role() ---
360
361    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
362
363    fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
364        let _guard = ENV_LOCK.lock().unwrap();
365        match val {
366            Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
367            None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
368        }
369        f();
370        std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
371    }
372
373    #[test]
374    fn token_role_defaults_to_dev_when_unset() {
375        with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
376    }
377
378    #[test]
379    fn token_role_explicit_dev() {
380        with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
381    }
382
383    #[test]
384    fn token_role_observer() {
385        with_role_env(Some("observer"), || {
386            assert_eq!(token_role(), CallerRole::Observer)
387        });
388    }
389
390    #[test]
391    fn token_role_admin() {
392        with_role_env(Some("admin"), || {
393            assert_eq!(token_role(), CallerRole::Admin)
394        });
395    }
396
397    #[test]
398    fn token_role_boot() {
399        with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
400    }
401
402    #[test]
403    fn token_role_unknown_value_fails_closed_to_observer() {
404        // An unrecognized value is a misconfiguration; fail closed to the
405        // least-privileged (read-only) role rather than granting Dev.
406        with_role_env(Some("superuser"), || {
407            assert_eq!(token_role(), CallerRole::Observer)
408        });
409    }
410
411    #[test]
412    fn token_role_is_case_insensitive() {
413        with_role_env(Some("ADMIN"), || {
414            assert_eq!(token_role(), CallerRole::Admin)
415        });
416    }
417
418    #[test]
419    fn token_role_trims_surrounding_whitespace() {
420        // Env values sourced from files often carry a trailing newline; it must
421        // not turn a valid role into an unknown value.
422        with_role_env(Some("  admin\n"), || {
423            assert_eq!(token_role(), CallerRole::Admin)
424        });
425    }
426}