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    /// URLs whose CRL is confirmed present in `cache`. Permanent dedup: a URL
112    /// here is never re-enqueued for discovery.
113    seen_urls: Mutex<HashSet<String>>,
114    /// URLs admitted to the discovery channel but not yet confirmed cached.
115    ///
116    /// This exists so a queued URL is not re-enqueued while its fetch is in
117    /// flight, WITHOUT permanently suppressing it. Promotion to `seen_urls`
118    /// happens only once the CRL is actually in the cache; a fetch error or a
119    /// cache-cap rejection clears the entry so a later handshake can retry.
120    /// Merging the two states is exactly the bug this separation fixes: a
121    /// first-fetch failure would otherwise suppress the URL for the process
122    /// lifetime, silently disabling revocation for that CDP.
123    pending_urls: Mutex<HashSet<String>>,
124    cached_urls: Mutex<HashSet<String>>,
125    /// Global cap on simultaneous CRL HTTP fetches (SSRF amplification guard).
126    global_fetch_sem: Arc<Semaphore>,
127    /// Per-host serializer (one in-flight fetch per origin host). Bounded
128    /// by `crl_max_host_semaphores`; at the cap, idle entries are evicted
129    /// on demand (see [`acquire_host_semaphore`]), so the cap only rejects
130    /// genuinely concurrent fetch floods and is never a permanent lockout.
131    host_semaphores: Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
132    /// Global rate-limiter on discovery URL submissions; protects against
133    /// cert-driven URL flooding by a malicious mTLS peer.
134    ///
135    /// Note: this ships as a process-global limiter; per-source-IP scoping
136    /// is deferred to a future release because the rustls
137    /// `verify_client_cert` callback does not carry a `SocketAddr` for the
138    /// peer. This is a CRL-discovery limiter in the TLS verifier path —
139    /// distinct from the bearer pre-auth limiter (`AuthState`), which is
140    /// already keyed per-IP via a bounded keyed governor and lives in the
141    /// ordinary request middleware path.
142    discovery_limiter: Arc<DefaultDirectRateLimiter>,
143    /// Cached cap on per-fetch response body size; copied from `config` so the
144    /// hot path doesn't re-read the (rarely changing) config struct.
145    max_response_bytes: u64,
146    last_cap_warn: Mutex<HashMap<&'static str, Instant>>,
147}
148
149impl CrlSet {
150    fn new(
151        roots: Arc<RootCertStore>,
152        config: MtlsConfig,
153        discover_tx: mpsc::UnboundedSender<String>,
154        initial_cache: HashMap<String, CachedCrl>,
155    ) -> Result<Arc<Self>, McpxError> {
156        // M-H2: install the SSRF screening resolver on the CRL fetcher.
157        // CRL CDP URLs come from attacker-controllable client certs and
158        // their hosts are re-resolved per fetch -- exactly the TOCTOU
159        // class M-H2 closes. The allowlist is empty (default-strict),
160        // matching the existing CRL pre-flight posture; operators who
161        // need internal CDPs would extend this with the same
162        // CompiledSsrfAllowlist plumbing used by oauth.
163        let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
164        let resolver: Arc<dyn reqwest::dns::Resolve> =
165            Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
166                Arc::clone(&allowlist),
167                #[cfg(any(test, feature = "test-helpers"))]
168                Arc::new(std::sync::atomic::AtomicBool::new(false)),
169                #[cfg(not(any(test, feature = "test-helpers")))]
170                (),
171            ));
172
173        let client = reqwest::Client::builder()
174            // M-H2/N1: see oauth.rs::OauthHttpClient::build for rationale.
175            .no_proxy()
176            .dns_resolver(Arc::clone(&resolver))
177            .timeout(config.crl_fetch_timeout)
178            .connect_timeout(CRL_CONNECT_TIMEOUT)
179            .tcp_keepalive(None)
180            .redirect(reqwest::redirect::Policy::none())
181            .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
182            .build()
183            .map_err(|error| McpxError::Startup(format!("CRL HTTP client init: {error}")))?;
184
185        let initial_verifier = rebuild_verifier(&roots, &config, &initial_cache)?;
186        let seen_urls = initial_cache.keys().cloned().collect::<HashSet<_>>();
187        let cached_urls = seen_urls.clone();
188
189        let concurrency = config.crl_max_concurrent_fetches.max(1);
190        let global_fetch_sem = Arc::new(Semaphore::new(concurrency));
191        let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
192
193        let rate =
194            NonZeroU32::new(config.crl_discovery_rate_per_min.max(1)).unwrap_or(NonZeroU32::MIN);
195        let discovery_limiter = Arc::new(RateLimiter::direct(Quota::per_minute(rate)));
196
197        let max_response_bytes = config.crl_max_response_bytes;
198
199        Ok(Arc::new(Self {
200            inner_verifier: ArcSwap::from_pointee(VerifierHandle(initial_verifier)),
201            cache: RwLock::new(initial_cache),
202            roots,
203            config,
204            discover_tx,
205            client,
206            seen_urls: Mutex::new(seen_urls),
207            pending_urls: Mutex::new(HashSet::new()),
208            cached_urls: Mutex::new(cached_urls),
209            global_fetch_sem,
210            host_semaphores,
211            discovery_limiter,
212            max_response_bytes,
213            last_cap_warn: Mutex::new(HashMap::new()),
214        }))
215    }
216
217    fn warn_cap_exceeded_throttled(&self, which: &'static str) {
218        let now = Instant::now();
219        let cooldown = Duration::from_mins(1);
220        let should_warn = match self.last_cap_warn.lock() {
221            Ok(mut guard) => {
222                let should_emit = guard
223                    .get(which)
224                    .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
225                if should_emit {
226                    guard.insert(which, now);
227                }
228                should_emit
229            }
230            Err(poisoned) => {
231                let mut guard = poisoned.into_inner();
232                let should_emit = guard
233                    .get(which)
234                    .is_none_or(|last| now.saturating_duration_since(*last) >= cooldown);
235                if should_emit {
236                    guard.insert(which, now);
237                }
238                should_emit
239            }
240        };
241
242        if should_warn {
243            tracing::warn!(which = which, "CRL map cap exceeded; dropping newest entry");
244        }
245    }
246
247    async fn commit_cache_update_atomically(
248        &self,
249        inserts: Vec<(String, CachedCrl)>,
250        removals: &[String],
251    ) -> Result<bool, McpxError> {
252        let mut cache = self.cache.write().await;
253        let mut candidate = cache.clone();
254        let mut admitted_urls = Vec::new();
255
256        // POLICY: at cap the NEWEST entry is rejected, never an existing
257        // one (no LRU). Under adversarial unique-CDP churn an LRU would
258        // let an attacker evict the legitimate warm set by spamming
259        // throwaway CDP URLs; rejecting newcomers instead preserves
260        // revocation coverage for the established CA estate. Confirmed
261        // by Oracle review of the 1.13.0 rust-review fix plan.
262        for (url, cached) in inserts {
263            if candidate.len() >= self.config.crl_max_cache_entries && !candidate.contains_key(&url)
264            {
265                self.warn_cap_exceeded_throttled("cache");
266                continue;
267            }
268            candidate.insert(url.clone(), cached);
269            admitted_urls.push(url);
270        }
271
272        for url in removals {
273            candidate.remove(url);
274        }
275
276        // SECURITY: `cached_urls` is the synchronous fail-closed precheck's
277        // trust hint. It must never get ahead of `inner_verifier`; otherwise a
278        // handshake could skip the unavailable-CRL fast-fail for a URL the live
279        // rustls verifier cannot enforce. Build from the full candidate cache
280        // first, then swap verifier, then publish cache/cached_urls together.
281        let verifier = rebuild_verifier(&self.roots, &self.config, &candidate)?;
282        self.inner_verifier
283            .store(Arc::new(VerifierHandle(verifier)));
284        let changed = !admitted_urls.is_empty() || !removals.is_empty();
285        *cache = candidate;
286        drop(cache);
287
288        match self.cached_urls.lock() {
289            Ok(mut cached_urls) => {
290                for url in admitted_urls {
291                    cached_urls.insert(url);
292                }
293                for url in removals {
294                    cached_urls.remove(url);
295                }
296            }
297            Err(poisoned) => {
298                let mut cached_urls = poisoned.into_inner();
299                for url in admitted_urls {
300                    cached_urls.insert(url);
301                }
302                for url in removals {
303                    cached_urls.remove(url);
304                }
305            }
306        }
307
308        // A removed CRL must become fully re-discoverable, so clear the URL
309        // from BOTH dedup states. Clearing only `seen_urls` would leave a
310        // stale `pending_urls` entry suppressing re-enqueue forever.
311        {
312            let mut seen = self
313                .seen_urls
314                .lock()
315                .unwrap_or_else(std::sync::PoisonError::into_inner);
316            for url in removals {
317                seen.remove(url);
318            }
319        }
320        {
321            let mut pending = self
322                .pending_urls
323                .lock()
324                .unwrap_or_else(std::sync::PoisonError::into_inner);
325            for url in removals {
326                pending.remove(url);
327            }
328        }
329
330        Ok(changed)
331    }
332
333    /// Force an immediate refresh of all currently known CRL URLs.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if rebuilding the inner verifier fails.
338    pub async fn force_refresh(&self) -> Result<(), McpxError> {
339        let urls = {
340            let cache = self.cache.read().await;
341            cache.keys().cloned().collect::<Vec<_>>()
342        };
343        self.refresh_urls(urls).await
344    }
345
346    async fn refresh_due_urls(&self) -> Result<(), McpxError> {
347        let now = SystemTime::now();
348        let urls = {
349            let cache = self.cache.read().await;
350            cache
351                .iter()
352                .filter(|(_, cached)| {
353                    should_refresh_cached(cached, now, self.config.crl_refresh_interval)
354                })
355                .map(|(url, _)| url.clone())
356                .collect::<Vec<_>>()
357        };
358
359        if urls.is_empty() {
360            return Ok(());
361        }
362
363        self.refresh_urls(urls).await
364    }
365
366    async fn refresh_urls(&self, urls: Vec<String>) -> Result<(), McpxError> {
367        let results = self.fetch_url_results(urls).await;
368        let now = SystemTime::now();
369        let cache = self.cache.read().await;
370        let mut inserts = Vec::new();
371        let mut removals = Vec::new();
372
373        for (url, result) in results {
374            match result {
375                Ok(cached) => {
376                    inserts.push((url, cached));
377                }
378                Err(error) => {
379                    let remove_entry = cache.get(&url).is_some_and(|existing| {
380                        existing
381                            .next_update
382                            .and_then(|next| next.checked_add(self.config.crl_stale_grace))
383                            .is_some_and(|deadline| now > deadline)
384                    });
385                    tracing::warn!(url = %url, error = %error, "CRL refresh failed");
386                    if remove_entry {
387                        removals.push(url);
388                    }
389                }
390            }
391        }
392        drop(cache);
393
394        if !inserts.is_empty() || !removals.is_empty() {
395            let _ = self
396                .commit_cache_update_atomically(inserts, &removals)
397                .await?;
398        }
399
400        Ok(())
401    }
402
403    /// Fetch a CRL and commit it to the cache.
404    ///
405    /// Returns whether the CRL is actually present in the cache afterwards.
406    /// A successful HTTP fetch is NOT sufficient:
407    /// [`Self::commit_cache_update_atomically`] rejects new entries once
408    /// `crl_max_cache_entries` is reached. Only a URL that genuinely landed in
409    /// the cache may be promoted to the permanent `seen_urls` dedup set —
410    /// promoting on fetch success alone would suppress a URL that was never
411    /// cached, which is the same revocation-bypass this state split fixes.
412    async fn fetch_and_store_url(&self, url: String) -> Result<bool, McpxError> {
413        let cached = gated_fetch(
414            &self.client,
415            &self.global_fetch_sem,
416            &self.host_semaphores,
417            &url,
418            self.config.crl_allow_http,
419            self.max_response_bytes,
420            self.config.crl_max_host_semaphores,
421        )
422        .await?;
423        let _ = self
424            .commit_cache_update_atomically(vec![(url.clone(), cached)], &[])
425            .await?;
426        Ok(self.cache.read().await.contains_key(&url))
427    }
428
429    /// Promote a URL from the in-flight set to the permanent dedup set.
430    /// Called only once its CRL is confirmed present in the cache.
431    fn promote_pending_to_seen(&self, url: &str) {
432        {
433            let mut pending = self
434                .pending_urls
435                .lock()
436                .unwrap_or_else(std::sync::PoisonError::into_inner);
437            pending.remove(url);
438        }
439        let mut seen = self
440            .seen_urls
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner);
443        if seen.len() >= self.config.crl_max_seen_urls && !seen.contains(url) {
444            self.warn_cap_exceeded_throttled("seen_urls");
445            return;
446        }
447        seen.insert(url.to_owned());
448    }
449
450    /// Clear a URL's in-flight marker without promoting it, so a later
451    /// handshake can re-enqueue it. Used when the fetch failed or the cache
452    /// refused the entry.
453    fn clear_pending(&self, url: &str) {
454        let mut pending = self
455            .pending_urls
456            .lock()
457            .unwrap_or_else(std::sync::PoisonError::into_inner);
458        pending.remove(url);
459    }
460
461    fn note_discovered_urls(
462        &self,
463        end_entity_urls: &[String],
464        intermediate_urls: &[String],
465    ) -> bool {
466        // INVARIANT: only called post-handshake from
467        // `DynamicClientCertVerifier::verify_client_cert`. The peer has
468        // already presented a chain that parses; this method must not panic
469        // under attacker-controlled URL contents.
470        //
471        // SECURITY: see `DynamicClientCertVerifier::verify_client_cert` for
472        // the rationale on why accepting URLs from an unverified cert is
473        // safe (no HTTP on this path; fetch is off-path and SSRF-gated).
474        let mut all_urls = Vec::with_capacity(end_entity_urls.len() + intermediate_urls.len());
475        all_urls.extend_from_slice(end_entity_urls);
476        all_urls.extend_from_slice(intermediate_urls);
477        all_urls.sort();
478        all_urls.dedup();
479
480        // Snapshot both dedup sets under their locks; do NOT mutate yet.
481        // A URL is skipped if it is already cached (`seen_urls`) or already
482        // queued and awaiting its fetch (`pending_urls`). Promotion to
483        // `seen_urls` happens only after the CRL is confirmed in the cache,
484        // so a URL that loses the limiter race, hits a closed channel, fails
485        // to fetch, or is rejected by the cache cap stays retriable. Marking
486        // "seen" any earlier permanently black-holes the URL: every later
487        // handshake would treat it as known and skip discovery, while no CRL
488        // was ever cached. With `crl_deny_on_unavailable = true` that is a
489        // persistent handshake failure; with fail-open it silently disables
490        // revocation checking for that CDP for the process lifetime.
491        let candidates: Vec<String> = {
492            let seen = self
493                .seen_urls
494                .lock()
495                .unwrap_or_else(std::sync::PoisonError::into_inner);
496            let pending = self
497                .pending_urls
498                .lock()
499                .unwrap_or_else(std::sync::PoisonError::into_inner);
500            all_urls
501                .iter()
502                .filter(|url| !seen.contains(*url) && !pending.contains(*url))
503                .cloned()
504                .collect()
505        };
506
507        // Rate-limit gate: drop excess submissions on the floor with a WARN.
508        // The mTLS verifier must remain non-blocking, so we use the
509        // synchronous `check()` API and never await here.
510        for url in candidates {
511            if self.discovery_limiter.check().is_err() {
512                tracing::warn!(
513                    url = %url,
514                    "discovery_rate_limited: dropped CDP URL beyond per-minute cap (will be retried on next handshake observing this URL)"
515                );
516                continue;
517            }
518            if self.discover_tx.send(url.clone()).is_err() {
519                // Receiver gone (shutdown). Do NOT mark pending so the
520                // URL can be retried after a reload / restart.
521                tracing::debug!(
522                    url = %url,
523                    "discover channel closed; dropping CDP URL without marking pending"
524                );
525                continue;
526            }
527            // Queued for fetch. Mark pending (not seen) so concurrent
528            // handshakes do not re-enqueue it while the fetch is in flight.
529            let mut guard = self
530                .pending_urls
531                .lock()
532                .unwrap_or_else(std::sync::PoisonError::into_inner);
533            if guard.len() >= self.config.crl_max_seen_urls {
534                self.warn_cap_exceeded_throttled("pending_urls");
535                break;
536            }
537            guard.insert(url);
538        }
539
540        if self.config.crl_deny_on_unavailable {
541            let cached = self
542                .cached_urls
543                .lock()
544                .ok()
545                .map(|guard| guard.clone())
546                .unwrap_or_default();
547            let relevant_urls = if self.config.crl_end_entity_only {
548                end_entity_urls
549            } else {
550                all_urls.as_slice()
551            };
552            return !relevant_urls.is_empty()
553                && relevant_urls.iter().all(|url| !cached.contains(url));
554        }
555
556        false
557    }
558
559    /// Test helper for constructing a CRL set from in-memory CRLs.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if the verifier cannot be built from the provided CRLs.
564    #[doc(hidden)]
565    pub fn __test_with_prepopulated_crls(
566        roots: Arc<RootCertStore>,
567        config: MtlsConfig,
568        prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
569    ) -> Result<Arc<Self>, McpxError> {
570        let (discover_tx, discover_rx) = mpsc::unbounded_channel();
571        drop(discover_rx);
572
573        let mut initial_cache = HashMap::new();
574        for (index, der) in prefilled_crls.into_iter().enumerate() {
575            let source_url = format!("memory://crl/{index}");
576            let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
577            initial_cache.insert(
578                source_url.clone(),
579                CachedCrl {
580                    der,
581                    this_update,
582                    next_update,
583                    fetched_at: SystemTime::now(),
584                    source_url,
585                },
586            );
587        }
588
589        Self::new(roots, config, discover_tx, initial_cache)
590    }
591
592    /// Test-only: same as [`Self::__test_with_prepopulated_crls`] but
593    /// returns the discover-channel receiver to the caller so the
594    /// background channel `send`s succeed (the receiver stays alive
595    /// for the duration of the test). Required by the B2 dedup
596    /// regression test, which must observe URLs being committed to
597    /// `seen_urls` after a successful limiter+send sequence. Not part
598    /// of the public API.
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if the verifier cannot be built from the provided CRLs.
603    #[doc(hidden)]
604    pub fn __test_with_kept_receiver(
605        roots: Arc<RootCertStore>,
606        config: MtlsConfig,
607        prefilled_crls: Vec<CertificateRevocationListDer<'static>>,
608    ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<String>), McpxError> {
609        let (discover_tx, discover_rx) = mpsc::unbounded_channel();
610
611        let mut initial_cache = HashMap::new();
612        for (index, der) in prefilled_crls.into_iter().enumerate() {
613            let source_url = format!("memory://crl/{index}");
614            let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
615            initial_cache.insert(
616                source_url.clone(),
617                CachedCrl {
618                    der,
619                    this_update,
620                    next_update,
621                    fetched_at: SystemTime::now(),
622                    source_url,
623                },
624            );
625        }
626
627        let crl_set = Self::new(roots, config, discover_tx, initial_cache)?;
628        Ok((crl_set, discover_rx))
629    }
630
631    /// Test-only: directly invoke the discovery rate-limiter on a batch of URLs
632    /// and return `(accepted, dropped)`. Bypasses the dedup `seen_urls` set so
633    /// callers can deterministically saturate the limiter; mutates the limiter
634    /// state in place. Not part of the public API.
635    #[doc(hidden)]
636    pub fn __test_check_discovery_rate(&self, urls: &[String]) -> (usize, usize) {
637        let mut accepted = 0usize;
638        let mut dropped = 0usize;
639        for url in urls {
640            if self.discovery_limiter.check().is_ok() {
641                let _ = self.discover_tx.send(url.clone());
642                accepted += 1;
643            } else {
644                dropped += 1;
645            }
646        }
647        (accepted, dropped)
648    }
649
650    /// Test-only: invoke the real `note_discovered_urls` so dedup + rate-limit
651    /// + cached-fallback paths are all exercised. Returns the `missing_cached`
652    /// flag the production verifier uses to decide whether to fail the handshake.
653    ///
654    /// When no receiver is attached (the usual unit-test setup), the send fails
655    /// and production correctly records nothing, so this mirrors the admission
656    /// bookkeeping by marking the URL **pending** — matching what a live
657    /// refresher would observe between enqueue and fetch.
658    #[doc(hidden)]
659    pub fn __test_note_discovered_urls(&self, urls: &[String]) -> bool {
660        let missing_cached = self.note_discovered_urls(urls, &[]);
661        if self.discover_tx.is_closed() {
662            let already_seen: HashSet<String> = {
663                let seen = self
664                    .seen_urls
665                    .lock()
666                    .unwrap_or_else(std::sync::PoisonError::into_inner);
667                urls.iter()
668                    .filter(|url| seen.contains(*url))
669                    .cloned()
670                    .collect()
671            };
672            let mut pending = self
673                .pending_urls
674                .lock()
675                .unwrap_or_else(std::sync::PoisonError::into_inner);
676            for url in urls {
677                if already_seen.contains(url) || pending.contains(url) {
678                    continue;
679                }
680                if pending.len() >= self.config.crl_max_seen_urls {
681                    self.warn_cap_exceeded_throttled("pending_urls");
682                    break;
683                }
684                pending.insert(url.clone());
685            }
686        }
687        missing_cached
688    }
689
690    /// Test-only: invoke the real precheck with separate end-entity and
691    /// intermediate CDP sets.
692    #[cfg(any(test, feature = "test-helpers"))]
693    #[doc(hidden)]
694    pub fn __test_note_discovered_urls_by_cert(
695        &self,
696        end_entity_urls: &[String],
697        intermediate_urls: &[String],
698    ) -> bool {
699        self.note_discovered_urls(end_entity_urls, intermediate_urls)
700    }
701
702    /// Test-only: report whether a URL is currently suppressed from
703    /// re-discovery — i.e. present in EITHER dedup state.
704    ///
705    /// This is the property callers actually care about: "will a future
706    /// handshake re-enqueue this URL?". Use
707    /// [`Self::__test_is_permanently_seen`] when the distinction between
708    /// in-flight and confirmed-cached matters.
709    #[doc(hidden)]
710    pub fn __test_is_seen(&self, url: &str) -> bool {
711        let in_seen = {
712            let seen = self
713                .seen_urls
714                .lock()
715                .unwrap_or_else(std::sync::PoisonError::into_inner);
716            seen.contains(url)
717        };
718        if in_seen {
719            return true;
720        }
721        let pending = self
722            .pending_urls
723            .lock()
724            .unwrap_or_else(std::sync::PoisonError::into_inner);
725        pending.contains(url)
726    }
727
728    /// Test-only: report whether a URL reached the PERMANENT dedup set,
729    /// which happens only after its CRL is confirmed present in the cache.
730    /// A URL that was merely queued, or whose fetch failed, is not counted.
731    #[cfg(any(test, feature = "test-helpers"))]
732    #[doc(hidden)]
733    pub fn __test_is_permanently_seen(&self, url: &str) -> bool {
734        let seen = self
735            .seen_urls
736            .lock()
737            .unwrap_or_else(std::sync::PoisonError::into_inner);
738        seen.contains(url)
739    }
740
741    /// Test-only: drive the post-fetch bookkeeping without performing HTTP.
742    /// `admitted` mirrors [`Self::fetch_and_store_url`]'s return value:
743    /// `true` when the CRL landed in the cache, `false` when the fetch
744    /// failed or the cache cap refused it.
745    #[cfg(any(test, feature = "test-helpers"))]
746    #[doc(hidden)]
747    pub fn __test_settle_pending(&self, url: &str, admitted: bool) {
748        if admitted {
749            self.promote_pending_to_seen(url);
750        } else {
751            self.clear_pending(url);
752        }
753    }
754
755    /// Test-only: current count of host semaphores. Used by
756    /// `tests/crl_map_bounds.rs` to assert the cap is enforced.
757    #[cfg(any(test, feature = "test-helpers"))]
758    #[doc(hidden)]
759    pub fn __test_host_semaphore_count(&self) -> usize {
760        self.host_semaphores
761            .try_lock()
762            .map_or(0, |guard| guard.len())
763    }
764
765    /// Test-only: current number of entries in the CRL cache.
766    #[cfg(any(test, feature = "test-helpers"))]
767    #[doc(hidden)]
768    pub fn __test_cache_len(&self) -> usize {
769        self.cache.try_read().map_or(0, |guard| guard.len())
770    }
771
772    /// Test-only: whether a specific URL is currently cached.
773    #[cfg(any(test, feature = "test-helpers"))]
774    #[doc(hidden)]
775    pub fn __test_cache_contains(&self, url: &str) -> bool {
776        self.cache
777            .try_read()
778            .is_ok_and(|guard| guard.contains_key(url))
779    }
780
781    /// Test-only: whether a URL is advertised to the fail-closed precheck as
782    /// present in the live verifier.
783    #[cfg(any(test, feature = "test-helpers"))]
784    #[doc(hidden)]
785    pub fn __test_cached_url_contains(&self, url: &str) -> bool {
786        self.cached_urls
787            .lock()
788            .is_ok_and(|guard| guard.contains(url))
789    }
790
791    /// Test-only: triggers the request-hot-path fetch path for `url`
792    /// WITHOUT going through the TLS handshake. Returns any error the
793    /// host-semaphore cap check produces. A network-unreachable
794    /// failure for the fetch itself is treated as `Ok(())` (test only
795    /// cares about the cap; real tests use mock hosts that won't
796    /// resolve — the cap must fire BEFORE network I/O).
797    #[cfg(any(test, feature = "test-helpers"))]
798    #[doc(hidden)]
799    pub async fn __test_trigger_fetch(&self, url: &str) -> Result<(), McpxError> {
800        if let Err(error) = gated_fetch(
801            &self.client,
802            &self.global_fetch_sem,
803            &self.host_semaphores,
804            url,
805            self.config.crl_allow_http,
806            self.max_response_bytes,
807            self.config.crl_max_host_semaphores,
808        )
809        .await
810        {
811            if error
812                .to_string()
813                .contains("crl_host_semaphore_cap_exceeded")
814            {
815                Err(error)
816            } else {
817                Ok(())
818            }
819        } else {
820            Ok(())
821        }
822    }
823
824    /// Test-only: directly insert `cached` under `url` into both
825    /// `cache` and `cached_urls`, bypassing HTTP. Does NOT enforce
826    /// `crl_max_cache_entries` when called pre-cap — the test uses it
827    /// to stage preconditions. For cap-breach coverage, tests invoke
828    /// the real production insertion path.
829    ///
830    /// Wait — the `cache_hard_cap_drops_newest` test DOES use this
831    /// helper to assert the cap fires. Therefore this helper MUST
832    /// enforce the hard cap (silent drop with warn!) the same way the
833    /// production code does. The helper is a thin wrapper around the
834    /// same internal insertion fn the production path uses.
835    #[cfg(any(test, feature = "test-helpers"))]
836    #[doc(hidden)]
837    pub async fn __test_insert_cache(&self, url: &str, cached: CachedCrl) {
838        let _ = self
839            .commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
840            .await;
841    }
842
843    /// Test-only: direct cache insertion that returns verifier rebuild errors.
844    #[cfg(any(test, feature = "test-helpers"))]
845    #[doc(hidden)]
846    pub async fn __test_try_insert_cache(
847        &self,
848        url: &str,
849        cached: CachedCrl,
850    ) -> Result<bool, McpxError> {
851        self.commit_cache_update_atomically(vec![(url.to_owned(), cached)], &[])
852            .await
853    }
854
855    /// Test-only: replace a cache entry without rebuilding the verifier.
856    #[cfg(any(test, feature = "test-helpers"))]
857    #[doc(hidden)]
858    pub async fn __test_replace_cache_entry_unverified(&self, url: &str, cached: CachedCrl) {
859        let mut cache = self.cache.write().await;
860        cache.insert(url.to_owned(), cached);
861    }
862
863    /// Test-only: trigger a refresh cycle for a single URL. Exercises
864    /// the same stale-grace / fetch-failure path as `refresh_urls()`.
865    /// Returns the refresh error (if any) — most tests ignore it
866    /// because they assert post-state, not the transient error.
867    #[cfg(any(test, feature = "test-helpers"))]
868    #[doc(hidden)]
869    pub async fn __test_trigger_refresh_url(&self, url: &str) -> Result<(), McpxError> {
870        self.refresh_urls(vec![url.to_owned()]).await
871    }
872
873    async fn fetch_url_results(
874        &self,
875        urls: Vec<String>,
876    ) -> Vec<(String, Result<CachedCrl, McpxError>)> {
877        let mut tasks = JoinSet::new();
878        for url in urls {
879            let client = self.client.clone();
880            let global_sem = Arc::clone(&self.global_fetch_sem);
881            let host_map = Arc::clone(&self.host_semaphores);
882            let allow_http = self.config.crl_allow_http;
883            let max_bytes = self.max_response_bytes;
884            let max_host_semaphores = self.config.crl_max_host_semaphores;
885            tasks.spawn(async move {
886                let result = gated_fetch(
887                    &client,
888                    &global_sem,
889                    &host_map,
890                    &url,
891                    allow_http,
892                    max_bytes,
893                    max_host_semaphores,
894                )
895                .await;
896                (url, result)
897            });
898        }
899
900        let mut results = Vec::new();
901        while let Some(joined) = tasks.join_next().await {
902            match joined {
903                Ok(result) => results.push(result),
904                Err(error) => {
905                    tracing::warn!(error = %error, "CRL refresh task join failed");
906                }
907            }
908        }
909
910        results
911    }
912}
913
914#[cfg(any(test, feature = "test-helpers"))]
915const SYNTHETIC_TEST_CRL_DER: &[u8] = &[
916    48, 129, 199, 48, 110, 2, 1, 1, 48, 10, 6, 8, 42, 134, 72, 206, 61, 4, 3, 2, 48, 14, 49, 12,
917    48, 10, 6, 3, 85, 4, 3, 12, 3, 99, 114, 108, 23, 13, 50, 54, 48, 49, 48, 49, 48, 48, 48, 48,
918    48, 48, 90, 23, 13, 50, 55, 48, 49, 48, 49, 48, 48, 48, 48, 48, 48, 90, 160, 47, 48, 45, 48,
919    31, 6, 3, 85, 29, 35, 4, 24, 48, 22, 128, 20, 14, 62, 48, 146, 7, 182, 179, 215, 90, 226, 214,
920    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,
921    42, 134, 72, 206, 61, 4, 3, 2, 3, 73, 0, 48, 70, 2, 33, 0, 250, 240, 103, 87, 60, 78, 208, 171,
922    184, 206, 117, 134, 236, 234, 53, 115, 122, 90, 64, 217, 146, 27, 32, 103, 170, 222, 240, 159,
923    137, 187, 116, 6, 2, 33, 0, 188, 23, 204, 232, 130, 84, 135, 249, 43, 208, 224, 220, 202, 57,
924    98, 140, 4, 251, 148, 189, 105, 68, 105, 40, 53, 180, 208, 38, 193, 120, 118, 100,
925];
926
927impl CachedCrl {
928    /// Test-only: synthesize a cache entry that looks valid, `next_update`
929    /// = now + 24h. Fields used only to populate the HashMap — the bytes
930    /// are a minimal CRL-shape that won't be parsed by tests.
931    #[cfg(any(test, feature = "test-helpers"))]
932    #[doc(hidden)]
933    #[must_use]
934    pub fn __test_synthetic(now: SystemTime) -> Self {
935        Self {
936            der: CertificateRevocationListDer::from(SYNTHETIC_TEST_CRL_DER.to_vec()),
937            this_update: now,
938            next_update: now.checked_add(Duration::from_hours(24)),
939            fetched_at: now,
940            source_url: "test://synthetic".to_owned(),
941        }
942    }
943
944    /// Test-only: synthesize a STALE cache entry (`next_update` in the
945    /// deep past so `is_stale_beyond_grace` fires with any sensible
946    /// `crl_stale_grace`).
947    #[cfg(any(test, feature = "test-helpers"))]
948    #[doc(hidden)]
949    #[must_use]
950    pub fn __test_stale(reference_past: SystemTime) -> Self {
951        Self {
952            der: CertificateRevocationListDer::from(vec![0x30, 0x00]),
953            this_update: reference_past,
954            next_update: Some(reference_past),
955            fetched_at: reference_past,
956            source_url: "test://stale".to_owned(),
957        }
958    }
959}
960
961/// Stable outer verifier that delegates all TLS verification behavior to the
962/// atomically swappable inner verifier.
963pub struct DynamicClientCertVerifier {
964    inner: Arc<CrlSet>,
965    dn_subjects: Vec<DistinguishedName>,
966}
967
968impl DynamicClientCertVerifier {
969    /// Construct a new dynamic verifier from a shared [`CrlSet`].
970    #[must_use]
971    pub fn new(inner: Arc<CrlSet>) -> Self {
972        Self {
973            dn_subjects: inner.roots.subjects(),
974            inner,
975        }
976    }
977}
978
979impl std::fmt::Debug for DynamicClientCertVerifier {
980    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981        f.debug_struct("DynamicClientCertVerifier")
982            .field("dn_subjects_len", &self.dn_subjects.len())
983            .finish_non_exhaustive()
984    }
985}
986
987impl ClientCertVerifier for DynamicClientCertVerifier {
988    fn offer_client_auth(&self) -> bool {
989        let verifier = self.inner.inner_verifier.load();
990        verifier.0.offer_client_auth()
991    }
992
993    fn client_auth_mandatory(&self) -> bool {
994        let verifier = self.inner.inner_verifier.load();
995        verifier.0.client_auth_mandatory()
996    }
997
998    fn root_hint_subjects(&self) -> &[DistinguishedName] {
999        &self.dn_subjects
1000    }
1001
1002    fn verify_client_cert(
1003        &self,
1004        end_entity: &CertificateDer<'_>,
1005        intermediates: &[CertificateDer<'_>],
1006        now: UnixTime,
1007    ) -> Result<ClientCertVerified, TlsError> {
1008        // SECURITY: extracting CDP URLs from an unverified client cert
1009        // here is intentional. No HTTP happens on this path -- the call
1010        // to `note_discovered_urls` only enqueues onto a bounded,
1011        // rate-limited channel. The actual fetch runs off-path in
1012        // `run_crl_refresher` and is gated by SSRF screening
1013        // (`src/ssrf.rs`), body-size cap, deadline, and the
1014        // `crl_allow_http` policy. CRLs are CA-signed (RFC 5280 §5), so
1015        // http(s) CDP URLs are protocol design, not an SSRF sink. The
1016        // discovery must happen BEFORE delegating to the inner verifier
1017        // so `crl_deny_on_unavailable = true` can fail-closed on a
1018        // never-fetched CDP. Do NOT reorder.
1019        let mut end_entity_urls =
1020            extract_cdp_urls(end_entity.as_ref(), self.inner.config.crl_allow_http);
1021        end_entity_urls.sort();
1022        end_entity_urls.dedup();
1023
1024        let mut intermediate_urls = Vec::new();
1025        for intermediate in intermediates {
1026            intermediate_urls.extend(extract_cdp_urls(
1027                intermediate.as_ref(),
1028                self.inner.config.crl_allow_http,
1029            ));
1030        }
1031        intermediate_urls.sort();
1032        intermediate_urls.dedup();
1033
1034        if self
1035            .inner
1036            .note_discovered_urls(&end_entity_urls, &intermediate_urls)
1037        {
1038            return Err(TlsError::General(
1039                "client certificate revocation status unavailable".to_owned(),
1040            ));
1041        }
1042
1043        let verifier = self.inner.inner_verifier.load();
1044        verifier
1045            .0
1046            .verify_client_cert(end_entity, intermediates, now)
1047    }
1048
1049    fn verify_tls12_signature(
1050        &self,
1051        message: &[u8],
1052        cert: &CertificateDer<'_>,
1053        dss: &DigitallySignedStruct,
1054    ) -> Result<HandshakeSignatureValid, TlsError> {
1055        let verifier = self.inner.inner_verifier.load();
1056        verifier.0.verify_tls12_signature(message, cert, dss)
1057    }
1058
1059    fn verify_tls13_signature(
1060        &self,
1061        message: &[u8],
1062        cert: &CertificateDer<'_>,
1063        dss: &DigitallySignedStruct,
1064    ) -> Result<HandshakeSignatureValid, TlsError> {
1065        let verifier = self.inner.inner_verifier.load();
1066        verifier.0.verify_tls13_signature(message, cert, dss)
1067    }
1068
1069    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
1070        let verifier = self.inner.inner_verifier.load();
1071        verifier.0.supported_verify_schemes()
1072    }
1073
1074    fn requires_raw_public_keys(&self) -> bool {
1075        let verifier = self.inner.inner_verifier.load();
1076        verifier.0.requires_raw_public_keys()
1077    }
1078}
1079
1080/// Extract CRL Distribution Point URLs from a DER-encoded certificate.
1081///
1082/// URLs are validated with `url::Url::parse` (case-insensitive scheme handling)
1083/// and filtered through an internal scheme guard. Malformed URLs, URLs
1084/// using disallowed schemes, and URLs carrying embedded credentials
1085/// (userinfo) are silently dropped. SSRF defenses against private
1086/// IP literals and metadata endpoints are applied later, at fetch time, after
1087/// DNS resolution.
1088#[must_use]
1089pub fn extract_cdp_urls(cert_der: &[u8], allow_http: bool) -> Vec<String> {
1090    let Ok((_, cert)) = X509Certificate::from_der(cert_der) else {
1091        return Vec::new();
1092    };
1093
1094    let mut urls = Vec::new();
1095    for ext in cert.extensions() {
1096        if let ParsedExtension::CRLDistributionPoints(cdps) = ext.parsed_extension() {
1097            for point in cdps.iter() {
1098                if let Some(DistributionPointName::FullName(names)) = &point.distribution_point {
1099                    for name in names {
1100                        if let GeneralName::URI(uri) = name {
1101                            let raw = *uri;
1102                            let Ok(parsed) = Url::parse(raw) else {
1103                                // `?raw` (Debug) escapes control characters the
1104                                // failed parse may have left in this
1105                                // attacker-supplied string.
1106                                tracing::debug!(url = ?raw, "CDP URL parse failed; dropped");
1107                                continue;
1108                            };
1109                            if let Err(reason) = check_scheme(&parsed, allow_http) {
1110                                tracing::debug!(
1111                                    url = %sanitized_url_for_log(&parsed),
1112                                    reason,
1113                                    "CDP URL rejected by scheme guard; dropped"
1114                                );
1115                                continue;
1116                            }
1117                            urls.push(parsed.into());
1118                        }
1119                    }
1120                }
1121            }
1122        }
1123    }
1124
1125    urls
1126}
1127
1128/// Bootstrap the CRL cache by extracting CDP URLs from the CA chain and
1129/// fetching any reachable CRLs with a 10-second total deadline.
1130///
1131/// # Errors
1132///
1133/// Returns an error if the initial verifier cannot be built.
1134#[allow(
1135    clippy::cognitive_complexity,
1136    reason = "bootstrap coordinates timeout, parallel fetches, and partial-cache recovery"
1137)]
1138pub async fn bootstrap_fetch(
1139    roots: Arc<RootCertStore>,
1140    ca_certs: &[CertificateDer<'static>],
1141    config: MtlsConfig,
1142) -> Result<(Arc<CrlSet>, mpsc::UnboundedReceiver<String>), McpxError> {
1143    let (discover_tx, discover_rx) = mpsc::unbounded_channel();
1144
1145    let mut urls = ca_certs
1146        .iter()
1147        .flat_map(|cert| extract_cdp_urls(cert.as_ref(), config.crl_allow_http))
1148        .collect::<Vec<_>>();
1149    urls.sort();
1150    urls.dedup();
1151
1152    // M-H2: same SSRF resolver hardening as CrlSet::new -- bootstrap
1153    // fetches the same attacker-controlled CDP URLs, just earlier in
1154    // the lifecycle.
1155    let bootstrap_allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
1156    let bootstrap_resolver: Arc<dyn reqwest::dns::Resolve> =
1157        Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1158            Arc::clone(&bootstrap_allowlist),
1159            #[cfg(any(test, feature = "test-helpers"))]
1160            Arc::new(std::sync::atomic::AtomicBool::new(false)),
1161            #[cfg(not(any(test, feature = "test-helpers")))]
1162            (),
1163        ));
1164
1165    let client = reqwest::Client::builder()
1166        // M-H2/N1: see oauth.rs::OauthHttpClient::build for rationale.
1167        .no_proxy()
1168        .dns_resolver(Arc::clone(&bootstrap_resolver))
1169        .timeout(config.crl_fetch_timeout)
1170        .connect_timeout(CRL_CONNECT_TIMEOUT)
1171        .tcp_keepalive(None)
1172        .redirect(reqwest::redirect::Policy::none())
1173        .user_agent(format!("rmcp-server-kit/{}", env!("CARGO_PKG_VERSION")))
1174        .build()
1175        .map_err(|error| McpxError::Startup(format!("CRL HTTP client init: {error}")))?;
1176
1177    // Bootstrap shares the same global concurrency + per-host cap as the
1178    // hot-path verifier so a maliciously broad CA chain cannot overwhelm
1179    // the network at startup.
1180    let bootstrap_concurrency = config.crl_max_concurrent_fetches.max(1);
1181    let global_sem = Arc::new(Semaphore::new(bootstrap_concurrency));
1182    let host_semaphores = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
1183    let allow_http = config.crl_allow_http;
1184    let max_bytes = config.crl_max_response_bytes;
1185    let max_host_semaphores = config.crl_max_host_semaphores;
1186
1187    let mut initial_cache = HashMap::new();
1188    let mut tasks = JoinSet::new();
1189    for url in &urls {
1190        let client = client.clone();
1191        let url = url.clone();
1192        let global_sem = Arc::clone(&global_sem);
1193        let host_semaphores = Arc::clone(&host_semaphores);
1194        tasks.spawn(async move {
1195            let result = gated_fetch(
1196                &client,
1197                &global_sem,
1198                &host_semaphores,
1199                &url,
1200                allow_http,
1201                max_bytes,
1202                max_host_semaphores,
1203            )
1204            .await;
1205            (url, result)
1206        });
1207    }
1208
1209    let timeout: Sleep = tokio::time::sleep(BOOTSTRAP_TIMEOUT);
1210    tokio::pin!(timeout);
1211
1212    while !tasks.is_empty() {
1213        // cancel-safe: pinned Sleep and JoinSet::join_next are cancel-safe
1214        // (tokio docs); on timeout the loop breaks and dropping the JoinSet
1215        // aborts remaining fetches — the intended deadline behavior.
1216        tokio::select! {
1217            () = &mut timeout => {
1218                tracing::warn!("CRL bootstrap timed out after {:?}", BOOTSTRAP_TIMEOUT);
1219                break;
1220            }
1221            maybe_joined = tasks.join_next() => {
1222                let Some(joined) = maybe_joined else {
1223                    break;
1224                };
1225                match joined {
1226                    Ok((url, Ok(cached))) => {
1227                        initial_cache.insert(url, cached);
1228                    }
1229                    Ok((url, Err(error))) => {
1230                        tracing::warn!(url = %url, error = %error, "CRL bootstrap fetch failed");
1231                    }
1232                    Err(error) => {
1233                        tracing::warn!(error = %error, "CRL bootstrap task join failed");
1234                    }
1235                }
1236            }
1237        }
1238    }
1239
1240    let set = CrlSet::new(roots, config, discover_tx, initial_cache)?;
1241    Ok((set, discover_rx))
1242}
1243
1244/// Run the CRL refresher loop until shutdown.
1245#[allow(
1246    clippy::cognitive_complexity,
1247    reason = "refresher loop intentionally handles shutdown, timer, and discovery in one select"
1248)]
1249pub async fn run_crl_refresher(
1250    set: Arc<CrlSet>,
1251    mut discover_rx: mpsc::UnboundedReceiver<String>,
1252    shutdown: CancellationToken,
1253) {
1254    let mut refresh_sleep = schedule_next_refresh(&set).await;
1255
1256    loop {
1257        // cancel-safe: CancellationToken::cancelled, pinned &mut Sleep, and
1258        // mpsc::UnboundedReceiver::recv are all cancel-safe (tokio docs);
1259        // refresh work happens inside arm bodies, never in the raced futures.
1260        tokio::select! {
1261            () = shutdown.cancelled() => {
1262                break;
1263            }
1264            () = &mut refresh_sleep => {
1265                if let Err(error) = set.refresh_due_urls().await {
1266                    tracing::warn!(error = %error, "CRL periodic refresh failed");
1267                }
1268                refresh_sleep = schedule_next_refresh(&set).await;
1269            }
1270            maybe_url = discover_rx.recv() => {
1271                let Some(url) = maybe_url else {
1272                    break;
1273                };
1274                match set.fetch_and_store_url(url.clone()).await {
1275                    // Cached: safe to suppress this URL permanently.
1276                    Ok(true) => set.promote_pending_to_seen(&url),
1277                    // Fetched but refused by the cache cap. Clear the
1278                    // in-flight marker so a later handshake can retry;
1279                    // suppressing it here would disable revocation for
1280                    // this CDP even though no CRL was ever cached.
1281                    Ok(false) => {
1282                        set.clear_pending(&url);
1283                        tracing::warn!(
1284                            url = %url,
1285                            "CRL fetched but not admitted to cache (cap reached); will retry on a later handshake"
1286                        );
1287                    }
1288                    Err(error) => {
1289                        set.clear_pending(&url);
1290                        tracing::warn!(
1291                            url = %url,
1292                            error = %error,
1293                            "CRL discovery fetch failed; will retry on a later handshake"
1294                        );
1295                    }
1296                }
1297                refresh_sleep = schedule_next_refresh(&set).await;
1298            }
1299        }
1300    }
1301}
1302
1303/// Rebuild the inner rustls verifier from the current CRL cache.
1304///
1305/// # Errors
1306///
1307/// Returns an error if rustls rejects the verifier configuration.
1308pub fn rebuild_verifier<S: std::hash::BuildHasher>(
1309    roots: &Arc<RootCertStore>,
1310    config: &MtlsConfig,
1311    cache: &HashMap<String, CachedCrl, S>,
1312) -> Result<Arc<dyn ClientCertVerifier>, McpxError> {
1313    let mut builder = WebPkiClientVerifier::builder(Arc::clone(roots));
1314
1315    if !cache.is_empty() {
1316        let crls = cache
1317            .values()
1318            .map(|cached| cached.der.clone())
1319            .collect::<Vec<_>>();
1320        builder = builder.with_crls(crls);
1321    }
1322    if config.crl_end_entity_only {
1323        builder = builder.only_check_end_entity_revocation();
1324    }
1325    if !config.crl_deny_on_unavailable {
1326        builder = builder.allow_unknown_revocation_status();
1327    }
1328    if config.crl_enforce_expiration {
1329        builder = builder.enforce_revocation_expiration();
1330    }
1331    if !config.required {
1332        builder = builder.allow_unauthenticated();
1333    }
1334
1335    builder
1336        .build()
1337        .map_err(|error| McpxError::Tls(format!("mTLS verifier error: {error}")))
1338}
1339
1340/// Parse `thisUpdate` and `nextUpdate` metadata from a DER-encoded CRL.
1341///
1342/// # Errors
1343///
1344/// Returns an error if the CRL cannot be parsed.
1345pub fn parse_crl_metadata(der: &[u8]) -> Result<(SystemTime, Option<SystemTime>), McpxError> {
1346    let (_, crl) = CertificateRevocationList::from_der(der)
1347        .map_err(|error| McpxError::Tls(format!("invalid CRL DER: {error:?}")))?;
1348
1349    Ok((
1350        asn1_time_to_system_time(crl.last_update()),
1351        crl.next_update().map(asn1_time_to_system_time),
1352    ))
1353}
1354
1355async fn schedule_next_refresh(set: &CrlSet) -> Pin<Box<Sleep>> {
1356    let duration = next_refresh_delay(set).await;
1357    boxed_sleep(duration)
1358}
1359
1360fn boxed_sleep(duration: Duration) -> Pin<Box<Sleep>> {
1361    Box::pin(tokio::time::sleep_until(Instant::now() + duration))
1362}
1363
1364async fn next_refresh_delay(set: &CrlSet) -> Duration {
1365    if let Some(interval) = set.config.crl_refresh_interval {
1366        return clamp_refresh(interval);
1367    }
1368
1369    let now = SystemTime::now();
1370    let cache = set.cache.read().await;
1371    let mut next = MAX_AUTO_REFRESH;
1372
1373    for cached in cache.values() {
1374        if let Some(next_update) = cached.next_update {
1375            let duration = next_update.duration_since(now).unwrap_or(Duration::ZERO);
1376            next = next.min(clamp_refresh(duration));
1377        }
1378    }
1379    drop(cache);
1380
1381    next
1382}
1383
1384/// Get-or-insert the per-host fetch semaphore for `host_key`.
1385///
1386/// When the map is at `max_host_semaphores`, idle entries (no in-flight
1387/// fetch) are evicted before rejecting, so the cap only fails when `max`
1388/// distinct hosts are *concurrently* fetching — it is never a permanent
1389/// lockout. Every clone of a host semaphore is created while holding the
1390/// map lock, and a clone outlives the critical section only while a fetch
1391/// is in flight, so an entry with `Arc::strong_count == 1` is provably
1392/// idle and safe to drop.
1393fn acquire_host_semaphore(
1394    map: &mut HashMap<String, Arc<Semaphore>>,
1395    host_key: &str,
1396    max_host_semaphores: usize,
1397) -> Result<Arc<Semaphore>, McpxError> {
1398    if !map.contains_key(host_key) {
1399        if map.len() >= max_host_semaphores {
1400            // Self-heal: drop semaphores with no in-flight fetch.
1401            map.retain(|_, semaphore| Arc::strong_count(semaphore) > 1);
1402        }
1403        if map.len() >= max_host_semaphores {
1404            return Err(McpxError::Config(
1405                "crl_host_semaphore_cap_exceeded: too many distinct CRL hosts in flight".to_owned(),
1406            ));
1407        }
1408        map.insert(host_key.to_owned(), Arc::new(Semaphore::new(1)));
1409    }
1410    match map.get(host_key) {
1411        Some(semaphore) => Ok(Arc::clone(semaphore)),
1412        None => Err(McpxError::Tls(
1413            "CRL host semaphore missing after insertion".to_owned(),
1414        )),
1415    }
1416}
1417
1418/// Fetch a single CRL URL through the global + per-host concurrency caps.
1419///
1420/// `global_sem` caps total simultaneous CRL fetches process-wide.
1421/// `host_semaphores` ensures at most one in-flight fetch per origin host
1422/// (an SSRF amplification defense); at the host cap, idle entries are
1423/// evicted on demand. Both permits are dropped when the returned future
1424/// completes (whether `Ok` or `Err`).
1425async fn gated_fetch(
1426    client: &reqwest::Client,
1427    global_sem: &Arc<Semaphore>,
1428    host_semaphores: &Arc<tokio::sync::Mutex<HashMap<String, Arc<Semaphore>>>>,
1429    url: &str,
1430    allow_http: bool,
1431    max_bytes: u64,
1432    max_host_semaphores: usize,
1433) -> Result<CachedCrl, McpxError> {
1434    let host_key = Url::parse(url)
1435        .ok()
1436        .and_then(|u| u.host_str().map(str::to_owned))
1437        .unwrap_or_else(|| url.to_owned());
1438
1439    let host_sem = {
1440        let mut map = host_semaphores.lock().await;
1441        acquire_host_semaphore(&mut map, &host_key, max_host_semaphores)?
1442    };
1443
1444    let _global_permit = Arc::clone(global_sem)
1445        .acquire_owned()
1446        .await
1447        .map_err(|error| McpxError::Tls(format!("CRL global semaphore closed: {error}")))?;
1448    let _host_permit = host_sem
1449        .acquire_owned()
1450        .await
1451        .map_err(|error| McpxError::Tls(format!("CRL host semaphore closed: {error}")))?;
1452
1453    fetch_crl(client, url, allow_http, max_bytes).await
1454}
1455
1456async fn fetch_crl(
1457    client: &reqwest::Client,
1458    url: &str,
1459    allow_http: bool,
1460    max_bytes: u64,
1461) -> Result<CachedCrl, McpxError> {
1462    let parsed =
1463        Url::parse(url).map_err(|error| McpxError::Tls(format!("CRL URL parse {url}: {error}")))?;
1464
1465    if let Err(reason) = check_scheme(&parsed, allow_http) {
1466        // Sanitized: the gate must not echo what it rejects (the URL may
1467        // carry userinfo credentials — the very thing being refused).
1468        let sanitized = sanitized_url_for_log(&parsed);
1469        tracing::warn!(url = %sanitized, reason, "CRL fetch denied: scheme");
1470        return Err(McpxError::Tls(format!(
1471            "CRL scheme rejected ({reason}): {sanitized}"
1472        )));
1473    }
1474
1475    let host = parsed
1476        .host_str()
1477        .ok_or_else(|| McpxError::Tls(format!("CRL URL has no host: {url}")))?;
1478    let port = parsed
1479        .port_or_known_default()
1480        .ok_or_else(|| McpxError::Tls(format!("CRL URL has no known port: {url}")))?;
1481
1482    let addrs = lookup_host((host, port))
1483        .await
1484        .map_err(|error| McpxError::Tls(format!("CRL DNS resolution {url}: {error}")))?;
1485
1486    let mut any_addr = false;
1487    for addr in addrs {
1488        any_addr = true;
1489        if let Some(reason) = ip_block_reason(addr.ip()) {
1490            tracing::warn!(
1491                url = %url,
1492                resolved_ip = %addr.ip(),
1493                reason,
1494                "CRL fetch denied: blocked IP"
1495            );
1496            return Err(McpxError::Tls(format!(
1497                "CRL host resolved to blocked IP ({reason}): {url}"
1498            )));
1499        }
1500    }
1501    if !any_addr {
1502        return Err(McpxError::Tls(format!(
1503            "CRL DNS resolution returned no addresses: {url}"
1504        )));
1505    }
1506
1507    let mut response = client
1508        .get(url)
1509        .send()
1510        .await
1511        .map_err(|error| McpxError::Tls(format!("CRL fetch {url}: {error}")))?
1512        .error_for_status()
1513        .map_err(|error| McpxError::Tls(format!("CRL fetch {url}: {error}")))?;
1514
1515    // Enforce body cap by streaming chunk-by-chunk; a malicious or
1516    // misconfigured server cannot allocate more than `max_bytes` of memory.
1517    let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
1518    let mut body: Vec<u8> = Vec::with_capacity(initial_capacity);
1519    while let Some(chunk) = response
1520        .chunk()
1521        .await
1522        .map_err(|error| McpxError::Tls(format!("CRL read {url}: {error}")))?
1523    {
1524        let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
1525        let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
1526        if body_len.saturating_add(chunk_len) > max_bytes {
1527            return Err(McpxError::Tls(format!(
1528                "CRL body exceeded cap of {max_bytes} bytes: {url}"
1529            )));
1530        }
1531        body.extend_from_slice(&chunk);
1532    }
1533
1534    let der = CertificateRevocationListDer::from(body);
1535    let (this_update, next_update) = parse_crl_metadata(der.as_ref())?;
1536
1537    Ok(CachedCrl {
1538        der,
1539        this_update,
1540        next_update,
1541        fetched_at: SystemTime::now(),
1542        source_url: url.to_owned(),
1543    })
1544}
1545
1546fn should_refresh_cached(
1547    cached: &CachedCrl,
1548    now: SystemTime,
1549    fixed_interval: Option<Duration>,
1550) -> bool {
1551    if let Some(interval) = fixed_interval {
1552        return cached
1553            .fetched_at
1554            .checked_add(clamp_refresh(interval))
1555            .is_none_or(|deadline| now >= deadline);
1556    }
1557
1558    cached
1559        .next_update
1560        .is_none_or(|next_update| now >= next_update)
1561}
1562
1563fn clamp_refresh(duration: Duration) -> Duration {
1564    duration.clamp(MIN_AUTO_REFRESH, MAX_AUTO_REFRESH)
1565}
1566
1567/// 9999-12-31T23:59:59Z — the maximum instant expressible as an ASN.1
1568/// GeneralizedTime (four-digit year). Used to clamp absurd positive
1569/// timestamps before converting to [`SystemTime`].
1570const MAX_ASN1_TIMESTAMP_SECS: u64 = 253_402_300_799;
1571
1572/// Convert an ASN.1 time to [`SystemTime`] without ever panicking.
1573///
1574/// CRL metadata is parsed from raw fetched bytes *before* signature
1575/// validation, so timestamps are attacker-controlled. Platform
1576/// `SystemTime` ranges differ (Windows cannot represent pre-1601);
1577/// unrepresentable values are clamped toward [`UNIX_EPOCH`], which is the
1578/// safe direction: it can only make a CRL look *older* (forcing an
1579/// eager refresh), never fresher.
1580fn asn1_time_to_system_time(time: x509_parser::time::ASN1Time) -> SystemTime {
1581    let timestamp = time.timestamp();
1582    if timestamp >= 0 {
1583        let seconds = u64::try_from(timestamp)
1584            .unwrap_or(0)
1585            .min(MAX_ASN1_TIMESTAMP_SECS);
1586        UNIX_EPOCH
1587            .checked_add(Duration::from_secs(seconds))
1588            .unwrap_or(UNIX_EPOCH)
1589    } else {
1590        UNIX_EPOCH
1591            .checked_sub(Duration::from_secs(timestamp.unsigned_abs()))
1592            .unwrap_or(UNIX_EPOCH)
1593    }
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598    use super::*;
1599
1600    fn asn1(timestamp: i64) -> x509_parser::time::ASN1Time {
1601        x509_parser::time::ASN1Time::from_timestamp(timestamp).expect("valid ASN.1 timestamp")
1602    }
1603
1604    /// The userinfo gate fires before DNS resolution (no network needed)
1605    /// and the surfaced error must not echo the rejected credentials.
1606    #[tokio::test]
1607    async fn fetch_crl_rejects_userinfo_without_echoing_credentials() {
1608        // reqwest with `rustls-no-provider` requires a process-wide crypto
1609        // provider before any Client is built (same pattern as the
1610        // transport/oauth test suites).
1611        let _ = rustls::crypto::ring::default_provider().install_default();
1612        let client = reqwest::Client::new();
1613        let err = fetch_crl(&client, "https://u:p@crl.example/ca.crl", false, 1024)
1614            .await
1615            .expect_err("userinfo-bearing CRL URL must be rejected");
1616        let rendered = err.to_string();
1617        assert!(
1618            rendered.contains("userinfo_forbidden"),
1619            "error must carry the rejection reason: {rendered}"
1620        );
1621        assert!(
1622            !rendered.contains("u:p"),
1623            "error must not echo the rejected credentials: {rendered}"
1624        );
1625    }
1626
1627    /// `extract_cdp_urls`'s scheme/userinfo guard reuses the same gate;
1628    /// the sanitizer keeps credentials out of its debug logging too.
1629    #[test]
1630    fn sanitizer_used_by_rejection_sites_strips_credentials() {
1631        let parsed = Url::parse("https://u:p@crl.example/ca.crl").expect("parse");
1632        let sanitized = sanitized_url_for_log(&parsed);
1633        assert_eq!(sanitized, "https://crl.example");
1634        assert!(!sanitized.contains("u:p"));
1635    }
1636
1637    #[test]
1638    fn asn1_time_clamps_unrepresentable_timestamps() {
1639        // Year 1500 — pre-1601, NOT representable by Windows `SystemTime`.
1640        // Pre-fix this panicked on Windows; now it must return a value no
1641        // later than the epoch on every platform (clamped to UNIX_EPOCH on
1642        // Windows, the real instant on platforms that can represent it).
1643        let year_1500 = asn1_time_to_system_time(asn1(-14_831_769_600));
1644        assert!(year_1500 <= UNIX_EPOCH);
1645        #[cfg(windows)]
1646        assert_eq!(year_1500, UNIX_EPOCH);
1647
1648        // 1601-01-01T00:00:00Z — the exact Windows epoch boundary, which IS
1649        // representable everywhere. No clamp, no panic.
1650        let year_1601 = asn1_time_to_system_time(asn1(-11_644_473_600));
1651        assert!(year_1601 <= UNIX_EPOCH);
1652
1653        // Mildly negative (pre-1970) stays at-or-before the epoch.
1654        assert!(asn1_time_to_system_time(asn1(-2)) <= UNIX_EPOCH);
1655
1656        // Normal positive timestamps round-trip exactly.
1657        assert_eq!(
1658            asn1_time_to_system_time(asn1(1_700_000_000)),
1659            UNIX_EPOCH + Duration::from_secs(1_700_000_000)
1660        );
1661
1662        // The ASN.1 maximum (9999-12-31) is representable and preserved.
1663        let max = i64::try_from(MAX_ASN1_TIMESTAMP_SECS).expect("fits in i64");
1664        assert_eq!(
1665            asn1_time_to_system_time(asn1(max)),
1666            UNIX_EPOCH + Duration::from_secs(MAX_ASN1_TIMESTAMP_SECS)
1667        );
1668    }
1669
1670    #[test]
1671    fn host_semaphore_evicts_idle_at_cap() {
1672        let mut map = HashMap::new();
1673        for i in 0..4 {
1674            // Dropped immediately: only the map holds each semaphore (idle).
1675            drop(
1676                acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 4)
1677                    .expect("under cap"),
1678            );
1679        }
1680        assert_eq!(map.len(), 4);
1681
1682        // At the cap, a NEW host must succeed by evicting idle entries —
1683        // the cap error is not sticky.
1684        let sem = acquire_host_semaphore(&mut map, "new-host.example", 4)
1685            .expect("idle eviction frees space for a new host");
1686        assert!(map.contains_key("new-host.example"));
1687        drop(sem);
1688    }
1689
1690    #[test]
1691    fn host_semaphore_keeps_inflight_at_cap() {
1692        let mut map = HashMap::new();
1693        // Held across the cap check: simulates an in-flight fetch.
1694        let inflight = acquire_host_semaphore(&mut map, "busy.example", 3).expect("under cap");
1695        for i in 0..2 {
1696            drop(
1697                acquire_host_semaphore(&mut map, &format!("idle-{i}.example"), 3)
1698                    .expect("under cap"),
1699            );
1700        }
1701        assert_eq!(map.len(), 3);
1702
1703        drop(
1704            acquire_host_semaphore(&mut map, "new-host.example", 3)
1705                .expect("idle entries evicted while in-flight survives"),
1706        );
1707        assert!(
1708            map.contains_key("busy.example"),
1709            "in-flight host must survive eviction"
1710        );
1711        assert!(map.contains_key("new-host.example"));
1712        drop(inflight);
1713    }
1714
1715    #[test]
1716    fn host_semaphore_cap_error_when_all_inflight() {
1717        let mut map = HashMap::new();
1718        let held: Vec<_> = (0..2)
1719            .map(|i| {
1720                acquire_host_semaphore(&mut map, &format!("busy-{i}.example"), 2)
1721                    .expect("under cap")
1722            })
1723            .collect();
1724
1725        let result = acquire_host_semaphore(&mut map, "new-host.example", 2);
1726        assert!(
1727            result.is_err(),
1728            "cap must still reject when every entry has an in-flight fetch"
1729        );
1730        drop(held);
1731    }
1732}