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// Caller principal
65// ---------------------------------------------------------------------------
66
67/// Who asked, as opposed to what they were allowed to do.
68///
69/// [`CallerRole`] answers "was this permitted"; two members of
70/// `sysknife-admin` share a role and are indistinguishable by it. The principal
71/// answers "which account", and is signed into the audit chain
72/// ([`crate::audit_chain::ChainIdentity::V3`]) so the trail can name the account
73/// rather than the class.
74///
75/// An account is not a person: a uid survives `su`, shared logins, and reuse
76/// after a user is deleted. See the attribution-strength limit in `SECURITY.md`.
77///
78/// Every rendering is `scheme:value`, and the scheme is signed along with the
79/// value on purpose, because the strength of the evidence differs. `uid:1000`
80/// was attested by the kernel through `SO_PEERCRED`. `token:vsock` means a
81/// pre-shared secret was presented, and any holder of that file could have
82/// presented it. `none:unattributed` means the daemon could establish neither.
83/// A bare string would erase those distinctions.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CallerPrincipal {
86    /// A Unix-socket peer whose uid the kernel supplied at `connect()`.
87    Uid(u32),
88    /// A vsock connection authenticated by the pre-shared token. No uid exists
89    /// on that path: the peer is in another kernel.
90    VsockToken,
91    /// The daemon could not establish an account for this connection, either
92    /// because `SO_PEERCRED` failed, or because it returned no usable pid, or
93    /// because the peer is not representable in this daemon's namespaces (the
94    /// kernel then reports the overflow uid, which names `nobody` rather than
95    /// the caller).
96    ///
97    /// The connection is still handled, at `Observer` (the pre-existing
98    /// fallback), and the row records that attribution failed instead of
99    /// inventing a uid. A signed lie about who acted would be worse than a
100    /// signed admission of ignorance.
101    Unattributed,
102}
103
104impl CallerPrincipal {
105    /// Evidence class of this principal. Signed as the part before the colon.
106    ///
107    /// Split out from [`Self::as_signed_str`] so a new variant cannot be added
108    /// without declaring which class it belongs to, and so
109    /// `every_principal_renders_as_a_known_scheme` can walk the whole set.
110    pub fn scheme(&self) -> &'static str {
111        match self {
112            Self::Uid(_) => "uid",
113            Self::VsockToken => "token",
114            Self::Unattributed => "none",
115        }
116    }
117
118    /// The scheme-specific part, signed after the colon.
119    fn value(&self) -> std::borrow::Cow<'static, str> {
120        match self {
121            Self::Uid(uid) => std::borrow::Cow::Owned(uid.to_string()),
122            Self::VsockToken => std::borrow::Cow::Borrowed("vsock"),
123            Self::Unattributed => std::borrow::Cow::Borrowed("unattributed"),
124        }
125    }
126
127    /// The exact string signed into the chain and stored in `caller_principal`.
128    ///
129    /// Never empty for any variant, which matters: `ChainRow::identity` treats an
130    /// empty principal on a `chain_version = 3` row as a missing one and reports
131    /// the row broken. A variant that rendered empty would turn every honest row
132    /// on an affected host into a false tamper report.
133    pub fn as_signed_str(&self) -> String {
134        format!("{}:{}", self.scheme(), self.value())
135    }
136
137    /// What this principal claims about who acted.
138    ///
139    /// Exhaustive on `self`, so a new variant cannot be added without deciding
140    /// here whether it names an account. [`Self::classify`] answers for a stored
141    /// string by rebuilding the variant and then asking this.
142    pub fn claim(&self) -> PrincipalClaim {
143        match self {
144            Self::Uid(_) | Self::VsockToken => PrincipalClaim::Account,
145            Self::Unattributed => PrincipalClaim::AttributionFailed,
146        }
147    }
148
149    /// Rebuild the principal a stored `caller_principal` string was rendered
150    /// from, if any variant could have rendered exactly that string.
151    ///
152    /// The round-trip check is the whole point: a candidate is accepted only when
153    /// `as_signed_str` reproduces the input byte for byte, so `uid:007`,
154    /// `uid:1000:extra`, `uid:notanumber` and `token:not-vsock` are all refused.
155    /// Without it "starts with a known scheme" would pass for "names an account",
156    /// and the census would credit values this daemon cannot produce.
157    ///
158    /// A scheme belonging to no variant reads as `None`, which
159    /// [`Self::classify`] turns into [`PrincipalClaim::Unrecognized`]. That is the
160    /// safe default, and it is also a maintenance obligation: a new variant with a
161    /// new scheme must be added to the `candidate` chain below and to the
162    /// round-trip tests, or rows the daemon signs will be reported as unattested.
163    fn from_signed(stored: &str) -> Option<Self> {
164        let (scheme, value) = stored.split_once(':')?;
165        let candidate = if scheme == Self::Uid(0).scheme() {
166            Self::Uid(value.parse::<u32>().ok()?)
167        } else if scheme == Self::VsockToken.scheme() {
168            Self::VsockToken
169        } else if scheme == Self::Unattributed.scheme() {
170            Self::Unattributed
171        } else {
172            return None;
173        };
174        (candidate.as_signed_str() == stored).then_some(candidate)
175    }
176
177    /// Read a stored `caller_principal` back into what it claims about who acted.
178    ///
179    /// Deliberately the inverse of [`Self::as_signed_str`] and kept beside it, so
180    /// the writer and the reader of this column cannot drift apart.
181    ///
182    /// This is not a parse into `Self` for the caller's use, and that is the
183    /// point: it classifies strings this daemon may never have written. The census
184    /// reads a database column rather than a live connection, so it meets rows
185    /// that were tampered with, and rows signed by a newer daemon using a scheme
186    /// this binary does not know. Both must come back as
187    /// [`PrincipalClaim::Unrecognized`] instead of being credited as an account: a
188    /// report that turns an unreadable principal into "someone was named" is the
189    /// failure mode worth designing against.
190    pub fn classify(stored: &str) -> PrincipalClaim {
191        match Self::from_signed(stored) {
192            Some(principal) => principal.claim(),
193            None => PrincipalClaim::Unrecognized,
194        }
195    }
196}
197
198/// What a stored `caller_principal` string claims about who acted.
199///
200/// Three outcomes rather than a `bool`, because "names an account", "says it
201/// could not name one", and "cannot be read at all" call for three different
202/// lines in an audit report. Collapsing the last two would report a corrupt or
203/// future-encoded principal as an honest attribution failure.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum PrincipalClaim {
206    /// A known scheme with a non-empty value: `uid:1000`, `token:vsock`.
207    ///
208    /// Names an account, which is not the same as naming a person. See the
209    /// caveats in `SECURITY.md`: a uid is only as strong as account hygiene,
210    /// and `token:vsock` proves possession of a file.
211    Account,
212    /// Exactly the string the daemon signs when attribution failed.
213    AttributionFailed,
214    /// Anything else. Not an account, and not a recognised admission either.
215    Unrecognized,
216}
217
218/// Role plus principal for one connection, resolved by the daemon and never
219/// taken from the request body.
220///
221/// Fields are private and the constructors are per transport, so the pairings
222/// that matter cannot be built by accident. In particular
223/// [`Self::unattributed`] hardcodes `Observer`: an attribution failure must never
224/// carry a privileged role, because that combination signs the worst possible
225/// record — a claim that an Admin acted, naming nobody.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct CallerAttribution {
228    /// Authorization input. Decides what the connection may call.
229    role: CallerRole,
230    /// Audit-only. Never an authorization input: a principal must not be able to
231    /// widen what a role permits.
232    principal: CallerPrincipal,
233}
234
235impl CallerAttribution {
236    /// A Unix-socket peer, attributed to the uid `SO_PEERCRED` reported.
237    pub fn from_peer_uid(uid: u32, role: CallerRole) -> Self {
238        Self {
239            role,
240            principal: CallerPrincipal::Uid(uid),
241        }
242    }
243
244    /// A vsock peer that presented the pre-shared token.
245    pub fn from_vsock_token(role: CallerRole) -> Self {
246        Self {
247            role,
248            principal: CallerPrincipal::VsockToken,
249        }
250    }
251
252    /// A connection the daemon could not attribute. Always `Observer`.
253    pub fn unattributed() -> Self {
254        Self {
255            role: CallerRole::Observer,
256            principal: CallerPrincipal::Unattributed,
257        }
258    }
259
260    pub fn role(&self) -> CallerRole {
261        self.role
262    }
263
264    pub fn principal(&self) -> CallerPrincipal {
265        self.principal
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Token authentication (vsock connections)
271// ---------------------------------------------------------------------------
272
273/// Validate `presented_token` against the token stored in `token_path`.
274///
275/// Returns the role the token holder is granted (read from the
276/// `SYSKNIFE_TOKEN_ROLE` env var, defaulting to `Dev`) on success, or `None`
277/// if the token file is absent, unreadable, or the token does not match.
278///
279/// Whitespace (including trailing newlines) is stripped from the stored token
280/// before comparison. Provision the file with `install -m 600` — a token file
281/// readable by group or other is rejected outright (see below).
282///
283/// # Permissions
284///
285/// The file must not be group- or world-accessible (`mode & 0o077 == 0`),
286/// mirroring the check `AuditKey::load_or_generate` applies to the Ed25519
287/// signing key. Without it, a token written under a default `umask 022` lands
288/// at `0644`, and any local user who can read it can authenticate over vsock
289/// and receive `token_role()` — bypassing the `SO_PEERCRED` → group →
290/// `CallerRole` mechanism that is the documented authorization model for the
291/// Unix path.
292pub fn validate_token_against_file(
293    presented_token: &str,
294    token_path: &std::path::Path,
295) -> Option<CallerRole> {
296    if presented_token.is_empty() {
297        return None;
298    }
299    match token_file_permissions_ok(token_path) {
300        Ok(true) => {}
301        Ok(false) => return None,
302        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
303            eprintln!(
304                "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
305                token_path.display()
306            );
307            return None;
308        }
309        Err(e) => {
310            eprintln!(
311                "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
312                token_path.display()
313            );
314            return None;
315        }
316    }
317    // Absence is already reported by the permission check above; anything
318    // reaching here is a genuine read failure (or a file removed in between).
319    let stored = match std::fs::read_to_string(token_path) {
320        Ok(s) => s,
321        Err(e) => {
322            eprintln!(
323                "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
324                token_path.display()
325            );
326            return None;
327        }
328    };
329    let stored = stored.trim();
330    if stored.is_empty() {
331        return None;
332    }
333    // Constant-time comparison to prevent timing oracles on credentials.
334    // Using `==` here would allow an attacker to learn the stored token
335    // byte-by-byte from response-time differences. `subtle::ConstantTimeEq`
336    // returns a `Choice` that takes the same time regardless of how many
337    // leading bytes match. Length mismatch short-circuits — that is fine,
338    // the secret is the bytes of the token, not its length class.
339    if stored.len() != presented_token.len() {
340        return None;
341    }
342    use subtle::ConstantTimeEq;
343    if stored
344        .as_bytes()
345        .ct_eq(presented_token.as_bytes())
346        .unwrap_u8()
347        != 1
348    {
349        return None;
350    }
351    Some(token_role())
352}
353
354/// Is the token file protected from other local users?
355///
356/// Returns `Ok(false)` (after warning) when the file is group- or
357/// world-accessible. The token is a bearer credential: anything that can read
358/// it can present it.
359fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
360    use std::os::unix::fs::PermissionsExt;
361
362    let mode = std::fs::metadata(token_path)?.permissions().mode();
363    if mode & 0o077 != 0 {
364        eprintln!(
365            "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
366             readable by group or other (any local user could authenticate with it). \
367             Rejecting vsock auth — fix with: chmod 600 {}",
368            token_path.display(),
369            mode & 0o7777,
370            token_path.display()
371        );
372        return Ok(false);
373    }
374    Ok(true)
375}
376
377/// Return the `CallerRole` granted to token-authenticated vsock connections.
378///
379/// Reads `SYSKNIFE_TOKEN_ROLE` env var (surrounding whitespace is trimmed).
380/// When unset or empty it defaults to `Dev` — the documented default for
381/// token-authenticated vsock guests. An **unrecognized** value fails *closed*
382/// to `Observer` (read-only) with a warning, rather than silently granting the
383/// mutating `Dev` tier on an operator typo.
384pub fn token_role() -> CallerRole {
385    let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
386    match raw.trim().to_ascii_lowercase().as_str() {
387        "observer" => CallerRole::Observer,
388        "admin" => CallerRole::Admin,
389        "boot" => CallerRole::Boot,
390        "dev" | "" => CallerRole::Dev,
391        other => {
392            eprintln!(
393                "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
394                 failing closed to Observer (read-only)"
395            );
396            CallerRole::Observer
397        }
398    }
399}
400
401/// Default path for the daemon token file.
402pub fn default_token_path() -> std::path::PathBuf {
403    sysknife_core::config::prefs_path()
404        .parent()
405        .unwrap_or_else(|| {
406            eprintln!(
407                "[sysknife-daemon] WARNING: prefs_path() has no parent; \
408                 falling back to /tmp for token file — this is a misconfiguration"
409            );
410            std::path::Path::new("/tmp")
411        })
412        .join("token")
413}
414
415// ---------------------------------------------------------------------------
416// Tests
417// ---------------------------------------------------------------------------
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    /// Provision a token file the way the daemon requires (`install -m 600`).
424    /// A default-umask write lands at 0644, which is refused: a token any
425    /// local user can read is not a credential.
426    fn secure(path: &std::path::Path) {
427        use std::os::unix::fs::PermissionsExt;
428        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
429    }
430
431    fn role(groups: &[&str]) -> CallerRole {
432        highest_role_from_groups(groups.iter().copied())
433    }
434
435    // -----------------------------------------------------------------
436    // Telling a denied caller how to stop being denied
437    //
438    // Denials named the action and the caller's role and stopped there:
439    // "action 'AptInstall' is not allowed for Observer role". Accurate,
440    // and a dead end — the group that grants the role is not discoverable
441    // from that sentence, and unlike every other well-written message in
442    // this codebase it offered no command to run.
443    // -----------------------------------------------------------------
444
445    #[test]
446    fn every_role_maps_back_to_the_group_that_grants_it() {
447        assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
448        assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
449        assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
450        assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
451    }
452
453    #[test]
454    fn the_group_a_role_maps_to_actually_grants_that_role() {
455        // Guards against the two mappings drifting apart: whatever group we
456        // tell the user to join must resolve back to the role they need.
457        for wanted in [
458            CallerRole::Observer,
459            CallerRole::Dev,
460            CallerRole::Admin,
461            CallerRole::Boot,
462        ] {
463            let group = group_for_role(wanted);
464            assert_eq!(
465                role(&[group]),
466                wanted,
467                "joining {group} must grant {wanted:?}"
468            );
469        }
470    }
471
472    #[test]
473    fn a_denial_says_which_group_to_join_and_how() {
474        let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
475        assert!(msg.contains("AptInstall"), "names the action: {msg}");
476        assert!(msg.contains("Observer"), "names the current role: {msg}");
477        assert!(msg.contains("Dev"), "names the required role: {msg}");
478        assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
479        assert!(msg.contains("usermod"), "gives the command: {msg}");
480        assert!(
481            msg.contains("log out") || msg.contains("new login"),
482            "group membership needs a fresh session; say so: {msg}"
483        );
484    }
485
486    #[test]
487    fn empty_groups_resolves_to_observer() {
488        assert_eq!(role(&[]), CallerRole::Observer);
489    }
490
491    #[test]
492    fn unknown_group_resolves_to_observer() {
493        assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
494    }
495
496    #[test]
497    fn lacs_observer_group_resolves_to_observer() {
498        assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
499    }
500
501    #[test]
502    fn lacs_dev_group_resolves_to_dev() {
503        assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
504    }
505
506    #[test]
507    fn lacs_admin_group_resolves_to_admin() {
508        assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
509    }
510
511    #[test]
512    fn wheel_group_resolves_to_admin() {
513        assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
514    }
515
516    #[test]
517    fn lacs_boot_group_resolves_to_boot() {
518        assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
519    }
520
521    #[test]
522    fn highest_role_wins_when_multiple_groups_present() {
523        // A user in both sysknife-dev and wheel gets Admin (wheel > Dev).
524        assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
525    }
526
527    #[test]
528    fn boot_role_beats_admin_and_wheel() {
529        assert_eq!(
530            role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
531            CallerRole::Boot
532        );
533    }
534
535    #[test]
536    fn mixed_known_and_unknown_groups_returns_highest_known() {
537        assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
538    }
539
540    // --- token auth ---
541
542    #[test]
543    fn valid_token_matches_and_returns_dev_by_default() {
544        let dir = tempfile::tempdir().unwrap();
545        let path = dir.path().join("token");
546        std::fs::write(&path, "secret123").unwrap();
547        secure(&path);
548        assert_eq!(
549            validate_token_against_file("secret123", &path),
550            Some(CallerRole::Dev)
551        );
552    }
553
554    #[test]
555    fn token_file_with_trailing_newline_still_matches() {
556        // `echo TOKEN > file` appends a newline — must still work.
557        let dir = tempfile::tempdir().unwrap();
558        let path = dir.path().join("token");
559        std::fs::write(&path, "secret123\n").unwrap();
560        secure(&path);
561        assert_eq!(
562            validate_token_against_file("secret123", &path),
563            Some(CallerRole::Dev)
564        );
565    }
566
567    #[test]
568    fn wrong_token_returns_none() {
569        let dir = tempfile::tempdir().unwrap();
570        let path = dir.path().join("token");
571        std::fs::write(&path, "correct\n").unwrap();
572        secure(&path);
573        assert_eq!(validate_token_against_file("wrong", &path), None);
574    }
575
576    #[test]
577    fn absent_token_file_returns_none() {
578        let dir = tempfile::tempdir().unwrap();
579        let path = dir.path().join("nonexistent");
580        assert_eq!(validate_token_against_file("any", &path), None);
581    }
582
583    #[test]
584    fn empty_presented_token_is_always_rejected() {
585        let dir = tempfile::tempdir().unwrap();
586        let path = dir.path().join("token");
587        std::fs::write(&path, "").unwrap();
588        secure(&path);
589        assert_eq!(validate_token_against_file("", &path), None);
590    }
591
592    #[test]
593    fn empty_stored_token_is_rejected_even_with_matching_presented() {
594        let dir = tempfile::tempdir().unwrap();
595        let path = dir.path().join("token");
596        std::fs::write(&path, "\n").unwrap();
597        secure(&path);
598        assert_eq!(validate_token_against_file("", &path), None);
599    }
600
601    /// Regression test for the constant-time comparison path.
602    ///
603    /// We can't measure timing variance reliably in CI, so this just
604    /// documents intent: the comparator must return the same boolean
605    /// answer for an exact match, a wrong-prefix-same-length token, and a
606    /// wrong-suffix-same-length token. Equal-length non-matching inputs
607    /// are the path that would leak timing under a non-constant-time
608    /// compare; this test exercises that path.
609    #[test]
610    fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
611        let dir = tempfile::tempdir().unwrap();
612        let path = dir.path().join("token");
613        // Stored token is exactly 9 bytes; both candidates below are also
614        // exactly 9 bytes, so the length-mismatch shortcut does not apply
615        // and we genuinely traverse the constant-time `ct_eq` path.
616        std::fs::write(&path, "abcdefghi").unwrap();
617        secure(&path);
618
619        // Exact match → accepted.
620        assert_eq!(
621            validate_token_against_file("abcdefghi", &path),
622            Some(CallerRole::Dev)
623        );
624        // Equal-length, wrong first byte → rejected.
625        assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
626        // Equal-length, wrong last byte → rejected.
627        assert_eq!(validate_token_against_file("abcdefghX", &path), None);
628        // Equal-length, completely different → rejected.
629        assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
630    }
631
632    // --- token_role() ---
633
634    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
635
636    fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
637        let _guard = ENV_LOCK.lock().unwrap();
638        match val {
639            Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
640            None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
641        }
642        f();
643        std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
644    }
645
646    #[test]
647    fn token_role_defaults_to_dev_when_unset() {
648        with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
649    }
650
651    #[test]
652    fn token_role_explicit_dev() {
653        with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
654    }
655
656    #[test]
657    fn token_role_observer() {
658        with_role_env(Some("observer"), || {
659            assert_eq!(token_role(), CallerRole::Observer)
660        });
661    }
662
663    #[test]
664    fn token_role_admin() {
665        with_role_env(Some("admin"), || {
666            assert_eq!(token_role(), CallerRole::Admin)
667        });
668    }
669
670    #[test]
671    fn token_role_boot() {
672        with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
673    }
674
675    #[test]
676    fn token_role_unknown_value_fails_closed_to_observer() {
677        // An unrecognized value is a misconfiguration; fail closed to the
678        // least-privileged (read-only) role rather than granting Dev.
679        with_role_env(Some("superuser"), || {
680            assert_eq!(token_role(), CallerRole::Observer)
681        });
682    }
683
684    #[test]
685    fn token_role_is_case_insensitive() {
686        with_role_env(Some("ADMIN"), || {
687            assert_eq!(token_role(), CallerRole::Admin)
688        });
689    }
690
691    #[test]
692    fn token_role_trims_surrounding_whitespace() {
693        // Env values sourced from files often carry a trailing newline; it must
694        // not turn a valid role into an unknown value.
695        with_role_env(Some("  admin\n"), || {
696            assert_eq!(token_role(), CallerRole::Admin)
697        });
698    }
699    // ── Principal rendering ───────────────────────────────────────────────
700
701    /// Walks every variant through an exhaustive match, so adding one without
702    /// deciding how it renders is a compile error rather than a silent new
703    /// string in the audit chain.
704    ///
705    /// The empty check is the load-bearing one. `ChainRow::identity` treats an
706    /// empty `caller_principal` on a v3 row as missing and reports the row
707    /// *broken*, so a variant that rendered empty would turn every honest row on
708    /// an affected host into a false tamper report. That is a worse outcome than
709    /// the attribution failure it would be describing.
710    /// Round-trip: everything the daemon can sign must classify back to the
711    /// class it belongs to. The match is exhaustive so a new variant cannot be
712    /// added without deciding, here, whether it names an account.
713    #[test]
714    fn every_principal_the_daemon_signs_classifies_back_to_its_own_class() {
715        for principal in [
716            CallerPrincipal::Uid(0),
717            CallerPrincipal::Uid(1000),
718            CallerPrincipal::Uid(u32::MAX),
719            CallerPrincipal::VsockToken,
720            CallerPrincipal::Unattributed,
721        ] {
722            assert_eq!(
723                CallerPrincipal::classify(&principal.as_signed_str()),
724                principal.claim(),
725                "{principal:?} must read back as the class it declares"
726            );
727        }
728    }
729
730    /// The strings a tampered column or a newer daemon can hold. None of them
731    /// names an account, and the one that matters most is `none:` with an
732    /// unfamiliar value: guessing that it is an attribution failure would let a
733    /// future scheme be reported as something this binary actually understood.
734    #[test]
735    fn a_principal_this_binary_cannot_read_is_never_credited_as_an_account() {
736        for stored in [
737            "",
738            " ",
739            "uid",
740            "uid:",
741            "token:",
742            "none:",
743            "garbage",
744            "none:overflow",
745            "fingerprint:ab12",
746            ":1000",
747            // A scheme this daemon knows carrying a value it cannot render.
748            // Without the round-trip check in `from_signed`, every one of these
749            // passed for an account: matching the scheme alone was enough.
750            "uid:notanumber",
751            "uid:1000:extra",
752            "uid:007",
753            "uid:-1",
754            "uid: 1000",
755            "uid:1000 ",
756            "uid:0\n",
757            "uid:99999999999999999999",
758            "token:not-vsock",
759            "token:vsock:extra",
760            "UID:1000",
761        ] {
762            assert_eq!(
763                CallerPrincipal::classify(stored),
764                PrincipalClaim::Unrecognized,
765                "{stored:?} must not be read as naming an account"
766            );
767        }
768    }
769
770    #[test]
771    fn every_principal_renders_as_a_non_empty_known_scheme() {
772        const SCHEMES: [&str; 3] = ["uid", "token", "none"];
773
774        for principal in [
775            CallerPrincipal::Uid(0),
776            CallerPrincipal::Uid(1000),
777            CallerPrincipal::Uid(u32::MAX),
778            CallerPrincipal::VsockToken,
779            CallerPrincipal::Unattributed,
780        ] {
781            // Exhaustive on purpose: a new variant must be added here too.
782            let expected_scheme = match principal {
783                CallerPrincipal::Uid(_) => "uid",
784                CallerPrincipal::VsockToken => "token",
785                CallerPrincipal::Unattributed => "none",
786            };
787
788            let signed = principal.as_signed_str();
789            assert!(
790                !signed.is_empty(),
791                "{principal:?} renders empty, which a v3 row reports as tampering"
792            );
793            assert_eq!(principal.scheme(), expected_scheme);
794            let (scheme, value) = signed
795                .split_once(':')
796                .unwrap_or_else(|| panic!("{principal:?} rendered {signed:?} with no scheme"));
797            assert_eq!(scheme, expected_scheme);
798            assert!(
799                !value.is_empty(),
800                "{principal:?} rendered {signed:?} with an empty value"
801            );
802            assert!(SCHEMES.contains(&scheme), "unknown scheme in {signed:?}");
803        }
804    }
805
806    /// Exact renderings, frozen. These strings are signed into audit rows, so
807    /// changing one silently re-encodes every future row and makes chains written
808    /// by two builds disagree about who acted.
809    #[test]
810    fn principal_renderings_are_frozen() {
811        assert_eq!(CallerPrincipal::Uid(0).as_signed_str(), "uid:0");
812        assert_eq!(CallerPrincipal::Uid(1000).as_signed_str(), "uid:1000");
813        assert_eq!(
814            CallerPrincipal::Uid(u32::MAX).as_signed_str(),
815            "uid:4294967295"
816        );
817        assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
818        assert_eq!(
819            CallerPrincipal::Unattributed.as_signed_str(),
820            "none:unattributed"
821        );
822    }
823
824    /// No scheme may prefix another, or a stored principal could be read as
825    /// belonging to a different evidence class than the one that signed it.
826    #[test]
827    fn no_scheme_is_a_prefix_of_another() {
828        let schemes = ["uid", "token", "none"];
829        for a in schemes {
830            for b in schemes {
831                if a != b {
832                    assert!(
833                        !a.starts_with(b) && !b.starts_with(a),
834                        "scheme {a:?} and {b:?} are prefix-ambiguous"
835                    );
836                }
837            }
838        }
839    }
840
841    // ── Attribution pairing ───────────────────────────────────────────────
842
843    /// The pairing that must never exist: a privileged role with no account.
844    /// `unattributed()` hardcodes Observer, and the private fields mean no call
845    /// site can assemble the other combination.
846    #[test]
847    fn an_unattributed_caller_is_never_privileged() {
848        let caller = CallerAttribution::unattributed();
849        assert_eq!(caller.role(), CallerRole::Observer);
850        assert_eq!(caller.principal(), CallerPrincipal::Unattributed);
851    }
852
853    #[test]
854    fn transport_constructors_pick_the_matching_evidence_class() {
855        assert_eq!(
856            CallerAttribution::from_peer_uid(4242, CallerRole::Admin).principal(),
857            CallerPrincipal::Uid(4242)
858        );
859        assert_eq!(
860            CallerAttribution::from_vsock_token(CallerRole::Dev).principal(),
861            CallerPrincipal::VsockToken
862        );
863    }
864}