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