use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::crypto::{hash, Hash, PublicKey, Sig};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentAttestation {
agent_id: PublicKey,
code_hash: Hash,
config_hash: Hash,
prompt_hash: Hash,
tools: Vec<ToolAttestation>,
runtime: RuntimeAttestation,
attested_at: i64,
validity_period_ms: u64,
authority_signature: Sig,
authority: PublicKey,
}
impl AgentAttestation {
pub fn builder() -> AgentAttestationBuilder {
AgentAttestationBuilder::new()
}
pub fn agent_id(&self) -> &PublicKey {
&self.agent_id
}
pub fn code_hash(&self) -> &Hash {
&self.code_hash
}
pub fn config_hash(&self) -> &Hash {
&self.config_hash
}
pub fn prompt_hash(&self) -> &Hash {
&self.prompt_hash
}
pub fn tools(&self) -> &[ToolAttestation] {
&self.tools
}
pub fn runtime(&self) -> &RuntimeAttestation {
&self.runtime
}
pub fn attested_at(&self) -> i64 {
self.attested_at
}
pub fn validity_period(&self) -> Duration {
Duration::from_millis(self.validity_period_ms)
}
pub fn authority_signature(&self) -> &Sig {
&self.authority_signature
}
pub fn authority(&self) -> &PublicKey {
&self.authority
}
pub fn is_valid_at(&self, check_time: i64) -> bool {
let expires_at = self
.attested_at
.saturating_add(self.validity_period_ms as i64);
check_time < expires_at
}
pub fn expires_at(&self) -> i64 {
self.attested_at
.saturating_add(self.validity_period_ms as i64)
}
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&self.agent_id.as_bytes());
data.extend_from_slice(self.code_hash.as_bytes());
data.extend_from_slice(self.config_hash.as_bytes());
data.extend_from_slice(self.prompt_hash.as_bytes());
for tool in &self.tools {
data.extend_from_slice(tool.tool_id.as_bytes());
data.extend_from_slice(tool.version.as_bytes());
data.extend_from_slice(tool.implementation_hash.as_bytes());
}
data.extend_from_slice(self.runtime.runtime_id.as_bytes());
data.extend_from_slice(self.runtime.runtime_hash.as_bytes());
data.extend_from_slice(&self.attested_at.to_le_bytes());
data.extend_from_slice(&self.validity_period_ms.to_le_bytes());
data.extend_from_slice(&self.authority.as_bytes());
data
}
pub fn hash(&self) -> Hash {
hash(&self.canonical_bytes())
}
pub fn verify_signature(&self) -> Result<()> {
let bytes = self.canonical_bytes();
self.authority
.verify(&bytes, &self.authority_signature)
.map_err(|_| Error::invalid_input("Attestation signature verification failed"))
}
pub fn has_tool(&self, tool_id: &str) -> bool {
self.tools.iter().any(|t| t.tool_id == tool_id)
}
pub fn get_tool(&self, tool_id: &str) -> Option<&ToolAttestation> {
self.tools.iter().find(|t| t.tool_id == tool_id)
}
pub fn validate_binding(&self, actor_key: &PublicKey) -> Result<()> {
if self.agent_id != *actor_key {
return Err(Error::invalid_input(
"attestation agent_id does not match event actor",
));
}
Ok(())
}
pub fn validate_tool(&self, tool_id: &str) -> Result<()> {
if !self.has_tool(tool_id) {
return Err(Error::invalid_input(format!(
"tool '{}' not found in agent attestation",
tool_id
)));
}
Ok(())
}
pub fn validate_for_action(&self, actor_key: &PublicKey, action_time: i64) -> Result<()> {
if !self.is_valid_at(action_time) {
return Err(Error::invalid_input("attestation has expired"));
}
self.verify_signature()?;
self.validate_binding(actor_key)?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct AgentAttestationBuilder {
agent_id: Option<PublicKey>,
code_hash: Option<Hash>,
config_hash: Option<Hash>,
prompt_hash: Option<Hash>,
tools: Vec<ToolAttestation>,
runtime: Option<RuntimeAttestation>,
attested_at: Option<i64>,
validity_period_ms: Option<u64>,
authority: Option<PublicKey>,
}
impl AgentAttestationBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn agent_id(mut self, agent_id: PublicKey) -> Self {
self.agent_id = Some(agent_id);
self
}
pub fn code_hash(mut self, hash: Hash) -> Self {
self.code_hash = Some(hash);
self
}
pub fn config_hash(mut self, hash: Hash) -> Self {
self.config_hash = Some(hash);
self
}
pub fn prompt_hash(mut self, hash: Hash) -> Self {
self.prompt_hash = Some(hash);
self
}
pub fn tool(mut self, tool: ToolAttestation) -> Self {
self.tools.push(tool);
self
}
pub fn tools(mut self, tools: Vec<ToolAttestation>) -> Self {
self.tools = tools;
self
}
pub fn runtime(mut self, runtime: RuntimeAttestation) -> Self {
self.runtime = Some(runtime);
self
}
pub fn attested_at(mut self, timestamp: i64) -> Self {
self.attested_at = Some(timestamp);
self
}
pub fn validity_period(mut self, duration: Duration) -> Self {
self.validity_period_ms = Some(duration.as_millis() as u64);
self
}
pub fn authority(mut self, authority: PublicKey) -> Self {
self.authority = Some(authority);
self
}
pub fn sign(self, authority_key: &crate::crypto::SecretKey) -> Result<AgentAttestation> {
let agent_id = self
.agent_id
.ok_or_else(|| Error::invalid_input("agent_id is required"))?;
let code_hash = self
.code_hash
.ok_or_else(|| Error::invalid_input("code_hash is required"))?;
if code_hash.as_bytes().iter().all(|&b| b == 0) {
return Err(Error::invalid_input("code_hash cannot be empty"));
}
let config_hash = self
.config_hash
.ok_or_else(|| Error::invalid_input("config_hash is required"))?;
let prompt_hash = self
.prompt_hash
.ok_or_else(|| Error::invalid_input("prompt_hash is required"))?;
let runtime = self
.runtime
.ok_or_else(|| Error::invalid_input("runtime is required"))?;
let attested_at = self
.attested_at
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
let validity_period_ms = self.validity_period_ms.unwrap_or(3600 * 1000);
if validity_period_ms == 0 {
return Err(Error::invalid_input("validity_period must be positive"));
}
let authority = self.authority.unwrap_or_else(|| authority_key.public_key());
let mut attestation = AgentAttestation {
agent_id,
code_hash,
config_hash,
prompt_hash,
tools: self.tools,
runtime,
attested_at,
validity_period_ms,
authority_signature: Sig::empty(), authority,
};
let canonical = attestation.canonical_bytes();
attestation.authority_signature = authority_key.sign(&canonical);
Ok(attestation)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RequiredCapability {
Read,
Write,
Delete,
Execute,
InvokeTool { tool_id: String },
SpawnAgent,
DelegateCapability,
SendMessage { channel: String },
ReceiveMessage { channel: String },
Spend { currency: String },
ModifyPermissions,
ViewAuditLog,
FileSystem,
Network,
}
impl RequiredCapability {
pub fn read() -> Self {
Self::Read
}
pub fn write() -> Self {
Self::Write
}
pub fn execute() -> Self {
Self::Execute
}
pub fn invoke_tool(tool_id: impl Into<String>) -> Self {
Self::InvokeTool {
tool_id: tool_id.into(),
}
}
pub fn file_system() -> Self {
Self::FileSystem
}
pub fn network() -> Self {
Self::Network
}
}
impl std::fmt::Display for RequiredCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RequiredCapability::Read => write!(f, "read"),
RequiredCapability::Write => write!(f, "write"),
RequiredCapability::Delete => write!(f, "delete"),
RequiredCapability::Execute => write!(f, "execute"),
RequiredCapability::InvokeTool { tool_id } => write!(f, "invoke_tool:{}", tool_id),
RequiredCapability::SpawnAgent => write!(f, "spawn_agent"),
RequiredCapability::DelegateCapability => write!(f, "delegate_capability"),
RequiredCapability::SendMessage { channel } => write!(f, "send_message:{}", channel),
RequiredCapability::ReceiveMessage { channel } => {
write!(f, "receive_message:{}", channel)
}
RequiredCapability::Spend { currency } => write!(f, "spend:{}", currency),
RequiredCapability::ModifyPermissions => write!(f, "modify_permissions"),
RequiredCapability::ViewAuditLog => write!(f, "view_audit_log"),
RequiredCapability::FileSystem => write!(f, "file_system"),
RequiredCapability::Network => write!(f, "network"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolAttestation {
pub tool_id: String,
pub version: String,
pub implementation_hash: Hash,
pub required_capabilities: Vec<RequiredCapability>,
}
impl ToolAttestation {
pub fn new(
tool_id: impl Into<String>,
version: impl Into<String>,
implementation_hash: Hash,
) -> Self {
Self {
tool_id: tool_id.into(),
version: version.into(),
implementation_hash,
required_capabilities: Vec::new(),
}
}
pub fn with_capability(mut self, capability: RequiredCapability) -> Self {
self.required_capabilities.push(capability);
self
}
pub fn with_capabilities(mut self, capabilities: Vec<RequiredCapability>) -> Self {
self.required_capabilities = capabilities;
self
}
pub fn requires(&self, capability: &RequiredCapability) -> bool {
self.required_capabilities.contains(capability)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeAttestation {
pub runtime_id: String,
pub runtime_hash: Hash,
pub tee_quote: Option<TeeQuote>,
pub platform_hash: Option<Hash>,
}
impl RuntimeAttestation {
pub fn new(runtime_id: impl Into<String>, runtime_hash: Hash) -> Self {
Self {
runtime_id: runtime_id.into(),
runtime_hash,
tee_quote: None,
platform_hash: None,
}
}
pub fn with_tee(mut self, quote: TeeQuote) -> Self {
self.tee_quote = Some(quote);
self
}
pub fn with_platform_hash(mut self, hash: Hash) -> Self {
self.platform_hash = Some(hash);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeeQuote {
pub tee_type: TeeType,
pub quote: Vec<u8>,
pub measurements: Vec<Hash>,
}
impl TeeQuote {
pub fn new(tee_type: TeeType, quote: Vec<u8>) -> Self {
Self {
tee_type,
quote,
measurements: Vec::new(),
}
}
pub fn with_measurement(mut self, hash: Hash) -> Self {
self.measurements.push(hash);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeeType {
IntelSgx,
IntelTdx,
AmdSevSnp,
ArmCca,
Software,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttestationError {
NotFound,
Expired,
Revoked,
UntrustedAuthority,
InvalidSignature,
AgentMismatch,
ToolNotAttested(String),
}
impl std::fmt::Display for AttestationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AttestationError::NotFound => write!(f, "Attestation not found"),
AttestationError::Expired => write!(f, "Attestation has expired"),
AttestationError::Revoked => write!(f, "Attestation has been revoked"),
AttestationError::UntrustedAuthority => write!(f, "Attestation authority not trusted"),
AttestationError::InvalidSignature => write!(f, "Attestation signature invalid"),
AttestationError::AgentMismatch => write!(f, "Agent ID does not match attestation"),
AttestationError::ToolNotAttested(tool) => {
write!(f, "Tool '{}' not in agent's attestation", tool)
}
}
}
}
impl std::error::Error for AttestationError {}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{hash, SecretKey};
fn test_runtime() -> RuntimeAttestation {
RuntimeAttestation::new("test-runtime-v1.0.0", hash(b"runtime-binary"))
}
fn test_tool() -> ToolAttestation {
ToolAttestation::new("read_file", "1.0.0", hash(b"read-file-impl"))
}
#[test]
fn attestation_requires_code_hash() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let result = AgentAttestation::builder()
.agent_id(agent.public_key())
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.sign(&authority);
assert!(result.is_err());
}
#[test]
fn attestation_rejects_empty_code_hash() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let result = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(Hash::from_bytes([0u8; 32])) .config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.sign(&authority);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn attestation_requires_valid_signature() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.sign(&authority)
.unwrap();
assert!(attestation.verify_signature().is_ok());
let mut tampered = attestation.clone();
tampered.attested_at += 1;
assert!(tampered.verify_signature().is_err());
}
#[test]
fn attestation_validity_period_must_be_positive() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let result = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.validity_period(Duration::from_secs(0)) .sign(&authority);
assert!(result.is_err());
}
#[test]
fn attestation_valid_within_validity_period() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let start = 1000000i64;
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(start)
.validity_period(Duration::from_secs(3600)) .sign(&authority)
.unwrap();
assert!(attestation.is_valid_at(start));
assert!(attestation.is_valid_at(start + 1800 * 1000));
assert!(attestation.is_valid_at(start + 3540 * 1000));
}
#[test]
fn attestation_invalid_after_expiry() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let start = 1000000i64;
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(start)
.validity_period(Duration::from_secs(3600)) .sign(&authority)
.unwrap();
assert!(!attestation.is_valid_at(start + 3600 * 1000));
assert!(!attestation.is_valid_at(start + 3601 * 1000));
}
#[test]
fn tool_attestation_includes_version() {
let tool = ToolAttestation::new("bash", "5.1.0", hash(b"bash-impl"));
assert_eq!(tool.version, "5.1.0");
}
#[test]
fn tool_attestation_includes_implementation_hash() {
let impl_hash = hash(b"tool-implementation");
let tool = ToolAttestation::new("read_file", "1.0.0", impl_hash);
assert_eq!(tool.implementation_hash, impl_hash);
}
#[test]
fn tool_attestation_with_capabilities() {
let tool = ToolAttestation::new("bash", "5.1.0", hash(b"bash-impl"))
.with_capability(RequiredCapability::Execute)
.with_capability(RequiredCapability::FileSystem);
assert_eq!(tool.required_capabilities.len(), 2);
assert!(tool.requires(&RequiredCapability::Execute));
assert!(tool.requires(&RequiredCapability::FileSystem));
assert!(!tool.requires(&RequiredCapability::Network));
}
#[test]
fn attestation_has_tool_check() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.tool(test_tool())
.tool(ToolAttestation::new("bash", "5.1", hash(b"bash")))
.sign(&authority)
.unwrap();
assert!(attestation.has_tool("read_file"));
assert!(attestation.has_tool("bash"));
assert!(!attestation.has_tool("write_file"));
}
#[test]
fn runtime_attestation_with_tee() {
let runtime = RuntimeAttestation::new("claude-v1", hash(b"runtime"))
.with_tee(TeeQuote::new(TeeType::IntelSgx, vec![1, 2, 3, 4]));
assert!(runtime.tee_quote.is_some());
assert_eq!(runtime.tee_quote.unwrap().tee_type, TeeType::IntelSgx);
}
#[test]
fn runtime_attestation_with_platform_hash() {
let platform = hash(b"platform-measurements");
let runtime =
RuntimeAttestation::new("claude-v1", hash(b"runtime")).with_platform_hash(platform);
assert_eq!(runtime.platform_hash, Some(platform));
}
#[test]
fn attestation_hash_deterministic() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000000)
.sign(&authority)
.unwrap();
let h1 = attestation.hash();
let h2 = attestation.hash();
assert_eq!(h1, h2);
}
#[test]
fn different_attestations_different_hash() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation1 = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code1"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000000)
.sign(&authority)
.unwrap();
let attestation2 = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code2")) .config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000000)
.sign(&authority)
.unwrap();
assert_ne!(attestation1.hash(), attestation2.hash());
}
#[test]
fn attestation_getters_work() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let code = hash(b"code");
let config = hash(b"config");
let prompt = hash(b"prompt");
let runtime = test_runtime();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(code)
.config_hash(config)
.prompt_hash(prompt)
.runtime(runtime.clone())
.attested_at(1000000)
.validity_period(Duration::from_secs(7200))
.sign(&authority)
.unwrap();
assert_eq!(attestation.agent_id(), &agent.public_key());
assert_eq!(attestation.code_hash(), &code);
assert_eq!(attestation.config_hash(), &config);
assert_eq!(attestation.prompt_hash(), &prompt);
assert_eq!(attestation.runtime().runtime_id, runtime.runtime_id);
assert_eq!(attestation.attested_at(), 1000000);
assert_eq!(attestation.validity_period(), Duration::from_secs(7200));
assert_eq!(attestation.authority(), &authority.public_key());
assert_eq!(attestation.expires_at(), 1000000 + 7200 * 1000);
}
#[test]
fn validate_binding_passes_for_matching_agent() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.sign(&authority)
.unwrap();
assert!(attestation.validate_binding(&agent.public_key()).is_ok());
}
#[test]
fn validate_binding_fails_for_different_agent() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let other = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.sign(&authority)
.unwrap();
let result = attestation.validate_binding(&other.public_key());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not match"));
}
#[test]
fn validate_tool_passes_for_attested_tool() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.tool(test_tool())
.sign(&authority)
.unwrap();
assert!(attestation.validate_tool("read_file").is_ok());
}
#[test]
fn validate_tool_fails_for_unattested_tool() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.tool(test_tool())
.sign(&authority)
.unwrap();
let result = attestation.validate_tool("execute_code");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn validate_for_action_passes_when_valid() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000)
.validity_period(Duration::from_secs(3600))
.sign(&authority)
.unwrap();
assert!(attestation
.validate_for_action(&agent.public_key(), 2000)
.is_ok());
}
#[test]
fn validate_for_action_fails_when_expired() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000)
.validity_period(Duration::from_secs(1))
.sign(&authority)
.unwrap();
let result = attestation.validate_for_action(&agent.public_key(), 3000);
assert!(result.is_err());
}
#[test]
fn validate_for_action_fails_for_wrong_agent() {
let authority = SecretKey::generate();
let agent = SecretKey::generate();
let other = SecretKey::generate();
let attestation = AgentAttestation::builder()
.agent_id(agent.public_key())
.code_hash(hash(b"code"))
.config_hash(hash(b"config"))
.prompt_hash(hash(b"prompt"))
.runtime(test_runtime())
.attested_at(1000)
.validity_period(Duration::from_secs(3600))
.sign(&authority)
.unwrap();
let result = attestation.validate_for_action(&other.public_key(), 2000);
assert!(result.is_err());
}
}