symbi_runtime/integrations/agentpin/
verifier.rs1use std::fs;
6
7use async_trait::async_trait;
8
9use agentpin::resolver::{
10 ChainResolver, DiscoveryResolver, LocalFileResolver, TrustBundleResolver,
11};
12use agentpin::types::bundle::TrustBundle;
13use agentpin::verification::{VerificationResult as ApVerificationResult, VerifierConfig};
14
15use super::discovery::DiscoveryCache;
16use super::key_store::AgentPinKeyStore;
17use super::types::{AgentPinConfig, AgentPinError, AgentVerificationResult, DiscoveryMode};
18
19#[async_trait]
21pub trait AgentPinVerifier: Send + Sync {
22 async fn verify_credential(&self, jwt: &str) -> Result<AgentVerificationResult, AgentPinError>;
24}
25
26pub struct DefaultAgentPinVerifier {
30 config: AgentPinConfig,
31 key_store: AgentPinKeyStore,
32 sync_resolver: Option<Box<dyn DiscoveryResolver>>,
33}
34
35impl DefaultAgentPinVerifier {
36 pub fn new(config: AgentPinConfig) -> Result<Self, AgentPinError> {
40 let key_store = AgentPinKeyStore::new(&config.key_store_path).map_err(|e| {
41 AgentPinError::KeyStoreError {
42 reason: e.to_string(),
43 }
44 })?;
45
46 let sync_resolver = Self::build_sync_resolver(&config)?;
47
48 Ok(Self {
49 config,
50 key_store,
51 sync_resolver,
52 })
53 }
54
55 fn build_sync_resolver(
57 config: &AgentPinConfig,
58 ) -> Result<Option<Box<dyn DiscoveryResolver>>, AgentPinError> {
59 match config.discovery_mode {
60 DiscoveryMode::Bundle => {
61 let path = config.trust_bundle_path.as_ref().ok_or_else(|| {
62 AgentPinError::ConfigError {
63 reason: "trust_bundle_path required for bundle mode".to_string(),
64 }
65 })?;
66 let json = fs::read_to_string(path).map_err(|e| AgentPinError::IoError {
67 reason: format!("Failed to read trust bundle: {}", e),
68 })?;
69 let bundle: TrustBundle =
70 serde_json::from_str(&json).map_err(|e| AgentPinError::ConfigError {
71 reason: format!("Invalid trust bundle JSON: {}", e),
72 })?;
73 Ok(Some(Box::new(TrustBundleResolver::new(&bundle))))
74 }
75 DiscoveryMode::Local => {
76 let dir = config.local_discovery_dir.as_ref().ok_or_else(|| {
77 AgentPinError::ConfigError {
78 reason: "local_discovery_dir required for local mode".to_string(),
79 }
80 })?;
81 Ok(Some(Box::new(LocalFileResolver::new(
82 dir,
83 config.local_revocation_dir.as_deref(),
84 ))))
85 }
86 DiscoveryMode::Chain => {
87 let mut resolvers: Vec<Box<dyn DiscoveryResolver>> = Vec::new();
88
89 if let Some(ref path) = config.trust_bundle_path {
90 if let Ok(json) = fs::read_to_string(path) {
91 if let Ok(bundle) = serde_json::from_str::<TrustBundle>(&json) {
92 resolvers.push(Box::new(TrustBundleResolver::new(&bundle)));
93 }
94 }
95 }
96
97 if let Some(ref dir) = config.local_discovery_dir {
98 resolvers.push(Box::new(LocalFileResolver::new(
99 dir,
100 config.local_revocation_dir.as_deref(),
101 )));
102 }
103
104 if resolvers.is_empty() {
105 Ok(None)
107 } else {
108 Ok(Some(Box::new(ChainResolver::new(resolvers))))
109 }
110 }
111 DiscoveryMode::WellKnown => Ok(None),
112 }
113 }
114
115 fn convert_result(result: &ApVerificationResult) -> AgentVerificationResult {
117 if result.valid {
118 let capabilities = result
119 .capabilities
120 .as_ref()
121 .map(|caps| caps.iter().map(|c| c.to_string()).collect())
122 .unwrap_or_default();
123
124 AgentVerificationResult {
125 valid: true,
126 agent_id: result.agent_id.clone(),
127 issuer: result.issuer.clone(),
128 capabilities,
129 delegation_verified: result.delegation_verified,
130 error_message: None,
131 warnings: result.warnings.clone(),
132 }
133 } else {
134 AgentVerificationResult {
135 valid: false,
136 agent_id: result.agent_id.clone(),
137 issuer: result.issuer.clone(),
138 capabilities: vec![],
139 delegation_verified: None,
140 error_message: result.error_message.clone(),
141 warnings: result.warnings.clone(),
142 }
143 }
144 }
145}
146
147#[async_trait]
148impl AgentPinVerifier for DefaultAgentPinVerifier {
149 async fn verify_credential(&self, jwt: &str) -> Result<AgentVerificationResult, AgentPinError> {
150 let verifier_config = VerifierConfig {
151 clock_skew_secs: self.config.clock_skew_secs,
152 max_ttl_secs: self.config.max_ttl_secs,
153 };
154
155 let audience = self.config.audience.as_deref();
156
157 let mut pin_store = self.key_store.load_pin_store()?;
158
159 let result = if let Some(ref resolver) = self.sync_resolver {
160 agentpin::verification::verify_credential_with_resolver(
162 jwt,
163 resolver.as_ref(),
164 &mut pin_store,
165 audience,
166 &verifier_config,
167 )
168 } else {
169 agentpin::verification::verify_credential(
171 jwt,
172 &mut pin_store,
173 audience,
174 &verifier_config,
175 )
176 .await
177 };
178
179 if let Err(e) = self.key_store.save_pin_store(&pin_store) {
181 tracing::warn!("Failed to persist AgentPin key store: {}", e);
182 }
183
184 Ok(Self::convert_result(&result))
185 }
186}
187
188pub struct CachingResolver<R: DiscoveryResolver> {
191 inner: R,
192 cache: DiscoveryCache,
193}
194
195impl<R: DiscoveryResolver> CachingResolver<R> {
196 pub fn new(inner: R, cache: DiscoveryCache) -> Self {
197 Self { inner, cache }
198 }
199}
200
201impl<R: DiscoveryResolver> DiscoveryResolver for CachingResolver<R> {
202 fn resolve_discovery(
203 &self,
204 domain: &str,
205 ) -> Result<agentpin::types::discovery::DiscoveryDocument, agentpin::error::Error> {
206 if let Some(cached) = self.cache.get(domain) {
207 return Ok(cached);
208 }
209
210 let doc = self.inner.resolve_discovery(domain)?;
211
212 let _ = self.cache.put(domain, &doc);
214
215 Ok(doc)
216 }
217
218 fn resolve_revocation(
219 &self,
220 domain: &str,
221 discovery: &agentpin::types::discovery::DiscoveryDocument,
222 ) -> Result<Option<agentpin::types::revocation::RevocationDocument>, agentpin::error::Error>
223 {
224 self.inner.resolve_revocation(domain, discovery)
225 }
226}
227
228pub struct MockAgentPinVerifier {
230 should_succeed: bool,
231 mock_agent_id: String,
232 mock_issuer: String,
233 mock_capabilities: Vec<String>,
234}
235
236impl MockAgentPinVerifier {
237 pub fn new_success() -> Self {
239 Self {
240 should_succeed: true,
241 mock_agent_id: "mock-agent-001".to_string(),
242 mock_issuer: "mock.example.com".to_string(),
243 mock_capabilities: vec!["execute:*".to_string()],
244 }
245 }
246
247 pub fn new_failure() -> Self {
249 Self {
250 should_succeed: false,
251 mock_agent_id: String::new(),
252 mock_issuer: String::new(),
253 mock_capabilities: vec![],
254 }
255 }
256
257 pub fn with_identity(agent_id: String, issuer: String, capabilities: Vec<String>) -> Self {
259 Self {
260 should_succeed: true,
261 mock_agent_id: agent_id,
262 mock_issuer: issuer,
263 mock_capabilities: capabilities,
264 }
265 }
266}
267
268#[async_trait]
269impl AgentPinVerifier for MockAgentPinVerifier {
270 async fn verify_credential(
271 &self,
272 _jwt: &str,
273 ) -> Result<AgentVerificationResult, AgentPinError> {
274 if self.should_succeed {
275 Ok(AgentVerificationResult::success(
276 self.mock_agent_id.clone(),
277 self.mock_issuer.clone(),
278 self.mock_capabilities.clone(),
279 ))
280 } else {
281 Ok(AgentVerificationResult::failure(
282 "Mock verification failed".to_string(),
283 ))
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[tokio::test]
293 async fn test_mock_verifier_success() {
294 let verifier = MockAgentPinVerifier::new_success();
295 let result = verifier.verify_credential("dummy.jwt.token").await.unwrap();
296 assert!(result.valid);
297 assert_eq!(result.agent_id, Some("mock-agent-001".to_string()));
298 assert_eq!(result.issuer, Some("mock.example.com".to_string()));
299 assert!(!result.capabilities.is_empty());
300 }
301
302 #[tokio::test]
303 async fn test_mock_verifier_failure() {
304 let verifier = MockAgentPinVerifier::new_failure();
305 let result = verifier.verify_credential("dummy.jwt.token").await.unwrap();
306 assert!(!result.valid);
307 assert!(result.error_message.is_some());
308 }
309
310 #[tokio::test]
311 async fn test_mock_verifier_custom_identity() {
312 let verifier = MockAgentPinVerifier::with_identity(
313 "custom-agent".to_string(),
314 "custom.example.com".to_string(),
315 vec!["read:data".to_string(), "write:data".to_string()],
316 );
317 let result = verifier.verify_credential("dummy.jwt.token").await.unwrap();
318 assert!(result.valid);
319 assert_eq!(result.agent_id, Some("custom-agent".to_string()));
320 assert_eq!(result.issuer, Some("custom.example.com".to_string()));
321 assert_eq!(result.capabilities.len(), 2);
322 }
323
324 #[test]
325 fn test_caching_resolver() {
326 use agentpin::types::bundle::TrustBundle;
327
328 let temp_dir = tempfile::tempdir().unwrap();
329 let cache = DiscoveryCache::new(temp_dir.path(), 3600).unwrap();
330
331 let doc = agentpin::discovery::build_discovery_document(
332 "cached.example.com",
333 agentpin::types::discovery::EntityType::Maker,
334 vec![agentpin::jwk::Jwk {
335 kid: "k1".to_string(),
336 kty: "EC".to_string(),
337 crv: "P-256".to_string(),
338 x: "x".to_string(),
339 y: "y".to_string(),
340 use_: "sig".to_string(),
341 key_ops: None,
342 exp: None,
343 }],
344 vec![],
345 2,
346 "2026-02-10T00:00:00Z",
347 );
348
349 let bundle = TrustBundle {
350 agentpin_bundle_version: "0.1".to_string(),
351 created_at: "2026-02-10T00:00:00Z".to_string(),
352 documents: vec![doc],
353 revocations: vec![],
354 };
355 let inner = TrustBundleResolver::new(&bundle);
356 let resolver = CachingResolver::new(inner, cache);
357
358 let resolved = resolver.resolve_discovery("cached.example.com").unwrap();
360 assert_eq!(resolved.entity, "cached.example.com");
361
362 let resolved2 = resolver.resolve_discovery("cached.example.com").unwrap();
364 assert_eq!(resolved2.entity, "cached.example.com");
365 }
366}