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.as_slice().try_into().map_err(|_| {
192        CertificateVerifyError::BadPublicKey(format!("expected 32 bytes, got {}", pk_bytes.len()))
193    })?;
194    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
195        .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
196
197    // Trust pin -- fail-closed before signature math runs. We
198    // distinguish "no trust configured at all (for this kind)" from
199    // "trust configured but this issuer isn't in it" so the CLI can
200    // render a more useful remediation.
201    if !trust.contains(&verifying_key, TrustRootKind::AgentCert) {
202        if trust.is_empty_for_kind(TrustRootKind::AgentCert) {
203            return Err(CertificateVerifyError::NoTrustConfigured);
204        }
205        return Err(CertificateVerifyError::UntrustedIssuer {
206            key_id: cert.signature.key_id.clone(),
207        });
208    }
209
210    let sig_bytes = URL_SAFE_NO_PAD
211        .decode(&cert.signature.signature)
212        .map_err(|e| CertificateVerifyError::BadSignature(e.to_string()))?;
213    let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
214        CertificateVerifyError::BadSignature(format!("expected 64 bytes, got {}", sig_bytes.len()))
215    })?;
216    let signature = DalekSignature::from_bytes(&sig_arr);
217
218    // Reconstruct the canonical signed payload exactly as the issuer did:
219    // {identity, capabilities, declaration} serialized with serde_json (which
220    // preserves struct field declaration order).
221    let payload = serde_json::json!({
222        "identity": cert.identity,
223        "capabilities": cert.capabilities,
224        "declaration": cert.declaration,
225    });
226    let canonical = serde_json::to_vec(&payload)
227        .map_err(|e| CertificateVerifyError::PayloadEncode(e.to_string()))?;
228
229    verifying_key
230        .verify_strict(&canonical, &signature)
231        .map_err(|_| CertificateVerifyError::InvalidSignature)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn sample_certificate(schema_version: Option<&str>) -> AgentCertificate {
239        AgentCertificate {
240            r#type: CERTIFICATE_TYPE.into(),
241            schema_version: schema_version.map(|s| s.to_string()),
242            identity: AgentIdentity {
243                agent_name: "agent-007".into(),
244                ship_id: "ship_demo".into(),
245                public_key: "pk_b64".into(),
246                issuer: "ship://ship_demo".into(),
247                issued_at: "2026-04-15T00:00:00Z".into(),
248                valid_until: "2026-10-15T00:00:00Z".into(),
249                model: None,
250                description: None,
251            },
252            capabilities: AgentCapabilities {
253                tools: vec![ToolCapability {
254                    name: "Bash".into(),
255                    description: None,
256                }],
257                api_endpoints: vec![],
258                mcp_servers: vec![],
259            },
260            declaration: AgentDeclaration {
261                bounded_actions: vec!["Bash".into()],
262                forbidden: vec![],
263                escalation_required: vec![],
264            },
265            signature: CertificateSignature {
266                algorithm: "ed25519".into(),
267                key_id: "key_demo".into(),
268                public_key: "pk_b64".into(),
269                signature: "sig_b64".into(),
270                signed_fields: "identity+capabilities+declaration".into(),
271            },
272        }
273    }
274
275    #[test]
276    fn legacy_certificate_round_trips_byte_identical() {
277        // schema_version=None mimics a pre-v0.9.0 certificate. Re-serializing
278        // must skip the field entirely so the original bytes (and therefore
279        // any signature over those bytes if a future format binds them) is
280        // preserved.
281        let cert = sample_certificate(None);
282        let bytes = serde_json::to_vec(&cert).unwrap();
283        let s = std::str::from_utf8(&bytes).unwrap();
284        assert!(
285            !s.contains("schema_version"),
286            "legacy cert must omit schema_version, got: {s}"
287        );
288
289        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
290        assert!(parsed.schema_version.is_none());
291        let reserialized = serde_json::to_vec(&parsed).unwrap();
292        assert_eq!(bytes, reserialized);
293        assert_eq!(
294            effective_schema_version(parsed.schema_version.as_deref()),
295            "0"
296        );
297    }
298
299    /// Build a single-entry trust store pinning `pk_b64` for kind
300    /// `AgentCert`. Tests that exercise valid signatures use this so the
301    /// trust pin doesn't short-circuit before signature verification.
302    fn trust_with(pk_b64: &str) -> crate::trust::TrustRootStore {
303        use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
304        TrustRootStore::with_roots(vec![TrustRoot {
305            key_id: "key_demo".into(),
306            public_key: format!("ed25519:{pk_b64}"),
307            kind: TrustRootKind::AgentCert,
308            label: "test issuer".into(),
309            added_at: "2026-05-15T00:00:00Z".into(),
310        }])
311    }
312
313    #[test]
314    fn verify_certificate_round_trip() {
315        // Mint a cert, sign it the way the CLI does, then call verify.
316        use crate::attestation::{Ed25519Signer, Signer};
317        let signer = Ed25519Signer::generate("key_demo").unwrap();
318        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
319
320        let identity = AgentIdentity {
321            agent_name: "agent-007".into(),
322            ship_id: "ship_x".into(),
323            public_key: pk_b64.clone(),
324            issuer: "ship://ship_x".into(),
325            issued_at: "2026-04-15T00:00:00Z".into(),
326            valid_until: "2027-04-15T00:00:00Z".into(),
327            model: None,
328            description: None,
329        };
330        let capabilities = AgentCapabilities {
331            tools: vec![ToolCapability {
332                name: "Bash".into(),
333                description: None,
334            }],
335            api_endpoints: vec![],
336            mcp_servers: vec![],
337        };
338        let declaration = AgentDeclaration {
339            bounded_actions: vec!["Bash".into()],
340            forbidden: vec![],
341            escalation_required: vec![],
342        };
343        let payload = serde_json::json!({
344            "identity": identity, "capabilities": capabilities, "declaration": declaration,
345        });
346        let canonical = serde_json::to_vec(&payload).unwrap();
347        let sig = signer.sign(&canonical).unwrap();
348
349        let cert = AgentCertificate {
350            r#type: CERTIFICATE_TYPE.into(),
351            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
352            identity,
353            capabilities,
354            declaration,
355            signature: CertificateSignature {
356                algorithm: "ed25519".into(),
357                key_id: "key_demo".into(),
358                public_key: pk_b64.clone(),
359                signature: URL_SAFE_NO_PAD.encode(sig),
360                signed_fields: "identity+capabilities+declaration".into(),
361            },
362        };
363
364        let trust = trust_with(&pk_b64);
365        verify_certificate(&cert, &trust).expect("freshly-signed cert must verify");
366    }
367
368    #[test]
369    fn verify_certificate_detects_tampered_payload() {
370        use crate::attestation::{Ed25519Signer, Signer};
371        let signer = Ed25519Signer::generate("key_demo").unwrap();
372        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
373
374        let identity = AgentIdentity {
375            agent_name: "agent-007".into(),
376            ship_id: "ship_x".into(),
377            public_key: pk_b64.clone(),
378            issuer: "ship://ship_x".into(),
379            issued_at: "2026-04-15T00:00:00Z".into(),
380            valid_until: "2027-04-15T00:00:00Z".into(),
381            model: None,
382            description: None,
383        };
384        let capabilities = AgentCapabilities {
385            tools: vec![ToolCapability {
386                name: "Bash".into(),
387                description: None,
388            }],
389            api_endpoints: vec![],
390            mcp_servers: vec![],
391        };
392        let declaration = AgentDeclaration {
393            bounded_actions: vec!["Bash".into()],
394            forbidden: vec![],
395            escalation_required: vec![],
396        };
397        let payload = serde_json::json!({
398            "identity": identity, "capabilities": capabilities, "declaration": declaration,
399        });
400        let canonical = serde_json::to_vec(&payload).unwrap();
401        let sig = signer.sign(&canonical).unwrap();
402
403        // Tamper: expand the tools list AFTER signing. Signature was computed
404        // over the smaller list so it should no longer verify.
405        let evil_caps = AgentCapabilities {
406            tools: vec![
407                ToolCapability {
408                    name: "Bash".into(),
409                    description: None,
410                },
411                ToolCapability {
412                    name: "DropDatabase".into(),
413                    description: None,
414                },
415            ],
416            api_endpoints: vec![],
417            mcp_servers: vec![],
418        };
419
420        let cert = AgentCertificate {
421            r#type: CERTIFICATE_TYPE.into(),
422            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
423            identity,
424            capabilities: evil_caps,
425            declaration,
426            signature: CertificateSignature {
427                algorithm: "ed25519".into(),
428                key_id: "key_demo".into(),
429                public_key: pk_b64.clone(),
430                signature: URL_SAFE_NO_PAD.encode(sig),
431                signed_fields: "identity+capabilities+declaration".into(),
432            },
433        };
434
435        let trust = trust_with(&pk_b64);
436        let err = verify_certificate(&cert, &trust).unwrap_err();
437        assert!(
438            matches!(err, CertificateVerifyError::InvalidSignature),
439            "expected InvalidSignature, got: {err}"
440        );
441    }
442
443    #[test]
444    fn verify_certificate_rejects_unsupported_algorithm() {
445        let mut cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
446        cert.signature.algorithm = "rsa-pss-sha256".into();
447        let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
448        assert!(matches!(
449            err,
450            CertificateVerifyError::UnsupportedAlgorithm(_)
451        ));
452    }
453
454    /// Trust pin headline: a freshly-signed cert whose issuer key is
455    /// NOT in the operator's trust store must be rejected with
456    /// `UntrustedIssuer` -- even though the signature math is fine.
457    #[test]
458    fn verify_certificate_rejects_unknown_issuer() {
459        use crate::attestation::{Ed25519Signer, Signer};
460        let signer = Ed25519Signer::generate("key_attacker").unwrap();
461        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
462
463        let identity = AgentIdentity {
464            agent_name: "agent-007".into(),
465            ship_id: "ship_x".into(),
466            public_key: pk_b64.clone(),
467            issuer: "ship://attacker-claims-zerker".into(),
468            issued_at: "2026-04-15T00:00:00Z".into(),
469            valid_until: "2027-04-15T00:00:00Z".into(),
470            model: None,
471            description: None,
472        };
473        let capabilities = AgentCapabilities {
474            tools: vec![ToolCapability {
475                name: "Bash".into(),
476                description: None,
477            }],
478            api_endpoints: vec![],
479            mcp_servers: vec![],
480        };
481        let declaration = AgentDeclaration {
482            bounded_actions: vec!["Bash".into()],
483            forbidden: vec![],
484            escalation_required: vec![],
485        };
486        let payload = serde_json::json!({
487            "identity": identity, "capabilities": capabilities, "declaration": declaration,
488        });
489        let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
490        let cert = AgentCertificate {
491            r#type: CERTIFICATE_TYPE.into(),
492            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
493            identity,
494            capabilities,
495            declaration,
496            signature: CertificateSignature {
497                algorithm: "ed25519".into(),
498                key_id: "key_attacker".into(),
499                public_key: pk_b64,
500                signature: URL_SAFE_NO_PAD.encode(sig),
501                signed_fields: "identity+capabilities+declaration".into(),
502            },
503        };
504
505        // Trust an unrelated issuer.
506        let honest = Ed25519Signer::generate("honest_issuer").unwrap();
507        let honest_pk = URL_SAFE_NO_PAD.encode(honest.public_key_bytes());
508        let trust = trust_with(&honest_pk);
509
510        let err = verify_certificate(&cert, &trust).unwrap_err();
511        assert!(
512            matches!(err, CertificateVerifyError::UntrustedIssuer { .. }),
513            "expected UntrustedIssuer, got: {err}"
514        );
515    }
516
517    /// Empty trust store yields `NoTrustConfigured` so the CLI can
518    /// render the install-time remediation distinct from "this
519    /// particular key is wrong".
520    #[test]
521    fn verify_certificate_rejects_with_no_trust_configured() {
522        use crate::attestation::{Ed25519Signer, Signer};
523        let signer = Ed25519Signer::generate("key_demo").unwrap();
524        let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
525
526        let identity = AgentIdentity {
527            agent_name: "agent-007".into(),
528            ship_id: "ship_x".into(),
529            public_key: pk_b64.clone(),
530            issuer: "ship://ship_x".into(),
531            issued_at: "2026-04-15T00:00:00Z".into(),
532            valid_until: "2027-04-15T00:00:00Z".into(),
533            model: None,
534            description: None,
535        };
536        let capabilities = AgentCapabilities {
537            tools: vec![ToolCapability {
538                name: "Bash".into(),
539                description: None,
540            }],
541            api_endpoints: vec![],
542            mcp_servers: vec![],
543        };
544        let declaration = AgentDeclaration {
545            bounded_actions: vec!["Bash".into()],
546            forbidden: vec![],
547            escalation_required: vec![],
548        };
549        let payload = serde_json::json!({
550            "identity": identity, "capabilities": capabilities, "declaration": declaration,
551        });
552        let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
553        let cert = AgentCertificate {
554            r#type: CERTIFICATE_TYPE.into(),
555            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
556            identity,
557            capabilities,
558            declaration,
559            signature: CertificateSignature {
560                algorithm: "ed25519".into(),
561                key_id: "key_demo".into(),
562                public_key: pk_b64,
563                signature: URL_SAFE_NO_PAD.encode(sig),
564                signed_fields: "identity+capabilities+declaration".into(),
565            },
566        };
567
568        let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
569        assert!(
570            matches!(err, CertificateVerifyError::NoTrustConfigured),
571            "expected NoTrustConfigured, got: {err}"
572        );
573        // And the error must reference the CLI remediation.
574        let msg = format!("{err}");
575        assert!(
576            msg.contains("treeship trust add"),
577            "remediation must mention treeship trust add: {msg}"
578        );
579    }
580
581    #[test]
582    fn current_certificate_carries_schema_version_one() {
583        let cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
584        let bytes = serde_json::to_vec(&cert).unwrap();
585        let s = std::str::from_utf8(&bytes).unwrap();
586        assert!(
587            s.contains(r#""schema_version":"1""#),
588            "current cert must include schema_version=1, got: {s}"
589        );
590        let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
591        assert_eq!(
592            effective_schema_version(parsed.schema_version.as_deref()),
593            "1"
594        );
595    }
596}