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}