Skip to main content

rmcp_server_kit/
mtls_revocation.rs

1//! CDP-driven CRL revocation support for mTLS.
2//!
3//! When mTLS is configured with CRL checks enabled, startup performs a bounded
4//! bootstrap pass over the configured CA bundle, extracts CRL Distribution
5//! Point (CDP) URLs, fetches reachable CRLs, and builds the initial inner
6//! `rustls` verifier from that cache.
7//!
8//! During handshakes, the outer verifier remains stable for the lifetime of the
9//! TLS acceptor while its inner `WebPkiClientVerifier` is swapped atomically via
10//! `ArcSwap` as CRLs are discovered or refreshed. Discovery from connecting
11//! client certificates is fire-and-forget and never blocks the synchronous
12//! handshake path.
13//!
14//! Security note: CDP URLs are extracted from attacker-controllable client
15//! certs *before* chain validation. This is safe by design; see the
16//! `// SECURITY:` comment on `DynamicClientCertVerifier::verify_client_cert`
17//! for the full rationale before changing the discovery ordering.
18//!
19//! Semantics:
20//! - `crl_deny_on_unavailable = false` => fail open with warn logs.
21//! - `crl_deny_on_unavailable = true` => fail closed when a certificate
22//!   advertises CDP URLs whose revocation status is not yet available.
23
24use 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);
66/// Connection timeout for CRL HTTP fetches. Independent of overall fetch
67/// timeout to bound time spent on unreachable hosts.
68const CRL_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
69
70/// Parsed CRL cached in memory and keyed by its source URL.
71#[derive(Clone, Debug)]
72#[non_exhaustive]
73pub struct CachedCrl {
74    /// DER bytes for the CRL.
75    pub der: CertificateRevocationListDer<'static>,
76    /// `thisUpdate` field from the CRL.
77    pub this_update: SystemTime,
78    /// `nextUpdate` field from the CRL, if present.
79    pub next_update: Option<SystemTime>,
80    /// Time the server fetched this CRL.
81    pub fetched_at: SystemTime,
82    /// Source URL used for retrieval.
83    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/// Shared CRL state backing the dynamic mTLS verifier.
95#[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    /// Cached CRLs keyed by URL.
103    pub cache: RwLock<HashMap<String, CachedCrl>>,
104    /// Immutable client-auth root store.
105    pub roots: Arc<RootCertStore>,
106    /// mTLS CRL configuration.
107    pub config: MtlsConfig,
108    /// Fire-and-forget discovery channel for newly-seen CDP URLs.
109    pub discover_tx: mpsc::UnboundedSender<String>,
110    client: reqwest::Client,
111    seen_urls: Mutex<HashSet<String>>,
112    cached_urls: Mutex<HashSet<String>>,
113    /// Global cap on simultaneous CRL HTTP fetches (SSRF amplification guard).
114    global_fetch_sem: Arc<Semaphore>,
115    /// Per-host serializer (one in-flight fetch per origin host). Bounded
116    /// by `crl_max_host_semaphores`; at the cap, idle entries are evicted
117    /// on demand (see [`acquire_host_semaphore`]), so the cap only rejects
118    /// genuinely concurrent fetch floods and is never a permanent lockout.
119    host_semaphores: Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
120    /// Global rate-limiter on discovery URL submissions; protects against
121    /// cert-driven URL flooding by a malicious mTLS peer.
122    ///
123    /// Note: this ships as a process-global limiter; per-source-IP scoping
124    /// is deferred to a future release because the rustls
125    /// `verify_client_cert` callback does not carry a `SocketAddr` for the
126    /// peer. This is a CRL-discovery limiter in the TLS verifier path —
127    /// distinct from the bearer pre-auth limiter (`AuthState`), which is
128    /// already keyed per-IP via a bounded keyed governor and lives in the
129    /// ordinary request middleware path.
130    discovery_limiter: Arc<DefaultDirectRateLimiter>,
131    /// Cached cap on per-fetch response body size; copied from `config` so the
132    /// hot path doesn't re-read the (rarely changing) config struct.
133    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        // M-H2: install the SSRF screening resolver on the CRL fetcher.
145        // CRL CDP URLs come from attacker-controllable client certs and
146        // their hosts are re-resolved per fetch -- exactly the TOCTOU
147        // class M-H2 closes. The allowlist is empty (default-strict),
148        // matching the existing CRL pre-flight posture; operators who
149        // need internal CDPs would extend this with the same
150        // CompiledSsrfAllowlist plumbing used by oauth.
151        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            // M-H2/N1: see oauth.rs::OauthHttpClient::build for rationale.
163            .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        // POLICY: at cap the NEWEST entry is rejected, never an existing
244        // one (no LRU). Under adversarial unique-CDP churn an LRU would
245        // let an attacker evict the legitimate warm set by spamming
246        // throwaway CDP URLs; rejecting newcomers instead preserves
247        // revocation coverage for the established CA estate. Confirmed
248        // by Oracle review of the 1.13.0 rust-review fix plan.
249        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        // SECURITY: `cached_urls` is the synchronous fail-closed precheck's
264        // trust hint. It must never get ahead of `inner_verifier`; otherwise a
265        // handshake could skip the unavailable-CRL fast-fail for a URL the live
266        // rustls verifier cannot enforce. Build from the full candidate cache
267        // first, then swap verifier, then publish cache/cached_urls together.
268        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    /// Force an immediate refresh of all currently known CRL URLs.
313    ///
314    /// # Errors
315    ///
316    /// Returns an error if rebuilding the inner verifier fails.
317    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        // INVARIANT: only called post-handshake from
405        // `DynamicClientCertVerifier::verify_client_cert`. The peer has
406        // already presented a chain that parses; this method must not panic
407        // under attacker-controlled URL contents.
408        //
409        // SECURITY: see `DynamicClientCertVerifier::verify_client_cert` for
410        // the rationale on why accepting URLs from an unverified cert is
411        // safe (no HTTP on this path; fetch is off-path and SSRF-gated).
412        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        // Snapshot the dedup set under the lock; do NOT mutate it yet.
419        // We promote a URL to "seen" only after it is actually admitted
420        // by the rate-limiter and queued on the discover channel.
421        // Otherwise a single rate-limited handshake would permanently
422        // black-hole the URL: every subsequent handshake would see it as
423        // "already known" and skip the limiter entirely, while the
424        // background fetcher would never have received it. With
425        // `crl_deny_on_unavailable = true` that produces persistent
426        // handshake failures; with fail-open it silently disables CRL
427        // discovery for that endpoint forever.
428        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        // Rate-limit gate: drop excess submissions on the floor with a WARN.
438        // The mTLS verifier must remain non-blocking, so we use the
439        // synchronous `check()` API and never await here. Only on a
440        // successful `check()` AND a successful `send()` do we commit
441        // the URL to `seen_urls`; this guarantees retriability of any
442        // URL that lost the limiter race.
443        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                // Receiver gone (shutdown). Do NOT mark as seen so the
453                // URL can be retried after a reload / restart.
454                tracing::debug!(
455                    url = %url,
456                    "discover channel closed; dropping CDP URL without marking seen"
457                );
458                continue;
459            }
460            // Admission succeeded: now safe to dedup permanently.
461            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    /// Test helper for constructing a CRL set from in-memory CRLs.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if the verifier cannot be built from the provided CRLs.
496    #[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    /// Test-only: same as [`Self::__test_with_prepopulated_crls`] but
525    /// returns the discover-channel receiver to the caller so the
526    /// background channel `send`s succeed (the receiver stays alive
527    /// for the duration of the test). Required by the B2 dedup
528    /// regression test, which must observe URLs being committed to
529    /// `seen_urls` after a successful limiter+send sequence. Not part
530    /// of the public API.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if the verifier cannot be built from the provided CRLs.
535    #[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    /// Test-only: directly invoke the discovery rate-limiter on a batch of URLs
564    /// and return `(accepted, dropped)`. Bypasses the dedup `seen_urls` set so
565    /// callers can deterministically saturate the limiter; mutates the limiter
566    /// state in place. Not part of the public API.
567    #[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    /// Test-only: invoke the real `note_discovered_urls` so dedup + rate-limit
583    /// + cached-fallback paths are all exercised. Returns the `missing_cached`
584    /// flag the production verifier uses to decide whether to fail the handshake.
585    #[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    /// Test-only: invoke the real precheck with separate end-entity and
621    /// intermediate CDP sets.
622    #[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    /// Test-only: report whether a URL has been promoted to the
633    /// permanent dedup set. Used by the B2 retriability regression
634    /// test to assert that rate-limited URLs are NOT marked seen.
635    /// Not part of the public API.
636    #[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    /// Test-only: current count of host semaphores. Used by
645    /// `tests/crl_map_bounds.rs` to assert the cap is enforced.
646    #[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    /// Test-only: current number of entries in the CRL cache.
655    #[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    /// Test-only: whether a specific URL is currently cached.
662    #[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    /// Test-only: whether a URL is advertised to the fail-closed precheck as
671    /// present in the live verifier.
672    #[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    /// Test-only: triggers the request-hot-path fetch path for `url`
681    /// WITHOUT going through the TLS handshake. Returns any error the
682    /// host-semaphore cap check produces. A network-unreachable
683    /// failure for the fetch itself is treated as `Ok(())` (test only
684    /// cares about the cap; real tests use mock hosts that won't
685    /// resolve — the cap must fire BEFORE network I/O).
686    #[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    /// Test-only: directly insert `cached` under `url` into both
714    /// `cache` and `cached_urls`, bypassing HTTP. Does NOT enforce
715    /// `crl_max_cache_entries` when called pre-cap — the test uses it
716    /// to stage preconditions. For cap-breach coverage, tests invoke
717    /// the real production insertion path.
718    ///
719    /// Wait — the `cache_hard_cap_drops_newest` test DOES use this
720    /// helper to assert the cap fires. Therefore this helper MUST
721    /// enforce the hard cap (silent drop with warn!) the same way the
722    /// production code does. The helper is a thin wrapper around the
723    /// same internal insertion fn the production path uses.
724    #[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    /// Test-only: direct cache insertion that returns verifier rebuild errors.
733    #[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    /// Test-only: replace a cache entry without rebuilding the verifier.
745    #[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    /// Test-only: trigger a refresh cycle for a single URL. Exercises
753    /// the same stale-grace / fetch-failure path as `refresh_urls()`.
754    /// Returns the refresh error (if any) — most tests ignore it
755    /// because they assert post-state, not the transient error.
756    #[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    /// Test-only: synthesize a cache entry that looks valid, `next_update`
818    /// = now + 24h. Fields used only to populate the HashMap — the bytes
819    /// are a minimal CRL-shape that won't be parsed by tests.
820    #[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    /// Test-only: synthesize a STALE cache entry (`next_update` in the
834    /// deep past so `is_stale_beyond_grace` fires with any sensible
835    /// `crl_stale_grace`).
836    #[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
850/// Stable outer verifier that delegates all TLS verification behavior to the
851/// atomically swappable inner verifier.
852pub struct DynamicClientCertVerifier {
853    inner: Arc<CrlSet>,
854    dn_subjects: Vec<DistinguishedName>,
855}
856
857impl DynamicClientCertVerifier {
858    /// Construct a new dynamic verifier from a shared [`CrlSet`].
859    #[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        // SECURITY: extracting CDP URLs from an unverified client cert
898        // here is intentional. No HTTP happens on this path -- the call
899        // to `note_discovered_urls` only enqueues onto a bounded,
900        // rate-limited channel. The actual fetch runs off-path in
901        // `run_crl_refresher` and is gated by SSRF screening
902        // (`src/ssrf.rs`), body-size cap, deadline, and the
903        // `crl_allow_http` policy. CRLs are CA-signed (RFC 5280 §5), so
904        // http(s) CDP URLs are protocol design, not an SSRF sink. The
905        // discovery must happen BEFORE delegating to the inner verifier
906        // so `crl_deny_on_unavailable = true` can fail-closed on a
907        // never-fetched CDP. Do NOT reorder.
908        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/// Extract CRL Distribution Point URLs from a DER-encoded certificate.
970///
971/// URLs are validated with `url::Url::parse` (case-insensitive scheme handling)
972/// and filtered through an internal scheme guard. Malformed URLs, URLs
973/// using disallowed schemes, and URLs carrying embedded credentials
974/// (userinfo) are silently dropped. SSRF defenses against private
975/// IP literals and metadata endpoints are applied later, at fetch time, after
976/// DNS resolution.
977#[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                                // `?raw` (Debug) escapes control characters the
993                                // failed parse may have left in this
994                                // attacker-supplied string.
995                                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/// Bootstrap the CRL cache by extracting CDP URLs from the CA chain and
1018/// fetching any reachable CRLs with a 10-second total deadline.
1019///
1020/// # Errors
1021///
1022/// Returns an error if the initial verifier cannot be built.
1023#[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    // M-H2: same SSRF resolver hardening as CrlSet::new -- bootstrap
1042    // fetches the same attacker-controlled CDP URLs, just earlier in
1043    // the lifecycle.
1044    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        // M-H2/N1: see oauth.rs::OauthHttpClient::build for rationale.
1056        .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    // Bootstrap shares the same global concurrency + per-host cap as the
1067    // hot-path verifier so a maliciously broad CA chain cannot overwhelm
1068    // the network at startup.
1069    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        // cancel-safe: pinned Sleep and JoinSet::join_next are cancel-safe
1103        // (tokio docs); on timeout the loop breaks and dropping the JoinSet
1104        // aborts remaining fetches — the intended deadline behavior.
1105        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/// Run the CRL refresher loop until shutdown.
1134#[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        // cancel-safe: CancellationToken::cancelled, pinned &mut Sleep, and
1147        // mpsc::UnboundedReceiver::recv are all cancel-safe (tokio docs);
1148        // refresh work happens inside arm bodies, never in the raced futures.
1149        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
1172/// Rebuild the inner rustls verifier from the current CRL cache.
1173///
1174/// # Errors
1175///
1176/// Returns an error if rustls rejects the verifier configuration.
1177pub 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
1209/// Parse `thisUpdate` and `nextUpdate` metadata from a DER-encoded CRL.
1210///
1211/// # Errors
1212///
1213/// Returns an error if the CRL cannot be parsed.
1214pub 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
1253/// Get-or-insert the per-host fetch semaphore for `host_key`.
1254///
1255/// When the map is at `max_host_semaphores`, idle entries (no in-flight
1256/// fetch) are evicted before rejecting, so the cap only fails when `max`
1257/// distinct hosts are *concurrently* fetching — it is never a permanent
1258/// lockout. Every clone of a host semaphore is created while holding the
1259/// map lock, and a clone outlives the critical section only while a fetch
1260/// is in flight, so an entry with `Arc::strong_count == 1` is provably
1261/// idle and safe to drop.
1262fn 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            // Self-heal: drop semaphores with no in-flight fetch.
1270            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
1287/// Fetch a single CRL URL through the global + per-host concurrency caps.
1288///
1289/// `global_sem` caps total simultaneous CRL fetches process-wide.
1290/// `host_semaphores` ensures at most one in-flight fetch per origin host
1291/// (an SSRF amplification defense); at the host cap, idle entries are
1292/// evicted on demand. Both permits are dropped when the returned future
1293/// completes (whether `Ok` or `Err`).
1294async 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        // Sanitized: the gate must not echo what it rejects (the URL may
1336        // carry userinfo credentials — the very thing being refused).
1337        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    // Enforce body cap by streaming chunk-by-chunk; a malicious or
1385    // misconfigured server cannot allocate more than `max_bytes` of memory.
1386    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
1436/// 9999-12-31T23:59:59Z — the maximum instant expressible as an ASN.1
1437/// GeneralizedTime (four-digit year). Used to clamp absurd positive
1438/// timestamps before converting to [`SystemTime`].
1439const MAX_ASN1_TIMESTAMP_SECS: u64 = 253_402_300_799;
1440
1441/// Convert an ASN.1 time to [`SystemTime`] without ever panicking.
1442///
1443/// CRL metadata is parsed from raw fetched bytes *before* signature
1444/// validation, so timestamps are attacker-controlled. Platform
1445/// `SystemTime` ranges differ (Windows cannot represent pre-1601);
1446/// unrepresentable values are clamped toward [`UNIX_EPOCH`], which is the
1447/// safe direction: it can only make a CRL look *older* (forcing an
1448/// eager refresh), never fresher.
1449fn 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    /// The userinfo gate fires before DNS resolution (no network needed)
1474    /// and the surfaced error must not echo the rejected credentials.
1475    #[tokio::test]
1476    async fn fetch_crl_rejects_userinfo_without_echoing_credentials() {
1477        // reqwest with `rustls-no-provider` requires a process-wide crypto
1478        // provider before any Client is built (same pattern as the
1479        // transport/oauth test suites).
1480        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    /// `extract_cdp_urls`'s scheme/userinfo guard reuses the same gate;
1497    /// the sanitizer keeps credentials out of its debug logging too.
1498    #[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        // Year 1500 — pre-1601, NOT representable by Windows `SystemTime`.
1509        // Pre-fix this panicked on Windows; now it must return a value no
1510        // later than the epoch on every platform (clamped to UNIX_EPOCH on
1511        // Windows, the real instant on platforms that can represent it).
1512        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        // 1601-01-01T00:00:00Z — the exact Windows epoch boundary, which IS
1518        // representable everywhere. No clamp, no panic.
1519        let year_1601 = asn1_time_to_system_time(asn1(-11_644_473_600));
1520        assert!(year_1601 <= UNIX_EPOCH);
1521
1522        // Mildly negative (pre-1970) stays at-or-before the epoch.
1523        assert!(asn1_time_to_system_time(asn1(-2)) <= UNIX_EPOCH);
1524
1525        // Normal positive timestamps round-trip exactly.
1526        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        // The ASN.1 maximum (9999-12-31) is representable and preserved.
1532        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            // Dropped immediately: only the map holds each semaphore (idle).
1544            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        // At the cap, a NEW host must succeed by evicting idle entries —
1552        // the cap error is not sticky.
1553        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        // Held across the cap check: simulates an in-flight fetch.
1563        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}