Skip to main content

symbi_runtime/integrations/agentpin/
verifier.rs

1//! AgentPin Verifier
2//!
3//! Provides trait and implementations for verifying AgentPin credentials.
4
5use 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/// Trait for verifying AgentPin credentials
20#[async_trait]
21pub trait AgentPinVerifier: Send + Sync {
22    /// Verify a JWT credential and return the verification result
23    async fn verify_credential(&self, jwt: &str) -> Result<AgentVerificationResult, AgentPinError>;
24}
25
26/// Default verifier that delegates to the agentpin crate's verification engine.
27///
28/// Dispatches to the appropriate resolver based on `DiscoveryMode`.
29pub struct DefaultAgentPinVerifier {
30    config: AgentPinConfig,
31    key_store: AgentPinKeyStore,
32    sync_resolver: Option<Box<dyn DiscoveryResolver>>,
33}
34
35impl DefaultAgentPinVerifier {
36    /// Create a new verifier from configuration.
37    ///
38    /// Pre-loads trust bundle and builds resolvers based on `discovery_mode`.
39    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    /// Build a sync resolver from config, if applicable.
56    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                    // Chain with no sync resolvers — fall back to WellKnown
106                    Ok(None)
107                } else {
108                    Ok(Some(Box::new(ChainResolver::new(resolvers))))
109                }
110            }
111            DiscoveryMode::WellKnown => Ok(None),
112        }
113    }
114
115    /// Convert agentpin crate's VerificationResult to our integration type
116    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            // Use the sync resolver (bundle, local, or chain)
161            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            // Fall back to online WellKnown resolution
170            agentpin::verification::verify_credential(
171                jwt,
172                &mut pin_store,
173                audience,
174                &verifier_config,
175            )
176            .await
177        };
178
179        // Persist updated pin store (may have new TOFU entries)
180        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
188/// Caching wrapper around a [`DiscoveryResolver`] that checks the
189/// [`DiscoveryCache`] before delegating.
190pub 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        // Best-effort cache write
213        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
228/// Mock verifier for testing
229pub 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    /// Create a mock verifier that always succeeds
238    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    /// Create a mock verifier that always fails
248    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    /// Create a mock with custom identity
258    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        // First call: miss, delegates to inner
359        let resolved = resolver.resolve_discovery("cached.example.com").unwrap();
360        assert_eq!(resolved.entity, "cached.example.com");
361
362        // Second call: should be cached
363        let resolved2 = resolver.resolve_discovery("cached.example.com").unwrap();
364        assert_eq!(resolved2.entity, "cached.example.com");
365    }
366}