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.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 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 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 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 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 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 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 #[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 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 #[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 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}