pub use filament_cap::capability::*;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
type CachedStore = (std::path::PathBuf, u128, Vec<Value>); static CAP_CACHE: OnceLock<Mutex<Option<CachedStore>>> = OnceLock::new();
fn cache_init() -> &'static Mutex<Option<CachedStore>> {
CAP_CACHE.get_or_init(|| Mutex::new(None))
}
pub fn load_cap_store(config_dir: &std::path::Path) -> Vec<Value> {
let p = config_dir.join("caps.json");
let mtime = std::fs::metadata(&p)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos());
if let Some(mtime_val) = mtime {
let cache = cache_init().lock().unwrap();
if let Some((cached_path, cached_mtime, cached_store)) = cache.as_ref() {
if cached_path == &p && *cached_mtime == mtime_val {
return cached_store.clone();
}
}
}
let store = std::fs::read_to_string(&p)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
if let Some(mtime_val) = mtime {
let mut cache = cache_init().lock().unwrap();
*cache = Some((p, mtime_val, store.clone()));
}
store
}
pub(crate) fn save_cap_store(config_dir: &std::path::Path, store: &[Value]) -> std::io::Result<()> {
let p = config_dir.join("caps.json");
let data = serde_json::to_string_pretty(&serde_json::json!(store))
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::platform::SecretFile::write_str(&p, &data)?;
let cache = cache_init();
if let Ok(mut c) = cache.lock() {
*c = None;
}
Ok(())
}
pub fn invalidate_cap_cache() {
if let Ok(mut c) = cache_init().lock() {
*c = None;
}
}
pub fn save_and_list_revoked(
store: &[Value],
config_dir: &std::path::Path,
) -> std::io::Result<Vec<String>> {
save_cap_store(config_dir, store)?;
Ok(devices_with_shell_revoked(config_dir))
}
pub fn cap_authoritative() -> bool {
std::env::var("FILAMENT_CAP_AUTHORITATIVE")
.map(|x| x == "1" || x.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
static LA_AUTHORIZED: AtomicU64 = AtomicU64::new(0);
static LA_DENIED: AtomicU64 = AtomicU64::new(0);
static LA_NO_HEADER: AtomicU64 = AtomicU64::new(0);
static LD_AUTHORIZED: AtomicU64 = AtomicU64::new(0);
static LD_DENIED: AtomicU64 = AtomicU64::new(0);
static LD_NO_HEADER: AtomicU64 = AtomicU64::new(0);
static CEILING_DENIED: AtomicU64 = AtomicU64::new(0);
static PA_CEILING_DENIED: OnceLock<ActionCounters> = OnceLock::new();
type ActionCounters = Mutex<HashMap<String, AtomicU64>>;
static PA_LA_AUTHORIZED: OnceLock<ActionCounters> = OnceLock::new();
static PA_LA_DENIED: OnceLock<ActionCounters> = OnceLock::new();
static PA_LA_NO_HEADER: OnceLock<ActionCounters> = OnceLock::new();
static PA_LD_AUTHORIZED: OnceLock<ActionCounters> = OnceLock::new();
static PA_LD_DENIED: OnceLock<ActionCounters> = OnceLock::new();
static PA_LD_NO_HEADER: OnceLock<ActionCounters> = OnceLock::new();
#[allow(dead_code)]
fn pa_get(map: &ActionCounters, action: &str) -> u64 {
map.lock().unwrap().get(action).map(|a| a.load(Ordering::Relaxed)).unwrap_or(0)
}
fn pa_inc(map: &ActionCounters, action: &str) {
map.lock().unwrap()
.entry(action.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(1, Ordering::Relaxed);
}
pub fn cap_action_counts() -> Vec<ActionCounts> {
let mut actions: HashMap<String, ActionCounts> = HashMap::new();
let maps: [(&ActionCounters, fn(&mut ActionCounts, &str, u64)); 6] = [
(PA_LA_AUTHORIZED.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.la_authorized += val),
(PA_LA_DENIED.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.la_denied += val),
(PA_LA_NO_HEADER.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.la_no_header += val),
(PA_LD_AUTHORIZED.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.ld_authorized += val),
(PA_LD_DENIED.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.ld_denied += val),
(PA_LD_NO_HEADER.get_or_init(|| Mutex::new(HashMap::new())), |a, _action, val| a.ld_no_header += val),
];
for (map, setter) in maps {
for (action, val) in map.lock().unwrap().iter() {
let entry = actions.entry(action.clone()).or_insert_with(|| ActionCounts {
action: action.clone(),
la_authorized: 0, la_denied: 0, la_no_header: 0,
ld_authorized: 0, ld_denied: 0, ld_no_header: 0,
});
setter(entry, action, val.load(Ordering::Relaxed));
}
}
let mut v: Vec<_> = actions.into_values().collect();
v.sort_by(|a, b| a.action.cmp(&b.action));
v
}
pub fn cap_shadow_counts() -> ShadowCounts {
ShadowCounts {
la_authorized: LA_AUTHORIZED.load(Ordering::Relaxed),
la_denied: LA_DENIED.load(Ordering::Relaxed),
la_no_header: LA_NO_HEADER.load(Ordering::Relaxed),
ld_authorized: LD_AUTHORIZED.load(Ordering::Relaxed),
ld_denied: LD_DENIED.load(Ordering::Relaxed),
ld_no_header: LD_NO_HEADER.load(Ordering::Relaxed),
ceiling_denied: CEILING_DENIED.load(Ordering::Relaxed),
}
}
fn log_once(key: String, msg: &str) {
const LOG_ONCE_CAP: usize = 4096;
static SEEN: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
let seen = SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
if let Ok(mut g) = seen.lock() {
if g.contains(&key) || g.len() >= LOG_ONCE_CAP {
return;
}
g.insert(key);
eprintln!("{msg}");
}
}
pub fn cap_authorize(
config_dir: &std::path::Path,
resource: &str,
action: &str,
principal_device_pub: Option<&[u8; 32]>,
principal_user_pub: Option<&[u8; 32]>,
auth_key_caps: Option<&[String]>,
) -> CapOutcome {
let store = load_cap_store(config_dir);
let header = store
.iter()
.find(|e| {
e.get("type").and_then(|v| v.as_str()) == Some("cap_header")
&& e["resource"].as_str() == Some(resource)
})
.and_then(CapHeader::from_json);
let device_pub = principal_device_pub.copied().unwrap_or([0u8; 32]);
let user_pub = principal_user_pub.copied().unwrap_or([0u8; 32]);
match header {
None => CapOutcome::Unprovisioned,
Some(hdr) => match evaluate(
&store, &hdr, &device_pub, &user_pub, resource, action, now_secs(), auth_key_caps,
) {
Decision::Authorized => CapOutcome::Authorized,
Decision::Denied(reason) => CapOutcome::Denied(reason),
},
}
}
pub fn cap_fleet_inputs(
config_dir: &std::path::Path,
resource: &str,
action: &str,
principal_device_pub: Option<&[u8; 32]>,
principal_user_pub: Option<&[u8; 32]>,
auth_key_caps: Option<&[String]>,
) -> (Option<[u8; 32]>, bool) {
let store = load_cap_store(config_dir);
let header = store
.iter()
.find(|e| {
e.get("type").and_then(|v| v.as_str()) == Some("cap_header")
&& e["resource"].as_str() == Some(resource)
})
.and_then(CapHeader::from_json);
let Some(hdr) = header else {
return (None, false);
};
let device_pub = principal_device_pub.copied().unwrap_or([0u8; 32]);
let user_pub = principal_user_pub.copied().unwrap_or([0u8; 32]);
let explicit = matches!(
evaluate_grants_only(
&store, &hdr, &device_pub, &user_pub, resource, action, now_secs(), auth_key_caps,
),
Decision::Authorized
);
(Some(hdr.owner_pub), explicit)
}
#[allow(clippy::too_many_arguments)]
pub fn cap_gate_effective(
legacy_allowed: bool,
outcome: &CapOutcome,
action: &str,
resource: &str,
device_pub: Option<&[u8; 32]>,
user_pub: Option<&[u8; 32]>,
binding: BindingStrength,
cert_expires: Option<u64>,
auth_key_caps: Option<&[String]>,
own_user_pub: Option<&[u8; 32]>,
scoped_in_bounds: bool,
has_explicit_grant: bool,
) -> GateDecision {
let authoritative = cap_authoritative();
if let Some(caps) = auth_key_caps {
let action_lc = action.to_lowercase();
if !caps.iter().any(|c| c.to_lowercase() == action_lc) {
CEILING_DENIED.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_CEILING_DENIED.get_or_init(|| Mutex::new(HashMap::new())), action);
return GateDecision::Deny { cap_reason: Some("not in auth key caps".into()) };
}
}
let peer_user = user_pub.copied().unwrap_or([0u8; 32]);
let same_owner = own_user_pub.map_or(false, |o| o == &peer_user) && peer_user != [0u8; 32];
let fleet_ok = fleet_auto_trust(same_owner, binding, scoped_in_bounds);
if same_owner && !fleet_ok && has_explicit_grant && binding != BindingStrength::Proven {
let dev = device_pub.copied().unwrap_or([0u8; 32]);
log_once(
format!("infgrant|{action}|{}", hex::encode(dev)),
&format!(
"CAP-INFERRED-GRANT: fleet device dev={} authorized '{action}' via an explicit grant on a {binding:?} (non-Proven) binding. Fleet auto-trust requires Proven; explicit grants are still honored on Inferred pending universal identity-expose. Measured so Proven can be required for grants without silently revoking established devices.",
hex::encode(dev),
),
);
}
let base_outcome = if same_owner {
if fleet_ok || has_explicit_grant {
CapOutcome::Authorized
} else {
CapOutcome::Denied(
"fleet device: action needs an explicit grant (deliberate tier or out of scope)".into(),
)
}
} else {
outcome.clone()
};
let outcome = &cap_authorize_proven(
&base_outcome, binding, authoritative,
);
let outcome = &cap_authorize_expired(
outcome, cert_expires, authoritative,
);
let fleet_allow = fleet_ok
&& matches!(
cap_authorize_expired(&CapOutcome::Authorized, cert_expires, true),
CapOutcome::Authorized
);
match (legacy_allowed, outcome) {
(true, CapOutcome::Authorized) => {
LA_AUTHORIZED.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LA_AUTHORIZED.get_or_init(|| Mutex::new(HashMap::new())), action);
}
(true, CapOutcome::Denied(_)) => {
LA_DENIED.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LA_DENIED.get_or_init(|| Mutex::new(HashMap::new())), action);
}
(true, CapOutcome::Unprovisioned) => {
LA_NO_HEADER.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LA_NO_HEADER.get_or_init(|| Mutex::new(HashMap::new())), action);
}
(false, CapOutcome::Authorized) => {
LD_AUTHORIZED.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LD_AUTHORIZED.get_or_init(|| Mutex::new(HashMap::new())), action);
}
(false, CapOutcome::Denied(_)) => {
LD_DENIED.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LD_DENIED.get_or_init(|| Mutex::new(HashMap::new())), action);
}
(false, CapOutcome::Unprovisioned) => {
LD_NO_HEADER.fetch_add(1, Ordering::Relaxed);
pa_inc(PA_LD_NO_HEADER.get_or_init(|| Mutex::new(HashMap::new())), action);
}
}
let effective = if fleet_allow {
true
} else if authoritative {
matches!(outcome, CapOutcome::Authorized)
} else {
legacy_allowed
};
let dev = device_pub.copied().unwrap_or([0u8; 32]);
let usr = user_pub.copied().unwrap_or([0u8; 32]);
if !authoritative {
match (legacy_allowed, outcome) {
(true, CapOutcome::Denied(reason)) => {
eprintln!(
"CAP-SHADOW CRITICAL: a header EXISTS and DENIES '{action}' on '{resource}' for dev={} user={} that legacy ALLOWED (reason: {reason}); a flip would BREAK this open [{}]",
hex::encode(dev),
hex::encode(usr),
cap_shadow_counts().summary(),
);
}
(true, CapOutcome::Unprovisioned) => log_once(
format!("nh|{resource}"),
&format!(
"CAP-SHADOW [unprovisioned]: resource '{resource}' has no capability header; opens rely on legacy authz. Expected until you provision (filament grant/init); not a disagreement."
),
),
(false, CapOutcome::Authorized) => log_once(
format!("wd|{action}|{resource}|{}", hex::encode(dev)),
&format!(
"CAP-SHADOW WIDENING: '{action}' on '{resource}' for dev={} user={} was REFUSED by legacy but cap AUTHORIZES; a flip will NEWLY PERMIT this open. Enumerate it in the flip decision.",
hex::encode(dev),
hex::encode(usr),
),
),
_ => {}
}
} else {
}
if effective {
GateDecision::Allow
} else if authoritative {
let reason = match outcome {
CapOutcome::Denied(r) => r.clone(),
CapOutcome::Unprovisioned => "resource unprovisioned (no capability header); run filament grant/init".to_string(),
CapOutcome::Authorized => String::new(),
};
GateDecision::Deny { cap_reason: Some(reason) }
} else {
GateDecision::Deny { cap_reason: None }
}
}
pub fn devices_with_shell_revoked(config_dir: &std::path::Path) -> Vec<String> {
let cap_store = load_cap_store(config_dir);
let devices_path = config_dir.join("devices.json");
let devices: Vec<Value> = std::fs::read_to_string(&devices_path)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
let header = cap_store
.iter()
.find(|e| {
e.get("type").and_then(|v| v.as_str()) == Some("cap_header")
&& e["resource"].as_str() == Some("self")
})
.and_then(CapHeader::from_json);
let Some(hdr) = header else {
return Vec::new(); };
let now = now_secs();
let mut revoked = Vec::new();
for entry in &devices {
let name = entry["name"].as_str().unwrap_or("");
if name.is_empty() {
continue;
}
let cert_json = match entry.get("deviceCert") {
Some(cj) => cj,
None => continue,
};
let Some(cert) = crate::identity::DeviceCert::from_json(cert_json) else {
continue;
};
let d = evaluate_grants_only(
&cap_store,
&hdr,
&cert.device_pub,
&cert.user_pub,
&hdr.resource,
"shell",
now,
None,
);
if matches!(d, Decision::Denied(_)) {
revoked.push(name.to_string());
}
}
revoked
}
pub fn reconcile_shell_keys(revoked: &[String], ak_content: &str, authoritative: bool) -> String {
if !authoritative {
return ak_content.to_string();
}
let mut content = ak_content.to_string();
for device in revoked {
content = crate::sshkeys::strip_block(&content, device);
}
content
}
#[cfg(test)]
mod tests {
use super::*;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
fn make_owner() -> Ed25519KeyPair {
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
}
fn owner_pub(keypair: &Ed25519KeyPair) -> [u8; 32] {
let mut buf = [0u8; 32];
buf.copy_from_slice(keypair.public_key().as_ref());
buf
}
fn make_grant(
owner: &Ed25519KeyPair,
target: CapTarget,
resource: &str,
permissions: &[&str],
version: u64,
ttl_secs: u64,
) -> CapOp {
let grantor = owner_pub(owner);
let issued_at = now_secs();
let mut op = CapOp {
op: CapOpKind::Grant,
grantor,
target_kind: target.kind_byte(),
target: target.target_bytes(),
resource: resource.to_string(),
permissions: permissions.iter().map(|s| s.to_string()).collect(),
expires: issued_at.saturating_add(ttl_secs),
issued_at,
version,
sig: [0u8; 64],
};
op.sig = sign_cap_op(&op, owner);
op
}
fn make_revoke(
owner: &Ed25519KeyPair,
target: CapTarget,
resource: &str,
version: u64,
ttl_secs: u64,
) -> CapOp {
let grantor = owner_pub(owner);
let issued_at = now_secs();
let mut op = CapOp {
op: CapOpKind::Revoke,
grantor,
target_kind: target.kind_byte(),
target: target.target_bytes(),
resource: resource.to_string(),
permissions: vec![],
expires: issued_at.saturating_add(ttl_secs),
issued_at,
version,
sig: [0u8; 64],
};
op.sig = sign_cap_op(&op, owner);
op
}
fn make_self_header(pk: &[u8; 32], resource: &str, nonce: &[u8; 32], owner: &Ed25519KeyPair) -> CapHeader {
let mut h = CapHeader {
resource: resource.to_string(), epoch: 0, owner_pub: *pk,
nonce: *nonce, floors: vec![],
issued_at: now_secs(), prev_owner_pub: None, prev_header_hash: None, sig: [0u8; 64],
};
h.sig = sign_cap_header(&h, owner);
CapHeader { resource: "self".to_string(), ..h }
}
fn temp_dir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("filament-cap-test-{}-{}", std::process::id(), name))
}
#[test]
fn cap_authorize_no_header_is_unprovisioned() {
let tmp = std::env::temp_dir().join(format!("fil-cap-{}", std::process::id()));
std::fs::create_dir_all(&tmp).ok();
let d = cap_authorize(&tmp, "self", "shell", Some(&[0xcc; 32]), Some(&[0xaa; 32]), None);
match d {
CapOutcome::Unprovisioned => {}
other => panic!("no-header must be Unprovisioned, got {other:?}"),
}
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn delegated_ceiling_gates_before_owner_shortcut() {
let owner = make_owner();
let owner_pub = owner_pub(&owner);
let mut nonce = [0u8; 32];
let rng = ring::rand::SystemRandom::new();
ring::rand::SecureRandom::fill(&rng, &mut nonce).unwrap();
let header = make_self_header(&owner_pub, "self", &nonce, &owner);
let tmp = std::env::temp_dir().join(format!("fil-authcap-{}", std::process::id()));
std::fs::create_dir_all(&tmp).ok();
save_cap_store(&tmp, &[header.to_json()]).unwrap();
let ak_caps = vec!["transfer".to_string()];
let r1 = cap_authorize(&tmp, "self", "transfer", Some(&[0xCC; 32]), Some(&owner_pub), Some(&ak_caps));
assert_eq!(r1, CapOutcome::Authorized,
"transfer in auth_key_caps must authorize (ceiling passed)");
let r2 = cap_authorize(&tmp, "self", "shell", Some(&[0xCC; 32]), Some(&owner_pub), Some(&ak_caps));
assert!(matches!(r2, CapOutcome::Denied(_)),
"shell not in auth_key_caps must be denied (ceiling enforced)");
let r3 = cap_authorize(&tmp, "self", "mount", Some(&[0xCC; 32]), Some(&owner_pub), Some(&ak_caps));
assert!(matches!(r3, CapOutcome::Denied(_)),
"mount not in auth_key_caps must be denied (ceiling enforced)");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn per_action_counters_bucket_correctly() {
let uk = [0xaa; 32];
for action in ["shell", "mount"] {
cap_gate_effective(true, &CapOutcome::Unprovisioned, action, "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
}
cap_gate_effective(true, &CapOutcome::Denied("test".into()), "transfer", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
cap_gate_effective(false, &CapOutcome::Authorized, "mount", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let ac = cap_action_counts();
let global = cap_shadow_counts();
let total_pa_la: u64 = ac.iter().map(|a| a.la_authorized).sum();
let total_pa_ld: u64 = ac.iter().map(|a| a.la_denied).sum();
let total_pa_ln: u64 = ac.iter().map(|a| a.la_no_header).sum();
let total_pa_wa: u64 = ac.iter().map(|a| a.ld_authorized).sum();
let total_pa_wd: u64 = ac.iter().map(|a| a.ld_denied).sum();
let total_pa_wn: u64 = ac.iter().map(|a| a.ld_no_header).sum();
assert!(total_pa_la <= global.la_authorized,
"per-action la_authorized sum ({total_pa_la}) <= global ({})", global.la_authorized);
assert!(total_pa_ld <= global.la_denied);
assert!(total_pa_ln <= global.la_no_header);
assert!(total_pa_wa <= global.ld_authorized);
assert!(total_pa_wd <= global.ld_denied);
assert!(total_pa_wn <= global.ld_no_header);
let find = |action: &str, expected: bool| {
for a in &ac {
if a.action == action { return true; }
}
if expected { panic!("action '{action}' must appear in per-action counters"); }
false
};
assert!(find("shell", true));
assert!(find("mount", true));
assert!(find("transfer", true));
}
#[test]
fn shadow_detector_proof_six_buckets() {
let uk = [0xaa; 32];
fn snap() -> [u64; 6] {
[
LA_AUTHORIZED.load(Ordering::Relaxed),
LA_DENIED.load(Ordering::Relaxed),
LA_NO_HEADER.load(Ordering::Relaxed),
LD_AUTHORIZED.load(Ordering::Relaxed),
LD_DENIED.load(Ordering::Relaxed),
LD_NO_HEADER.load(Ordering::Relaxed),
]
}
let before = snap();
cap_gate_effective(true, &CapOutcome::Authorized, "shell", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 1, "LA_AUTHORIZED must increment");
assert_eq!(after[1] - before[1], 0);
assert_eq!(after[2] - before[2], 0);
assert_eq!(after[3] - before[3], 0);
assert_eq!(after[4] - before[4], 0);
assert_eq!(after[5] - before[5], 0);
let before = snap();
cap_gate_effective(true, &CapOutcome::Denied("test".into()), "mount", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 0);
assert_eq!(after[1] - before[1], 1, "LA_DENIED must increment");
assert_eq!(after[2] - before[2], 0);
assert_eq!(after[3] - before[3], 0);
assert_eq!(after[4] - before[4], 0);
assert_eq!(after[5] - before[5], 0);
let before = snap();
cap_gate_effective(true, &CapOutcome::Unprovisioned, "transfer", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 0);
assert_eq!(after[1] - before[1], 0);
assert_eq!(after[2] - before[2], 1, "LA_NO_HEADER must increment");
assert_eq!(after[3] - before[3], 0);
assert_eq!(after[4] - before[4], 0);
assert_eq!(after[5] - before[5], 0);
let before = snap();
cap_gate_effective(false, &CapOutcome::Authorized, "shell", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 0);
assert_eq!(after[1] - before[1], 0);
assert_eq!(after[2] - before[2], 0);
assert_eq!(after[3] - before[3], 1, "LD_AUTHORIZED must increment");
assert_eq!(after[4] - before[4], 0);
assert_eq!(after[5] - before[5], 0);
let before = snap();
cap_gate_effective(false, &CapOutcome::Denied("test".into()), "mount", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 0);
assert_eq!(after[1] - before[1], 0);
assert_eq!(after[2] - before[2], 0);
assert_eq!(after[3] - before[3], 0);
assert_eq!(after[4] - before[4], 1, "LD_DENIED must increment");
assert_eq!(after[5] - before[5], 0);
let before = snap();
cap_gate_effective(false, &CapOutcome::Unprovisioned, "transfer", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 0);
assert_eq!(after[1] - before[1], 0);
assert_eq!(after[2] - before[2], 0);
assert_eq!(after[3] - before[3], 0);
assert_eq!(after[4] - before[4], 0);
assert_eq!(after[5] - before[5], 1, "LD_NO_HEADER must increment");
let before = snap();
cap_gate_effective(true, &CapOutcome::Authorized, "shell", "self", None, Some(&uk), BindingStrength::Proven, Some(u64::MAX), None, None, false, false);
let after = snap();
assert_eq!(after[0] - before[0], 1, "second call same bucket must increment");
assert_eq!(after[1] - before[1], 0);
}
#[test]
fn shell_revoke_returns_device_in_revoked_list() {
let owner = make_owner();
let nonce = self_resource_nonce();
let pk = owner_pub(&owner);
let resource = self_resource_id(&pk);
let target = CapTarget::Device([0xcc; 32]);
let mut hdr = CapHeader {
resource: resource.clone(),
epoch: 0,
owner_pub: pk,
nonce,
floors: vec![],
issued_at: now_secs(),
prev_owner_pub: None,
prev_header_hash: None,
sig: [0u8; 64],
};
hdr.sig = sign_cap_header(&hdr, &owner);
let store_hdr = CapHeader {
resource: "self".to_string(),
..hdr.clone()
};
let v1 = hlc_next(0, now_ms());
let grant = make_grant(&owner, target, "self", &["shell"], v1, 86400);
let mut store = vec![];
let mut hdr_json = hdr.to_json();
hdr_json["resource"] = serde_json::json!("self");
store.push(hdr_json);
apply_cap_op(&mut store, &store_hdr, &grant, now_secs()).unwrap();
let bob_user_pub = [0xaa; 32];
let cert_json = serde_json::json!({
"devicePub": hex::encode([0xcc; 32]),
"userPub": hex::encode(bob_user_pub),
"expires": now_secs() + 90 * 24 * 3600,
"issued": now_secs(),
"sig": hex::encode([0u8; 64]),
});
let devices = vec![serde_json::json!({
"name": "bob",
"secret": "aa".repeat(32),
"v": 2,
"caps": ["transfer"],
"deviceCert": cert_json,
"userKey": hex::encode(bob_user_pub),
})];
let tmp = std::env::temp_dir().join(format!("fil-recon-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store)).unwrap()).unwrap();
std::fs::write(&tmp.join("devices.json"), serde_json::to_string(&serde_json::json!(devices)).unwrap()).unwrap();
let before = devices_with_shell_revoked(&tmp);
assert!(before.is_empty(), "before revoke, device must not be in revoked list");
let v2 = hlc_next(v1, now_ms());
let revoke = make_revoke(&owner, target, "self", v2, 86400);
apply_cap_op(&mut store, &store_hdr, &revoke, now_secs()).unwrap();
std::fs::write(&tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store)).unwrap()).unwrap();
let after = devices_with_shell_revoked(&tmp);
assert_eq!(after, vec!["bob".to_string()],
"device must appear in revoked list after shell revoke (authorized_keys block still exists — reconciler needed)");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn shell_revoke_e2e_block_removed_after_reconcile() {
let owner = make_owner();
let nonce = self_resource_nonce();
let pk = owner_pub(&owner);
let resource = self_resource_id(&pk);
let target = CapTarget::Device([0xcc; 32]);
let bob_user_pub = [0xaa; 32];
let mut hdr = CapHeader {
resource: resource.clone(),
epoch: 0,
owner_pub: pk,
nonce,
floors: vec![],
issued_at: now_secs(),
prev_owner_pub: None,
prev_header_hash: None,
sig: [0u8; 64],
};
hdr.sig = sign_cap_header(&hdr, &owner);
let store_hdr = CapHeader { resource: "self".to_string(), ..hdr.clone() };
let v1 = hlc_next(0, now_ms());
let grant = make_grant(&owner, target, "self", &["shell"], v1, 86400);
let v2 = hlc_next(v1, now_ms());
let revoke = make_revoke(&owner, target, "self", v2, 86400);
let cert_json = serde_json::json!({
"devicePub": hex::encode([0xcc; 32]),
"userPub": hex::encode(bob_user_pub),
"expires": now_secs() + 90 * 24 * 3600,
"issued": now_secs(),
"sig": hex::encode([0u8; 64]),
});
let tmp = std::env::temp_dir().join(format!("fil-recon2-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let mut store = vec![];
let mut hdr_json = hdr.to_json();
hdr_json["resource"] = serde_json::json!("self");
store.push(hdr_json);
apply_cap_op(&mut store, &store_hdr, &grant, now_secs()).unwrap();
std::fs::write(&tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store)).unwrap()).unwrap();
let devices = vec![serde_json::json!({
"name": "bob",
"secret": "aa".repeat(32),
"v": 2,
"caps": ["transfer"],
"deviceCert": cert_json,
"userKey": hex::encode(bob_user_pub),
})];
std::fs::write(&tmp.join("devices.json"), serde_json::to_string(&serde_json::json!(devices)).unwrap()).unwrap();
let mock_ak = format!(
"# BEGIN filament-managed bob\nssh-ed25519 AAAAfake filament-managed\n# END filament-managed bob\n"
);
assert!(crate::sshkeys::has_block(&mock_ak, "bob"));
apply_cap_op(&mut store, &store_hdr, &revoke, now_secs()).unwrap();
std::fs::write(&tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store)).unwrap()).unwrap();
let revoked = devices_with_shell_revoked(&tmp);
assert!(revoked.contains(&"bob".to_string()),
"bob must appear in revoked list after shell revoke");
let mut cleaned = mock_ak.clone();
for device in &revoked {
cleaned = crate::sshkeys::strip_block(&cleaned, device);
}
assert!(!crate::sshkeys::has_block(&cleaned, "bob"),
"e2e: after reconcile, NO filament-managed authorized_keys block must remain for bob");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn reconcile_shell_keys_gated_on_authoritative() {
let mock_ak = "# BEGIN filament-managed bob\nssh-ed25519 AAAAfake filament-managed\n# END filament-managed bob\n";
let revoked = vec!["bob".to_string()];
let out_auth = reconcile_shell_keys(&revoked, mock_ak, true);
assert!(!crate::sshkeys::has_block(&out_auth, "bob"),
"authoritative: block must be removed");
let out_shadow = reconcile_shell_keys(&revoked, mock_ak, false);
assert!(crate::sshkeys::has_block(&out_shadow, "bob"),
"shadow: block must NOT be removed — report-only");
}
#[test]
fn fleet_device_shell_key_tracks_grant_not_owner_shortcut() {
let owner = make_owner();
let nonce = self_resource_nonce();
let pk = owner_pub(&owner);
let resource = self_resource_id(&pk);
let device_pub = [0xcc; 32];
let target = CapTarget::Device(device_pub);
let mut hdr = CapHeader {
resource: resource.clone(), epoch: 0, owner_pub: pk, nonce, floors: vec![],
issued_at: now_secs(), prev_owner_pub: None, prev_header_hash: None, sig: [0u8; 64],
};
hdr.sig = sign_cap_header(&hdr, &owner);
let store_hdr = CapHeader { resource: "self".to_string(), ..hdr.clone() };
let cert_json = serde_json::json!({
"devicePub": hex::encode(device_pub),
"userPub": hex::encode(pk),
"expires": now_secs() + 90 * 24 * 3600,
"issued": now_secs(),
"sig": hex::encode([0u8; 64]),
});
let devices = vec![serde_json::json!({
"name": "my-laptop",
"secret": "aa".repeat(32),
"v": 2,
"caps": ["transfer"],
"deviceCert": cert_json,
"userKey": hex::encode(pk),
})];
let tmp = std::env::temp_dir().join(format!("fil-fleet-recon-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("devices.json"), serde_json::to_string(&serde_json::json!(devices)).unwrap()).unwrap();
let mut hdr_json = hdr.to_json();
hdr_json["resource"] = serde_json::json!("self");
let store_nogrant = vec![hdr_json.clone()];
std::fs::write(tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store_nogrant)).unwrap()).unwrap();
invalidate_cap_cache();
let revoked = devices_with_shell_revoked(&tmp);
assert!(revoked.contains(&"my-laptop".to_string()),
"same-owner fleet device with NO shell grant MUST be in the revoked set (owner shortcut must NOT exempt it)");
let v1 = hlc_next(0, now_ms());
let grant = make_grant(&owner, target, "self", &["shell"], v1, 86400);
let mut store_grant = vec![hdr_json];
apply_cap_op(&mut store_grant, &store_hdr, &grant, now_secs()).unwrap();
std::fs::write(tmp.join("caps.json"), serde_json::to_string(&serde_json::json!(store_grant)).unwrap()).unwrap();
invalidate_cap_cache();
let revoked2 = devices_with_shell_revoked(&tmp);
assert!(!revoked2.contains(&"my-laptop".to_string()),
"same-owner fleet device WITH an explicit shell grant must NOT be revoked");
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn cache_matches_uncached_load() {
let dir = temp_dir("cache-match");
let store = vec![serde_json::json!({"op":"grant","perms":["shell"]})];
save_cap_store(&dir, &store).unwrap();
let loaded = load_cap_store(&dir);
let cached = load_cap_store(&dir);
assert_eq!(loaded, cached, "cached load must match uncached");
}
#[test]
fn cache_invalidated_on_save() {
let dir = temp_dir("cache-inval");
let store = vec![serde_json::json!({"perm":"shell"})];
save_cap_store(&dir, &store).unwrap();
assert_eq!(load_cap_store(&dir).len(), 1);
let updated = vec![serde_json::json!({"perm":"shell"}), serde_json::json!({"perm":"deploy"})];
save_cap_store(&dir, &updated).unwrap();
let loaded = load_cap_store(&dir);
assert_eq!(loaded.len(), 2, "cache must be invalidated after save, reflecting new grant");
}
#[test]
fn fleet_gate_allows_scoped_default_proven_in_scope_without_grant() {
let me = [0x11u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("no grant".into()), "transfer", "self",
None, Some(&me), BindingStrength::Proven, Some(u64::MAX), None,
Some(&me), true, false,
);
assert!(d.allowed(), "same-owner Proven in-scope must ALLOW without a grant");
}
#[test]
fn fleet_gate_denies_deliberate_without_grant() {
let me = [0x22u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("x".into()), "shell", "self",
None, Some(&me), BindingStrength::Proven, Some(u64::MAX), None,
Some(&me), false, false,
);
assert!(!d.allowed(), "same-owner Proven deliberate action without grant must DENY");
}
#[test]
fn fleet_gate_denies_inferred_binding() {
let me = [0x33u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("x".into()), "transfer", "self",
None, Some(&me), BindingStrength::Inferred, Some(u64::MAX), None,
Some(&me), true, false,
);
assert!(!d.allowed(), "same-owner INFERRED must get nothing, even in-scope");
}
#[test]
fn fleet_gate_denies_out_of_scope() {
let me = [0x44u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("x".into()), "mount", "self",
None, Some(&me), BindingStrength::Proven, Some(u64::MAX), None,
Some(&me), false, false,
);
assert!(!d.allowed(), "same-owner Proven out-of-scope must DENY");
}
#[test]
fn fleet_gate_denies_different_owner() {
let me = [0x55u8; 32];
let other = [0x66u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("x".into()), "transfer", "self",
None, Some(&other), BindingStrength::Proven, Some(u64::MAX), None,
Some(&me), true, false,
);
assert!(!d.allowed(), "different-owner peer must be denied by default");
}
#[test]
fn fleet_gate_expired_cert_denies_even_in_scope() {
let me = [0x77u8; 32];
let d = cap_gate_effective(
false, &CapOutcome::Denied("x".into()), "transfer", "self",
None, Some(&me), BindingStrength::Proven, Some(0), None,
Some(&me), true, false,
);
assert!(!d.allowed(), "expired cert must deny fleet auto-trust even in-scope");
}
}