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
34pub 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
49pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CallerPrincipal {
86 Uid(u32),
88 VsockToken,
91 Unattributed,
102}
103
104impl CallerPrincipal {
105 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 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 pub fn as_signed_str(&self) -> String {
134 format!("{}:{}", self.scheme(), self.value())
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct CallerAttribution {
148 role: CallerRole,
150 principal: CallerPrincipal,
153}
154
155impl CallerAttribution {
156 pub fn from_peer_uid(uid: u32, role: CallerRole) -> Self {
158 Self {
159 role,
160 principal: CallerPrincipal::Uid(uid),
161 }
162 }
163
164 pub fn from_vsock_token(role: CallerRole) -> Self {
166 Self {
167 role,
168 principal: CallerPrincipal::VsockToken,
169 }
170 }
171
172 pub fn unattributed() -> Self {
174 Self {
175 role: CallerRole::Observer,
176 principal: CallerPrincipal::Unattributed,
177 }
178 }
179
180 pub fn role(&self) -> CallerRole {
181 self.role
182 }
183
184 pub fn principal(&self) -> CallerPrincipal {
185 self.principal
186 }
187}
188
189pub fn validate_token_against_file(
213 presented_token: &str,
214 token_path: &std::path::Path,
215) -> Option<CallerRole> {
216 if presented_token.is_empty() {
217 return None;
218 }
219 match token_file_permissions_ok(token_path) {
220 Ok(true) => {}
221 Ok(false) => return None,
222 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
223 eprintln!(
224 "[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
225 token_path.display()
226 );
227 return None;
228 }
229 Err(e) => {
230 eprintln!(
231 "[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
232 token_path.display()
233 );
234 return None;
235 }
236 }
237 let stored = match std::fs::read_to_string(token_path) {
240 Ok(s) => s,
241 Err(e) => {
242 eprintln!(
243 "[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
244 token_path.display()
245 );
246 return None;
247 }
248 };
249 let stored = stored.trim();
250 if stored.is_empty() {
251 return None;
252 }
253 if stored.len() != presented_token.len() {
260 return None;
261 }
262 use subtle::ConstantTimeEq;
263 if stored
264 .as_bytes()
265 .ct_eq(presented_token.as_bytes())
266 .unwrap_u8()
267 != 1
268 {
269 return None;
270 }
271 Some(token_role())
272}
273
274fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
280 use std::os::unix::fs::PermissionsExt;
281
282 let mode = std::fs::metadata(token_path)?.permissions().mode();
283 if mode & 0o077 != 0 {
284 eprintln!(
285 "[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
286 readable by group or other (any local user could authenticate with it). \
287 Rejecting vsock auth — fix with: chmod 600 {}",
288 token_path.display(),
289 mode & 0o7777,
290 token_path.display()
291 );
292 return Ok(false);
293 }
294 Ok(true)
295}
296
297pub fn token_role() -> CallerRole {
305 let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
306 match raw.trim().to_ascii_lowercase().as_str() {
307 "observer" => CallerRole::Observer,
308 "admin" => CallerRole::Admin,
309 "boot" => CallerRole::Boot,
310 "dev" | "" => CallerRole::Dev,
311 other => {
312 eprintln!(
313 "[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
314 failing closed to Observer (read-only)"
315 );
316 CallerRole::Observer
317 }
318 }
319}
320
321pub fn default_token_path() -> std::path::PathBuf {
323 sysknife_core::config::prefs_path()
324 .parent()
325 .unwrap_or_else(|| {
326 eprintln!(
327 "[sysknife-daemon] WARNING: prefs_path() has no parent; \
328 falling back to /tmp for token file — this is a misconfiguration"
329 );
330 std::path::Path::new("/tmp")
331 })
332 .join("token")
333}
334
335#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn secure(path: &std::path::Path) {
347 use std::os::unix::fs::PermissionsExt;
348 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
349 }
350
351 fn role(groups: &[&str]) -> CallerRole {
352 highest_role_from_groups(groups.iter().copied())
353 }
354
355 #[test]
366 fn every_role_maps_back_to_the_group_that_grants_it() {
367 assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
368 assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
369 assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
370 assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
371 }
372
373 #[test]
374 fn the_group_a_role_maps_to_actually_grants_that_role() {
375 for wanted in [
378 CallerRole::Observer,
379 CallerRole::Dev,
380 CallerRole::Admin,
381 CallerRole::Boot,
382 ] {
383 let group = group_for_role(wanted);
384 assert_eq!(
385 role(&[group]),
386 wanted,
387 "joining {group} must grant {wanted:?}"
388 );
389 }
390 }
391
392 #[test]
393 fn a_denial_says_which_group_to_join_and_how() {
394 let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
395 assert!(msg.contains("AptInstall"), "names the action: {msg}");
396 assert!(msg.contains("Observer"), "names the current role: {msg}");
397 assert!(msg.contains("Dev"), "names the required role: {msg}");
398 assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
399 assert!(msg.contains("usermod"), "gives the command: {msg}");
400 assert!(
401 msg.contains("log out") || msg.contains("new login"),
402 "group membership needs a fresh session; say so: {msg}"
403 );
404 }
405
406 #[test]
407 fn empty_groups_resolves_to_observer() {
408 assert_eq!(role(&[]), CallerRole::Observer);
409 }
410
411 #[test]
412 fn unknown_group_resolves_to_observer() {
413 assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
414 }
415
416 #[test]
417 fn lacs_observer_group_resolves_to_observer() {
418 assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
419 }
420
421 #[test]
422 fn lacs_dev_group_resolves_to_dev() {
423 assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
424 }
425
426 #[test]
427 fn lacs_admin_group_resolves_to_admin() {
428 assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
429 }
430
431 #[test]
432 fn wheel_group_resolves_to_admin() {
433 assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
434 }
435
436 #[test]
437 fn lacs_boot_group_resolves_to_boot() {
438 assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
439 }
440
441 #[test]
442 fn highest_role_wins_when_multiple_groups_present() {
443 assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
445 }
446
447 #[test]
448 fn boot_role_beats_admin_and_wheel() {
449 assert_eq!(
450 role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
451 CallerRole::Boot
452 );
453 }
454
455 #[test]
456 fn mixed_known_and_unknown_groups_returns_highest_known() {
457 assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
458 }
459
460 #[test]
463 fn valid_token_matches_and_returns_dev_by_default() {
464 let dir = tempfile::tempdir().unwrap();
465 let path = dir.path().join("token");
466 std::fs::write(&path, "secret123").unwrap();
467 secure(&path);
468 assert_eq!(
469 validate_token_against_file("secret123", &path),
470 Some(CallerRole::Dev)
471 );
472 }
473
474 #[test]
475 fn token_file_with_trailing_newline_still_matches() {
476 let dir = tempfile::tempdir().unwrap();
478 let path = dir.path().join("token");
479 std::fs::write(&path, "secret123\n").unwrap();
480 secure(&path);
481 assert_eq!(
482 validate_token_against_file("secret123", &path),
483 Some(CallerRole::Dev)
484 );
485 }
486
487 #[test]
488 fn wrong_token_returns_none() {
489 let dir = tempfile::tempdir().unwrap();
490 let path = dir.path().join("token");
491 std::fs::write(&path, "correct\n").unwrap();
492 secure(&path);
493 assert_eq!(validate_token_against_file("wrong", &path), None);
494 }
495
496 #[test]
497 fn absent_token_file_returns_none() {
498 let dir = tempfile::tempdir().unwrap();
499 let path = dir.path().join("nonexistent");
500 assert_eq!(validate_token_against_file("any", &path), None);
501 }
502
503 #[test]
504 fn empty_presented_token_is_always_rejected() {
505 let dir = tempfile::tempdir().unwrap();
506 let path = dir.path().join("token");
507 std::fs::write(&path, "").unwrap();
508 secure(&path);
509 assert_eq!(validate_token_against_file("", &path), None);
510 }
511
512 #[test]
513 fn empty_stored_token_is_rejected_even_with_matching_presented() {
514 let dir = tempfile::tempdir().unwrap();
515 let path = dir.path().join("token");
516 std::fs::write(&path, "\n").unwrap();
517 secure(&path);
518 assert_eq!(validate_token_against_file("", &path), None);
519 }
520
521 #[test]
530 fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
531 let dir = tempfile::tempdir().unwrap();
532 let path = dir.path().join("token");
533 std::fs::write(&path, "abcdefghi").unwrap();
537 secure(&path);
538
539 assert_eq!(
541 validate_token_against_file("abcdefghi", &path),
542 Some(CallerRole::Dev)
543 );
544 assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
546 assert_eq!(validate_token_against_file("abcdefghX", &path), None);
548 assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
550 }
551
552 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
555
556 fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
557 let _guard = ENV_LOCK.lock().unwrap();
558 match val {
559 Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
560 None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
561 }
562 f();
563 std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
564 }
565
566 #[test]
567 fn token_role_defaults_to_dev_when_unset() {
568 with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
569 }
570
571 #[test]
572 fn token_role_explicit_dev() {
573 with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
574 }
575
576 #[test]
577 fn token_role_observer() {
578 with_role_env(Some("observer"), || {
579 assert_eq!(token_role(), CallerRole::Observer)
580 });
581 }
582
583 #[test]
584 fn token_role_admin() {
585 with_role_env(Some("admin"), || {
586 assert_eq!(token_role(), CallerRole::Admin)
587 });
588 }
589
590 #[test]
591 fn token_role_boot() {
592 with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
593 }
594
595 #[test]
596 fn token_role_unknown_value_fails_closed_to_observer() {
597 with_role_env(Some("superuser"), || {
600 assert_eq!(token_role(), CallerRole::Observer)
601 });
602 }
603
604 #[test]
605 fn token_role_is_case_insensitive() {
606 with_role_env(Some("ADMIN"), || {
607 assert_eq!(token_role(), CallerRole::Admin)
608 });
609 }
610
611 #[test]
612 fn token_role_trims_surrounding_whitespace() {
613 with_role_env(Some(" admin\n"), || {
616 assert_eq!(token_role(), CallerRole::Admin)
617 });
618 }
619 #[test]
631 fn every_principal_renders_as_a_non_empty_known_scheme() {
632 const SCHEMES: [&str; 3] = ["uid", "token", "none"];
633
634 for principal in [
635 CallerPrincipal::Uid(0),
636 CallerPrincipal::Uid(1000),
637 CallerPrincipal::Uid(u32::MAX),
638 CallerPrincipal::VsockToken,
639 CallerPrincipal::Unattributed,
640 ] {
641 let expected_scheme = match principal {
643 CallerPrincipal::Uid(_) => "uid",
644 CallerPrincipal::VsockToken => "token",
645 CallerPrincipal::Unattributed => "none",
646 };
647
648 let signed = principal.as_signed_str();
649 assert!(
650 !signed.is_empty(),
651 "{principal:?} renders empty, which a v3 row reports as tampering"
652 );
653 assert_eq!(principal.scheme(), expected_scheme);
654 let (scheme, value) = signed
655 .split_once(':')
656 .unwrap_or_else(|| panic!("{principal:?} rendered {signed:?} with no scheme"));
657 assert_eq!(scheme, expected_scheme);
658 assert!(
659 !value.is_empty(),
660 "{principal:?} rendered {signed:?} with an empty value"
661 );
662 assert!(SCHEMES.contains(&scheme), "unknown scheme in {signed:?}");
663 }
664 }
665
666 #[test]
670 fn principal_renderings_are_frozen() {
671 assert_eq!(CallerPrincipal::Uid(0).as_signed_str(), "uid:0");
672 assert_eq!(CallerPrincipal::Uid(1000).as_signed_str(), "uid:1000");
673 assert_eq!(
674 CallerPrincipal::Uid(u32::MAX).as_signed_str(),
675 "uid:4294967295"
676 );
677 assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
678 assert_eq!(
679 CallerPrincipal::Unattributed.as_signed_str(),
680 "none:unattributed"
681 );
682 }
683
684 #[test]
687 fn no_scheme_is_a_prefix_of_another() {
688 let schemes = ["uid", "token", "none"];
689 for a in schemes {
690 for b in schemes {
691 if a != b {
692 assert!(
693 !a.starts_with(b) && !b.starts_with(a),
694 "scheme {a:?} and {b:?} are prefix-ambiguous"
695 );
696 }
697 }
698 }
699 }
700
701 #[test]
707 fn an_unattributed_caller_is_never_privileged() {
708 let caller = CallerAttribution::unattributed();
709 assert_eq!(caller.role(), CallerRole::Observer);
710 assert_eq!(caller.principal(), CallerPrincipal::Unattributed);
711 }
712
713 #[test]
714 fn transport_constructors_pick_the_matching_evidence_class() {
715 assert_eq!(
716 CallerAttribution::from_peer_uid(4242, CallerRole::Admin).principal(),
717 CallerPrincipal::Uid(4242)
718 );
719 assert_eq!(
720 CallerAttribution::from_vsock_token(CallerRole::Dev).principal(),
721 CallerPrincipal::VsockToken
722 );
723 }
724}