use exo_core::{Did, Hash256, PublicKey, Signature, Timestamp};
use serde::{Deserialize, Serialize};
use crate::{
error::AuthorityError,
permission::{Permission, PermissionSet},
};
pub const DEFAULT_MAX_DEPTH: usize = 5;
pub const AUTHORITY_LINK_SIGNING_DOMAIN: &str = "exo.authority.delegation.v1";
const AUTHORITY_LINK_SIGNING_SCHEMA_VERSION: u16 = 1;
#[derive(Serialize)]
struct AuthorityLinkSigningPayload<'a> {
domain: &'static str,
schema_version: u16,
delegator_did: &'a Did,
delegate_did: &'a Did,
scope: &'a [Permission],
created: &'a Timestamp,
expires: &'a Option<Timestamp>,
depth: u32,
delegatee_kind: &'a DelegateeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DelegateeKind {
Human,
AiAgent { model_id: String },
#[default]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityLink {
pub delegator_did: Did,
pub delegate_did: Did,
pub scope: Vec<Permission>,
pub created: Timestamp,
pub expires: Option<Timestamp>,
pub signature: Signature,
pub depth: usize,
#[serde(default)]
pub delegatee_kind: DelegateeKind,
}
impl AuthorityLink {
pub fn id(&self) -> Result<Hash256, AuthorityError> {
Ok(Hash256::digest(&self.signing_payload()?))
}
pub fn signing_payload(&self) -> Result<Vec<u8>, AuthorityError> {
let scope: Vec<Permission> = PermissionSet::from_permissions(&self.scope)
.iter()
.copied()
.collect();
let depth =
u32::try_from(self.depth).map_err(|_| AuthorityError::SigningPayloadEncoding {
reason: format!(
"authority link depth {} exceeds u32 signing payload capacity",
self.depth
),
})?;
let payload = AuthorityLinkSigningPayload {
domain: AUTHORITY_LINK_SIGNING_DOMAIN,
schema_version: AUTHORITY_LINK_SIGNING_SCHEMA_VERSION,
delegator_did: &self.delegator_did,
delegate_did: &self.delegate_did,
scope: &scope,
created: &self.created,
expires: &self.expires,
depth,
delegatee_kind: &self.delegatee_kind,
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&payload, &mut buf).map_err(|e| {
AuthorityError::SigningPayloadEncoding {
reason: e.to_string(),
}
})?;
Ok(buf)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityChain {
pub links: Vec<AuthorityLink>,
pub max_depth: usize,
}
impl AuthorityChain {
#[must_use]
pub fn depth(&self) -> usize {
self.links.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.links.is_empty()
}
#[must_use]
pub fn root(&self) -> Option<&Did> {
self.links.first().map(|l| &l.delegator_did)
}
#[must_use]
pub fn leaf(&self) -> Option<&Did> {
self.links.last().map(|l| &l.delegate_did)
}
}
pub fn build_chain(links: &[AuthorityLink]) -> Result<AuthorityChain, AuthorityError> {
build_chain_with_depth(links, DEFAULT_MAX_DEPTH)
}
pub fn build_chain_with_depth(
links: &[AuthorityLink],
max_depth: usize,
) -> Result<AuthorityChain, AuthorityError> {
validate_chain_topology(links, max_depth)?;
Ok(AuthorityChain {
links: links.to_vec(),
max_depth,
})
}
fn validate_chain_topology(
links: &[AuthorityLink],
max_depth: usize,
) -> Result<(), AuthorityError> {
if links.is_empty() {
return Err(AuthorityError::EmptyChain);
}
if links.len() > max_depth {
return Err(AuthorityError::DepthExceeded {
depth: links.len(),
max_depth,
});
}
let first_depth = links[0].depth;
let chain_end_depth =
first_depth
.checked_add(links.len())
.ok_or(AuthorityError::DepthExceeded {
depth: first_depth,
max_depth,
})?;
if chain_end_depth > max_depth {
return Err(AuthorityError::DepthExceeded {
depth: chain_end_depth,
max_depth,
});
}
for (i, link) in links.iter().enumerate() {
let expected_depth = first_depth
.checked_add(i)
.ok_or(AuthorityError::DepthExceeded {
depth: first_depth,
max_depth,
})?;
if link.depth != expected_depth {
return Err(AuthorityError::ChainBroken {
index: i,
reason: format!("expected depth {expected_depth}, got {}", link.depth),
});
}
if i > 0 {
let prev = &links[i - 1];
if prev.delegate_did != link.delegator_did {
return Err(AuthorityError::ChainBroken {
index: i,
reason: format!(
"gap: {} -> {} but expected {}",
prev.delegate_did, link.delegator_did, prev.delegate_did
),
});
}
}
}
Ok(())
}
pub fn verify_chain<F>(
chain: &AuthorityChain,
now: &Timestamp,
resolve_key: F,
) -> Result<(), AuthorityError>
where
F: Fn(&Did) -> Option<PublicKey>,
{
if chain.links.is_empty() {
return Err(AuthorityError::EmptyChain);
}
if chain.links.len() > chain.max_depth {
return Err(AuthorityError::DepthExceeded {
depth: chain.links.len(),
max_depth: chain.max_depth,
});
}
validate_chain_topology(&chain.links, chain.max_depth)?;
let mut prev_scope: Option<PermissionSet> = None;
for (i, link) in chain.links.iter().enumerate() {
if link.signature.is_empty() {
return Err(AuthorityError::InvalidSignature { index: i });
}
let pub_key = resolve_key(&link.delegator_did)
.ok_or(AuthorityError::InvalidSignature { index: i })?;
let payload = link.signing_payload()?;
if !exo_core::crypto::verify(&payload, &link.signature, &pub_key) {
return Err(AuthorityError::InvalidSignature { index: i });
}
if let Some(exp) = &link.expires {
if exp.is_expired(now) {
return Err(AuthorityError::ExpiredLink { index: i });
}
}
let current_scope = PermissionSet::from_permissions(&link.scope);
if let Some(ref prev) = prev_scope {
if !PermissionSet::is_subset(¤t_scope, prev) {
return Err(AuthorityError::ScopeWidening { index: i });
}
}
prev_scope = Some(current_scope);
}
Ok(())
}
#[must_use]
pub fn has_permission(chain: &AuthorityChain, permission: &Permission) -> bool {
if chain.links.is_empty() {
return false;
}
chain
.links
.iter()
.all(|link| link.scope.contains(permission))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use exo_core::crypto::KeyPair;
use super::*;
fn did(name: &str) -> Did {
Did::new(&format!("did:exo:{name}")).unwrap()
}
fn ts(ms: u64) -> Timestamp {
Timestamp::new(ms, 0)
}
fn now() -> Timestamp {
ts(5000)
}
#[test]
fn chain_module_does_not_use_hashmap_or_hashset() {
let source = include_str!("chain.rs");
let nondeterministic_map = ["Hash", "Map"].concat();
let nondeterministic_set = ["Hash", "Set"].concat();
assert!(
!source.contains(&nondeterministic_map),
"authority chain code and tests must use deterministic maps"
);
assert!(
!source.contains(&nondeterministic_set),
"authority chain code and tests must use deterministic sets"
);
}
struct KeyRegistry {
keys: BTreeMap<String, KeyPair>,
}
impl KeyRegistry {
fn new() -> Self {
Self {
keys: BTreeMap::new(),
}
}
fn register(&mut self, name: &str) -> PublicKey {
let kp = KeyPair::generate();
let pk = *kp.public_key();
self.keys.insert(format!("did:exo:{name}"), kp);
pk
}
fn resolve(&self, did: &Did) -> Option<PublicKey> {
self.keys.get(did.as_str()).map(|kp| *kp.public_key())
}
fn resolver(&self) -> impl Fn(&Did) -> Option<PublicKey> + '_ {
|did| self.resolve(did)
}
}
fn signed_link(
registry: &KeyRegistry,
from: &str,
to: &str,
scope: Vec<Permission>,
depth: usize,
exp: Option<Timestamp>,
) -> AuthorityLink {
let mut link = AuthorityLink {
delegator_did: did(from),
delegate_did: did(to),
scope,
created: ts(1000),
expires: exp,
signature: Signature::empty(),
depth,
delegatee_kind: DelegateeKind::Human,
};
let payload = link.signing_payload().expect("canonical signing payload");
let kp = registry
.keys
.get(&format!("did:exo:{from}"))
.expect("key not registered");
link.signature = kp.sign(&payload);
link
}
fn fake_link(
from: &str,
to: &str,
scope: Vec<Permission>,
depth: usize,
exp: Option<Timestamp>,
) -> AuthorityLink {
AuthorityLink {
delegator_did: did(from),
delegate_did: did(to),
scope,
created: ts(1000),
expires: exp,
signature: Signature::from_bytes([0xA5u8; 64]),
depth,
delegatee_kind: DelegateeKind::Human,
}
}
#[test]
fn build_single_link() {
let links = vec![fake_link(
"root",
"alice",
vec![Permission::Read, Permission::Write],
0,
None,
)];
let chain = build_chain(&links);
assert!(chain.is_ok());
let c = chain.unwrap();
assert_eq!(c.depth(), 1);
assert_eq!(c.root().unwrap(), &did("root"));
assert_eq!(c.leaf().unwrap(), &did("alice"));
}
#[test]
fn build_multi_link() {
let links = vec![
fake_link(
"root",
"alice",
vec![Permission::Read, Permission::Write, Permission::Delegate],
0,
None,
),
fake_link(
"alice",
"bob",
vec![Permission::Read, Permission::Write],
1,
None,
),
fake_link("bob", "charlie", vec![Permission::Read], 2, None),
];
let chain = build_chain(&links).unwrap();
assert_eq!(chain.depth(), 3);
}
#[test]
fn build_accepts_contiguous_depth_offset() {
let links = vec![
fake_link("alice", "bob", vec![Permission::Read], 1, None),
fake_link("bob", "charlie", vec![Permission::Read], 2, None),
];
let chain = build_chain(&links).unwrap();
assert_eq!(chain.depth(), 2);
assert_eq!(chain.links[0].depth, 1);
assert_eq!(chain.links[1].depth, 2);
}
#[test]
fn build_rejects_empty() {
assert_eq!(build_chain(&[]), Err(AuthorityError::EmptyChain));
}
#[test]
fn build_rejects_depth_exceeded() {
let links: Vec<AuthorityLink> = (0..6)
.map(|i| {
fake_link(
&format!("n{i}"),
&format!("n{}", i + 1),
vec![Permission::Read],
i,
None,
)
})
.collect();
let result = build_chain(&links);
assert!(matches!(result, Err(AuthorityError::DepthExceeded { .. })));
}
#[test]
fn build_custom_depth() {
let links: Vec<AuthorityLink> = (0..3)
.map(|i| {
fake_link(
&format!("n{i}"),
&format!("n{}", i + 1),
vec![Permission::Read],
i,
None,
)
})
.collect();
assert!(build_chain_with_depth(&links, 2).is_err());
assert!(build_chain_with_depth(&links, 3).is_ok());
}
#[test]
fn build_rejects_gap() {
let links = vec![
fake_link("root", "alice", vec![Permission::Read], 0, None),
fake_link("bob", "charlie", vec![Permission::Read], 1, None),
];
assert!(matches!(
build_chain(&links),
Err(AuthorityError::ChainBroken { .. })
));
}
#[test]
fn build_rejects_wrong_depth() {
let links = vec![
fake_link("root", "alice", vec![Permission::Read], 0, None),
fake_link("alice", "bob", vec![Permission::Read], 5, None),
];
assert!(matches!(
build_chain(&links),
Err(AuthorityError::ChainBroken { .. })
));
}
#[test]
fn verify_valid_chain_real_signatures() {
let mut reg = KeyRegistry::new();
reg.register("root");
reg.register("alice");
let links = vec![
signed_link(
®,
"root",
"alice",
vec![Permission::Read, Permission::Write],
0,
None,
),
signed_link(®, "alice", "bob", vec![Permission::Read], 1, None),
];
let chain = build_chain(&links).unwrap();
assert!(verify_chain(&chain, &now(), reg.resolver()).is_ok());
}
#[test]
fn verify_rejects_forged_signature() {
let mut reg = KeyRegistry::new();
reg.register("root");
let mut link = signed_link(®, "root", "alice", vec![Permission::Read], 0, None);
link.signature = Signature::from_bytes([0xDE; 64]);
let chain = build_chain(&[link]).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::InvalidSignature { index: 0 })
));
}
#[test]
fn verify_rejects_wrong_key_signature() {
let mut reg = KeyRegistry::new();
reg.register("root");
reg.register("alice");
let mut link = AuthorityLink {
delegator_did: did("root"),
delegate_did: did("alice"),
scope: vec![Permission::Read],
created: ts(1000),
expires: None,
signature: Signature::empty(),
depth: 0,
delegatee_kind: DelegateeKind::Human,
};
let payload = link.signing_payload().expect("canonical signing payload");
let alice_kp = reg.keys.get("did:exo:alice").unwrap();
link.signature = alice_kp.sign(&payload);
let chain = build_chain(&[link]).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::InvalidSignature { index: 0 })
));
}
#[test]
fn verify_rejects_tampered_payload() {
let mut reg = KeyRegistry::new();
reg.register("root");
let mut link = signed_link(®, "root", "alice", vec![Permission::Read], 0, None);
link.delegate_did = did("mallory");
let chain = build_chain(&[link]).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::InvalidSignature { index: 0 })
));
}
#[test]
fn verify_rejects_empty_signature() {
let mut reg = KeyRegistry::new();
reg.register("root");
let mut link = signed_link(®, "root", "alice", vec![Permission::Read], 0, None);
link.signature = Signature::empty();
let chain = build_chain(&[link]).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::InvalidSignature { .. })
));
}
#[test]
fn verify_rejects_expired_link() {
let mut reg = KeyRegistry::new();
reg.register("root");
let links = vec![signed_link(
®,
"root",
"alice",
vec![Permission::Read],
0,
Some(ts(1000)),
)];
let chain = build_chain(&links).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::ExpiredLink { .. })
));
}
#[test]
fn verify_rejects_scope_widening() {
let mut reg = KeyRegistry::new();
reg.register("root");
reg.register("alice");
let links = vec![
signed_link(®, "root", "alice", vec![Permission::Read], 0, None),
signed_link(
®,
"alice",
"bob",
vec![Permission::Read, Permission::Write],
1,
None,
),
];
let chain = build_chain(&links).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::ScopeWidening { .. })
));
}
#[test]
fn verify_accepts_equal_scope() {
let mut reg = KeyRegistry::new();
reg.register("root");
reg.register("alice");
let links = vec![
signed_link(
®,
"root",
"alice",
vec![Permission::Read, Permission::Write],
0,
None,
),
signed_link(
®,
"alice",
"bob",
vec![Permission::Read, Permission::Write],
1,
None,
),
];
let chain = build_chain(&links).unwrap();
assert!(verify_chain(&chain, &now(), reg.resolver()).is_ok());
}
#[test]
fn verify_rejects_unknown_delegator() {
let reg = KeyRegistry::new();
let link = fake_link("root", "alice", vec![Permission::Read], 0, None);
let chain = build_chain(&[link]).unwrap();
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::InvalidSignature { index: 0 })
));
}
#[test]
fn verify_rejects_prebuilt_chain_with_broken_topology() {
let mut reg = KeyRegistry::new();
reg.register("root");
reg.register("charlie");
let chain = AuthorityChain {
links: vec![
signed_link(®, "root", "alice", vec![Permission::Read], 0, None),
signed_link(®, "charlie", "bob", vec![Permission::Read], 1, None),
],
max_depth: DEFAULT_MAX_DEPTH,
};
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::ChainBroken { index: 1, .. })
));
}
#[test]
fn verify_rejects_prebuilt_chain_with_forged_depth() {
let mut reg = KeyRegistry::new();
reg.register("root");
let chain = AuthorityChain {
links: vec![signed_link(
®,
"root",
"alice",
vec![Permission::Read],
7,
None,
)],
max_depth: DEFAULT_MAX_DEPTH,
};
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::DepthExceeded {
depth: 8,
max_depth: DEFAULT_MAX_DEPTH,
})
));
}
#[test]
fn has_permission_present() {
let links = vec![
fake_link(
"root",
"alice",
vec![Permission::Read, Permission::Write],
0,
None,
),
fake_link("alice", "bob", vec![Permission::Read], 1, None),
];
let chain = build_chain(&links).unwrap();
assert!(has_permission(&chain, &Permission::Read));
assert!(!has_permission(&chain, &Permission::Write));
}
#[test]
fn has_permission_empty_chain() {
let chain = AuthorityChain {
links: vec![],
max_depth: 5,
};
assert!(!has_permission(&chain, &Permission::Read));
}
#[test]
fn link_id_deterministic() {
let l = fake_link("root", "alice", vec![Permission::Read], 0, None);
let id1 = l.id().expect("canonical link id");
let id2 = l.id().expect("canonical link id");
assert_eq!(id1, id2);
}
#[test]
fn signing_payload_deterministic() {
let l = fake_link("root", "alice", vec![Permission::Read], 0, None);
assert_eq!(
l.signing_payload().expect("canonical signing payload"),
l.signing_payload().expect("canonical signing payload")
);
}
#[test]
fn authority_link_signing_payload_is_domain_tagged_cbor() {
#[derive(Deserialize)]
struct DecodedPayload {
domain: String,
schema_version: u16,
}
let link = fake_link("root", "alice", vec![Permission::Read], 0, None);
let payload = link.signing_payload().expect("canonical signing payload");
let decoded: DecodedPayload =
ciborium::from_reader(payload.as_slice()).expect("decode authority payload");
assert_eq!(decoded.domain, AUTHORITY_LINK_SIGNING_DOMAIN);
assert_eq!(decoded.schema_version, 1);
}
#[test]
fn authority_link_signing_payload_does_not_serialize_usize_depth() {
let production = include_str!("chain.rs")
.split("#[cfg(test)]")
.next()
.expect("production section");
let payload_section = production
.split("struct AuthorityLinkSigningPayload")
.nth(1)
.expect("authority signing payload section")
.split("/// Distinguishes human")
.next()
.expect("end of authority signing payload section");
assert!(
!payload_section.contains("depth: usize,"),
"signed authority payload must use a fixed-width integer depth"
);
}
#[test]
fn authority_link_signing_payload_rejects_non_portable_depth() {
let link = fake_link("root", "alice", vec![Permission::Read], usize::MAX, None);
assert!(matches!(
link.signing_payload(),
Err(AuthorityError::SigningPayloadEncoding { .. })
));
}
#[test]
fn chain_is_empty() {
let chain = AuthorityChain {
links: vec![],
max_depth: 5,
};
assert!(chain.is_empty());
assert!(chain.root().is_none());
assert!(chain.leaf().is_none());
}
#[test]
fn verify_chain_rejects_over_depth() {
let mut reg = KeyRegistry::new();
for i in 0..3 {
reg.register(&format!("n{i}"));
}
let links: Vec<AuthorityLink> = (0..3)
.map(|i| {
signed_link(
®,
&format!("n{i}"),
&format!("n{}", i + 1),
vec![Permission::Read],
i,
None,
)
})
.collect();
let mut chain = build_chain(&links).unwrap();
chain.max_depth = 2;
assert!(matches!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::DepthExceeded { .. })
));
}
#[test]
fn verify_empty_chain_errors() {
let chain = AuthorityChain {
links: vec![],
max_depth: 5,
};
let reg = KeyRegistry::new();
assert_eq!(
verify_chain(&chain, &now(), reg.resolver()),
Err(AuthorityError::EmptyChain)
);
}
#[test]
fn verify_non_expired_link() {
let mut reg = KeyRegistry::new();
reg.register("root");
let links = vec![signed_link(
®,
"root",
"alice",
vec![Permission::Read],
0,
Some(ts(10000)),
)];
let chain = build_chain(&links).unwrap();
assert!(verify_chain(&chain, &now(), reg.resolver()).is_ok());
}
#[test]
fn verify_three_link_chain_real_crypto() {
let mut reg = KeyRegistry::new();
reg.register("ceo");
reg.register("vp");
reg.register("manager");
let links = vec![
signed_link(
®,
"ceo",
"vp",
vec![Permission::Read, Permission::Write, Permission::Delegate],
0,
None,
),
signed_link(
®,
"vp",
"manager",
vec![Permission::Read, Permission::Write],
1,
None,
),
signed_link(®, "manager", "analyst", vec![Permission::Read], 2, None),
];
let chain = build_chain(&links).unwrap();
assert!(verify_chain(&chain, &now(), reg.resolver()).is_ok());
}
}