1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AgentCapabilities {
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub tools: Vec<ToolCapability>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub api_endpoints: Vec<String>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub mcp_servers: Vec<String>,
39}
40
41#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AgentCertificate {
64 pub r#type: String, #[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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CertificateSignature {
79 pub algorithm: String, pub key_id: String,
81 pub public_key: String, pub signature: String, pub signed_fields: String, }
85
86pub const CERTIFICATE_TYPE: &str = "treeship/agent-certificate/v1";
87
88pub const CERTIFICATE_SCHEMA_VERSION: &str = "1";
91
92pub fn effective_schema_version(field: Option<&str>) -> &str {
96 field.unwrap_or("0")
97}
98
99#[derive(Debug)]
101pub enum CertificateVerifyError {
102 BadPublicKey(String),
104 BadSignature(String),
106 PayloadEncode(String),
108 InvalidSignature,
110 UnsupportedAlgorithm(String),
112 UnsupportedSignedFields(String),
114 UntrustedIssuer { key_id: String },
120 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
154const SIGNED_FIELDS_V1: &str = "identity+capabilities+declaration";
156
157pub 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 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 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 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 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 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 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 #[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 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 #[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 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}