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}
115
116impl std::fmt::Display for CertificateVerifyError {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::BadPublicKey(s) => write!(f, "certificate public key: {s}"),
120 Self::BadSignature(s) => write!(f, "certificate signature bytes: {s}"),
121 Self::PayloadEncode(s) => write!(f, "certificate canonical encoding: {s}"),
122 Self::InvalidSignature => write!(f, "certificate signature did not verify"),
123 Self::UnsupportedAlgorithm(s) => write!(f, "certificate algorithm '{s}' not supported"),
124 Self::UnsupportedSignedFields(s) => {
125 write!(f, "certificate signed_fields '{s}' not recognized")
126 }
127 }
128 }
129}
130
131impl std::error::Error for CertificateVerifyError {}
132
133const SIGNED_FIELDS_V1: &str = "identity+capabilities+declaration";
135
136pub fn verify_certificate(cert: &AgentCertificate) -> Result<(), CertificateVerifyError> {
144 if cert.signature.algorithm != "ed25519" {
145 return Err(CertificateVerifyError::UnsupportedAlgorithm(
146 cert.signature.algorithm.clone(),
147 ));
148 }
149 if cert.signature.signed_fields != SIGNED_FIELDS_V1 {
150 return Err(CertificateVerifyError::UnsupportedSignedFields(
151 cert.signature.signed_fields.clone(),
152 ));
153 }
154
155 let pk_bytes = URL_SAFE_NO_PAD
156 .decode(&cert.signature.public_key)
157 .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
158 let pk_arr: [u8; 32] = pk_bytes
159 .as_slice()
160 .try_into()
161 .map_err(|_| CertificateVerifyError::BadPublicKey(format!("expected 32 bytes, got {}", pk_bytes.len())))?;
162 let verifying_key = VerifyingKey::from_bytes(&pk_arr)
163 .map_err(|e| CertificateVerifyError::BadPublicKey(e.to_string()))?;
164
165 let sig_bytes = URL_SAFE_NO_PAD
166 .decode(&cert.signature.signature)
167 .map_err(|e| CertificateVerifyError::BadSignature(e.to_string()))?;
168 let sig_arr: [u8; 64] = sig_bytes
169 .as_slice()
170 .try_into()
171 .map_err(|_| CertificateVerifyError::BadSignature(format!("expected 64 bytes, got {}", sig_bytes.len())))?;
172 let signature = DalekSignature::from_bytes(&sig_arr);
173
174 let payload = serde_json::json!({
178 "identity": cert.identity,
179 "capabilities": cert.capabilities,
180 "declaration": cert.declaration,
181 });
182 let canonical = serde_json::to_vec(&payload)
183 .map_err(|e| CertificateVerifyError::PayloadEncode(e.to_string()))?;
184
185 verifying_key
186 .verify(&canonical, &signature)
187 .map_err(|_| CertificateVerifyError::InvalidSignature)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn sample_certificate(schema_version: Option<&str>) -> AgentCertificate {
195 AgentCertificate {
196 r#type: CERTIFICATE_TYPE.into(),
197 schema_version: schema_version.map(|s| s.to_string()),
198 identity: AgentIdentity {
199 agent_name: "agent-007".into(),
200 ship_id: "ship_demo".into(),
201 public_key: "pk_b64".into(),
202 issuer: "ship://ship_demo".into(),
203 issued_at: "2026-04-15T00:00:00Z".into(),
204 valid_until: "2026-10-15T00:00:00Z".into(),
205 model: None,
206 description: None,
207 },
208 capabilities: AgentCapabilities {
209 tools: vec![ToolCapability { name: "Bash".into(), description: None }],
210 api_endpoints: vec![],
211 mcp_servers: vec![],
212 },
213 declaration: AgentDeclaration {
214 bounded_actions: vec!["Bash".into()],
215 forbidden: vec![],
216 escalation_required: vec![],
217 },
218 signature: CertificateSignature {
219 algorithm: "ed25519".into(),
220 key_id: "key_demo".into(),
221 public_key: "pk_b64".into(),
222 signature: "sig_b64".into(),
223 signed_fields: "identity+capabilities+declaration".into(),
224 },
225 }
226 }
227
228 #[test]
229 fn legacy_certificate_round_trips_byte_identical() {
230 let cert = sample_certificate(None);
235 let bytes = serde_json::to_vec(&cert).unwrap();
236 let s = std::str::from_utf8(&bytes).unwrap();
237 assert!(!s.contains("schema_version"),
238 "legacy cert must omit schema_version, got: {s}");
239
240 let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
241 assert!(parsed.schema_version.is_none());
242 let reserialized = serde_json::to_vec(&parsed).unwrap();
243 assert_eq!(bytes, reserialized);
244 assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "0");
245 }
246
247 #[test]
248 fn verify_certificate_round_trip() {
249 use crate::attestation::{Ed25519Signer, Signer};
251 let signer = Ed25519Signer::generate("key_demo").unwrap();
252 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
253
254 let identity = AgentIdentity {
255 agent_name: "agent-007".into(),
256 ship_id: "ship_x".into(),
257 public_key: pk_b64.clone(),
258 issuer: "ship://ship_x".into(),
259 issued_at: "2026-04-15T00:00:00Z".into(),
260 valid_until: "2027-04-15T00:00:00Z".into(),
261 model: None,
262 description: None,
263 };
264 let capabilities = AgentCapabilities {
265 tools: vec![ToolCapability { name: "Bash".into(), description: None }],
266 api_endpoints: vec![],
267 mcp_servers: vec![],
268 };
269 let declaration = AgentDeclaration {
270 bounded_actions: vec!["Bash".into()],
271 forbidden: vec![],
272 escalation_required: vec![],
273 };
274 let payload = serde_json::json!({
275 "identity": identity, "capabilities": capabilities, "declaration": declaration,
276 });
277 let canonical = serde_json::to_vec(&payload).unwrap();
278 let sig = signer.sign(&canonical).unwrap();
279
280 let cert = AgentCertificate {
281 r#type: CERTIFICATE_TYPE.into(),
282 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
283 identity,
284 capabilities,
285 declaration,
286 signature: CertificateSignature {
287 algorithm: "ed25519".into(),
288 key_id: "key_demo".into(),
289 public_key: pk_b64,
290 signature: URL_SAFE_NO_PAD.encode(sig),
291 signed_fields: "identity+capabilities+declaration".into(),
292 },
293 };
294
295 verify_certificate(&cert).expect("freshly-signed cert must verify");
296 }
297
298 #[test]
299 fn verify_certificate_detects_tampered_payload() {
300 use crate::attestation::{Ed25519Signer, Signer};
301 let signer = Ed25519Signer::generate("key_demo").unwrap();
302 let pk_b64 = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
303
304 let identity = AgentIdentity {
305 agent_name: "agent-007".into(),
306 ship_id: "ship_x".into(),
307 public_key: pk_b64.clone(),
308 issuer: "ship://ship_x".into(),
309 issued_at: "2026-04-15T00:00:00Z".into(),
310 valid_until: "2027-04-15T00:00:00Z".into(),
311 model: None,
312 description: None,
313 };
314 let capabilities = AgentCapabilities {
315 tools: vec![ToolCapability { name: "Bash".into(), description: None }],
316 api_endpoints: vec![],
317 mcp_servers: vec![],
318 };
319 let declaration = AgentDeclaration {
320 bounded_actions: vec!["Bash".into()],
321 forbidden: vec![],
322 escalation_required: vec![],
323 };
324 let payload = serde_json::json!({
325 "identity": identity, "capabilities": capabilities, "declaration": declaration,
326 });
327 let canonical = serde_json::to_vec(&payload).unwrap();
328 let sig = signer.sign(&canonical).unwrap();
329
330 let evil_caps = AgentCapabilities {
333 tools: vec![
334 ToolCapability { name: "Bash".into(), description: None },
335 ToolCapability { name: "DropDatabase".into(), description: None },
336 ],
337 api_endpoints: vec![],
338 mcp_servers: vec![],
339 };
340
341 let cert = AgentCertificate {
342 r#type: CERTIFICATE_TYPE.into(),
343 schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
344 identity,
345 capabilities: evil_caps,
346 declaration,
347 signature: CertificateSignature {
348 algorithm: "ed25519".into(),
349 key_id: "key_demo".into(),
350 public_key: pk_b64,
351 signature: URL_SAFE_NO_PAD.encode(sig),
352 signed_fields: "identity+capabilities+declaration".into(),
353 },
354 };
355
356 let err = verify_certificate(&cert).unwrap_err();
357 assert!(matches!(err, CertificateVerifyError::InvalidSignature),
358 "expected InvalidSignature, got: {err}");
359 }
360
361 #[test]
362 fn verify_certificate_rejects_unsupported_algorithm() {
363 let mut cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
364 cert.signature.algorithm = "rsa-pss-sha256".into();
365 let err = verify_certificate(&cert).unwrap_err();
366 assert!(matches!(err, CertificateVerifyError::UnsupportedAlgorithm(_)));
367 }
368
369 #[test]
370 fn current_certificate_carries_schema_version_one() {
371 let cert = sample_certificate(Some(CERTIFICATE_SCHEMA_VERSION));
372 let bytes = serde_json::to_vec(&cert).unwrap();
373 let s = std::str::from_utf8(&bytes).unwrap();
374 assert!(s.contains(r#""schema_version":"1""#),
375 "current cert must include schema_version=1, got: {s}");
376 let parsed: AgentCertificate = serde_json::from_slice(&bytes).unwrap();
377 assert_eq!(effective_schema_version(parsed.schema_version.as_deref()), "1");
378 }
379}