use crate::error::IdentityError;
use crate::identity::{AgentId, AgentKeypair};
use ant_quic::crypto::raw_public_keys::pqc::{
sign_with_ml_dsa, verify_with_ml_dsa, MlDsaSignature,
};
use ant_quic::MlDsaPublicKey;
use serde::{Deserialize, Serialize};
const AGENT_CARD_SIGNATURE_DOMAIN: &[u8] = b"x0x-agent-card-v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCard {
pub display_name: String,
pub agent_id: String,
pub machine_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(default)]
pub addresses: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<CardGroup>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stores: Vec<CardStore>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dm_capabilities: Option<crate::dm::DmCapabilities>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardGroup {
pub name: String,
pub invite_link: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardStore {
pub name: String,
pub topic: String,
}
impl AgentCard {
#[must_use]
pub fn new(display_name: String, agent_id: &AgentId, machine_id: &str) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
display_name,
agent_id: hex::encode(agent_id.as_bytes()),
machine_id: machine_id.to_string(),
user_id: None,
addresses: Vec::new(),
groups: Vec::new(),
stores: Vec::new(),
created_at: now,
dm_capabilities: Some(crate::dm::DmCapabilities::pending()),
agent_public_key: None,
signature: None,
}
}
#[must_use]
pub fn to_link(&self) -> String {
let json = serde_json::to_string(self).unwrap_or_default();
use base64::Engine;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes());
format!("x0x://agent/{b64}")
}
pub fn from_link(link: &str) -> std::result::Result<Self, String> {
let b64 = link.strip_prefix("x0x://agent/").unwrap_or(link).trim();
use base64::Engine;
let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64)
.map_err(|e| format!("invalid base64: {e}"))?;
let json_str = String::from_utf8(json_bytes).map_err(|e| format!("invalid UTF-8: {e}"))?;
serde_json::from_str(&json_str).map_err(|e| format!("invalid card JSON: {e}"))
}
#[must_use]
pub fn short_display(&self) -> String {
let id_short = if self.agent_id.len() >= 8 {
&self.agent_id[..8]
} else {
&self.agent_id
};
format!("{} ({}…)", self.display_name, id_short)
}
#[must_use]
pub fn signable_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(512);
buf.extend_from_slice(AGENT_CARD_SIGNATURE_DOMAIN);
push_len_prefixed(&mut buf, self.display_name.as_bytes());
push_len_prefixed(&mut buf, self.agent_id.as_bytes());
push_len_prefixed(&mut buf, self.machine_id.as_bytes());
push_len_prefixed(&mut buf, self.user_id.as_deref().unwrap_or("").as_bytes());
buf.extend_from_slice(&(self.addresses.len() as u32).to_le_bytes());
for a in &self.addresses {
push_len_prefixed(&mut buf, a.as_bytes());
}
buf.extend_from_slice(&(self.groups.len() as u32).to_le_bytes());
for g in &self.groups {
push_len_prefixed(&mut buf, g.name.as_bytes());
push_len_prefixed(&mut buf, g.invite_link.as_bytes());
}
buf.extend_from_slice(&(self.stores.len() as u32).to_le_bytes());
for s in &self.stores {
push_len_prefixed(&mut buf, s.name.as_bytes());
push_len_prefixed(&mut buf, s.topic.as_bytes());
}
buf.extend_from_slice(&self.created_at.to_le_bytes());
let dm_bytes = bincode::serialize(&self.dm_capabilities).unwrap_or_default();
push_len_prefixed(&mut buf, &dm_bytes);
push_len_prefixed(
&mut buf,
self.agent_public_key.as_deref().unwrap_or("").as_bytes(),
);
buf
}
pub fn sign(&mut self, keypair: &AgentKeypair) -> Result<(), IdentityError> {
self.agent_public_key = Some(hex::encode(keypair.public_key().as_bytes()));
self.signature = None;
let sig = sign_with_ml_dsa(keypair.secret_key(), &self.signable_bytes()).map_err(|e| {
IdentityError::CertificateVerification(format!("agent card sign: {e:?}"))
})?;
self.signature = Some(hex::encode(sig.as_bytes()));
Ok(())
}
pub fn verify_signature(&self) -> Result<(), IdentityError> {
let (Some(sig_hex), Some(pk_hex)) =
(self.signature.as_ref(), self.agent_public_key.as_ref())
else {
return Err(IdentityError::CertificateVerification(
"agent card is not signed".to_string(),
));
};
let pubkey_bytes = hex::decode(pk_hex)
.map_err(|e| IdentityError::CertificateVerification(format!("bad pubkey hex: {e}")))?;
let pubkey = MlDsaPublicKey::from_bytes(&pubkey_bytes)
.map_err(|e| IdentityError::CertificateVerification(format!("bad pubkey: {e:?}")))?;
let derived = hex::encode(ant_quic::derive_peer_id_from_public_key(&pubkey).0);
if derived != self.agent_id {
return Err(IdentityError::CertificateVerification(format!(
"agent_id {} does not match key-derived id {derived}",
self.agent_id
)));
}
let sig_bytes = hex::decode(sig_hex)
.map_err(|e| IdentityError::CertificateVerification(format!("bad sig hex: {e}")))?;
let sig = MlDsaSignature::from_bytes(&sig_bytes)
.map_err(|e| IdentityError::CertificateVerification(format!("bad sig: {e:?}")))?;
verify_with_ml_dsa(&pubkey, &self.signable_bytes(), &sig).map_err(|e| {
IdentityError::CertificateVerification(format!("agent card verify: {e:?}"))
})?;
Ok(())
}
}
fn push_len_prefixed(buf: &mut Vec<u8>, bytes: &[u8]) {
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u8) -> AgentId {
AgentId([n; 32])
}
#[test]
fn test_new_card() {
let card = AgentCard::new("David".to_string(), &agent(1), &hex::encode([2u8; 32]));
assert_eq!(card.display_name, "David");
assert_eq!(card.agent_id.len(), 64);
assert_eq!(card.machine_id.len(), 64);
assert!(card.user_id.is_none());
assert!(card.addresses.is_empty());
assert!(card.groups.is_empty());
assert!(card.created_at > 0);
}
#[test]
fn test_link_roundtrip() {
let mut card = AgentCard::new("Alice".to_string(), &agent(1), &hex::encode([2u8; 32]));
card.user_id = Some(hex::encode([3u8; 32]));
card.addresses = vec!["1.2.3.4:5483".to_string()];
card.groups.push(CardGroup {
name: "Team".to_string(),
invite_link: "x0x://invite/abc123".to_string(),
});
card.stores.push(CardStore {
name: "Shared".to_string(),
topic: "shared-kv".to_string(),
});
let link = card.to_link();
assert!(link.starts_with("x0x://agent/"));
let restored = AgentCard::from_link(&link).expect("parse");
assert_eq!(card.display_name, restored.display_name);
assert_eq!(card.agent_id, restored.agent_id);
assert_eq!(card.machine_id, restored.machine_id);
assert_eq!(card.user_id, restored.user_id);
assert_eq!(card.addresses, restored.addresses);
assert_eq!(card.groups.len(), 1);
assert_eq!(card.stores.len(), 1);
}
#[test]
fn test_from_link_raw_base64() {
let card = AgentCard::new("Bob".to_string(), &agent(5), &hex::encode([6u8; 32]));
let link = card.to_link();
let raw = link.strip_prefix("x0x://agent/").expect("prefix");
let restored = AgentCard::from_link(raw).expect("parse raw");
assert_eq!(card.agent_id, restored.agent_id);
}
#[test]
fn test_from_link_invalid() {
assert!(AgentCard::from_link("garbage!!!").is_err());
}
#[test]
fn test_short_display() {
let card = AgentCard::new("David".to_string(), &agent(1), &hex::encode([2u8; 32]));
let display = card.short_display();
assert!(display.starts_with("David ("));
assert!(display.contains('…'));
}
#[test]
fn test_minimal_card_no_optional_fields() {
let card = AgentCard::new("Minimal".to_string(), &agent(1), &hex::encode([2u8; 32]));
let json = serde_json::to_string(&card).expect("serialize");
assert!(!json.contains("user_id"));
assert!(!json.contains("groups"));
assert!(!json.contains("stores"));
}
#[test]
fn test_sign_and_verify_roundtrip() {
let kp = AgentKeypair::generate().expect("keypair");
let mut card = AgentCard::new(
"Signer".to_string(),
&kp.agent_id(),
&hex::encode([9u8; 32]),
);
card.addresses = vec!["1.2.3.4:5483".to_string()];
card.sign(&kp).expect("sign");
assert!(card.signature.is_some());
assert!(card.agent_public_key.is_some());
card.verify_signature().expect("verify");
}
#[test]
fn test_signature_detects_tamper() {
let kp = AgentKeypair::generate().expect("keypair");
let mut card = AgentCard::new(
"Signer".to_string(),
&kp.agent_id(),
&hex::encode([9u8; 32]),
);
card.sign(&kp).expect("sign");
let mut bad = card.clone();
bad.display_name = "Mallory".to_string();
assert!(bad.verify_signature().is_err());
let mut bad = card.clone();
bad.addresses.push("9.9.9.9:1".to_string());
assert!(bad.verify_signature().is_err());
}
#[test]
fn test_signature_rejects_forged_pubkey() {
let kp = AgentKeypair::generate().expect("kp");
let other = AgentKeypair::generate().expect("kp2");
let mut card = AgentCard::new(
"Signer".to_string(),
&kp.agent_id(),
&hex::encode([9u8; 32]),
);
card.sign(&kp).expect("sign");
card.agent_public_key = Some(hex::encode(other.public_key().as_bytes()));
assert!(card.verify_signature().is_err());
}
#[test]
fn test_unsigned_legacy_card_parses_but_verify_fails() {
let card = AgentCard::new("Legacy".to_string(), &agent(1), &hex::encode([2u8; 32]));
assert!(card.signature.is_none());
assert!(card.verify_signature().is_err());
let link = card.to_link();
let restored = AgentCard::from_link(&link).expect("parse");
assert!(restored.signature.is_none());
}
#[test]
fn test_signed_card_link_roundtrip_verifies() {
let kp = AgentKeypair::generate().expect("kp");
let mut card = AgentCard::new(
"Signer".to_string(),
&kp.agent_id(),
&hex::encode([9u8; 32]),
);
card.stores.push(CardStore {
name: "s".to_string(),
topic: "t".to_string(),
});
card.sign(&kp).expect("sign");
let link = card.to_link();
let restored = AgentCard::from_link(&link).expect("parse");
restored
.verify_signature()
.expect("verify after link roundtrip");
}
}