1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use ed25519_dalek::{Signature as DalekSignature, 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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub network: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AgentCertificate {
70 pub r#type: String, #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub schema_version: Option<String>,
76 pub identity: AgentIdentity,
77 pub capabilities: AgentCapabilities,
78 pub declaration: AgentDeclaration,
79 pub signature: CertificateSignature,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CertificateSignature {
85 pub algorithm: String, pub key_id: String,
87 pub public_key: String, pub signature: String, pub signed_fields: String, }
91
92pub const CERTIFICATE_TYPE: &str = "treeship/agent-certificate/v1";
93
94pub const CERTIFICATE_SCHEMA_VERSION: &str = "1";
97
98pub fn effective_schema_version(field: Option<&str>) -> &str {
102 field.unwrap_or("0")
103}
104
105#[derive(Debug)]
107pub enum CertificateVerifyError {
108 BadPublicKey(String),
110 BadSignature(String),
112 PayloadEncode(String),
114 InvalidSignature,
116 UnsupportedAlgorithm(String),
118 UnsupportedSignedFields(String),
120 UntrustedIssuer { key_id: String },
126 NoTrustConfigured,
130}
131
132impl std::fmt::Display for CertificateVerifyError {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 Self::BadPublicKey(s) => write!(f, "certificate public key: {s}"),
136 Self::BadSignature(s) => write!(f, "certificate signature bytes: {s}"),
137 Self::PayloadEncode(s) => write!(f, "certificate canonical encoding: {s}"),
138 Self::InvalidSignature => write!(f, "certificate signature did not verify"),
139 Self::UnsupportedAlgorithm(s) => write!(f, "certificate algorithm '{s}' not supported"),
140 Self::UnsupportedSignedFields(s) => {
141 write!(f, "certificate signed_fields '{s}' not recognized")
142 }
143 Self::UntrustedIssuer { key_id } => write!(
144 f,
145 "certificate issuer (key_id={key_id}) is not in the trust root store. \
146 Run `treeship trust add <key_id> <pubkey> --kind agent_cert` if you trust this issuer.",
147 ),
148 Self::NoTrustConfigured => write!(
149 f,
150 "no trust roots configured for agent certificates. \
151 Run `treeship trust add <key_id> <pubkey> --kind agent_cert` \
152 or sync from your hub via `treeship hub sync-trust`.",
153 ),
154 }
155 }
156}
157
158impl std::error::Error for CertificateVerifyError {}
159
160const SIGNED_FIELDS_V1: &str = "identity+capabilities+declaration";
162
163pub fn verify_certificate(
178 cert: &AgentCertificate,
179 trust: &crate::trust::TrustRootStore,
180) -> Result<(), CertificateVerifyError> {
181 use crate::trust::TrustRootKind;
182
183 if cert.signature.algorithm != "ed25519" {
184 return Err(CertificateVerifyError::UnsupportedAlgorithm(
185 cert.signature.algorithm.clone(),
186 ));
187 }
188 if cert.signature.signed_fields != SIGNED_FIELDS_V1 {
189 return Err(CertificateVerifyError::UnsupportedSignedFields(
190 cert.signature.signed_fields.clone(),
191 ));
192 }
193
194 let pk_bytes = URL_SAFE_NO_PAD
195 .decode(&cert.signature.public_key)
196 .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
197 let pk_arr: [u8; 32] = pk_bytes.as_slice().try_into().map_err(|_| {
198 CertificateVerifyError::BadPublicKey(format!("expected 32 bytes, got {}", pk_bytes.len()))
199 })?;
200 let verifying_key = VerifyingKey::from_bytes(&pk_arr)
201 .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
202
203 if !trust.contains(&verifying_key, TrustRootKind::AgentCert) {
208 if trust.is_empty_for_kind(TrustRootKind::AgentCert) {
209 return Err(CertificateVerifyError::NoTrustConfigured);
210 }
211 return Err(CertificateVerifyError::UntrustedIssuer {
212 key_id: cert.signature.key_id.clone(),
213 });
214 }
215
216 let sig_bytes = URL_SAFE_NO_PAD
217 .decode(&cert.signature.signature)
218 .map_err(|e| CertificateVerifyError::BadSignature(e.to_string()))?;
219 let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().map_err(|_| {
220 CertificateVerifyError::BadSignature(format!("expected 64 bytes, got {}", sig_bytes.len()))
221 })?;
222 let signature = DalekSignature::from_bytes(&sig_arr);
223
224 let payload = serde_json::json!({
228 "identity": cert.identity,
229 "capabilities": cert.capabilities,
230 "declaration": cert.declaration,
231 });
232 let canonical = serde_json::to_vec(&payload)
233 .map_err(|e| CertificateVerifyError::PayloadEncode(e.to_string()))?;
234
235 verifying_key
236 .verify_strict(&canonical, &signature)
237 .map_err(|_| CertificateVerifyError::InvalidSignature)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn sample_certificate(schema_version: Option<&str>) -> AgentCertificate {
245 AgentCertificate {
246 r#type: CERTIFICATE_TYPE.into(),
247 schema_version: schema_version.map(|s| s.to_string()),
248 identity: AgentIdentity {
249 agent_name: "agent-007".into(),
250 ship_id: "ship_demo".into(),
251 public_key: "pk_b64".into(),
252 issuer: "ship://ship_demo".into(),
253 issued_at: "2026-04-15T00:00:00Z".into(),
254 valid_until: "2026-10-15T00:00:00Z".into(),
255 model: None,
256 description: None,
257 },
258 capabilities: AgentCapabilities {
259 tools: vec![ToolCapability {
260 name: "Bash".into(),
261 description: None,
262 }],
263 api_endpoints: vec![],
264 mcp_servers: vec![],
265 },
266 declaration: AgentDeclaration {
267 bounded_actions: vec!["Bash".into()],
268 forbidden: vec![],
269 escalation_required: vec![],
270 network: Vec::new(),
271 },
272 signature: CertificateSignature {
273 algorithm: "ed25519".into(),
274 key_id: "key_demo".into(),
275 public_key: "pk_b64".into(),
276 signature: "sig_b64".into(),
277 signed_fields: "identity+capabilities+declaration".into(),
278 },
279 }
280 }
281
282 #[test]
283 fn legacy_certificate_round_trips_byte_identical() {
284 let cert = sample_certificate(None);
289 let bytes = serde_json::to_vec(&cert).unwrap();
290 let s = std::str::from_utf8(&bytes).unwrap();
291 assert!(
292 !s.contains("schema_version"),
293 "legacy cert must omit schema_version, got: {s}"
294 );
295
296 let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
297 assert!(parsed.schema_version.is_none());
298 let reserialized = serde_json::to_vec(&parsed).unwrap();
299 assert_eq!(bytes, reserialized);
300 assert_eq!(
301 effective_schema_version(parsed.schema_version.as_deref()),
302 "0"
303 );
304 }
305
306 fn trust_with(pk_b64: &str) -> crate::trust::TrustRootStore {
310 use crate::trust::{TrustRoot, TrustRootKind, TrustRootStore};
311 TrustRootStore::with_roots(vec![TrustRoot {
312 key_id: "key_demo".into(),
313 public_key: format!("ed25519:{pk_b64}"),
314 kind: TrustRootKind::AgentCert,
315 label: "test issuer".into(),
316 added_at: "2026-05-15T00:00:00Z".into(),
317 }])
318 }
319
320 #[test]
321 fn verify_certificate_round_trip() {
322 use crate::attestation::{Ed25519Signer, Signer};
324 let signer = Ed25519Signer::generate("key_demo").unwrap();
325 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
326
327 let identity = AgentIdentity {
328 agent_name: "agent-007".into(),
329 ship_id: "ship_x".into(),
330 public_key: pk_b64.clone(),
331 issuer: "ship://ship_x".into(),
332 issued_at: "2026-04-15T00:00:00Z".into(),
333 valid_until: "2027-04-15T00:00:00Z".into(),
334 model: None,
335 description: None,
336 };
337 let capabilities = AgentCapabilities {
338 tools: vec![ToolCapability {
339 name: "Bash".into(),
340 description: None,
341 }],
342 api_endpoints: vec![],
343 mcp_servers: vec![],
344 };
345 let declaration = AgentDeclaration {
346 bounded_actions: vec!["Bash".into()],
347 forbidden: vec![],
348 escalation_required: vec![],
349 network: Vec::new(),
350 };
351 let payload = serde_json::json!({
352 "identity": identity, "capabilities": capabilities, "declaration": declaration,
353 });
354 let canonical = serde_json::to_vec(&payload).unwrap();
355 let sig = signer.sign(&canonical).unwrap();
356
357 let cert = AgentCertificate {
358 r#type: CERTIFICATE_TYPE.into(),
359 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
360 identity,
361 capabilities,
362 declaration,
363 signature: CertificateSignature {
364 algorithm: "ed25519".into(),
365 key_id: "key_demo".into(),
366 public_key: pk_b64.clone(),
367 signature: URL_SAFE_NO_PAD.encode(sig),
368 signed_fields: "identity+capabilities+declaration".into(),
369 },
370 };
371
372 let trust = trust_with(&pk_b64);
373 verify_certificate(&cert, &trust).expect("freshly-signed cert must verify");
374 }
375
376 #[test]
377 fn verify_certificate_detects_tampered_payload() {
378 use crate::attestation::{Ed25519Signer, Signer};
379 let signer = Ed25519Signer::generate("key_demo").unwrap();
380 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
381
382 let identity = AgentIdentity {
383 agent_name: "agent-007".into(),
384 ship_id: "ship_x".into(),
385 public_key: pk_b64.clone(),
386 issuer: "ship://ship_x".into(),
387 issued_at: "2026-04-15T00:00:00Z".into(),
388 valid_until: "2027-04-15T00:00:00Z".into(),
389 model: None,
390 description: None,
391 };
392 let capabilities = AgentCapabilities {
393 tools: vec![ToolCapability {
394 name: "Bash".into(),
395 description: None,
396 }],
397 api_endpoints: vec![],
398 mcp_servers: vec![],
399 };
400 let declaration = AgentDeclaration {
401 bounded_actions: vec!["Bash".into()],
402 forbidden: vec![],
403 escalation_required: vec![],
404 network: Vec::new(),
405 };
406 let payload = serde_json::json!({
407 "identity": identity, "capabilities": capabilities, "declaration": declaration,
408 });
409 let canonical = serde_json::to_vec(&payload).unwrap();
410 let sig = signer.sign(&canonical).unwrap();
411
412 let evil_caps = AgentCapabilities {
415 tools: vec![
416 ToolCapability {
417 name: "Bash".into(),
418 description: None,
419 },
420 ToolCapability {
421 name: "DropDatabase".into(),
422 description: None,
423 },
424 ],
425 api_endpoints: vec![],
426 mcp_servers: vec![],
427 };
428
429 let cert = AgentCertificate {
430 r#type: CERTIFICATE_TYPE.into(),
431 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
432 identity,
433 capabilities: evil_caps,
434 declaration,
435 signature: CertificateSignature {
436 algorithm: "ed25519".into(),
437 key_id: "key_demo".into(),
438 public_key: pk_b64.clone(),
439 signature: URL_SAFE_NO_PAD.encode(sig),
440 signed_fields: "identity+capabilities+declaration".into(),
441 },
442 };
443
444 let trust = trust_with(&pk_b64);
445 let err = verify_certificate(&cert, &trust).unwrap_err();
446 assert!(
447 matches!(err, CertificateVerifyError::InvalidSignature),
448 "expected InvalidSignature, got: {err}"
449 );
450 }
451
452 #[test]
453 fn verify_certificate_rejects_unsupported_algorithm() {
454 let mut cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
455 cert.signature.algorithm = "rsa-pss-sha256".into();
456 let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
457 assert!(matches!(
458 err,
459 CertificateVerifyError::UnsupportedAlgorithm(_)
460 ));
461 }
462
463 #[test]
467 fn verify_certificate_rejects_unknown_issuer() {
468 use crate::attestation::{Ed25519Signer, Signer};
469 let signer = Ed25519Signer::generate("key_attacker").unwrap();
470 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
471
472 let identity = AgentIdentity {
473 agent_name: "agent-007".into(),
474 ship_id: "ship_x".into(),
475 public_key: pk_b64.clone(),
476 issuer: "ship://attacker-claims-zerker".into(),
477 issued_at: "2026-04-15T00:00:00Z".into(),
478 valid_until: "2027-04-15T00:00:00Z".into(),
479 model: None,
480 description: None,
481 };
482 let capabilities = AgentCapabilities {
483 tools: vec![ToolCapability {
484 name: "Bash".into(),
485 description: None,
486 }],
487 api_endpoints: vec![],
488 mcp_servers: vec![],
489 };
490 let declaration = AgentDeclaration {
491 bounded_actions: vec!["Bash".into()],
492 forbidden: vec![],
493 escalation_required: vec![],
494 network: Vec::new(),
495 };
496 let payload = serde_json::json!({
497 "identity": identity, "capabilities": capabilities, "declaration": declaration,
498 });
499 let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
500 let cert = AgentCertificate {
501 r#type: CERTIFICATE_TYPE.into(),
502 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
503 identity,
504 capabilities,
505 declaration,
506 signature: CertificateSignature {
507 algorithm: "ed25519".into(),
508 key_id: "key_attacker".into(),
509 public_key: pk_b64,
510 signature: URL_SAFE_NO_PAD.encode(sig),
511 signed_fields: "identity+capabilities+declaration".into(),
512 },
513 };
514
515 let honest = Ed25519Signer::generate("honest_issuer").unwrap();
517 let honest_pk = URL_SAFE_NO_PAD.encode(honest.public_key_bytes());
518 let trust = trust_with(&honest_pk);
519
520 let err = verify_certificate(&cert, &trust).unwrap_err();
521 assert!(
522 matches!(err, CertificateVerifyError::UntrustedIssuer { .. }),
523 "expected UntrustedIssuer, got: {err}"
524 );
525 }
526
527 #[test]
531 fn verify_certificate_rejects_with_no_trust_configured() {
532 use crate::attestation::{Ed25519Signer, Signer};
533 let signer = Ed25519Signer::generate("key_demo").unwrap();
534 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
535
536 let identity = AgentIdentity {
537 agent_name: "agent-007".into(),
538 ship_id: "ship_x".into(),
539 public_key: pk_b64.clone(),
540 issuer: "ship://ship_x".into(),
541 issued_at: "2026-04-15T00:00:00Z".into(),
542 valid_until: "2027-04-15T00:00:00Z".into(),
543 model: None,
544 description: None,
545 };
546 let capabilities = AgentCapabilities {
547 tools: vec![ToolCapability {
548 name: "Bash".into(),
549 description: None,
550 }],
551 api_endpoints: vec![],
552 mcp_servers: vec![],
553 };
554 let declaration = AgentDeclaration {
555 bounded_actions: vec!["Bash".into()],
556 forbidden: vec![],
557 escalation_required: vec![],
558 network: Vec::new(),
559 };
560 let payload = serde_json::json!({
561 "identity": identity, "capabilities": capabilities, "declaration": declaration,
562 });
563 let sig = signer.sign(&serde_json::to_vec(&payload).unwrap()).unwrap();
564 let cert = AgentCertificate {
565 r#type: CERTIFICATE_TYPE.into(),
566 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
567 identity,
568 capabilities,
569 declaration,
570 signature: CertificateSignature {
571 algorithm: "ed25519".into(),
572 key_id: "key_demo".into(),
573 public_key: pk_b64,
574 signature: URL_SAFE_NO_PAD.encode(sig),
575 signed_fields: "identity+capabilities+declaration".into(),
576 },
577 };
578
579 let err = verify_certificate(&cert, &crate::trust::TrustRootStore::empty()).unwrap_err();
580 assert!(
581 matches!(err, CertificateVerifyError::NoTrustConfigured),
582 "expected NoTrustConfigured, got: {err}"
583 );
584 let msg = format!("{err}");
586 assert!(
587 msg.contains("treeship trust add"),
588 "remediation must mention treeship trust add: {msg}"
589 );
590 }
591
592 #[test]
593 fn current_certificate_carries_schema_version_one() {
594 let cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
595 let bytes = serde_json::to_vec(&cert).unwrap();
596 let s = std::str::from_utf8(&bytes).unwrap();
597 assert!(
598 s.contains(r#""schema_version":"1""#),
599 "current cert must include schema_version=1, got: {s}"
600 );
601 let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
602 assert_eq!(
603 effective_schema_version(parsed.schema_version.as_deref()),
604 "1"
605 );
606 }
607}