use crate::caveats::Caveats;
use crate::fingerprint::Fingerprint;
use crate::user_key::{UserKey, UserPublic};
use crate::{MeshError, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
pub struct AgentKey {
signing: SigningKey,
cert: CertChain,
}
impl AgentKey {
pub fn issue(user: &UserKey, metadata: AgentMetadata) -> Self {
let mut csprng = OsRng;
let signing = SigningKey::generate(&mut csprng);
let agent_pubkey_bytes: [u8; 32] = *signing.verifying_key().as_bytes();
let to_sign = sign_payload(&agent_pubkey_bytes, &metadata);
let sig = user.sign(&to_sign);
let cert = CertChain {
agent_pubkey: agent_pubkey_bytes,
metadata,
issuer: Issuer::User(user.public()),
issuer_sig: SerdeSig(sig),
};
Self { signing, cert }
}
pub fn delegate(&self, metadata: AgentMetadata) -> Result<Self> {
if !metadata.caveats.leq(&self.cert.metadata.caveats) {
return Err(MeshError::CaveatAmplification);
}
let mut csprng = OsRng;
let signing = SigningKey::generate(&mut csprng);
let sub_pubkey: [u8; 32] = *signing.verifying_key().as_bytes();
let to_sign = sign_payload(&sub_pubkey, &metadata);
let sig = self.signing.sign(&to_sign);
let cert = CertChain {
agent_pubkey: sub_pubkey,
metadata,
issuer: Issuer::Agent {
pubkey: self.cert.agent_pubkey,
parent: Box::new(self.cert.clone()),
},
issuer_sig: SerdeSig(sig),
};
Ok(Self { signing, cert })
}
pub fn sign(&self, message: &[u8]) -> Signature {
self.signing.sign(message)
}
#[must_use]
pub fn fingerprint(&self) -> Fingerprint {
Fingerprint::of_bytes(&self.cert.agent_pubkey)
}
#[must_use]
pub fn cert(&self) -> &CertChain {
&self.cert
}
#[must_use]
pub fn public_bytes(&self) -> [u8; 32] {
self.cert.agent_pubkey
}
#[must_use]
pub fn signing_key_bytes(&self) -> [u8; 32] {
self.signing.to_bytes()
}
pub fn from_seed_and_cert(seed: &[u8; 32], cert: CertChain) -> Result<Self> {
let signing = ed25519_dalek::SigningKey::from_bytes(seed);
let derived_pub: [u8; 32] = *signing.verifying_key().as_bytes();
if derived_pub != cert.agent_pubkey {
return Err(MeshError::BadSignature);
}
Ok(Self { signing, cert })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentMetadata {
pub role: String,
pub host: String,
pub capabilities: Vec<String>,
pub issued_at: String,
pub expires_at: Option<String>,
#[serde(default)]
pub caveats: Caveats,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Issuer {
User(UserPublic),
Agent {
pubkey: [u8; 32],
parent: Box<CertChain>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CertChain {
pub agent_pubkey: [u8; 32],
pub metadata: AgentMetadata,
pub issuer: Issuer,
pub issuer_sig: SerdeSig,
}
impl CertChain {
pub fn verify(&self) -> Result<()> {
let to_verify = sign_payload(&self.agent_pubkey, &self.metadata);
match &self.issuer {
Issuer::User(user) => {
user.verify(&to_verify, &self.issuer_sig.0)
}
Issuer::Agent { pubkey, parent } => {
if *pubkey != parent.agent_pubkey {
return Err(MeshError::InvalidCertChain(
"delegated cert issuer pubkey does not match its parent".into(),
));
}
parent.verify()?;
verify_detached(pubkey, &to_verify, &self.issuer_sig.0)?;
if !self.metadata.caveats.leq(&parent.metadata.caveats) {
return Err(MeshError::CaveatAmplification);
}
Ok(())
}
}
}
#[must_use]
pub fn agent_fingerprint(&self) -> Fingerprint {
Fingerprint::of_bytes(&self.agent_pubkey)
}
#[must_use]
pub fn user_fingerprint(&self) -> Fingerprint {
match &self.issuer {
Issuer::User(user) => user.fingerprint(),
Issuer::Agent { parent, .. } => parent.user_fingerprint(),
}
}
#[must_use]
pub fn root_user_pubkey(&self) -> UserPublic {
match &self.issuer {
Issuer::User(user) => user.clone(),
Issuer::Agent { parent, .. } => parent.root_user_pubkey(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerdeSig(pub Signature);
impl Serialize for SerdeSig {
fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
let bytes: [u8; 64] = self.0.to_bytes();
bytes.serialize(ser)
}
}
impl<'de> Deserialize<'de> for SerdeSig {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
let bytes: Vec<u8> = Vec::deserialize(de)?;
if bytes.len() != 64 {
return Err(serde::de::Error::custom("expected 64-byte signature"));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&bytes);
Ok(Self(Signature::from_bytes(&arr)))
}
}
fn sign_payload(agent_pubkey: &[u8; 32], metadata: &AgentMetadata) -> Vec<u8> {
let meta_bytes =
serde_json::to_vec(metadata).expect("AgentMetadata serializes deterministically");
let mut out = Vec::with_capacity(32 + meta_bytes.len());
out.extend_from_slice(agent_pubkey);
out.extend_from_slice(&meta_bytes);
out
}
fn verify_detached(pubkey: &[u8; 32], msg: &[u8], sig: &Signature) -> Result<()> {
let vk = VerifyingKey::from_bytes(pubkey).map_err(|_| MeshError::BadSignature)?;
vk.verify_strict(msg, sig)
.map_err(|_| MeshError::BadSignature)
}
impl MeshError {
#[cfg(test)]
pub(crate) fn is_bad_signature(&self) -> bool {
matches!(self, Self::BadSignature)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_metadata(role: &str) -> AgentMetadata {
AgentMetadata {
role: role.to_string(),
host: "test-host".to_string(),
capabilities: vec!["test".to_string()],
issued_at: "2026-05-28T12:00:00Z".to_string(),
expires_at: None,
caveats: Caveats::top(),
}
}
#[test]
fn issue_agent_key_signed_by_user() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
assert_eq!(agent.cert().root_user_pubkey(), user.public());
assert_eq!(agent.cert().agent_pubkey, agent.public_bytes());
}
#[test]
fn verify_cert_chain_succeeds() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
agent.cert().verify().expect("fresh cert verifies");
}
#[test]
fn tampered_metadata_fails_verify() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let mut cert = agent.cert().clone();
cert.metadata.role = "evil".to_string();
assert!(cert.verify().unwrap_err().is_bad_signature());
}
#[test]
fn fixture_caveats_default_to_top() {
assert_eq!(fixture_metadata("worker").caveats, Caveats::top());
}
#[test]
fn bounded_caveats_roundtrip_and_verify() {
let mut meta = fixture_metadata("worker");
meta.caveats = Caveats {
exec: crate::Scope::only(["git".to_string()]),
max_calls: crate::CountBound::AtMost(8),
..Caveats::top()
};
let user = UserKey::generate();
let agent = AgentKey::issue(&user, meta.clone());
agent.cert().verify().expect("fresh cert verifies");
let json = serde_json::to_string(agent.cert()).unwrap();
let parsed: CertChain = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.metadata.caveats, meta.caveats);
parsed.verify().expect("roundtripped cert verifies");
}
#[test]
fn tampered_caveats_fails_verify() {
let mut meta = fixture_metadata("worker");
meta.caveats = Caveats {
exec: crate::Scope::only(["git".to_string()]),
..Caveats::top()
};
let user = UserKey::generate();
let agent = AgentKey::issue(&user, meta);
let mut cert = agent.cert().clone();
cert.metadata.caveats = Caveats::top(); assert!(cert.verify().unwrap_err().is_bad_signature());
}
#[test]
fn absent_caveats_default_to_top() {
let json = r#"{"role":"w","host":"h","capabilities":[],"issued_at":"2026-05-28T00:00:00Z","expires_at":null}"#;
let meta: AgentMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.caveats, Caveats::top());
}
#[test]
fn tampered_agent_pubkey_fails_verify() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let mut cert = agent.cert().clone();
cert.agent_pubkey[0] ^= 0xff;
assert!(cert.verify().unwrap_err().is_bad_signature());
}
#[test]
fn wrong_user_fails_verify() {
let user = UserKey::generate();
let other = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let mut cert = agent.cert().clone();
cert.issuer = Issuer::User(other.public());
assert!(cert.verify().unwrap_err().is_bad_signature());
}
#[test]
fn serde_roundtrip_cert_chain() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let json = serde_json::to_string(agent.cert()).unwrap();
let parsed: CertChain = serde_json::from_str(&json).unwrap();
assert_eq!(&parsed, agent.cert());
parsed.verify().expect("roundtripped cert still verifies");
}
#[test]
fn fingerprints_match() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let cert = agent.cert();
assert_eq!(agent.fingerprint(), cert.agent_fingerprint());
assert_eq!(cert.user_fingerprint(), user.fingerprint());
}
#[test]
fn agent_sign_distinct_from_user_sign() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let user_sig = user.sign(b"x");
let agent_sig = agent.sign(b"x");
assert_ne!(user_sig.to_bytes(), agent_sig.to_bytes());
}
#[test]
fn signing_key_bytes_roundtrip_signs_identically() {
use ed25519_dalek::Signer;
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let bytes = agent.signing_key_bytes();
assert_eq!(bytes.len(), 32);
let rebuilt = ed25519_dalek::SigningKey::from_bytes(&bytes);
let msg = b"transport-layer-handshake";
let from_agent = agent.sign(msg);
let from_rebuilt = rebuilt.sign(msg);
assert_eq!(from_agent.to_bytes(), from_rebuilt.to_bytes());
}
#[test]
fn from_seed_and_cert_roundtrips() {
let user = UserKey::generate();
let agent = AgentKey::issue(&user, fixture_metadata("worker"));
let seed = agent.signing_key_bytes();
let cert = agent.cert().clone();
let rebuilt = AgentKey::from_seed_and_cert(&seed, cert).expect("seed+cert valid");
assert_eq!(rebuilt.fingerprint(), agent.fingerprint());
let msg = b"rebuild-test";
assert_eq!(rebuilt.sign(msg).to_bytes(), agent.sign(msg).to_bytes());
}
#[test]
fn from_seed_and_cert_rejects_mismatched_pairing() {
let user = UserKey::generate();
let agent_a = AgentKey::issue(&user, fixture_metadata("a"));
let agent_b = AgentKey::issue(&user, fixture_metadata("b"));
let res =
AgentKey::from_seed_and_cert(&agent_b.signing_key_bytes(), agent_a.cert().clone());
match res {
Ok(_) => panic!("mismatched pairing must reject"),
Err(e) => assert!(matches!(e, MeshError::BadSignature)),
}
}
#[test]
fn metadata_with_expiry_roundtrips() {
let mut meta = fixture_metadata("worker");
meta.expires_at = Some("2027-01-01T00:00:00Z".to_string());
let user = UserKey::generate();
let agent = AgentKey::issue(&user, meta.clone());
let cert = agent.cert();
assert_eq!(
cert.metadata.expires_at.as_deref(),
Some("2027-01-01T00:00:00Z")
);
let json = serde_json::to_string(cert).unwrap();
let parsed: CertChain = serde_json::from_str(&json).unwrap();
parsed.verify().unwrap();
}
fn meta_exec(role: &str, cmds: &[&str]) -> AgentMetadata {
AgentMetadata {
caveats: Caveats {
exec: crate::Scope::only(cmds.iter().map(|s| s.to_string())),
..Caveats::top()
},
..fixture_metadata(role)
}
}
#[test]
fn delegate_accepts_attenuation_and_roots_at_user() {
let user = UserKey::generate();
let parent = AgentKey::issue(&user, meta_exec("parent", &["git", "cargo"]));
let child = parent
.delegate(meta_exec("child", &["git"]))
.expect("attenuating delegation succeeds");
child.cert().verify().expect("delegated cert verifies");
assert_eq!(child.cert().user_fingerprint(), user.fingerprint());
assert_eq!(child.cert().root_user_pubkey(), user.public());
}
#[test]
fn delegate_rejects_amplification() {
let user = UserKey::generate();
let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
let child = AgentMetadata {
caveats: Caveats::top(),
..fixture_metadata("child")
};
assert!(matches!(
parent.delegate(child),
Err(MeshError::CaveatAmplification)
));
}
#[test]
fn multi_level_delegation_attenuates_each_link() {
let user = UserKey::generate();
let a = AgentKey::issue(&user, meta_exec("a", &["git", "cargo"]));
let b = a.delegate(meta_exec("b", &["git"])).expect("B ⊑ A");
b.cert().verify().expect("B verifies through the chain");
assert_eq!(b.cert().user_fingerprint(), user.fingerprint());
assert!(matches!(
b.delegate(meta_exec("c", &["git", "rm"])),
Err(MeshError::CaveatAmplification)
));
}
#[test]
fn delegated_cert_serde_roundtrips() {
let user = UserKey::generate();
let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
let json = serde_json::to_string(child.cert()).unwrap();
let parsed: CertChain = serde_json::from_str(&json).unwrap();
assert_eq!(&parsed, child.cert());
parsed
.verify()
.expect("roundtripped delegated cert verifies");
}
#[test]
fn forged_amplifying_chain_fails_verify() {
let user = UserKey::generate();
let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
let mut csprng = OsRng;
let child_signing = SigningKey::generate(&mut csprng);
let child_pubkey: [u8; 32] = *child_signing.verifying_key().as_bytes();
let child_meta = AgentMetadata {
caveats: Caveats::top(),
..fixture_metadata("child")
};
let to_sign = sign_payload(&child_pubkey, &child_meta);
let sig = parent.sign(&to_sign); let forged = CertChain {
agent_pubkey: child_pubkey,
metadata: child_meta,
issuer: Issuer::Agent {
pubkey: parent.public_bytes(),
parent: Box::new(parent.cert().clone()),
},
issuer_sig: SerdeSig(sig),
};
assert!(matches!(
forged.verify(),
Err(MeshError::CaveatAmplification)
));
}
#[test]
fn delegated_issuer_pubkey_must_match_parent() {
let user = UserKey::generate();
let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
let mut cert = child.cert().clone();
if let Issuer::Agent { pubkey, .. } = &mut cert.issuer {
pubkey[0] ^= 0xff;
}
assert!(matches!(cert.verify(), Err(MeshError::InvalidCertChain(_))));
}
}