use sysknife_types::CallerRole;
pub const OBSERVER_GROUP: &str = "sysknife-observer";
pub const DEV_GROUP: &str = "sysknife-dev";
pub const ADMIN_GROUP: &str = "sysknife-admin";
pub const BOOT_GROUP: &str = "sysknife-boot";
pub const WHEEL_GROUP: &str = "wheel";
pub fn highest_role_from_groups<I, S>(groups: I) -> CallerRole
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
groups
.into_iter()
.map(|group| role_for_group(group.as_ref()))
.fold(CallerRole::Observer, higher_role)
}
fn role_for_group(group: &str) -> CallerRole {
match group {
BOOT_GROUP => CallerRole::Boot,
ADMIN_GROUP | WHEEL_GROUP => CallerRole::Admin,
DEV_GROUP => CallerRole::Dev,
OBSERVER_GROUP => CallerRole::Observer,
_ => CallerRole::Observer,
}
}
fn higher_role(current: CallerRole, candidate: CallerRole) -> CallerRole {
current.max(candidate)
}
pub fn group_for_role(role: CallerRole) -> &'static str {
match role {
CallerRole::Boot => BOOT_GROUP,
CallerRole::Admin => ADMIN_GROUP,
CallerRole::Dev => DEV_GROUP,
CallerRole::Observer => OBSERVER_GROUP,
}
}
pub fn denial_message(action: &str, caller: CallerRole, required: CallerRole) -> String {
format!(
"action '{action}' requires the {required:?} role, but you have {caller:?}. \
Join the group that grants it: sudo usermod -aG {group} $USER, \
then log out and back in (group membership only applies to a new login).",
group = group_for_role(required),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallerPrincipal {
Uid(u32),
VsockToken,
Unattributed,
}
impl CallerPrincipal {
pub fn scheme(&self) -> &'static str {
match self {
Self::Uid(_) => "uid",
Self::VsockToken => "token",
Self::Unattributed => "none",
}
}
fn value(&self) -> std::borrow::Cow<'static, str> {
match self {
Self::Uid(uid) => std::borrow::Cow::Owned(uid.to_string()),
Self::VsockToken => std::borrow::Cow::Borrowed("vsock"),
Self::Unattributed => std::borrow::Cow::Borrowed("unattributed"),
}
}
pub fn as_signed_str(&self) -> String {
format!("{}:{}", self.scheme(), self.value())
}
pub fn claim(&self) -> PrincipalClaim {
match self {
Self::Uid(_) | Self::VsockToken => PrincipalClaim::Account,
Self::Unattributed => PrincipalClaim::AttributionFailed,
}
}
fn from_signed(stored: &str) -> Option<Self> {
let (scheme, value) = stored.split_once(':')?;
let candidate = if scheme == Self::Uid(0).scheme() {
Self::Uid(value.parse::<u32>().ok()?)
} else if scheme == Self::VsockToken.scheme() {
Self::VsockToken
} else if scheme == Self::Unattributed.scheme() {
Self::Unattributed
} else {
return None;
};
(candidate.as_signed_str() == stored).then_some(candidate)
}
pub fn classify(stored: &str) -> PrincipalClaim {
match Self::from_signed(stored) {
Some(principal) => principal.claim(),
None => PrincipalClaim::Unrecognized,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrincipalClaim {
Account,
AttributionFailed,
Unrecognized,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CallerAttribution {
role: CallerRole,
principal: CallerPrincipal,
}
impl CallerAttribution {
pub fn from_peer_uid(uid: u32, role: CallerRole) -> Self {
Self {
role,
principal: CallerPrincipal::Uid(uid),
}
}
pub fn from_vsock_token(role: CallerRole) -> Self {
Self {
role,
principal: CallerPrincipal::VsockToken,
}
}
pub fn unattributed() -> Self {
Self {
role: CallerRole::Observer,
principal: CallerPrincipal::Unattributed,
}
}
pub fn role(&self) -> CallerRole {
self.role
}
pub fn principal(&self) -> CallerPrincipal {
self.principal
}
}
pub fn validate_token_against_file(
presented_token: &str,
token_path: &std::path::Path,
) -> Option<CallerRole> {
if presented_token.is_empty() {
return None;
}
match token_file_permissions_ok(token_path) {
Ok(true) => {}
Ok(false) => return None,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!(
"[sysknife-daemon] WARNING: vsock token file {} does not exist; rejecting vsock auth (provision the token file to allow vsock connections)",
token_path.display()
);
return None;
}
Err(e) => {
eprintln!(
"[sysknife-daemon] WARNING: cannot stat token file {}: {e}; rejecting vsock auth",
token_path.display()
);
return None;
}
}
let stored = match std::fs::read_to_string(token_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"[sysknife-daemon] WARNING: cannot read token file {}: {e}; rejecting vsock auth",
token_path.display()
);
return None;
}
};
let stored = stored.trim();
if stored.is_empty() {
return None;
}
if stored.len() != presented_token.len() {
return None;
}
use subtle::ConstantTimeEq;
if stored
.as_bytes()
.ct_eq(presented_token.as_bytes())
.unwrap_u8()
!= 1
{
return None;
}
Some(token_role())
}
fn token_file_permissions_ok(token_path: &std::path::Path) -> std::io::Result<bool> {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(token_path)?.permissions().mode();
if mode & 0o077 != 0 {
eprintln!(
"[sysknife-daemon] WARNING: vsock token file {} has mode {:04o}; it must not be \
readable by group or other (any local user could authenticate with it). \
Rejecting vsock auth — fix with: chmod 600 {}",
token_path.display(),
mode & 0o7777,
token_path.display()
);
return Ok(false);
}
Ok(true)
}
pub fn token_role() -> CallerRole {
let raw = std::env::var("SYSKNIFE_TOKEN_ROLE").unwrap_or_default();
match raw.trim().to_ascii_lowercase().as_str() {
"observer" => CallerRole::Observer,
"admin" => CallerRole::Admin,
"boot" => CallerRole::Boot,
"dev" | "" => CallerRole::Dev,
other => {
eprintln!(
"[sysknife-daemon] WARNING: unknown SYSKNIFE_TOKEN_ROLE={other:?}; \
failing closed to Observer (read-only)"
);
CallerRole::Observer
}
}
}
pub fn default_token_path() -> std::path::PathBuf {
sysknife_core::config::prefs_path()
.parent()
.unwrap_or_else(|| {
eprintln!(
"[sysknife-daemon] WARNING: prefs_path() has no parent; \
falling back to /tmp for token file — this is a misconfiguration"
);
std::path::Path::new("/tmp")
})
.join("token")
}
#[cfg(test)]
mod tests {
use super::*;
fn secure(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
fn role(groups: &[&str]) -> CallerRole {
highest_role_from_groups(groups.iter().copied())
}
#[test]
fn every_role_maps_back_to_the_group_that_grants_it() {
assert_eq!(group_for_role(CallerRole::Observer), OBSERVER_GROUP);
assert_eq!(group_for_role(CallerRole::Dev), DEV_GROUP);
assert_eq!(group_for_role(CallerRole::Admin), ADMIN_GROUP);
assert_eq!(group_for_role(CallerRole::Boot), BOOT_GROUP);
}
#[test]
fn the_group_a_role_maps_to_actually_grants_that_role() {
for wanted in [
CallerRole::Observer,
CallerRole::Dev,
CallerRole::Admin,
CallerRole::Boot,
] {
let group = group_for_role(wanted);
assert_eq!(
role(&[group]),
wanted,
"joining {group} must grant {wanted:?}"
);
}
}
#[test]
fn a_denial_says_which_group_to_join_and_how() {
let msg = denial_message("AptInstall", CallerRole::Observer, CallerRole::Dev);
assert!(msg.contains("AptInstall"), "names the action: {msg}");
assert!(msg.contains("Observer"), "names the current role: {msg}");
assert!(msg.contains("Dev"), "names the required role: {msg}");
assert!(msg.contains(DEV_GROUP), "names the group: {msg}");
assert!(msg.contains("usermod"), "gives the command: {msg}");
assert!(
msg.contains("log out") || msg.contains("new login"),
"group membership needs a fresh session; say so: {msg}"
);
}
#[test]
fn empty_groups_resolves_to_observer() {
assert_eq!(role(&[]), CallerRole::Observer);
}
#[test]
fn unknown_group_resolves_to_observer() {
assert_eq!(role(&["plugdev", "dialout"]), CallerRole::Observer);
}
#[test]
fn lacs_observer_group_resolves_to_observer() {
assert_eq!(role(&[OBSERVER_GROUP]), CallerRole::Observer);
}
#[test]
fn lacs_dev_group_resolves_to_dev() {
assert_eq!(role(&[DEV_GROUP]), CallerRole::Dev);
}
#[test]
fn lacs_admin_group_resolves_to_admin() {
assert_eq!(role(&[ADMIN_GROUP]), CallerRole::Admin);
}
#[test]
fn wheel_group_resolves_to_admin() {
assert_eq!(role(&[WHEEL_GROUP]), CallerRole::Admin);
}
#[test]
fn lacs_boot_group_resolves_to_boot() {
assert_eq!(role(&[BOOT_GROUP]), CallerRole::Boot);
}
#[test]
fn highest_role_wins_when_multiple_groups_present() {
assert_eq!(role(&[DEV_GROUP, WHEEL_GROUP]), CallerRole::Admin);
}
#[test]
fn boot_role_beats_admin_and_wheel() {
assert_eq!(
role(&[BOOT_GROUP, ADMIN_GROUP, WHEEL_GROUP]),
CallerRole::Boot
);
}
#[test]
fn mixed_known_and_unknown_groups_returns_highest_known() {
assert_eq!(role(&["plugdev", DEV_GROUP, "audio"]), CallerRole::Dev);
}
#[test]
fn valid_token_matches_and_returns_dev_by_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "secret123").unwrap();
secure(&path);
assert_eq!(
validate_token_against_file("secret123", &path),
Some(CallerRole::Dev)
);
}
#[test]
fn token_file_with_trailing_newline_still_matches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "secret123\n").unwrap();
secure(&path);
assert_eq!(
validate_token_against_file("secret123", &path),
Some(CallerRole::Dev)
);
}
#[test]
fn wrong_token_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "correct\n").unwrap();
secure(&path);
assert_eq!(validate_token_against_file("wrong", &path), None);
}
#[test]
fn absent_token_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent");
assert_eq!(validate_token_against_file("any", &path), None);
}
#[test]
fn empty_presented_token_is_always_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "").unwrap();
secure(&path);
assert_eq!(validate_token_against_file("", &path), None);
}
#[test]
fn empty_stored_token_is_rejected_even_with_matching_presented() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "\n").unwrap();
secure(&path);
assert_eq!(validate_token_against_file("", &path), None);
}
#[test]
fn token_compare_rejects_equal_length_wrong_prefix_and_wrong_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("token");
std::fs::write(&path, "abcdefghi").unwrap();
secure(&path);
assert_eq!(
validate_token_against_file("abcdefghi", &path),
Some(CallerRole::Dev)
);
assert_eq!(validate_token_against_file("Xbcdefghi", &path), None);
assert_eq!(validate_token_against_file("abcdefghX", &path), None);
assert_eq!(validate_token_against_file("zzzzzzzzz", &path), None);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_role_env(val: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
match val {
Some(v) => std::env::set_var("SYSKNIFE_TOKEN_ROLE", v),
None => std::env::remove_var("SYSKNIFE_TOKEN_ROLE"),
}
f();
std::env::remove_var("SYSKNIFE_TOKEN_ROLE");
}
#[test]
fn token_role_defaults_to_dev_when_unset() {
with_role_env(None, || assert_eq!(token_role(), CallerRole::Dev));
}
#[test]
fn token_role_explicit_dev() {
with_role_env(Some("dev"), || assert_eq!(token_role(), CallerRole::Dev));
}
#[test]
fn token_role_observer() {
with_role_env(Some("observer"), || {
assert_eq!(token_role(), CallerRole::Observer)
});
}
#[test]
fn token_role_admin() {
with_role_env(Some("admin"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
#[test]
fn token_role_boot() {
with_role_env(Some("boot"), || assert_eq!(token_role(), CallerRole::Boot));
}
#[test]
fn token_role_unknown_value_fails_closed_to_observer() {
with_role_env(Some("superuser"), || {
assert_eq!(token_role(), CallerRole::Observer)
});
}
#[test]
fn token_role_is_case_insensitive() {
with_role_env(Some("ADMIN"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
#[test]
fn token_role_trims_surrounding_whitespace() {
with_role_env(Some(" admin\n"), || {
assert_eq!(token_role(), CallerRole::Admin)
});
}
#[test]
fn every_principal_the_daemon_signs_classifies_back_to_its_own_class() {
for principal in [
CallerPrincipal::Uid(0),
CallerPrincipal::Uid(1000),
CallerPrincipal::Uid(u32::MAX),
CallerPrincipal::VsockToken,
CallerPrincipal::Unattributed,
] {
assert_eq!(
CallerPrincipal::classify(&principal.as_signed_str()),
principal.claim(),
"{principal:?} must read back as the class it declares"
);
}
}
#[test]
fn a_principal_this_binary_cannot_read_is_never_credited_as_an_account() {
for stored in [
"",
" ",
"uid",
"uid:",
"token:",
"none:",
"garbage",
"none:overflow",
"fingerprint:ab12",
":1000",
"uid:notanumber",
"uid:1000:extra",
"uid:007",
"uid:-1",
"uid: 1000",
"uid:1000 ",
"uid:0\n",
"uid:99999999999999999999",
"token:not-vsock",
"token:vsock:extra",
"UID:1000",
] {
assert_eq!(
CallerPrincipal::classify(stored),
PrincipalClaim::Unrecognized,
"{stored:?} must not be read as naming an account"
);
}
}
#[test]
fn every_principal_renders_as_a_non_empty_known_scheme() {
const SCHEMES: [&str; 3] = ["uid", "token", "none"];
for principal in [
CallerPrincipal::Uid(0),
CallerPrincipal::Uid(1000),
CallerPrincipal::Uid(u32::MAX),
CallerPrincipal::VsockToken,
CallerPrincipal::Unattributed,
] {
let expected_scheme = match principal {
CallerPrincipal::Uid(_) => "uid",
CallerPrincipal::VsockToken => "token",
CallerPrincipal::Unattributed => "none",
};
let signed = principal.as_signed_str();
assert!(
!signed.is_empty(),
"{principal:?} renders empty, which a v3 row reports as tampering"
);
assert_eq!(principal.scheme(), expected_scheme);
let (scheme, value) = signed
.split_once(':')
.unwrap_or_else(|| panic!("{principal:?} rendered {signed:?} with no scheme"));
assert_eq!(scheme, expected_scheme);
assert!(
!value.is_empty(),
"{principal:?} rendered {signed:?} with an empty value"
);
assert!(SCHEMES.contains(&scheme), "unknown scheme in {signed:?}");
}
}
#[test]
fn principal_renderings_are_frozen() {
assert_eq!(CallerPrincipal::Uid(0).as_signed_str(), "uid:0");
assert_eq!(CallerPrincipal::Uid(1000).as_signed_str(), "uid:1000");
assert_eq!(
CallerPrincipal::Uid(u32::MAX).as_signed_str(),
"uid:4294967295"
);
assert_eq!(CallerPrincipal::VsockToken.as_signed_str(), "token:vsock");
assert_eq!(
CallerPrincipal::Unattributed.as_signed_str(),
"none:unattributed"
);
}
#[test]
fn no_scheme_is_a_prefix_of_another() {
let schemes = ["uid", "token", "none"];
for a in schemes {
for b in schemes {
if a != b {
assert!(
!a.starts_with(b) && !b.starts_with(a),
"scheme {a:?} and {b:?} are prefix-ambiguous"
);
}
}
}
}
#[test]
fn an_unattributed_caller_is_never_privileged() {
let caller = CallerAttribution::unattributed();
assert_eq!(caller.role(), CallerRole::Observer);
assert_eq!(caller.principal(), CallerPrincipal::Unattributed);
}
#[test]
fn transport_constructors_pick_the_matching_evidence_class() {
assert_eq!(
CallerAttribution::from_peer_uid(4242, CallerRole::Admin).principal(),
CallerPrincipal::Uid(4242)
);
assert_eq!(
CallerAttribution::from_vsock_token(CallerRole::Dev).principal(),
CallerPrincipal::VsockToken
);
}
}