use std::collections::BTreeMap;
use std::path::PathBuf;
use ikigai_core::Capability;
pub fn default_principal(host: &str, fingerprint: &str) -> String {
format!("urn:iki:host:{host}:client:{}", normalize(fingerprint))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Authority {
pub grant: String,
pub capability: Capability,
pub principal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry {
grant: String,
principal: Option<String>,
}
pub fn clients_path() -> Option<PathBuf> {
std::env::var_os("IKIGAI_CLIENTS")
.map(PathBuf::from)
.or_else(|| crate::config::config_home().map(|dir| dir.join("clients.json")))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Enrolment {
clients: BTreeMap<String, Entry>,
default_grant: Option<String>,
}
impl Enrolment {
pub fn len(&self) -> usize {
self.clients.len()
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
pub fn default_grant(&self) -> Option<&str> {
self.default_grant.as_deref()
}
pub fn grant_for(&self, fingerprint: &str) -> Option<&str> {
self.clients
.get(&normalize(fingerprint))
.map(|entry| entry.grant.as_str())
.or_else(|| self.default_grant())
}
pub fn principal_for(&self, fingerprint: &str) -> Option<&str> {
self.clients
.get(&normalize(fingerprint))
.and_then(|entry| entry.principal.as_deref())
}
pub fn key_for(&self, named: &str) -> Option<String> {
let key = normalize(named);
self.clients.contains_key(&key).then_some(key)
}
pub fn keys_with_principal(&self, principal: &str) -> Vec<String> {
self.clients
.iter()
.filter(|(_, entry)| entry.principal.as_deref() == Some(principal))
.map(|(key, _)| key.clone())
.collect()
}
pub fn grant_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.clients
.values()
.map(|entry| entry.grant.clone())
.chain(self.default_grant.clone())
.collect();
names.sort();
names.dedup();
names
}
}
pub fn enrolment() -> Result<Option<Enrolment>, String> {
let Some(path) = clients_path() else {
return Ok(None);
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("read {}: {e}", path.display())),
};
parse(&text)
.map(Some)
.map_err(|e| format!("{}: {e}", path.display()))
}
pub(crate) fn parse(text: &str) -> Result<Enrolment, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
let mut clients = BTreeMap::new();
if let Some(map) = v.get("clients") {
let map = map
.as_object()
.ok_or("`clients` must be an object of fingerprint → grant")?;
for (fingerprint, entry) in map {
let parsed = match entry {
serde_json::Value::String(name) => Entry {
grant: name.clone(),
principal: None,
},
serde_json::Value::Object(_) => Entry {
grant: entry
.get("grant")
.and_then(|g| g.as_str())
.ok_or_else(|| format!("client `{fingerprint}` has no `grant`"))?
.to_string(),
principal: match entry.get("principal") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(iri)) => {
ikigai_core::principal_scope(iri).map_err(|e| {
format!("client `{fingerprint}` names an unusable principal: {e}")
})?;
Some(iri.clone())
}
Some(_) => {
return Err(format!(
"client `{fingerprint}`: `principal` must be an IRI string"
))
}
},
},
_ => {
return Err(format!(
"client `{fingerprint}` must be a grant name or an object with one"
))
}
};
clients.insert(normalize(fingerprint), parsed);
}
}
let default_grant = match v.get("default") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(name)) => Some(name.clone()),
Some(_) => return Err("`default` must be a grant name".to_string()),
};
Ok(Enrolment {
clients,
default_grant,
})
}
fn normalize(key: &str) -> String {
let compact: String = key
.chars()
.filter(|c| !c.is_whitespace() && *c != ':')
.collect();
if compact.len() == 64 && compact.chars().all(|c| c.is_ascii_hexdigit()) {
compact.to_ascii_lowercase()
} else {
key.trim().to_string()
}
}
pub fn authority(fingerprint: &str, ceiling: &Capability) -> Result<(String, Capability), String> {
authorize(fingerprint, ceiling).map(|authority| (authority.grant, authority.capability))
}
pub fn authorize(fingerprint: &str, ceiling: &Capability) -> Result<Authority, String> {
let enrolment = match enrolment()? {
Some(enrolment) => enrolment,
None => {
return Err(format!(
"no client enrolment file ({})",
clients_path().map_or_else(|| "unset".into(), |p| p.display().to_string())
))
}
};
let (grant, capability) = authority_in(&enrolment, fingerprint, ceiling, crate::grant_scopes)?;
Ok(Authority {
grant,
capability,
principal: enrolment.principal_for(fingerprint).map(str::to_string),
})
}
fn authority_in(
enrolment: &Enrolment,
fingerprint: &str,
ceiling: &Capability,
scopes_of: impl Fn(&str) -> Vec<String>,
) -> Result<(String, Capability), String> {
let Some(grant) = enrolment.grant_for(fingerprint) else {
return Err("no grant is configured for this certificate".to_string());
};
let scopes = scopes_of(grant);
if scopes.is_empty() {
return Err(format!(
"grant `{grant}` is unknown or grants no scopes (check grants.json)"
));
}
Ok((
grant.to_string(),
ceiling.clamp(&Capability::scoped(scopes)),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn grants(name: &str) -> Vec<String> {
match name {
"contacts-ro" => vec!["urn:cap:personal:contacts:read".to_string()],
"everything" => vec![
"urn:cap:personal:contacts:read".to_string(),
"urn:cap:personal:calendar:read:detail".to_string(),
],
_ => vec![],
}
}
const FP: &str = "6f1c00000000000000000000000000000000000000000000000000000000abcd";
fn enrolled(fingerprint: &str, grant: &str) -> Enrolment {
parse(&format!(
r#"{{"clients": {{"{fingerprint}": {{"grant": "{grant}", "label": "test"}}}}}}"#
))
.unwrap()
}
#[test]
fn an_openssl_style_fingerprint_names_the_same_client() {
let openssl = "6F:1C:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:AB:CD";
let e = enrolled(openssl, "contacts-ro");
assert_eq!(e.grant_for(FP), Some("contacts-ro"));
assert_eq!(e.grant_for(openssl), Some("contacts-ro"));
}
#[test]
fn a_peer_id_enrols_beside_a_fingerprint_and_keeps_its_case() {
let peer = "12D3KooWDpJ7As7BWAwRMfu1VU2WCqNjvq387JEYKDBj4kx6nXTN";
let e = parse(&format!(
r#"{{"clients": {{"{FP}": "everything", "{peer}": {{"grant": "contacts-ro", "label": "plasma over p2p"}}}}}}"#
))
.unwrap();
assert_eq!(e.grant_for(peer), Some("contacts-ro"));
assert_eq!(e.grant_for(FP), Some("everything"));
assert_eq!(e.grant_for(&peer.to_lowercase()), None);
let (grant, capability) = authority_in(&e, peer, &Capability::root(), grants).unwrap();
assert_eq!(grant, "contacts-ro");
assert!(!capability.allows("urn:cap:personal:calendar:read:detail"));
}
#[test]
fn both_entry_spellings_mean_the_same_grant() {
let object = enrolled(FP, "contacts-ro");
let bare = parse(&format!(r#"{{"clients": {{"{FP}": "contacts-ro"}}}}"#)).unwrap();
assert_eq!(object, bare);
}
#[test]
fn an_unenrolled_certificate_is_refused_not_given_the_ceiling() {
let enrolment = enrolled(FP, "contacts-ro");
let ceiling = Capability::scoped(["urn:cap:personal:contacts:read".to_string()]);
let stranger = "0000000000000000000000000000000000000000000000000000000000000000";
let refused = authority_in(&enrolment, stranger, &ceiling, grants).unwrap_err();
assert!(
refused.contains("no grant is configured"),
"unexpected reason: {refused}"
);
}
#[test]
fn an_enrolled_certificate_naming_an_empty_grant_is_refused() {
let enrolment = enrolled(FP, "typo-ro");
let refused = authority_in(&enrolment, FP, &Capability::root(), grants).unwrap_err();
assert!(refused.contains("typo-ro"), "unexpected reason: {refused}");
}
#[test]
fn a_shared_default_applies_only_when_explicitly_configured() {
let stranger = "0000000000000000000000000000000000000000000000000000000000000000";
let with = parse(&format!(
r#"{{"clients": {{"{FP}": "everything"}}, "default": "contacts-ro"}}"#
))
.unwrap();
let (grant, capability) =
authority_in(&with, stranger, &Capability::root(), grants).unwrap();
assert_eq!(grant, "contacts-ro");
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert_eq!(with.grant_for(FP), Some("everything"));
let without = parse(&format!(r#"{{"clients": {{"{FP}": "everything"}}}}"#)).unwrap();
assert!(authority_in(&without, stranger, &Capability::root(), grants).is_err());
}
#[test]
fn a_narrower_grant_narrows_below_the_operator_ceiling() {
let ceiling = Capability::scoped([
"urn:cap:personal:contacts:read".to_string(),
"urn:cap:personal:calendar:read:detail".to_string(),
]);
let (_, capability) =
authority_in(&enrolled(FP, "contacts-ro"), FP, &ceiling, grants).unwrap();
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert!(
!capability.allows("urn:cap:personal:calendar:read:detail"),
"a narrow grant must not keep the ceiling's other scopes"
);
}
#[test]
fn a_grant_cannot_widen_past_the_operator_ceiling() {
let ceiling = Capability::scoped(["urn:cap:personal:contacts:read".to_string()]);
let (_, capability) =
authority_in(&enrolled(FP, "everything"), FP, &ceiling, grants).unwrap();
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert!(
!capability.allows("urn:cap:personal:calendar:read:detail"),
"the grant named a scope the ceiling never granted — it must be clamped away"
);
let (_, unbounded) =
authority_in(&enrolled(FP, "everything"), FP, &Capability::root(), grants).unwrap();
assert!(unbounded.allows("urn:cap:personal:calendar:read:detail"));
}
#[test]
fn editing_the_enrolment_changes_the_next_connections_authority() {
let before = enrolled(FP, "everything");
let (grant, capability) = authority_in(&before, FP, &Capability::root(), grants).unwrap();
assert_eq!(grant, "everything");
assert!(capability.allows("urn:cap:personal:calendar:read:detail"));
let after = enrolled(FP, "contacts-ro");
let (grant, capability) = authority_in(&after, FP, &Capability::root(), grants).unwrap();
assert_eq!(grant, "contacts-ro");
assert!(!capability.allows("urn:cap:personal:calendar:read:detail"));
let revoked = parse(r#"{"clients": {}}"#).unwrap();
assert!(authority_in(&revoked, FP, &Capability::root(), grants).is_err());
}
#[test]
fn a_malformed_enrolment_is_an_error_rather_than_an_empty_one() {
assert!(parse("{ not json").is_err());
assert!(parse(r#"{"clients": ["a", "b"]}"#).is_err());
assert!(parse(&format!(r#"{{"clients": {{"{FP}": {{"label": "x"}}}}}}"#)).is_err());
assert!(parse(r#"{"default": 7}"#).is_err());
assert!(parse("{}").unwrap().is_empty());
}
#[test]
fn an_entry_may_name_its_principal_and_the_default_names_nobody() {
let alice = "a".repeat(64);
let stranger = "0".repeat(64);
let e = parse(&format!(
r#"{{"clients": {{
"{FP}": {{"grant": "everything", "principal": "urn:collab:participant:bobby"}},
"{alice}": {{"grant": "contacts-ro", "label": "alice"}}
}}, "default": "contacts-ro"}}"#
))
.unwrap();
assert_eq!(e.principal_for(FP), Some("urn:collab:participant:bobby"));
assert_eq!(
e.principal_for(&FP.to_uppercase()),
Some("urn:collab:participant:bobby")
);
assert_eq!(e.principal_for(&alice), None);
assert_eq!(e.grant_for(&stranger), Some("contacts-ro"));
assert_eq!(e.principal_for(&stranger), None, "the default names nobody");
}
#[test]
fn an_unusable_principal_is_an_error() {
for bad in [r#""not an iri""#, r#""urn:x:*""#, "7"] {
let text = format!(
r#"{{"clients": {{"{FP}": {{"grant": "everything", "principal": {bad}}}}}}}"#
);
let refused = parse(&text).unwrap_err();
assert!(refused.contains("principal"), "{bad}: {refused}");
}
}
#[test]
fn the_default_principal_is_exact() {
assert_eq!(
default_principal("serve", FP),
format!("urn:iki:host:serve:client:{FP}")
);
let openssl = FP
.to_uppercase()
.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).unwrap())
.collect::<Vec<_>>()
.join(":");
assert_eq!(
default_principal("serve", &openssl),
default_principal("serve", FP)
);
assert!(ikigai_core::principal_scope(&default_principal("layers", FP)).is_ok());
}
#[test]
fn the_declared_grant_names_include_the_default_and_dedupe() {
let e = parse(&format!(
r#"{{"clients": {{"{FP}": "contacts-ro", "aa": "contacts-ro"}}, "default": "everything"}}"#
))
.unwrap();
assert_eq!(e.grant_names(), vec!["contacts-ro", "everything"]);
assert_eq!(e.len(), 2);
}
}