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