Skip to main content

treeship_core/
agent.rs

1//! Agent Identity Certificate schema.
2//!
3//! An Agent Identity Certificate is a signed credential that proves who an
4//! agent is and what it is authorized to do. Produced once when an agent
5//! registers, lives permanently with the agent. The TLS certificate
6//! equivalent for AI agents.
7
8use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use ed25519_dalek::{Signature as DalekSignature, Verifier as DalekVerifier, VerifyingKey};
10use serde::{Deserialize, Serialize};
11
12/// Agent identity: who the agent is.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AgentIdentity {
15    pub agent_name: String,
16    pub ship_id: String,
17    pub public_key: String,
18    pub issuer: String,
19    pub issued_at: String,
20    pub valid_until: String,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub model: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25}
26
27/// Agent capabilities: what tools and services the agent is authorized to use.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AgentCapabilities {
30    /// Authorized MCP tool names.
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub tools: Vec<ToolCapability>,
33    /// Authorized API endpoints.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub api_endpoints: Vec<String>,
36    /// Authorized MCP server names.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub mcp_servers: Vec<String>,
39}
40
41/// A single authorized tool with optional description.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ToolCapability {
44    pub name: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub description: Option<String>,
47}
48
49/// Agent declaration: scope constraints.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct AgentDeclaration {
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub bounded_actions: Vec<String>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub forbidden: Vec<String>,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub escalation_required: Vec<String>,
58}
59
60/// The complete Agent Certificate -- identity + capabilities + declaration
61/// with a signature over the canonical JSON of all three.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AgentCertificate {
64    pub r#type: String, // "treeship/agent-certificate/v1"
65    /// Schema version. Absent on pre-v0.9.0 certificates (treated as "0").
66    /// Set to "1" for v0.9.0+. Informational only in v0.9.0; future versions
67    /// may use this to gate verification rule selection.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub schema_version: Option<String>,
70    pub identity: AgentIdentity,
71    pub capabilities: AgentCapabilities,
72    pub declaration: AgentDeclaration,
73    pub signature: CertificateSignature,
74}
75
76/// Signature over the certificate content.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CertificateSignature {
79    pub algorithm: String,     // "ed25519"
80    pub key_id: String,
81    pub public_key: String,    // base64url-encoded Ed25519 public key
82    pub signature: String,     // base64url-encoded Ed25519 signature
83    pub signed_fields: String, // "identity+capabilities+declaration"
84}
85
86pub const CERTIFICATE_TYPE: &str = "treeship/agent-certificate/v1";
87
88/// Current certificate schema version. Certificates without this field are
89/// treated as schema "0" and verified under legacy rules (pre-v0.9.0 shape).
90pub const CERTIFICATE_SCHEMA_VERSION: &str = "1";
91
92/// Resolve a schema_version Option to its effective string, defaulting to
93/// "0" when absent. Centralizing this avoids the legacy default leaking out
94/// across call sites.
95pub fn effective_schema_version(field: Option<&str>) -> &str {
96    field.unwrap_or("0")
97}
98
99/// Errors verifying an `AgentCertificate` signature.
100#[derive(Debug)]
101pub enum CertificateVerifyError {
102    /// Public key in `signature.public_key` was not valid base64url or wrong length.
103    BadPublicKey(String),
104    /// Signature bytes were not valid base64url or wrong length.
105    BadSignature(String),
106    /// Could not reconstruct canonical signed payload.
107    PayloadEncode(String),
108    /// Signature did not verify against the embedded public key.
109    InvalidSignature,
110    /// Signature algorithm is not supported (only `ed25519` is recognized).
111    UnsupportedAlgorithm(String),
112    /// `signed_fields` does not name the expected payload composition.
113    UnsupportedSignedFields(String),
114}
115
116impl std::fmt::Display for CertificateVerifyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::BadPublicKey(s) => write!(f, "certificate public key: {s}"),
120            Self::BadSignature(s) => write!(f, "certificate signature bytes: {s}"),
121            Self::PayloadEncode(s) => write!(f, "certificate canonical encoding: {s}"),
122            Self::InvalidSignature => write!(f, "certificate signature did not verify"),
123            Self::UnsupportedAlgorithm(s) => write!(f, "certificate algorithm '{s}' not supported"),
124            Self::UnsupportedSignedFields(s) => {
125                write!(f, "certificate signed_fields '{s}' not recognized")
126            }
127        }
128    }
129}
130
131impl std::error::Error for CertificateVerifyError {}
132
133/// The signed payload composition used by v0.x agent certificates.
134const SIGNED_FIELDS_V1: &str = "identity+capabilities+declaration";
135
136/// Verify the Ed25519 signature on an `AgentCertificate` against the public
137/// key embedded in `signature.public_key`. Reconstructs the same canonical
138/// JSON the issuer signed and checks the bytes match.
139///
140/// This does NOT check certificate validity (issued_at / valid_until) or
141/// chain to a trusted issuer. Validity is the cross-verifier's job; trust
142/// chaining is out of scope for v0.9.0.
143pub fn verify_certificate(cert: &AgentCertificate) -> Result<(), CertificateVerifyError> {
144    if cert.signature.algorithm != "ed25519" {
145        return Err(CertificateVerifyError::UnsupportedAlgorithm(
146            cert.signature.algorithm.clone(),
147        ));
148    }
149    if cert.signature.signed_fields != SIGNED_FIELDS_V1 {
150        return Err(CertificateVerifyError::UnsupportedSignedFields(
151            cert.signature.signed_fields.clone(),
152        ));
153    }
154
155    let pk_bytes = URL_SAFE_NO_PAD
156        .decode(&cert.signature.public_key)
157        .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
158    let pk_arr: [u8; 32] = pk_bytes
159        .as_slice()
160        .try_into()
161        .map_err(|_| CertificateVerifyError::BadPublicKey(format!("expected 32 bytes, got {}", pk_bytes.len())))?;
162    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
163        .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
164
165    let sig_bytes = URL_SAFE_NO_PAD
166        .decode(&cert.signature.signature)
167        .map_err(|e| CertificateVerifyError::BadSignature(e.to_string()))?;
168    let sig_arr: [u8; 64] = sig_bytes
169        .as_slice()
170        .try_into()
171        .map_err(|_| CertificateVerifyError::BadSignature(format!("expected 64 bytes, got {}", sig_bytes.len())))?;
172    let signature = DalekSignature::from_bytes(&sig_arr);
173
174    // Reconstruct the canonical signed payload exactly as the issuer did:
175    // {identity, capabilities, declaration} serialized with serde_json (which
176    // preserves struct field declaration order).
177    let payload = serde_json::json!({
178        "identity": cert.identity,
179        "capabilities": cert.capabilities,
180        "declaration": cert.declaration,
181    });
182    let canonical = serde_json::to_vec(&payload)
183        .map_err(|e| CertificateVerifyError::PayloadEncode(e.to_string()))?;
184
185    verifying_key
186        .verify(&canonical, &signature)
187        .map_err(|_| CertificateVerifyError::InvalidSignature)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn sample_certificate(schema_version: Option<&str>) -> AgentCertificate {
195        AgentCertificate {
196            r#type: CERTIFICATE_TYPE.into(),
197            schema_version: schema_version.map(|s| s.to_string()),
198            identity: AgentIdentity {
199                agent_name: "agent-007".into(),
200                ship_id: "ship_demo".into(),
201                public_key: "pk_b64".into(),
202                issuer: "ship://ship_demo".into(),
203                issued_at: "2026-04-15T00:00:00Z".into(),
204                valid_until: "2026-10-15T00:00:00Z".into(),
205                model: None,
206                description: None,
207            },
208            capabilities: AgentCapabilities {
209                tools: vec![ToolCapability { name: "Bash".into(), description: None }],
210                api_endpoints: vec![],
211                mcp_servers: vec![],
212            },
213            declaration: AgentDeclaration {
214                bounded_actions: vec!["Bash".into()],
215                forbidden: vec![],
216                escalation_required: vec![],
217            },
218            signature: CertificateSignature {
219                algorithm: "ed25519".into(),
220                key_id: "key_demo".into(),
221                public_key: "pk_b64".into(),
222                signature: "sig_b64".into(),
223                signed_fields: "identity+capabilities+declaration".into(),
224            },
225        }
226    }
227
228    #[test]
229    fn legacy_certificate_round_trips_byte_identical() {
230        // schema_version=None mimics a pre-v0.9.0 certificate. Re-serializing
231        // must skip the field entirely so the original bytes (and therefore
232        // any signature over those bytes if a future format binds them) is
233        // preserved.
234        let cert = sample_certificate(None);
235        let bytes = serde_json::to_vec(&cert).unwrap();
236        let s = std::str::from_utf8(&bytes).unwrap();
237        assert!(!s.contains("schema_version"),
238            "legacy cert must omit schema_version, got: {s}");
239
240        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
241        assert!(parsed.schema_version.is_none());
242        let reserialized = serde_json::to_vec(&parsed).unwrap();
243        assert_eq!(bytes, reserialized);
244        assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "0");
245    }
246
247    #[test]
248    fn verify_certificate_round_trip() {
249        // Mint a cert, sign it the way the CLI does, then call verify.
250        use crate::attestation::{Ed25519Signer, Signer};
251        let signer = Ed25519Signer::generate("key_demo").unwrap();
252        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
253
254        let identity = AgentIdentity {
255            agent_name: "agent-007".into(),
256            ship_id: "ship_x".into(),
257            public_key: pk_b64.clone(),
258            issuer: "ship://ship_x".into(),
259            issued_at: "2026-04-15T00:00:00Z".into(),
260            valid_until: "2027-04-15T00:00:00Z".into(),
261            model: None,
262            description: None,
263        };
264        let capabilities = AgentCapabilities {
265            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
266            api_endpoints: vec![],
267            mcp_servers: vec![],
268        };
269        let declaration = AgentDeclaration {
270            bounded_actions: vec!["Bash".into()],
271            forbidden: vec![],
272            escalation_required: vec![],
273        };
274        let payload = serde_json::json!({
275            "identity": identity, "capabilities": capabilities, "declaration": declaration,
276        });
277        let canonical = serde_json::to_vec(&payload).unwrap();
278        let sig = signer.sign(&canonical).unwrap();
279
280        let cert = AgentCertificate {
281            r#type: CERTIFICATE_TYPE.into(),
282            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
283            identity,
284            capabilities,
285            declaration,
286            signature: CertificateSignature {
287                algorithm: "ed25519".into(),
288                key_id: "key_demo".into(),
289                public_key: pk_b64,
290                signature: URL_SAFE_NO_PAD.encode(sig),
291                signed_fields: "identity+capabilities+declaration".into(),
292            },
293        };
294
295        verify_certificate(&cert).expect("freshly-signed cert must verify");
296    }
297
298    #[test]
299    fn verify_certificate_detects_tampered_payload() {
300        use crate::attestation::{Ed25519Signer, Signer};
301        let signer = Ed25519Signer::generate("key_demo").unwrap();
302        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
303
304        let identity = AgentIdentity {
305            agent_name: "agent-007".into(),
306            ship_id: "ship_x".into(),
307            public_key: pk_b64.clone(),
308            issuer: "ship://ship_x".into(),
309            issued_at: "2026-04-15T00:00:00Z".into(),
310            valid_until: "2027-04-15T00:00:00Z".into(),
311            model: None,
312            description: None,
313        };
314        let capabilities = AgentCapabilities {
315            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
316            api_endpoints: vec![],
317            mcp_servers: vec![],
318        };
319        let declaration = AgentDeclaration {
320            bounded_actions: vec!["Bash".into()],
321            forbidden: vec![],
322            escalation_required: vec![],
323        };
324        let payload = serde_json::json!({
325            "identity": identity, "capabilities": capabilities, "declaration": declaration,
326        });
327        let canonical = serde_json::to_vec(&payload).unwrap();
328        let sig = signer.sign(&canonical).unwrap();
329
330        // Tamper: expand the tools list AFTER signing. Signature was computed
331        // over the smaller list so it should no longer verify.
332        let evil_caps = AgentCapabilities {
333            tools: vec![
334                ToolCapability { name: "Bash".into(), description: None },
335                ToolCapability { name: "DropDatabase".into(), description: None },
336            ],
337            api_endpoints: vec![],
338            mcp_servers: vec![],
339        };
340
341        let cert = AgentCertificate {
342            r#type: CERTIFICATE_TYPE.into(),
343            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
344            identity,
345            capabilities: evil_caps,
346            declaration,
347            signature: CertificateSignature {
348                algorithm: "ed25519".into(),
349                key_id: "key_demo".into(),
350                public_key: pk_b64,
351                signature: URL_SAFE_NO_PAD.encode(sig),
352                signed_fields: "identity+capabilities+declaration".into(),
353            },
354        };
355
356        let err = verify_certificate(&cert).unwrap_err();
357        assert!(matches!(err, CertificateVerifyError::InvalidSignature),
358            "expected InvalidSignature, got: {err}");
359    }
360
361    #[test]
362    fn verify_certificate_rejects_unsupported_algorithm() {
363        let mut cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
364        cert.signature.algorithm = "rsa-pss-sha256".into();
365        let err = verify_certificate(&cert).unwrap_err();
366        assert!(matches!(err, CertificateVerifyError::UnsupportedAlgorithm(_)));
367    }
368
369    #[test]
370    fn current_certificate_carries_schema_version_one() {
371        let cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
372        let bytes = serde_json::to_vec(&cert).unwrap();
373        let s = std::str::from_utf8(&bytes).unwrap();
374        assert!(s.contains(r#""schema_version":"1""#),
375            "current cert must include schema_version=1, got: {s}");
376        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
377        assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "1");
378    }
379}