1use std::{
25 collections::{HashMap, HashSet},
26 num::NonZeroU32,
27 pin::Pin,
28 sync::{Arc, Mutex},
29 time::{Duration, SystemTime, UNIX_EPOCH},
30};
31
32use arc_swap::ArcSwap;
33use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
34use rustls::{
35 DigitallySignedStruct, DistinguishedName, Error as TlsError, RootCertStore, SignatureScheme,
36 client::danger::HandshakeSignatureValid,
37 pki_types::{CertificateDer, CertificateRevocationListDer, UnixTime},
38 server::{
39 WebPkiClientVerifier,
40 danger::{ClientCertVerified, ClientCertVerifier},
41 },
42};
43use tokio::{
44 net::lookup_host,
45 sync::{RwLock, Semaphore, mpsc},
46 task::JoinSet,
47 time::{Instant, Sleep},
48};
49use tokio_util::sync::CancellationToken;
50use url::Url;
51use x509_parser::{
52 extensions::{DistributionPointName, GeneralName, ParsedExtension},
53 prelude::{FromDer, X509Certificate},
54 revocation_list::CertificateRevocationList,
55};
56
57use crate::{
58 auth::MtlsConfig,
59 error::McpxError,
60 ssrf::{check_scheme, ip_block_reason, sanitized_url_for_log},
61};
62
63const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(10);
64const MIN_AUTO_REFRESH: Duration = Duration::from_mins(10);
65const MAX_AUTO_REFRESH: Duration = Duration::from_hours(24);
66const CRL_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
69
70#[derive(Clone, Debug)]
72#[non_exhaustive]
73pub struct CachedCrl {
74 pub der: CertificateRevocationListDer<'static>,
76 pub this_update: SystemTime,
78 pub next_update: Option<SystemTime>,
80 pub fetched_at: SystemTime,
82 pub source_url: String,
84}
85
86pub(crate) struct VerifierHandle(pub Arc<dyn ClientCertVerifier>);
87
88impl std::fmt::Debug for VerifierHandle {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("VerifierHandle").finish_non_exhaustive()
91 }
92}
93
94#[allow(
96 missing_debug_implementations,
97 reason = "contains ArcSwap and dyn verifier internals"
98)]
99#[non_exhaustive]
100pub struct CrlSet {
101 inner_verifier: ArcSwap<VerifierHandle>,
102 pub cache: RwLock<HashMap<String, CachedCrl>>,
104 pub roots: Arc<RootCertStore>,
106 pub config: MtlsConfig,
108 pub discover_tx: mpsc::UnboundedSender<String>,
110 client: reqwest::Client,
111 seen_urls: Mutex<HashSet<String>>,
112 cached_urls: Mutex<HashSet<String>>,
113 global_fetch_sem: Arc<Semaphore>,
115 host_semaphores: Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
120 discovery_limiter: Arc<DefaultDirectRateLimiter>,
131 max_response_bytes: u64,
134 last_cap_warn: Mutex<HashMap<&'static str, Instant>>,
135}
136
137impl CrlSet {
138 fn new(
139 roots: Arc<RootCertStore>,
140 config: MtlsConfig,
141 discover_tx: mpsc::UnboundedSender<String>,
142 initial_cache: HashMap<String, CachedCrl>,
143 ) -> Result<Arc<Self>, McpxError> {
144 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
152 let resolver: Arc<dyn reqwest::dns::Resolve> =
153 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
154 Arc::clone(&allowlist),
155 #[cfg(any(test, feature = "test-helpers"))]
156 Arc::new(std::sync::atomic::AtomicBool::new(false)),
157 #[cfg(not(any(test, feature = "test-helpers")))]
158 (),
159 ));
160
161 let client = reqwest::Client::builder()
162 .no_proxy()
164 .dns_resolver(Arc::clone(&resolver))
165 .timeout(config.crl_fetch_timeout)
166 .connect_timeout(CRL_CONNECT_TIMEOUT)
167 .tcp_keepalive(None)
168 .redirect(reqwest::redirect::Policy::none())
169 .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
170 .build()
171 .map_err(|error| McpxError::Startup(format!("CRL HTTP client init: {error}")))?;
172
173 let initial_verifier = rebuild_verifier(&roots, &config, &initial_cache)?;
174 let seen_urls = initial_cache.keys().cloned().collect::<HashSet<_>>();
175 let cached_urls = seen_urls.clone();
176
177 let concurrency = config.crl_max_concurrent_fetches.max(1);
178 let global_fetch_sem = Arc::new(Semaphore::new(concurrency));
179 let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
180
181 let rate =
182 NonZeroU32::new(config.crl_discovery_rate_per_min.max(1)).unwrap_or(NonZeroU32::MIN);
183 let discovery_limiter = Arc::new(RateLimiter::direct(Quota::per_minute(rate)));
184
185 let max_response_bytes = config.crl_max_response_bytes;
186
187 Ok(Arc::new(Self {
188 inner_verifier: ArcSwap::from_pointee(VerifierHandle(initial_verifier)),
189 cache: RwLock::new(initial_cache),
190 roots,
191 config,
192 discover_tx,
193 client,
194 seen_urls: Mutex::new(seen_urls),
195 cached_urls: Mutex::new(cached_urls),
196 global_fetch_sem,
197 host_semaphores,
198 discovery_limiter,
199 max_response_bytes,
200 last_cap_warn: Mutex::new(HashMap::new()),
201 }))
202 }
203
204 fn warn_cap_exceeded_throttled(&self, which: &'static str) {
205 let now = Instant::now();
206 let cooldown = Duration::from_mins(1);
207 let should_warn = match self.last_cap_warn.lock() {
208 Ok(mut guard) => {
209 let should_emit = guard
210 .get(which)
211 .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
212 if should_emit {
213 guard.insert(which, now);
214 }
215 should_emit
216 }
217 Err(poisoned) => {
218 let mut guard = poisoned.into_inner();
219 let should_emit = guard
220 .get(which)
221 .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
222 if should_emit {
223 guard.insert(which, now);
224 }
225 should_emit
226 }
227 };
228
229 if should_warn {
230 tracing::warn!(which = which, "CRL map cap exceeded; dropping newest entry");
231 }
232 }
233
234 async fn commit_cache_update_atomically(
235 &self,
236 inserts: Vec<(String, CachedCrl)>,
237 removals: &[String],
238 ) -> Result<bool, McpxError> {
239 let mut cache = self.cache.write().await;
240 let mut candidate = cache.clone();
241 let mut admitted_urls = Vec::new();
242
243 for (url, cached) in inserts {
250 if candidate.len() >= self.config.crl_max_cache_entries && !candidate.contains_key(&url)
251 {
252 self.warn_cap_exceeded_throttled("cache");
253 continue;
254 }
255 candidate.insert(url.clone(), cached);
256 admitted_urls.push(url);
257 }
258
259 for url in removals {
260 candidate.remove(url);
261 }
262
263 let verifier = rebuild_verifier(&self.roots, &self.config, &candidate)?;
269 self.inner_verifier
270 .store(Arc::new(VerifierHandle(verifier)));
271 let changed = !admitted_urls.is_empty() || !removals.is_empty();
272 *cache = candidate;
273 drop(cache);
274
275 match self.cached_urls.lock() {
276 Ok(mut cached_urls) => {
277 for url in admitted_urls {
278 cached_urls.insert(url);
279 }
280 for url in removals {
281 cached_urls.remove(url);
282 }
283 }
284 Err(poisoned) => {
285 let mut cached_urls = poisoned.into_inner();
286 for url in admitted_urls {
287 cached_urls.insert(url);
288 }
289 for url in removals {
290 cached_urls.remove(url);
291 }
292 }
293 }
294
295 match self.seen_urls.lock() {
296 Ok(mut seen_urls) => {
297 for url in removals {
298 seen_urls.remove(url);
299 }
300 }
301 Err(poisoned) => {
302 let mut seen_urls = poisoned.into_inner();
303 for url in removals {
304 seen_urls.remove(url);
305 }
306 }
307 }
308
309 Ok(changed)
310 }
311
312 pub async fn force_refresh(&self) -> Result<(), McpxError> {
318 let urls = {
319 let cache = self.cache.read().await;
320 cache.keys().cloned().collect::<Vec<_>>()
321 };
322 self.refresh_urls(urls).await
323 }
324
325 async fn refresh_due_urls(&self) -> Result<(), McpxError> {
326 let now = SystemTime::now();
327 let urls = {
328 let cache = self.cache.read().await;
329 cache
330 .iter()
331 .filter(|(_, cached)| {
332 should_refresh_cached(cached, now, self.config.crl_refresh_interval)
333 })
334 .map(|(url, _)| url.clone())
335 .collect::<Vec<_>>()
336 };
337
338 if urls.is_empty() {
339 return Ok(());
340 }
341
342 self.refresh_urls(urls).await
343 }
344
345 async fn refresh_urls(&self, urls: Vec<String>) -> Result<(), McpxError> {
346 let results = self.fetch_url_results(urls).await;
347 let now = SystemTime::now();
348 let cache = self.cache.read().await;
349 let mut inserts = Vec::new();
350 let mut removals = Vec::new();
351
352 for (url, result) in results {
353 match result {
354 Ok(cached) => {
355 inserts.push((url, cached));
356 }
357 Err(error) => {
358 let remove_entry = cache.get(&url).is_some_and(|existing| {
359 existing
360 .next_update
361 .and_then(|next| next.checked_add(self.config.crl_stale_grace))
362 .is_some_and(|deadline| now > deadline)
363 });
364 tracing::warn!(url = %url, error = %error, "CRL refresh failed");
365 if remove_entry {
366 removals.push(url);
367 }
368 }
369 }
370 }
371 drop(cache);
372
373 if !inserts.is_empty() || !removals.is_empty() {
374 let _ = self
375 .commit_cache_update_atomically(inserts, &removals)
376 .await?;
377 }
378
379 Ok(())
380 }
381
382 async fn fetch_and_store_url(&self, url: String) -> Result<(), McpxError> {
383 let cached = gated_fetch(
384 &self.client,
385 &self.global_fetch_sem,
386 &self.host_semaphores,
387 &url,
388 self.config.crl_allow_http,
389 self.max_response_bytes,
390 self.config.crl_max_host_semaphores,
391 )
392 .await?;
393 let _ = self
394 .commit_cache_update_atomically(vec![(url, cached)], &[])
395 .await?;
396 Ok(())
397 }
398
399 fn note_discovered_urls(
400 &self,
401 end_entity_urls: &[String],
402 intermediate_urls: &[String],
403 ) -> bool {
404 let mut all_urls = Vec::with_capacity(end_entity_urls.len() + intermediate_urls.len());
413 all_urls.extend_from_slice(end_entity_urls);
414 all_urls.extend_from_slice(intermediate_urls);
415 all_urls.sort();
416 all_urls.dedup();
417
418 let candidates: Vec<String> = match self.seen_urls.lock() {
429 Ok(seen) => all_urls
430 .iter()
431 .filter(|url| !seen.contains(*url))
432 .cloned()
433 .collect(),
434 Err(_) => Vec::new(),
435 };
436
437 for url in candidates {
444 if self.discovery_limiter.check().is_err() {
445 tracing::warn!(
446 url = %url,
447 "discovery_rate_limited: dropped CDP URL beyond per-minute cap (will be retried on next handshake observing this URL)"
448 );
449 continue;
450 }
451 if self.discover_tx.send(url.clone()).is_err() {
452 tracing::debug!(
455 url = %url,
456 "discover channel closed; dropping CDP URL without marking seen"
457 );
458 continue;
459 }
460 let mut guard = self
462 .seen_urls
463 .lock()
464 .unwrap_or_else(std::sync::PoisonError::into_inner);
465 if guard.len() >= self.config.crl_max_seen_urls {
466 self.warn_cap_exceeded_throttled("seen_urls");
467 break;
468 }
469 guard.insert(url);
470 }
471
472 if self.config.crl_deny_on_unavailable {
473 let cached = self
474 .cached_urls
475 .lock()
476 .ok()
477 .map(|guard| guard.clone())
478 .unwrap_or_default();
479 let relevant_urls = if self.config.crl_end_entity_only {
480 end_entity_urls
481 } else {
482 all_urls.as_slice()
483 };
484 return !relevant_urls.is_empty()
485 && relevant_urls.iter().all(|url| !cached.contains(url));
486 }
487
488 false
489 }
490
491 #[doc(hidden)]
497 pub fn __test_with_prepopulated_crls(
498 roots: Arc<RootCertStore>,
499 config: MtlsConfig,
500 prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
501 ) -> Result<Arc<Self>, McpxError> {
502 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
503 drop(discover_rx);
504
505 let mut initial_cache = HashMap::new();
506 for (index, der) in prefilled_crls.into_iter().enumerate() {
507 let source_url = format!("memory://crl/{index}");
508 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
509 initial_cache.insert(
510 source_url.clone(),
511 CachedCrl {
512 der,
513 this_update,
514 next_update,
515 fetched_at: SystemTime::now(),
516 source_url,
517 },
518 );
519 }
520
521 Self::new(roots, config, discover_tx, initial_cache)
522 }
523
524 #[doc(hidden)]
536 pub fn __test_with_kept_receiver(
537 roots: Arc<RootCertStore>,
538 config: MtlsConfig,
539 prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
540 ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<String>), McpxError> {
541 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
542
543 let mut initial_cache = HashMap::new();
544 for (index, der) in prefilled_crls.into_iter().enumerate() {
545 let source_url = format!("memory://crl/{index}");
546 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
547 initial_cache.insert(
548 source_url.clone(),
549 CachedCrl {
550 der,
551 this_update,
552 next_update,
553 fetched_at: SystemTime::now(),
554 source_url,
555 },
556 );
557 }
558
559 let crl_set = Self::new(roots, config, discover_tx, initial_cache)?;
560 Ok((crl_set, discover_rx))
561 }
562
563 #[doc(hidden)]
568 pub fn __test_check_discovery_rate(&self, urls: &[String]) -> (usize, usize) {
569 let mut accepted = 0usize;
570 let mut dropped = 0usize;
571 for url in urls {
572 if self.discovery_limiter.check().is_ok() {
573 let _ = self.discover_tx.send(url.clone());
574 accepted += 1;
575 } else {
576 dropped += 1;
577 }
578 }
579 (accepted, dropped)
580 }
581
582 #[doc(hidden)]
586 pub fn __test_note_discovered_urls(&self, urls: &[String]) -> bool {
587 let missing_cached = self.note_discovered_urls(urls, &[]);
588 if self.discover_tx.is_closed() {
589 match self.seen_urls.lock() {
590 Ok(mut guard) => {
591 for url in urls {
592 if guard.contains(url) {
593 continue;
594 }
595 if guard.len() >= self.config.crl_max_seen_urls {
596 self.warn_cap_exceeded_throttled("seen_urls");
597 break;
598 }
599 guard.insert(url.clone());
600 }
601 }
602 Err(poisoned) => {
603 let mut guard = poisoned.into_inner();
604 for url in urls {
605 if guard.contains(url) {
606 continue;
607 }
608 if guard.len() >= self.config.crl_max_seen_urls {
609 self.warn_cap_exceeded_throttled("seen_urls");
610 break;
611 }
612 guard.insert(url.clone());
613 }
614 }
615 }
616 }
617 missing_cached
618 }
619
620 #[cfg(any(test, feature = "test-helpers"))]
623 #[doc(hidden)]
624 pub fn __test_note_discovered_urls_by_cert(
625 &self,
626 end_entity_urls: &[String],
627 intermediate_urls: &[String],
628 ) -> bool {
629 self.note_discovered_urls(end_entity_urls, intermediate_urls)
630 }
631
632 #[doc(hidden)]
637 pub fn __test_is_seen(&self, url: &str) -> bool {
638 match self.seen_urls.lock() {
639 Ok(seen) => seen.contains(url),
640 Err(_) => false,
641 }
642 }
643
644 #[cfg(any(test, feature = "test-helpers"))]
647 #[doc(hidden)]
648 pub fn __test_host_semaphore_count(&self) -> usize {
649 self.host_semaphores
650 .try_lock()
651 .map_or(0, |guard| guard.len())
652 }
653
654 #[cfg(any(test, feature = "test-helpers"))]
656 #[doc(hidden)]
657 pub fn __test_cache_len(&self) -> usize {
658 self.cache.try_read().map_or(0, |guard| guard.len())
659 }
660
661 #[cfg(any(test, feature = "test-helpers"))]
663 #[doc(hidden)]
664 pub fn __test_cache_contains(&self, url: &str) -> bool {
665 self.cache
666 .try_read()
667 .is_ok_and(|guard| guard.contains_key(url))
668 }
669
670 #[cfg(any(test, feature = "test-helpers"))]
673 #[doc(hidden)]
674 pub fn __test_cached_url_contains(&self, url: &str) -> bool {
675 self.cached_urls
676 .lock()
677 .is_ok_and(|guard| guard.contains(url))
678 }
679
680 #[cfg(any(test, feature = "test-helpers"))]
687 #[doc(hidden)]
688 pub async fn __test_trigger_fetch(&self, url: &str) -> Result<(), McpxError> {
689 if let Err(error) = gated_fetch(
690 &self.client,
691 &self.global_fetch_sem,
692 &self.host_semaphores,
693 url,
694 self.config.crl_allow_http,
695 self.max_response_bytes,
696 self.config.crl_max_host_semaphores,
697 )
698 .await
699 {
700 if error
701 .to_string()
702 .contains("crl_host_semaphore_cap_exceeded")
703 {
704 Err(error)
705 } else {
706 Ok(())
707 }
708 } else {
709 Ok(())
710 }
711 }
712
713 #[cfg(any(test, feature = "test-helpers"))]
725 #[doc(hidden)]
726 pub async fn __test_insert_cache(&self, url: &str, cached: CachedCrl) {
727 let _ = self
728 .commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
729 .await;
730 }
731
732 #[cfg(any(test, feature = "test-helpers"))]
734 #[doc(hidden)]
735 pub async fn __test_try_insert_cache(
736 &self,
737 url: &str,
738 cached: CachedCrl,
739 ) -> Result<bool, McpxError> {
740 self.commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
741 .await
742 }
743
744 #[cfg(any(test, feature = "test-helpers"))]
746 #[doc(hidden)]
747 pub async fn __test_replace_cache_entry_unverified(&self, url: &str, cached: CachedCrl) {
748 let mut cache = self.cache.write().await;
749 cache.insert(url.to_owned(), cached);
750 }
751
752 #[cfg(any(test, feature = "test-helpers"))]
757 #[doc(hidden)]
758 pub async fn __test_trigger_refresh_url(&self, url: &str) -> Result<(), McpxError> {
759 self.refresh_urls(vec![url.to_owned()]).await
760 }
761
762 async fn fetch_url_results(
763 &self,
764 urls: Vec<String>,
765 ) -> Vec<(String, Result<CachedCrl, McpxError>)> {
766 let mut tasks = JoinSet::new();
767 for url in urls {
768 let client = self.client.clone();
769 let global_sem = Arc::clone(&self.global_fetch_sem);
770 let host_map = Arc::clone(&self.host_semaphores);
771 let allow_http = self.config.crl_allow_http;
772 let max_bytes = self.max_response_bytes;
773 let max_host_semaphores = self.config.crl_max_host_semaphores;
774 tasks.spawn(async move {
775 let result = gated_fetch(
776 &client,
777 &global_sem,
778 &host_map,
779 &url,
780 allow_http,
781 max_bytes,
782 max_host_semaphores,
783 )
784 .await;
785 (url, result)
786 });
787 }
788
789 let mut results = Vec::new();
790 while let Some(joined) = tasks.join_next().await {
791 match joined {
792 Ok(result) => results.push(result),
793 Err(error) => {
794 tracing::warn!(error = %error, "CRL refresh task join failed");
795 }
796 }
797 }
798
799 results
800 }
801}
802
803#[cfg(any(test, feature = "test-helpers"))]
804const SYNTHETIC_TEST_CRL_DER: &[u8] = &[
805 48, 129, 199, 48, 110, 2, 1, 1, 48, 10, 6, 8, 42, 134, 72, 206, 61, 4, 3, 2, 48, 14, 49, 12,
806 48, 10, 6, 3, 85, 4, 3, 12, 3, 99, 114, 108, 23, 13, 50, 54, 48, 49, 48, 49, 48, 48, 48, 48,
807 48, 48, 90, 23, 13, 50, 55, 48, 49, 48, 49, 48, 48, 48, 48, 48, 48, 90, 160, 47, 48, 45, 48,
808 31, 6, 3, 85, 29, 35, 4, 24, 48, 22, 128, 20, 14, 62, 48, 146, 7, 182, 179, 215, 90, 226, 214,
809 90, 201, 83, 149, 116, 34, 31, 26, 255, 48, 10, 6, 3, 85, 29, 20, 4, 3, 2, 1, 1, 48, 10, 6, 8,
810 42, 134, 72, 206, 61, 4, 3, 2, 3, 73, 0, 48, 70, 2, 33, 0, 250, 240, 103, 87, 60, 78, 208, 171,
811 184, 206, 117, 134, 236, 234, 53, 115, 122, 90, 64, 217, 146, 27, 32, 103, 170, 222, 240, 159,
812 137, 187, 116, 6, 2, 33, 0, 188, 23, 204, 232, 130, 84, 135, 249, 43, 208, 224, 220, 202, 57,
813 98, 140, 4, 251, 148, 189, 105, 68, 105, 40, 53, 180, 208, 38, 193, 120, 118, 100,
814];
815
816impl CachedCrl {
817 #[cfg(any(test, feature = "test-helpers"))]
821 #[doc(hidden)]
822 #[must_use]
823 pub fn __test_synthetic(now: SystemTime) -> Self {
824 Self {
825 der: CertificateRevocationListDer::from(SYNTHETIC_TEST_CRL_DER.to_vec()),
826 this_update: now,
827 next_update: now.checked_add(Duration::from_hours(24)),
828 fetched_at: now,
829 source_url: "test://synthetic".to_owned(),
830 }
831 }
832
833 #[cfg(any(test, feature = "test-helpers"))]
837 #[doc(hidden)]
838 #[must_use]
839 pub fn __test_stale(reference_past: SystemTime) -> Self {
840 Self {
841 der: CertificateRevocationListDer::from(vec![0x30, 0x00]),
842 this_update: reference_past,
843 next_update: Some(reference_past),
844 fetched_at: reference_past,
845 source_url: "test://stale".to_owned(),
846 }
847 }
848}
849
850pub struct DynamicClientCertVerifier {
853 inner: Arc<CrlSet>,
854 dn_subjects: Vec<DistinguishedName>,
855}
856
857impl DynamicClientCertVerifier {
858 #[must_use]
860 pub fn new(inner: Arc<CrlSet>) -> Self {
861 Self {
862 dn_subjects: inner.roots.subjects(),
863 inner,
864 }
865 }
866}
867
868impl std::fmt::Debug for DynamicClientCertVerifier {
869 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
870 f.debug_struct("DynamicClientCertVerifier")
871 .field("dn_subjects_len", &self.dn_subjects.len())
872 .finish_non_exhaustive()
873 }
874}
875
876impl ClientCertVerifier for DynamicClientCertVerifier {
877 fn offer_client_auth(&self) -> bool {
878 let verifier = self.inner.inner_verifier.load();
879 verifier.0.offer_client_auth()
880 }
881
882 fn client_auth_mandatory(&self) -> bool {
883 let verifier = self.inner.inner_verifier.load();
884 verifier.0.client_auth_mandatory()
885 }
886
887 fn root_hint_subjects(&self) -> &[DistinguishedName] {
888 &self.dn_subjects
889 }
890
891 fn verify_client_cert(
892 &self,
893 end_entity: &CertificateDer<'_>,
894 intermediates: &[CertificateDer<'_>],
895 now: UnixTime,
896 ) -> Result<ClientCertVerified, TlsError> {
897 let mut end_entity_urls =
909 extract_cdp_urls(end_entity.as_ref(), self.inner.config.crl_allow_http);
910 end_entity_urls.sort();
911 end_entity_urls.dedup();
912
913 let mut intermediate_urls = Vec::new();
914 for intermediate in intermediates {
915 intermediate_urls.extend(extract_cdp_urls(
916 intermediate.as_ref(),
917 self.inner.config.crl_allow_http,
918 ));
919 }
920 intermediate_urls.sort();
921 intermediate_urls.dedup();
922
923 if self
924 .inner
925 .note_discovered_urls(&end_entity_urls, &intermediate_urls)
926 {
927 return Err(TlsError::General(
928 "client certificate revocation status unavailable".to_owned(),
929 ));
930 }
931
932 let verifier = self.inner.inner_verifier.load();
933 verifier
934 .0
935 .verify_client_cert(end_entity, intermediates, now)
936 }
937
938 fn verify_tls12_signature(
939 &self,
940 message: &[u8],
941 cert: &CertificateDer<'_>,
942 dss: &DigitallySignedStruct,
943 ) -> Result<HandshakeSignatureValid, TlsError> {
944 let verifier = self.inner.inner_verifier.load();
945 verifier.0.verify_tls12_signature(message, cert, dss)
946 }
947
948 fn verify_tls13_signature(
949 &self,
950 message: &[u8],
951 cert: &CertificateDer<'_>,
952 dss: &DigitallySignedStruct,
953 ) -> Result<HandshakeSignatureValid, TlsError> {
954 let verifier = self.inner.inner_verifier.load();
955 verifier.0.verify_tls13_signature(message, cert, dss)
956 }
957
958 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
959 let verifier = self.inner.inner_verifier.load();
960 verifier.0.supported_verify_schemes()
961 }
962
963 fn requires_raw_public_keys(&self) -> bool {
964 let verifier = self.inner.inner_verifier.load();
965 verifier.0.requires_raw_public_keys()
966 }
967}
968
969#[must_use]
978pub fn extract_cdp_urls(cert_der: &[u8], allow_http: bool) -> Vec<String> {
979 let Ok((_, cert)) = X509Certificate::from_der(cert_der) else {
980 return Vec::new();
981 };
982
983 let mut urls = Vec::new();
984 for ext in cert.extensions() {
985 if let ParsedExtension::CRLDistributionPoints(cdps) = ext.parsed_extension() {
986 for point in cdps.iter() {
987 if let Some(DistributionPointName::FullName(names)) = &point.distribution_point {
988 for name in names {
989 if let GeneralName::URI(uri) = name {
990 let raw = *uri;
991 let Ok(parsed) = Url::parse(raw) else {
992 tracing::debug!(url = ?raw, "CDP URL parse failed; dropped");
996 continue;
997 };
998 if let Err(reason) = check_scheme(&parsed, allow_http) {
999 tracing::debug!(
1000 url = %sanitized_url_for_log(&parsed),
1001 reason,
1002 "CDP URL rejected by scheme guard; dropped"
1003 );
1004 continue;
1005 }
1006 urls.push(parsed.into());
1007 }
1008 }
1009 }
1010 }
1011 }
1012 }
1013
1014 urls
1015}
1016
1017#[allow(
1024 clippy::cognitive_complexity,
1025 reason = "bootstrap coordinates timeout, parallel fetches, and partial-cache recovery"
1026)]
1027pub async fn bootstrap_fetch(
1028 roots: Arc<RootCertStore>,
1029 ca_certs: &[CertificateDer<'static>],
1030 config: MtlsConfig,
1031) -> Result<(Arc<CrlSet>, mpsc::UnboundedReceiver<String>), McpxError> {
1032 let (discover_tx, discover_rx) = mpsc::unbounded_channel();
1033
1034 let mut urls = ca_certs
1035 .iter()
1036 .flat_map(|cert| extract_cdp_urls(cert.as_ref(), config.crl_allow_http))
1037 .collect::<Vec<_>>();
1038 urls.sort();
1039 urls.dedup();
1040
1041 let bootstrap_allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
1045 let bootstrap_resolver: Arc<dyn reqwest::dns::Resolve> =
1046 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1047 Arc::clone(&bootstrap_allowlist),
1048 #[cfg(any(test, feature = "test-helpers"))]
1049 Arc::new(std::sync::atomic::AtomicBool::new(false)),
1050 #[cfg(not(any(test, feature = "test-helpers")))]
1051 (),
1052 ));
1053
1054 let client = reqwest::Client::builder()
1055 .no_proxy()
1057 .dns_resolver(Arc::clone(&bootstrap_resolver))
1058 .timeout(config.crl_fetch_timeout)
1059 .connect_timeout(CRL_CONNECT_TIMEOUT)
1060 .tcp_keepalive(None)
1061 .redirect(reqwest::redirect::Policy::none())
1062 .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
1063 .build()
1064 .map_err(|error| McpxError::Startup(format!("CRL HTTP client init: {error}")))?;
1065
1066 let bootstrap_concurrency = config.crl_max_concurrent_fetches.max(1);
1070 let global_sem = Arc::new(Semaphore::new(bootstrap_concurrency));
1071 let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
1072 let allow_http = config.crl_allow_http;
1073 let max_bytes = config.crl_max_response_bytes;
1074 let max_host_semaphores = config.crl_max_host_semaphores;
1075
1076 let mut initial_cache = HashMap::new();
1077 let mut tasks = JoinSet::new();
1078 for url in &urls {
1079 let client = client.clone();
1080 let url = url.clone();
1081 let global_sem = Arc::clone(&global_sem);
1082 let host_semaphores = Arc::clone(&host_semaphores);
1083 tasks.spawn(async move {
1084 let result = gated_fetch(
1085 &client,
1086 &global_sem,
1087 &host_semaphores,
1088 &url,
1089 allow_http,
1090 max_bytes,
1091 max_host_semaphores,
1092 )
1093 .await;
1094 (url, result)
1095 });
1096 }
1097
1098 let timeout: Sleep = tokio::time::sleep(BOOTSTRAP_TIMEOUT);
1099 tokio::pin!(timeout);
1100
1101 while !tasks.is_empty() {
1102 tokio::select! {
1106 () = &mut timeout => {
1107 tracing::warn!("CRL bootstrap timed out after {:?}", BOOTSTRAP_TIMEOUT);
1108 break;
1109 }
1110 maybe_joined = tasks.join_next() => {
1111 let Some(joined) = maybe_joined else {
1112 break;
1113 };
1114 match joined {
1115 Ok((url, Ok(cached))) => {
1116 initial_cache.insert(url, cached);
1117 }
1118 Ok((url, Err(error))) => {
1119 tracing::warn!(url = %url, error = %error, "CRL bootstrap fetch failed");
1120 }
1121 Err(error) => {
1122 tracing::warn!(error = %error, "CRL bootstrap task join failed");
1123 }
1124 }
1125 }
1126 }
1127 }
1128
1129 let set = CrlSet::new(roots, config, discover_tx, initial_cache)?;
1130 Ok((set, discover_rx))
1131}
1132
1133#[allow(
1135 clippy::cognitive_complexity,
1136 reason = "refresher loop intentionally handles shutdown, timer, and discovery in one select"
1137)]
1138pub async fn run_crl_refresher(
1139 set: Arc<CrlSet>,
1140 mut discover_rx: mpsc::UnboundedReceiver<String>,
1141 shutdown: CancellationToken,
1142) {
1143 let mut refresh_sleep = schedule_next_refresh(&set).await;
1144
1145 loop {
1146 tokio::select! {
1150 () = shutdown.cancelled() => {
1151 break;
1152 }
1153 () = &mut refresh_sleep => {
1154 if let Err(error) = set.refresh_due_urls().await {
1155 tracing::warn!(error = %error, "CRL periodic refresh failed");
1156 }
1157 refresh_sleep = schedule_next_refresh(&set).await;
1158 }
1159 maybe_url = discover_rx.recv() => {
1160 let Some(url) = maybe_url else {
1161 break;
1162 };
1163 if let Err(error) = set.fetch_and_store_url(url.clone()).await {
1164 tracing::warn!(url = %url, error = %error, "CRL discovery fetch failed");
1165 }
1166 refresh_sleep = schedule_next_refresh(&set).await;
1167 }
1168 }
1169 }
1170}
1171
1172pub fn rebuild_verifier<S: std::hash::BuildHasher>(
1178 roots: &Arc<RootCertStore>,
1179 config: &MtlsConfig,
1180 cache: &HashMap<String, CachedCrl, S>,
1181) -> Result<Arc<dyn ClientCertVerifier>, McpxError> {
1182 let mut builder = WebPkiClientVerifier::builder(Arc::clone(roots));
1183
1184 if !cache.is_empty() {
1185 let crls = cache
1186 .values()
1187 .map(|cached| cached.der.clone())
1188 .collect::<Vec<_>>();
1189 builder = builder.with_crls(crls);
1190 }
1191 if config.crl_end_entity_only {
1192 builder = builder.only_check_end_entity_revocation();
1193 }
1194 if !config.crl_deny_on_unavailable {
1195 builder = builder.allow_unknown_revocation_status();
1196 }
1197 if config.crl_enforce_expiration {
1198 builder = builder.enforce_revocation_expiration();
1199 }
1200 if !config.required {
1201 builder = builder.allow_unauthenticated();
1202 }
1203
1204 builder
1205 .build()
1206 .map_err(|error| McpxError::Tls(format!("mTLS verifier error: {error}")))
1207}
1208
1209pub fn parse_crl_metadata(der: &[u8]) -> Result<(SystemTime, Option<SystemTime>), McpxError> {
1215 let (_, crl) = CertificateRevocationList::from_der(der)
1216 .map_err(|error| McpxError::Tls(format!("invalid CRL DER: {error:?}")))?;
1217
1218 Ok((
1219 asn1_time_to_system_time(crl.last_update()),
1220 crl.next_update().map(asn1_time_to_system_time),
1221 ))
1222}
1223
1224async fn schedule_next_refresh(set: &CrlSet) -> Pin<Box<Sleep>> {
1225 let duration = next_refresh_delay(set).await;
1226 boxed_sleep(duration)
1227}
1228
1229fn boxed_sleep(duration: Duration) -> Pin<Box<Sleep>> {
1230 Box::pin(tokio::time::sleep_until(Instant::now() + duration))
1231}
1232
1233async fn next_refresh_delay(set: &CrlSet) -> Duration {
1234 if let Some(interval) = set.config.crl_refresh_interval {
1235 return clamp_refresh(interval);
1236 }
1237
1238 let now = SystemTime::now();
1239 let cache = set.cache.read().await;
1240 let mut next = MAX_AUTO_REFRESH;
1241
1242 for cached in cache.values() {
1243 if let Some(next_update) = cached.next_update {
1244 let duration = next_update.duration_since(now).unwrap_or(Duration::ZERO);
1245 next = next.min(clamp_refresh(duration));
1246 }
1247 }
1248 drop(cache);
1249
1250 next
1251}
1252
1253fn acquire_host_semaphore(
1263 map: &mut HashMap<String, Arc<Semaphore>>,
1264 host_key: &str,
1265 max_host_semaphores: usize,
1266) -> Result<Arc<Semaphore>, McpxError> {
1267 if !map.contains_key(host_key) {
1268 if map.len() >= max_host_semaphores {
1269 map.retain(|_, semaphore| Arc::strong_count(semaphore) > 1);
1271 }
1272 if map.len() >= max_host_semaphores {
1273 return Err(McpxError::Config(
1274 "crl_host_semaphore_cap_exceeded: too many distinct CRL hosts in flight".to_owned(),
1275 ));
1276 }
1277 map.insert(host_key.to_owned(), Arc::new(Semaphore::new(1)));
1278 }
1279 match map.get(host_key) {
1280 Some(semaphore) => Ok(Arc::clone(semaphore)),
1281 None => Err(McpxError::Tls(
1282 "CRL host semaphore missing after insertion".to_owned(),
1283 )),
1284 }
1285}
1286
1287async fn gated_fetch(
1295 client: &reqwest::Client,
1296 global_sem: &Arc<Semaphore>,
1297 host_semaphores: &Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
1298 url: &str,
1299 allow_http: bool,
1300 max_bytes: u64,
1301 max_host_semaphores: usize,
1302) -> Result<CachedCrl, McpxError> {
1303 let host_key = Url::parse(url)
1304 .ok()
1305 .and_then(|u| u.host_str().map(str::to_owned))
1306 .unwrap_or_else(|| url.to_owned());
1307
1308 let host_sem = {
1309 let mut map = host_semaphores.lock().await;
1310 acquire_host_semaphore(&mut map, &host_key, max_host_semaphores)?
1311 };
1312
1313 let _global_permit = Arc::clone(global_sem)
1314 .acquire_owned()
1315 .await
1316 .map_err(|error| McpxError::Tls(format!("CRL global semaphore closed: {error}")))?;
1317 let _host_permit = host_sem
1318 .acquire_owned()
1319 .await
1320 .map_err(|error| McpxError::Tls(format!("CRL host semaphore closed: {error}")))?;
1321
1322 fetch_crl(client, url, allow_http, max_bytes).await
1323}
1324
1325async fn fetch_crl(
1326 client: &reqwest::Client,
1327 url: &str,
1328 allow_http: bool,
1329 max_bytes: u64,
1330) -> Result<CachedCrl, McpxError> {
1331 let parsed =
1332 Url::parse(url).map_err(|error| McpxError::Tls(format!("CRL URL parse {url}: {error}")))?;
1333
1334 if let Err(reason) = check_scheme(&parsed, allow_http) {
1335 let sanitized = sanitized_url_for_log(&parsed);
1338 tracing::warn!(url = %sanitized, reason, "CRL fetch denied: scheme");
1339 return Err(McpxError::Tls(format!(
1340 "CRL scheme rejected ({reason}): {sanitized}"
1341 )));
1342 }
1343
1344 let host = parsed
1345 .host_str()
1346 .ok_or_else(|| McpxError::Tls(format!("CRL URL has no host: {url}")))?;
1347 let port = parsed
1348 .port_or_known_default()
1349 .ok_or_else(|| McpxError::Tls(format!("CRL URL has no known port: {url}")))?;
1350
1351 let addrs = lookup_host((host, port))
1352 .await
1353 .map_err(|error| McpxError::Tls(format!("CRL DNS resolution {url}: {error}")))?;
1354
1355 let mut any_addr = false;
1356 for addr in addrs {
1357 any_addr = true;
1358 if let Some(reason) = ip_block_reason(addr.ip()) {
1359 tracing::warn!(
1360 url = %url,
1361 resolved_ip = %addr.ip(),
1362 reason,
1363 "CRL fetch denied: blocked IP"
1364 );
1365 return Err(McpxError::Tls(format!(
1366 "CRL host resolved to blocked IP ({reason}): {url}"
1367 )));
1368 }
1369 }
1370 if !any_addr {
1371 return Err(McpxError::Tls(format!(
1372 "CRL DNS resolution returned no addresses: {url}"
1373 )));
1374 }
1375
1376 let mut response = client
1377 .get(url)
1378 .send()
1379 .await
1380 .map_err(|error| McpxError::Tls(format!("CRL fetch {url}: {error}")))?
1381 .error_for_status()
1382 .map_err(|error| McpxError::Tls(format!("CRL fetch {url}: {error}")))?;
1383
1384 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
1387 let mut body: Vec<u8> = Vec::with_capacity(initial_capacity);
1388 while let Some(chunk) = response
1389 .chunk()
1390 .await
1391 .map_err(|error| McpxError::Tls(format!("CRL read {url}: {error}")))?
1392 {
1393 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
1394 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
1395 if body_len.saturating_add(chunk_len) > max_bytes {
1396 return Err(McpxError::Tls(format!(
1397 "CRL body exceeded cap of {max_bytes} bytes: {url}"
1398 )));
1399 }
1400 body.extend_from_slice(&chunk);
1401 }
1402
1403 let der = CertificateRevocationListDer::from(body);
1404 let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
1405
1406 Ok(CachedCrl {
1407 der,
1408 this_update,
1409 next_update,
1410 fetched_at: SystemTime::now(),
1411 source_url: url.to_owned(),
1412 })
1413}
1414
1415fn should_refresh_cached(
1416 cached: &CachedCrl,
1417 now: SystemTime,
1418 fixed_interval: Option<Duration>,
1419) -> bool {
1420 if let Some(interval) = fixed_interval {
1421 return cached
1422 .fetched_at
1423 .checked_add(clamp_refresh(interval))
1424 .is_none_or(|deadline| now >= deadline);
1425 }
1426
1427 cached
1428 .next_update
1429 .is_none_or(|next_update| now >= next_update)
1430}
1431
1432fn clamp_refresh(duration: Duration) -> Duration {
1433 duration.clamp(MIN_AUTO_REFRESH, MAX_AUTO_REFRESH)
1434}
1435
1436const MAX_ASN1_TIMESTAMP_SECS: u64 = 253_402_300_799;
1440
1441fn asn1_time_to_system_time(time: x509_parser::time::ASN1Time) -> SystemTime {
1450 let timestamp = time.timestamp();
1451 if timestamp >= 0 {
1452 let seconds = u64::try_from(timestamp)
1453 .unwrap_or(0)
1454 .min(MAX_ASN1_TIMESTAMP_SECS);
1455 UNIX_EPOCH
1456 .checked_add(Duration::from_secs(seconds))
1457 .unwrap_or(UNIX_EPOCH)
1458 } else {
1459 UNIX_EPOCH
1460 .checked_sub(Duration::from_secs(timestamp.unsigned_abs()))
1461 .unwrap_or(UNIX_EPOCH)
1462 }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468
1469 fn asn1(timestamp: i64) -> x509_parser::time::ASN1Time {
1470 x509_parser::time::ASN1Time::from_timestamp(timestamp).expect("valid ASN.1 timestamp")
1471 }
1472
1473 #[tokio::test]
1476 async fn fetch_crl_rejects_userinfo_without_echoing_credentials() {
1477 let _ = rustls::crypto::ring::default_provider().install_default();
1481 let client = reqwest::Client::new();
1482 let err = fetch_crl(&client, "https://u:p@crl.example/ca.crl", false, 1024)
1483 .await
1484 .expect_err("userinfo-bearing CRL URL must be rejected");
1485 let rendered = err.to_string();
1486 assert!(
1487 rendered.contains("userinfo_forbidden"),
1488 "error must carry the rejection reason: {rendered}"
1489 );
1490 assert!(
1491 !rendered.contains("u:p"),
1492 "error must not echo the rejected credentials: {rendered}"
1493 );
1494 }
1495
1496 #[test]
1499 fn sanitizer_used_by_rejection_sites_strips_credentials() {
1500 let parsed = Url::parse("https://u:p@crl.example/ca.crl").expect("parse");
1501 let sanitized = sanitized_url_for_log(&parsed);
1502 assert_eq!(sanitized, "https://crl.example");
1503 assert!(!sanitized.contains("u:p"));
1504 }
1505
1506 #[test]
1507 fn asn1_time_clamps_unrepresentable_timestamps() {
1508 let year_1500 = asn1_time_to_system_time(asn1(-14_831_769_600));
1513 assert!(year_1500 <= UNIX_EPOCH);
1514 #[cfg(windows)]
1515 assert_eq!(year_1500, UNIX_EPOCH);
1516
1517 let year_1601 = asn1_time_to_system_time(asn1(-11_644_473_600));
1520 assert!(year_1601 <= UNIX_EPOCH);
1521
1522 assert!(asn1_time_to_system_time(asn1(-2)) <= UNIX_EPOCH);
1524
1525 assert_eq!(
1527 asn1_time_to_system_time(asn1(1_700_000_000)),
1528 UNIX_EPOCH + Duration::from_secs(1_700_000_000)
1529 );
1530
1531 let max = i64::try_from(MAX_ASN1_TIMESTAMP_SECS).expect("fits in i64");
1533 assert_eq!(
1534 asn1_time_to_system_time(asn1(max)),
1535 UNIX_EPOCH + Duration::from_secs(MAX_ASN1_TIMESTAMP_SECS)
1536 );
1537 }
1538
1539 #[test]
1540 fn host_semaphore_evicts_idle_at_cap() {
1541 let mut map = HashMap::new();
1542 for i in 0..4 {
1543 drop(
1545 acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 4)
1546 .expect("under cap"),
1547 );
1548 }
1549 assert_eq!(map.len(), 4);
1550
1551 let sem = acquire_host_semaphore(&mut map, "new-host.example", 4)
1554 .expect("idle eviction frees space for a new host");
1555 assert!(map.contains_key("new-host.example"));
1556 drop(sem);
1557 }
1558
1559 #[test]
1560 fn host_semaphore_keeps_inflight_at_cap() {
1561 let mut map = HashMap::new();
1562 let inflight = acquire_host_semaphore(&mut map, "busy.example", 3).expect("under cap");
1564 for i in 0..2 {
1565 drop(
1566 acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 3)
1567 .expect("under cap"),
1568 );
1569 }
1570 assert_eq!(map.len(), 3);
1571
1572 drop(
1573 acquire_host_semaphore(&mut map, "new-host.example", 3)
1574 .expect("idle entries evicted while in-flight survives"),
1575 );
1576 assert!(
1577 map.contains_key("busy.example"),
1578 "in-flight host must survive eviction"
1579 );
1580 assert!(map.contains_key("new-host.example"));
1581 drop(inflight);
1582 }
1583
1584 #[test]
1585 fn host_semaphore_cap_error_when_all_inflight() {
1586 let mut map = HashMap::new();
1587 let held: Vec<_> = (0..2)
1588 .map(|i| {
1589 acquire_host_semaphore(&mut map, &format!("busy-{i}.example"), 2)
1590 .expect("under cap")
1591 })
1592 .collect();
1593
1594 let result = acquire_host_semaphore(&mut map, "new-host.example", 2);
1595 assert!(
1596 result.is_err(),
1597 "cap must still reject when every entry has an in-flight fetch"
1598 );
1599 drop(held);
1600 }
1601}