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 written by `echo`) is stripped from
58/// the stored token before comparison, so `echo TOKEN > ~/.config/sysknife/token`
59/// works without modification.
60pub fn validate_token_against_file(
61    presented_token: &str,
62    token_path: &std::path::Path,
63) -> Option<CallerRole> {
64    if presented_token.is_empty() {
65        return None;
66    }
67    let stored = match std::fs::read_to_string(token_path) {
68        Ok(s) => s,
69        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
70            eprintln!(
71                "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
72                token_path.display()
73            );
74            return None;
75        }
76        Err(e) => {
77            eprintln!(
78                "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
79                token_path.display()
80            );
81            return None;
82        }
83    };
84    let stored = stored.trim();
85    if stored.is_empty() {
86        return None;
87    }
88    // Constant-time comparison to prevent timing oracles on credentials.
89    // Using `==` here would allow an attacker to learn the stored token
90    // byte-by-byte from response-time differences. `subtle::ConstantTimeEq`
91    // returns a `Choice` that takes the same time regardless of how many
92    // leading bytes match. Length mismatch short-circuits — that is fine,
93    // the secret is the bytes of the token, not its length class.
94    if stored.len() != presented_token.len() {
95        return None;
96    }
97    use subtle::ConstantTimeEq;
98    if stored
99        .as_bytes()
100        .ct_eq(presented_token.as_bytes())
101        .unwrap_u8()
102        != 1
103    {
104        return None;
105    }
106    Some(token_role())
107}
108
109/// Return the `CallerRole` granted to token-authenticated vsock connections.
110///
111/// Reads `SYSKNIFE_TOKEN_ROLE` env var (surrounding whitespace is trimmed).
112/// When unset or empty it defaults to `Dev` — the documented default for
113/// token-authenticated vsock guests. An **unrecognized** value fails *closed*
114/// to `Observer` (read-only) with a warning, rather than silently granting the
115/// mutating `Dev` tier on an operator typo.
116pub fn token_role() -> CallerRole {
117    let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
118    match raw.trim().to_ascii_lowercase().as_str() {
119        "observer" => CallerRole::Observer,
120        "admin" => CallerRole::Admin,
121        "boot" => CallerRole::Boot,
122        "dev" | "" => CallerRole::Dev,
123        other => {
124            eprintln!(
125                "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
126                 failing closed to Observer (read-only)"
127            );
128            CallerRole::Observer
129        }
130    }
131}
132
133/// Default path for the daemon token file.
134pub fn default_token_path() -> std::path::PathBuf {
135    sysknife_core::config::prefs_path()
136        .parent()
137        .unwrap_or_else(|| {
138            eprintln!(
139                "[sysknife-daemon] WARNING: prefs_path() has no parent; \
140                 falling back to /tmp for token file — this is a misconfiguration"
141            );
142            std::path::Path::new("/tmp")
143        })
144        .join("token")
145}
146
147// ---------------------------------------------------------------------------
148// Tests
149// ---------------------------------------------------------------------------
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn role(groups: &[&str]) -> CallerRole {
156        highest_role_from_groups(groups.iter().copied())
157    }
158
159    #[test]
160    fn empty_groups_resolves_to_observer() {
161        assert_eq!(role(&[]), CallerRole::Observer);
162    }
163
164    #[test]
165    fn unknown_group_resolves_to_observer() {
166        assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
167    }
168
169    #[test]
170    fn lacs_observer_group_resolves_to_observer() {
171        assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
172    }
173
174    #[test]
175    fn lacs_dev_group_resolves_to_dev() {
176        assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
177    }
178
179    #[test]
180    fn lacs_admin_group_resolves_to_admin() {
181        assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
182    }
183
184    #[test]
185    fn wheel_group_resolves_to_admin() {
186        assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
187    }
188
189    #[test]
190    fn lacs_boot_group_resolves_to_boot() {
191        assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
192    }
193
194    #[test]
195    fn highest_role_wins_when_multiple_groups_present() {
196        // A user in both sysknife-dev and wheel gets Admin (wheel > Dev).
197        assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
198    }
199
200    #[test]
201    fn boot_role_beats_admin_and_wheel() {
202        assert_eq!(
203            role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
204            CallerRole::Boot
205        );
206    }
207
208    #[test]
209    fn mixed_known_and_unknown_groups_returns_highest_known() {
210        assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
211    }
212
213    // --- token auth ---
214
215    #[test]
216    fn valid_token_matches_and_returns_dev_by_default() {
217        let dir = tempfile::tempdir().unwrap();
218        let path = dir.path().join("token");
219        std::fs::write(&path, "secret123").unwrap();
220        assert_eq!(
221            validate_token_against_file("secret123", &path),
222            Some(CallerRole::Dev)
223        );
224    }
225
226    #[test]
227    fn token_file_with_trailing_newline_still_matches() {
228        // `echo TOKEN > file` appends a newline — must still work.
229        let dir = tempfile::tempdir().unwrap();
230        let path = dir.path().join("token");
231        std::fs::write(&path, "secret123\n").unwrap();
232        assert_eq!(
233            validate_token_against_file("secret123", &path),
234            Some(CallerRole::Dev)
235        );
236    }
237
238    #[test]
239    fn wrong_token_returns_none() {
240        let dir = tempfile::tempdir().unwrap();
241        let path = dir.path().join("token");
242        std::fs::write(&path, "correct\n").unwrap();
243        assert_eq!(validate_token_against_file("wrong", &path), None);
244    }
245
246    #[test]
247    fn absent_token_file_returns_none() {
248        let dir = tempfile::tempdir().unwrap();
249        let path = dir.path().join("nonexistent");
250        assert_eq!(validate_token_against_file("any", &path), None);
251    }
252
253    #[test]
254    fn empty_presented_token_is_always_rejected() {
255        let dir = tempfile::tempdir().unwrap();
256        let path = dir.path().join("token");
257        std::fs::write(&path, "").unwrap();
258        assert_eq!(validate_token_against_file("", &path), None);
259    }
260
261    #[test]
262    fn empty_stored_token_is_rejected_even_with_matching_presented() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().join("token");
265        std::fs::write(&path, "\n").unwrap();
266        assert_eq!(validate_token_against_file("", &path), None);
267    }
268
269    /// Regression test for the constant-time comparison path.
270    ///
271    /// We can't measure timing variance reliably in CI, so this just
272    /// documents intent: the comparator must return the same boolean
273    /// answer for an exact match, a wrong-prefix-same-length token, and a
274    /// wrong-suffix-same-length token. Equal-length non-matching inputs
275    /// are the path that would leak timing under a non-constant-time
276    /// compare; this test exercises that path.
277    #[test]
278    fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
279        let dir = tempfile::tempdir().unwrap();
280        let path = dir.path().join("token");
281        // Stored token is exactly 9 bytes; both candidates below are also
282        // exactly 9 bytes, so the length-mismatch shortcut does not apply
283        // and we genuinely traverse the constant-time `ct_eq` path.
284        std::fs::write(&path, "abcdefghi").unwrap();
285
286        // Exact match → accepted.
287        assert_eq!(
288            validate_token_against_file("abcdefghi", &path),
289            Some(CallerRole::Dev)
290        );
291        // Equal-length, wrong first byte → rejected.
292        assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
293        // Equal-length, wrong last byte → rejected.
294        assert_eq!(validate_token_against_file("abcdefghX", &path), None);
295        // Equal-length, completely different → rejected.
296        assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
297    }
298
299    // --- token_role() ---
300
301    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
302
303    fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
304        let _guard = ENV_LOCK.lock().unwrap();
305        match val {
306            Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
307            None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
308        }
309        f();
310        std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
311    }
312
313    #[test]
314    fn token_role_defaults_to_dev_when_unset() {
315        with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
316    }
317
318    #[test]
319    fn token_role_explicit_dev() {
320        with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
321    }
322
323    #[test]
324    fn token_role_observer() {
325        with_role_env(Some("observer"), || {
326            assert_eq!(token_role(), CallerRole::Observer)
327        });
328    }
329
330    #[test]
331    fn token_role_admin() {
332        with_role_env(Some("admin"), || {
333            assert_eq!(token_role(), CallerRole::Admin)
334        });
335    }
336
337    #[test]
338    fn token_role_boot() {
339        with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
340    }
341
342    #[test]
343    fn token_role_unknown_value_fails_closed_to_observer() {
344        // An unrecognized value is a misconfiguration; fail closed to the
345        // least-privileged (read-only) role rather than granting Dev.
346        with_role_env(Some("superuser"), || {
347            assert_eq!(token_role(), CallerRole::Observer)
348        });
349    }
350
351    #[test]
352    fn token_role_is_case_insensitive() {
353        with_role_env(Some("ADMIN"), || {
354            assert_eq!(token_role(), CallerRole::Admin)
355        });
356    }
357
358    #[test]
359    fn token_role_trims_surrounding_whitespace() {
360        // Env values sourced from files often carry a trailing newline; it must
361        // not turn a valid role into an unknown value.
362        with_role_env(Some("  admin\n"), || {
363            assert_eq!(token_role(), CallerRole::Admin)
364        });
365    }
366}