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    /// The embedded `signature.public_key` is not pinned in the
115    /// operator's trust root store under kind `AgentCert`. The signature
116    /// math may be internally consistent, but the issuer is unknown.
117    /// Self-signed certificates an attacker mints to authorize their own
118    /// agent's tool calls land here.
119    UntrustedIssuer { key_id: String },
120    /// No trust roots configured at all (or none for kind `AgentCert`).
121    /// Distinct from `UntrustedIssuer` so the CLI can render the
122    /// "configure trust" remediation rather than "key not in store".
123    NoTrustConfigured,
124}
125
126impl std::fmt::Display for CertificateVerifyError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::BadPublicKey(s) => write!(f, "certificate public key: {s}"),
130            Self::BadSignature(s) => write!(f, "certificate signature bytes: {s}"),
131            Self::PayloadEncode(s) => write!(f, "certificate canonical encoding: {s}"),
132            Self::InvalidSignature => write!(f, "certificate signature did not verify"),
133            Self::UnsupportedAlgorithm(s) => write!(f, "certificate algorithm '{s}' not supported"),
134            Self::UnsupportedSignedFields(s) => {
135                write!(f, "certificate signed_fields '{s}' not recognized")
136            }
137            Self::UntrustedIssuer { key_id } => write!(
138                f,
139                "certificate issuer (key_id={key_id}) is not in the trust root store. \
140                 Run `treeship trust add <key_id> <pubkey> --kind agent_cert` if you trust this issuer.",
141            ),
142            Self::NoTrustConfigured => write!(
143                f,
144                "no trust roots configured for agent certificates. \
145                 Run `treeship trust add <key_id> <pubkey> --kind agent_cert` \
146                 or sync from your hub via `treeship hub sync-trust`.",
147            ),
148        }
149    }
150}
151
152impl std::error::Error for CertificateVerifyError {}
153
154/// The signed payload composition used by v0.x agent certificates.
155const SIGNED_FIELDS_V1: &str = "identity+capabilities+declaration";
156
157/// Verify the Ed25519 signature on an `AgentCertificate`. Requires the
158/// embedded `signature.public_key` to be present in `trust` under kind
159/// `AgentCert`, then reconstructs the canonical JSON the issuer signed
160/// and verifies the signature bytes match.
161///
162/// Trust pinning is mandatory. Before this, the function trusted whichever
163/// public key the certificate happened to carry -- making the certificate
164/// self-signed. With it, an operator pins which issuer keys are allowed to
165/// vouch for agents, and any other issuer's certificate is rejected with
166/// `UntrustedIssuer` (or `NoTrustConfigured` when the store has nothing
167/// for kind `AgentCert`).
168///
169/// This does NOT check certificate validity windows (issued_at /
170/// valid_until). That's the cross-verifier's job.
171pub fn verify_certificate(
172    cert: &AgentCertificate,
173    trust: &crate::trust::TrustRootStore,
174) -> Result<(), CertificateVerifyError> {
175    use crate::trust::TrustRootKind;
176
177    if cert.signature.algorithm != "ed25519" {
178        return Err(CertificateVerifyError::UnsupportedAlgorithm(
179            cert.signature.algorithm.clone(),
180        ));
181    }
182    if cert.signature.signed_fields != SIGNED_FIELDS_V1 {
183        return Err(CertificateVerifyError::UnsupportedSignedFields(
184            cert.signature.signed_fields.clone(),
185        ));
186    }
187
188    let pk_bytes = URL_SAFE_NO_PAD
189        .decode(&cert.signature.public_key)
190        .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
191    let pk_arr: [u8; 32] = pk_bytes
192        .as_slice()
193        .try_into()
194        .map_err(|_| CertificateVerifyError::BadPublicKey(format!("expected 32 bytes, got {}", pk_bytes.len())))?;
195    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
196        .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
197
198    // Trust pin -- fail-closed before signature math runs. We
199    // distinguish "no trust configured at all (for this kind)" from
200    // "trust configured but this issuer isn't in it" so the CLI can
201    // render a more useful remediation.
202    if !trust.contains(&verifying_key, TrustRootKind::AgentCert) {
203        if trust.is_empty_for_kind(TrustRootKind::AgentCert) {
204            return Err(CertificateVerifyError::NoTrustConfigured);
205        }
206        return Err(CertificateVerifyError::UntrustedIssuer {
207            key_id: cert.signature.key_id.clone(),
208        });
209    }
210
211    let sig_bytes = URL_SAFE_NO_PAD
212        .decode(&cert.signature.signature)
213        .map_err(|e| CertificateVerifyError::BadSignature(e.to_string()))?;
214    let sig_arr: [u8; 64] = sig_bytes
215        .as_slice()
216        .try_into()
217        .map_err(|_| CertificateVerifyError::BadSignature(format!("expected 64 bytes, got {}", sig_bytes.len())))?;
218    let signature = DalekSignature::from_bytes(&sig_arr);
219
220    // Reconstruct the canonical signed payload exactly as the issuer did:
221    // {identity, capabilities, declaration} serialized with serde_json (which
222    // preserves struct field declaration order).
223    let payload = serde_json::json!({
224        "identity": cert.identity,
225        "capabilities": cert.capabilities,
226        "declaration": cert.declaration,
227    });
228    let canonical = serde_json::to_vec(&payload)
229        .map_err(|e| CertificateVerifyError::PayloadEncode(e.to_string()))?;
230
231    verifying_key
232        .verify(&canonical, &signature)
233        .map_err(|_| CertificateVerifyError::InvalidSignature)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn sample_certificate(schema_version: Option<&str>) -> AgentCertificate {
241        AgentCertificate {
242            r#type: CERTIFICATE_TYPE.into(),
243            schema_version: schema_version.map(|s| s.to_string()),
244            identity: AgentIdentity {
245                agent_name: "agent-007".into(),
246                ship_id: "ship_demo".into(),
247                public_key: "pk_b64".into(),
248                issuer: "ship://ship_demo".into(),
249                issued_at: "2026-04-15T00:00:00Z".into(),
250                valid_until: "2026-10-15T00:00:00Z".into(),
251                model: None,
252                description: None,
253            },
254            capabilities: AgentCapabilities {
255                tools: vec![ToolCapability { name: "Bash".into(), description: None }],
256                api_endpoints: vec![],
257                mcp_servers: vec![],
258            },
259            declaration: AgentDeclaration {
260                bounded_actions: vec!["Bash".into()],
261                forbidden: vec![],
262                escalation_required: vec![],
263            },
264            signature: CertificateSignature {
265                algorithm: "ed25519".into(),
266                key_id: "key_demo".into(),
267                public_key: "pk_b64".into(),
268                signature: "sig_b64".into(),
269                signed_fields: "identity+capabilities+declaration".into(),
270            },
271        }
272    }
273
274    #[test]
275    fn legacy_certificate_round_trips_byte_identical() {
276        // schema_version=None mimics a pre-v0.9.0 certificate. Re-serializing
277        // must skip the field entirely so the original bytes (and therefore
278        // any signature over those bytes if a future format binds them) is
279        // preserved.
280        let cert = sample_certificate(None);
281        let bytes = serde_json::to_vec(&cert).unwrap();
282        let s = std::str::from_utf8(&bytes).unwrap();
283        assert!(!s.contains("schema_version"),
284            "legacy cert must omit schema_version, got: {s}");
285
286        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
287        assert!(parsed.schema_version.is_none());
288        let reserialized = serde_json::to_vec(&parsed).unwrap();
289        assert_eq!(bytes, reserialized);
290        assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "0");
291    }
292
293    /// Build a single-entry trust store pinning `pk_b64` for kind
294    /// `AgentCert`. Tests that exercise valid signatures use this so the
295    /// trust pin doesn't short-circuit before signature verification.
296    fn trust_with(pk_b64: &str) -> crate::trust::TrustRootStore {
297        use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
298        TrustRootStore::with_roots(vec![TrustRoot {
299            key_id:     "key_demo".into(),
300            public_key: format!("ed25519:{pk_b64}"),
301            kind:       TrustRootKind::AgentCert,
302            label:      "test issuer".into(),
303            added_at:   "2026-05-15T00:00:00Z".into(),
304        }])
305    }
306
307    #[test]
308    fn verify_certificate_round_trip() {
309        // Mint a cert, sign it the way the CLI does, then call verify.
310        use crate::attestation::{Ed25519Signer, Signer};
311        let signer = Ed25519Signer::generate("key_demo").unwrap();
312        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
313
314        let identity = AgentIdentity {
315            agent_name: "agent-007".into(),
316            ship_id: "ship_x".into(),
317            public_key: pk_b64.clone(),
318            issuer: "ship://ship_x".into(),
319            issued_at: "2026-04-15T00:00:00Z".into(),
320            valid_until: "2027-04-15T00:00:00Z".into(),
321            model: None,
322            description: None,
323        };
324        let capabilities = AgentCapabilities {
325            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
326            api_endpoints: vec![],
327            mcp_servers: vec![],
328        };
329        let declaration = AgentDeclaration {
330            bounded_actions: vec!["Bash".into()],
331            forbidden: vec![],
332            escalation_required: vec![],
333        };
334        let payload = serde_json::json!({
335            "identity": identity, "capabilities": capabilities, "declaration": declaration,
336        });
337        let canonical = serde_json::to_vec(&payload).unwrap();
338        let sig = signer.sign(&canonical).unwrap();
339
340        let cert = AgentCertificate {
341            r#type: CERTIFICATE_TYPE.into(),
342            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
343            identity,
344            capabilities,
345            declaration,
346            signature: CertificateSignature {
347                algorithm: "ed25519".into(),
348                key_id: "key_demo".into(),
349                public_key: pk_b64.clone(),
350                signature: URL_SAFE_NO_PAD.encode(sig),
351                signed_fields: "identity+capabilities+declaration".into(),
352            },
353        };
354
355        let trust = trust_with(&pk_b64);
356        verify_certificate(&cert, &trust).expect("freshly-signed cert must verify");
357    }
358
359    #[test]
360    fn verify_certificate_detects_tampered_payload() {
361        use crate::attestation::{Ed25519Signer, Signer};
362        let signer = Ed25519Signer::generate("key_demo").unwrap();
363        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
364
365        let identity = AgentIdentity {
366            agent_name: "agent-007".into(),
367            ship_id: "ship_x".into(),
368            public_key: pk_b64.clone(),
369            issuer: "ship://ship_x".into(),
370            issued_at: "2026-04-15T00:00:00Z".into(),
371            valid_until: "2027-04-15T00:00:00Z".into(),
372            model: None,
373            description: None,
374        };
375        let capabilities = AgentCapabilities {
376            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
377            api_endpoints: vec![],
378            mcp_servers: vec![],
379        };
380        let declaration = AgentDeclaration {
381            bounded_actions: vec!["Bash".into()],
382            forbidden: vec![],
383            escalation_required: vec![],
384        };
385        let payload = serde_json::json!({
386            "identity": identity, "capabilities": capabilities, "declaration": declaration,
387        });
388        let canonical = serde_json::to_vec(&payload).unwrap();
389        let sig = signer.sign(&canonical).unwrap();
390
391        // Tamper: expand the tools list AFTER signing. Signature was computed
392        // over the smaller list so it should no longer verify.
393        let evil_caps = AgentCapabilities {
394            tools: vec![
395                ToolCapability { name: "Bash".into(), description: None },
396                ToolCapability { name: "DropDatabase".into(), description: None },
397            ],
398            api_endpoints: vec![],
399            mcp_servers: vec![],
400        };
401
402        let cert = AgentCertificate {
403            r#type: CERTIFICATE_TYPE.into(),
404            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
405            identity,
406            capabilities: evil_caps,
407            declaration,
408            signature: CertificateSignature {
409                algorithm: "ed25519".into(),
410                key_id: "key_demo".into(),
411                public_key: pk_b64.clone(),
412                signature: URL_SAFE_NO_PAD.encode(sig),
413                signed_fields: "identity+capabilities+declaration".into(),
414            },
415        };
416
417        let trust = trust_with(&pk_b64);
418        let err = verify_certificate(&cert, &trust).unwrap_err();
419        assert!(matches!(err, CertificateVerifyError::InvalidSignature),
420            "expected InvalidSignature, got: {err}");
421    }
422
423    #[test]
424    fn verify_certificate_rejects_unsupported_algorithm() {
425        let mut cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
426        cert.signature.algorithm = "rsa-pss-sha256".into();
427        let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
428        assert!(matches!(err, CertificateVerifyError::UnsupportedAlgorithm(_)));
429    }
430
431    /// Trust pin headline: a freshly-signed cert whose issuer key is
432    /// NOT in the operator's trust store must be rejected with
433    /// `UntrustedIssuer` -- even though the signature math is fine.
434    #[test]
435    fn verify_certificate_rejects_unknown_issuer() {
436        use crate::attestation::{Ed25519Signer, Signer};
437        let signer = Ed25519Signer::generate("key_attacker").unwrap();
438        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
439
440        let identity = AgentIdentity {
441            agent_name: "agent-007".into(),
442            ship_id: "ship_x".into(),
443            public_key: pk_b64.clone(),
444            issuer: "ship://attacker-claims-zerker".into(),
445            issued_at: "2026-04-15T00:00:00Z".into(),
446            valid_until: "2027-04-15T00:00:00Z".into(),
447            model: None,
448            description: None,
449        };
450        let capabilities = AgentCapabilities {
451            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
452            api_endpoints: vec![],
453            mcp_servers: vec![],
454        };
455        let declaration = AgentDeclaration {
456            bounded_actions: vec!["Bash".into()],
457            forbidden: vec![],
458            escalation_required: vec![],
459        };
460        let payload = serde_json::json!({
461            "identity": identity, "capabilities": capabilities, "declaration": declaration,
462        });
463        let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
464        let cert = AgentCertificate {
465            r#type: CERTIFICATE_TYPE.into(),
466            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
467            identity,
468            capabilities,
469            declaration,
470            signature: CertificateSignature {
471                algorithm: "ed25519".into(),
472                key_id: "key_attacker".into(),
473                public_key: pk_b64,
474                signature: URL_SAFE_NO_PAD.encode(sig),
475                signed_fields: "identity+capabilities+declaration".into(),
476            },
477        };
478
479        // Trust an unrelated issuer.
480        let honest = Ed25519Signer::generate("honest_issuer").unwrap();
481        let honest_pk = URL_SAFE_NO_PAD.encode(honest.public_key_bytes());
482        let trust = trust_with(&honest_pk);
483
484        let err = verify_certificate(&cert, &trust).unwrap_err();
485        assert!(matches!(err, CertificateVerifyError::UntrustedIssuer { .. }),
486            "expected UntrustedIssuer, got: {err}");
487    }
488
489    /// Empty trust store yields `NoTrustConfigured` so the CLI can
490    /// render the install-time remediation distinct from "this
491    /// particular key is wrong".
492    #[test]
493    fn verify_certificate_rejects_with_no_trust_configured() {
494        use crate::attestation::{Ed25519Signer, Signer};
495        let signer = Ed25519Signer::generate("key_demo").unwrap();
496        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
497
498        let identity = AgentIdentity {
499            agent_name: "agent-007".into(),
500            ship_id: "ship_x".into(),
501            public_key: pk_b64.clone(),
502            issuer: "ship://ship_x".into(),
503            issued_at: "2026-04-15T00:00:00Z".into(),
504            valid_until: "2027-04-15T00:00:00Z".into(),
505            model: None,
506            description: None,
507        };
508        let capabilities = AgentCapabilities {
509            tools: vec![ToolCapability { name: "Bash".into(), description: None }],
510            api_endpoints: vec![],
511            mcp_servers: vec![],
512        };
513        let declaration = AgentDeclaration {
514            bounded_actions: vec!["Bash".into()],
515            forbidden: vec![],
516            escalation_required: vec![],
517        };
518        let payload = serde_json::json!({
519            "identity": identity, "capabilities": capabilities, "declaration": declaration,
520        });
521        let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
522        let cert = AgentCertificate {
523            r#type: CERTIFICATE_TYPE.into(),
524            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
525            identity,
526            capabilities,
527            declaration,
528            signature: CertificateSignature {
529                algorithm: "ed25519".into(),
530                key_id: "key_demo".into(),
531                public_key: pk_b64,
532                signature: URL_SAFE_NO_PAD.encode(sig),
533                signed_fields: "identity+capabilities+declaration".into(),
534            },
535        };
536
537        let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
538        assert!(matches!(err, CertificateVerifyError::NoTrustConfigured),
539            "expected NoTrustConfigured, got: {err}");
540        // And the error must reference the CLI remediation.
541        let msg = format!("{err}");
542        assert!(msg.contains("treeship trust add"),
543                "remediation must mention treeship trust add: {msg}");
544    }
545
546    #[test]
547    fn current_certificate_carries_schema_version_one() {
548        let cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
549        let bytes = serde_json::to_vec(&cert).unwrap();
550        let s = std::str::from_utf8(&bytes).unwrap();
551        assert!(s.contains(r#""schema_version":"1""#),
552            "current cert must include schema_version=1, got: {s}");
553        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
554        assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "1");
555    }
556}