Skip to main content

toolkit_http/
tls.rs

1//! TLS utilities for the HTTP client.
2//!
3//! This gear provides cached loading of native root certificates to avoid
4//! repeated OS certificate store lookups (which can be slow on some platforms).
5
6use rustls_pki_types::CertificateDer;
7use std::sync::{Arc, OnceLock};
8
9/// Cached native root certificates.
10/// Always stores Ok; empty vec means no certs found (warned, not errored).
11static NATIVE_ROOTS_CACHE: OnceLock<Vec<CertificateDer<'static>>> = OnceLock::new();
12
13/// Counter for test verification that the loader only runs once.
14#[cfg(test)]
15static LOAD_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16
17/// Load native root certificates from the OS certificate store.
18///
19/// This function is called once and the result is cached for subsequent calls.
20/// Returns Ok with potentially empty vec; missing certs are warned, not errored.
21fn load_native_certs_inner() -> Vec<CertificateDer<'static>> {
22    #[cfg(test)]
23    LOAD_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
24
25    let result = rustls_native_certs::load_native_certs();
26
27    // Log any errors encountered during loading
28    if !result.errors.is_empty() {
29        for err in &result.errors {
30            tracing::warn!(error = %err, "error loading native root certificate");
31        }
32    }
33
34    let certs: Vec<CertificateDer<'static>> = result.certs;
35
36    if certs.is_empty() {
37        tracing::warn!("no native root CA certificates found");
38    } else {
39        tracing::debug!(count = certs.len(), "loaded native root certificates");
40    }
41
42    certs
43}
44
45/// Get cached native root certificates.
46///
47/// Returns a reference to the cached certificates (may be empty).
48/// The certificates are loaded lazily on first call and cached for all subsequent calls.
49pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
50    NATIVE_ROOTS_CACHE
51        .get_or_init(load_native_certs_inner)
52        .as_slice()
53}
54
55/// Get the crypto provider for TLS connections.
56///
57/// This function follows the reqwest pattern:
58/// 1. Check if a default provider is already installed globally
59/// 2. If yes, use that (respects user configuration)
60/// 3. If no, create a new aws-lc-rs / corecrypto / AWS-LC FIPS provider
61///    **without** installing it globally
62///
63/// This avoids global state mutation and is safe to call from multiple
64/// threads.
65///
66/// ## Two-providers caveat (non-FIPS only)
67///
68/// The fallback at step (3) does **not** call `install_default()`. If
69/// `toolkit::bootstrap::init_crypto_provider` (the canonical install
70/// site) has not yet run, every call into `get_crypto_provider()` will
71/// rebuild a provider — and `CryptoProvider::get_default()` continues
72/// to observe the absence. In practice this is benign because each
73/// build returns an `Arc` over the same gear-level statics inside
74/// the underlying provider crate (corecrypto's `default_provider()`
75/// itself caches a process-wide `Arc<CryptoProvider>`, so the
76/// "concurrent providers" are byte-identical handles), but it is **not
77/// the same** as having a global default installed: code paths that
78/// later call `get_default()` (e.g. rustls internals that re-detect)
79/// will still see `None`.
80///
81/// **The canonical entry point is `toolkit::bootstrap::init_crypto_provider`**.
82/// Callers outside the bootstrap path (probe binaries, ad-hoc tests)
83/// should invoke it first. The fallback here is a safety net for code
84/// that genuinely cannot run bootstrap, not a substitute for it.
85///
86/// Under `--features fips`, `build_client_config` bypasses this fallback
87/// entirely and instead returns [`TlsConfigError::NoCryptoProvider`] when
88/// no global provider is installed — silently constructing an uninstalled
89/// FIPS provider would mask a misconfigured bootstrap.
90// Under `--features fips`, `build_client_config` no longer routes through this
91// function — it goes straight to `CryptoProvider::get_default()` and fails
92// closed when none is installed. The function still exists for the non-FIPS
93// path; suppress the unused-function lint for the FIPS build.
94#[cfg_attr(feature = "fips", allow(dead_code))]
95pub fn get_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
96    rustls::crypto::CryptoProvider::get_default()
97        .cloned()
98        .unwrap_or_else(|| {
99            // Provider selection mirrors `toolkit::bootstrap::init_crypto_provider`:
100            //   - fips + macOS    → Apple corecrypto, TLS 1.3-only (via the
101            //                       corecrypto-provider's own `fips` feature
102            //                       forwarded by toolkit-http's `fips`).
103            //                       `default_provider()` under `feature = "fips"`
104            //                       is aliased to `fips_provider()` semantics by
105            //                       the corecrypto crate itself — same single-
106            //                       entry-point pattern as `rustls-cng-crypto`.
107            //   - fips + Windows  → Windows CNG (FIPS-Approved set).
108            //                       No `fips::enabled()` re-check here: that
109            //                       gate is owned by `init_crypto_provider`
110            //                       and runs once at startup.
111            //   - fips + other    → AWS-LC FIPS.
112            //   - non-fips        → AWS-LC default.
113            #[cfg(all(feature = "fips", target_os = "macos"))]
114            {
115                Arc::new(rustls_corecrypto_provider::default_provider())
116            }
117            #[cfg(all(feature = "fips", target_os = "windows"))]
118            {
119                let provider = rustls_cng_crypto::fips_provider();
120                assert!(
121                    !provider.cipher_suites.is_empty(),
122                    "Windows is not in FIPS mode (FipsAlgorithmPolicy != 1). \
123                     Enable system-wide FIPS via Group Policy and reboot, \
124                     or call toolkit::bootstrap::init_crypto_provider() first \
125                     for the canonical fail-closed path."
126                );
127                Arc::new(provider)
128            }
129            #[cfg(all(feature = "fips", not(any(target_os = "macos", target_os = "windows"))))]
130            {
131                Arc::new(rustls::crypto::default_fips_provider())
132            }
133            #[cfg(not(feature = "fips"))]
134            {
135                Arc::new(rustls::crypto::aws_lc_rs::default_provider())
136            }
137        })
138}
139
140/// Error type returned by the fallible TLS-config builders in this gear.
141///
142/// The `Other` variant carries a boxed `dyn Error` so the source-error chain
143/// from rustls (and any future foreign error) is preserved end-to-end —
144/// downstream `HttpError::Tls(_)` wraps this and reports a proper `.source()`.
145#[derive(Debug, thiserror::Error)]
146#[non_exhaustive]
147pub enum TlsConfigError {
148    /// `rustls::crypto::CryptoProvider::get_default()` returned `None` when a
149    /// FIPS-mode TLS config was requested. Under `--features fips` the
150    /// crypto provider must be installed up-front via
151    /// `toolkit::bootstrap::init_crypto_provider`; falling back to an
152    /// uninstalled provider here would silently bypass the canonical
153    /// install path and yield a config whose FIPS-validation status is
154    /// indeterminate.
155    #[error(
156        "rustls CryptoProvider has not been installed; call \
157         toolkit::bootstrap::init_crypto_provider() before building any TLS \
158         config under --features fips"
159    )]
160    NoCryptoProvider,
161
162    /// `apply_fips_hardening` rejected the freshly-built `ClientConfig`
163    /// because `ClientConfig::fips()` reported `false`. Carries the
164    /// human-readable diagnostic. Under normal bootstrap flow the
165    /// provider-level witness is already asserted by
166    /// `init_crypto_provider`; this error surfaces per-config issues
167    /// (e.g. missing `require_ems`) or a missed `init_crypto_provider()`
168    /// call.
169    #[error("{0}")]
170    FipsHardeningFailed(String),
171
172    /// Catch-all for foreign errors (`rustls::Error`, etc.) propagated
173    /// via `?`. Marked `#[error(transparent)]` so the `Display` and
174    /// `source()` impls forward to the inner error unchanged.
175    #[error(transparent)]
176    Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
177}
178
179/// Test-only auto-install of the platform-appropriate FIPS crypto provider.
180///
181/// Gated on `cfg(all(test, feature = "fips"))` — `cfg(test)` is only set when
182/// compiling the crate's own unit-test target, NOT when compiling the lib for
183/// use by integration tests under `tests/`. The regression test in
184/// `tests/no_crypto_provider_fips.rs` therefore sees a clean process state and
185/// can still assert `TlsConfigError::NoCryptoProvider`.
186///
187/// Forced from inside `build_client_config` so every TLS-config-building
188/// entry point (`webpki_roots_client_config`, `native_roots_client_config`,
189/// and `HttpClientBuilder::build`) auto-installs uniformly. nextest's default
190/// "process-per-test" execution means a builder-level `LazyLock` would not
191/// cover tests that bypass the builder.
192#[cfg(all(test, feature = "fips"))]
193mod fips_test_provider {
194    use std::sync::LazyLock;
195
196    pub(super) static INSTALL: LazyLock<()> = LazyLock::new(|| {
197        // `install_default()` returns `Err` if a provider is already installed
198        // (benign here); drop the result. `drop` rather than `let _ =`
199        // satisfies clippy::let_underscore_must_use.
200        #[cfg(target_os = "macos")]
201        drop(rustls_corecrypto_provider::default_provider().install_default());
202        #[cfg(target_os = "windows")]
203        drop(rustls_cng_crypto::fips_provider().install_default());
204        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
205        drop(rustls::crypto::default_fips_provider().install_default());
206    });
207}
208
209/// Build a rustls `ClientConfig` from the given root store.
210///
211/// Under the `fips` feature, applies [`apply_fips_hardening`] which:
212///   1. forces `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5), and
213///   2. verifies `config.fips() == true` and returns `Err` otherwise.
214///
215/// The bootstrap `assert!` in `init_crypto_provider` is the primary
216/// guard against a non-FIPS provider; this TLS-layer check is
217/// defence-in-depth for per-config settings (`require_ems`, protocol
218/// versions) that also affect `ClientConfig::fips()`.
219fn build_client_config(
220    root_store: rustls::RootCertStore,
221) -> Result<rustls::ClientConfig, TlsConfigError> {
222    // Test-only: ensure the platform-appropriate FIPS crypto provider is
223    // installed before this funnel runs. The fail-closed path below requires
224    // `toolkit::bootstrap::init_crypto_provider` to have run in production;
225    // in-crate tests that don't go through bootstrap (most of them) would
226    // otherwise hit `NoCryptoProvider`. Placed here rather than in the
227    // builder so it covers every TLS-config-building entry point uniformly
228    // (`webpki_roots_client_config`, `native_roots_client_config`, and any
229    // future addition). `cfg(test)` is set only for in-crate unit tests, NOT
230    // for integration tests under `tests/` — so
231    // `tests/no_crypto_provider_fips.rs` still sees a clean process state.
232    #[cfg(all(test, feature = "fips"))]
233    std::sync::LazyLock::force(&fips_test_provider::INSTALL);
234
235    // Under `--features fips` the crypto provider MUST have been installed
236    // up-front via `toolkit::bootstrap::init_crypto_provider` — otherwise
237    // `get_crypto_provider()`'s fallback would mint an *uninstalled* provider
238    // whose FIPS-validation status is indeterminate from the rustls global's
239    // point of view. Fail closed instead.
240    //
241    // The non-FIPS branch preserves the historical infallible fallback
242    // (see `get_crypto_provider` docstring).
243    #[cfg(feature = "fips")]
244    let provider = rustls::crypto::CryptoProvider::get_default()
245        .cloned()
246        .ok_or(TlsConfigError::NoCryptoProvider)?;
247    #[cfg(not(feature = "fips"))]
248    let provider = get_crypto_provider();
249
250    #[allow(unused_mut)]
251    let mut config = rustls::ClientConfig::builder_with_provider(provider)
252        .with_safe_default_protocol_versions()
253        .map_err(|e| TlsConfigError::Other(Box::new(e)))?
254        .with_root_certificates(root_store)
255        .with_no_client_auth();
256
257    #[cfg(feature = "fips")]
258    {
259        apply_fips_hardening(&mut config)?;
260    }
261
262    Ok(config)
263}
264
265/// Apply FIPS-mode hardening to a freshly-built `ClientConfig`:
266///   * set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5 — required for
267///     `ClientConfig::fips()` to consider TLS 1.2 sessions FIPS-compliant
268///     when the EMS extension is honoured by the peer).
269///   * verify the full FIPS chain reports `fips() == true`. If not, return
270///     `Err` — the bootstrap `assert!` in `init_crypto_provider` already
271///     guarantees the provider reports `fips() == true`, so a failure here
272///     indicates a per-config issue (e.g. missing `require_ems` or
273///     restricted protocol versions) rather than a provider-level problem.
274#[cfg(feature = "fips")]
275fn apply_fips_hardening(cfg: &mut rustls::ClientConfig) -> Result<(), TlsConfigError> {
276    cfg.require_ems = true;
277    if !cfg.fips() {
278        return Err(TlsConfigError::FipsHardeningFailed(
279            "TLS ClientConfig does not advertise FIPS after enabling require_ems. \
280             The bootstrap assert in init_crypto_provider should have caught a \
281             non-FIPS provider at startup; if you see this error the provider is \
282             FIPS-OK but a per-config setting (protocol versions, require_ems) is \
283             preventing ClientConfig::fips() from reporting true. \
284             If init_crypto_provider was not called, call it before building any \
285             TLS config."
286                .to_owned(),
287        ));
288    }
289    Ok(())
290}
291
292/// Build a rustls `ClientConfig` using the cached native root certificates.
293///
294/// # Errors
295///
296/// Returns an error if no valid root certificates are available:
297/// - OS certificate store is empty
298/// - All certificates failed to parse
299///
300/// This fail-fast behavior ensures TLS configuration errors are caught at client
301/// construction time rather than failing later during TLS handshakes.
302pub fn native_roots_client_config() -> Result<rustls::ClientConfig, TlsConfigError> {
303    let certs = native_root_certs();
304
305    let mut root_store = rustls::RootCertStore::empty();
306
307    if certs.is_empty() {
308        return Err(TlsConfigError::Other(
309            "no native root CA certificates found in OS certificate store".into(),
310        ));
311    }
312
313    let (added, ignored) = root_store.add_parsable_certificates(certs.iter().cloned());
314
315    if ignored > 0 {
316        tracing::warn!(
317            added = added,
318            ignored = ignored,
319            "some native root certificates could not be parsed"
320        );
321    }
322
323    if added == 0 {
324        return Err(TlsConfigError::Other(
325            format!(
326                "no valid native root CA certificates parsed (found {}, all {} failed to parse)",
327                certs.len(),
328                ignored
329            )
330            .into(),
331        ));
332    }
333
334    build_client_config(root_store)
335}
336
337/// Build a rustls `ClientConfig` using Mozilla's webpki-roots trust anchors.
338///
339/// Under the `fips` feature, `require_ems` is forced on (see
340/// [`build_client_config`]). This is the FIPS-conformant counterpart to the
341/// `hyper_rustls::HttpsConnectorBuilder::with_provider_and_webpki_roots`
342/// one-liner — we must build the config ourselves so we can flip the EMS bit.
343pub fn webpki_roots_client_config() -> Result<rustls::ClientConfig, TlsConfigError> {
344    let mut root_store = rustls::RootCertStore::empty();
345    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
346    build_client_config(root_store)
347}
348
349#[cfg(test)]
350#[cfg_attr(coverage_nightly, coverage(off))]
351mod tests {
352    use super::*;
353    use std::sync::atomic::Ordering;
354
355    /// Test that native root certs are cached after the first load.
356    ///
357    /// NOTE: This test verifies "at most one load" rather than "exactly one load"
358    /// because `LOAD_COUNT` is a global atomic shared across all tests. If another
359    /// test (or parallel test) calls `native_root_certs()` before this test runs,
360    /// the cache will already be initialized and `final_count - initial_count`
361    /// will be 0. The assertion handles this correctly.
362    #[test]
363    fn test_native_roots_cached() {
364        // Capture count before our calls (may be non-zero if cache already initialized)
365        let initial_count = LOAD_COUNT.load(Ordering::SeqCst);
366
367        // First call - loads if not cached, otherwise uses existing cache
368        let result1 = native_root_certs();
369
370        // Second call should use cache
371        let result2 = native_root_certs();
372
373        // Third call should also use cache
374        let result3 = native_root_certs();
375
376        // Verify loader was called at most once more than initial (0 if already cached, 1 if we triggered the load)
377        let final_count = LOAD_COUNT.load(Ordering::SeqCst);
378        assert!(
379            final_count <= initial_count + 1,
380            "loader should run at most once, but ran {} times since test start",
381            final_count - initial_count
382        );
383
384        // Results should be consistent (same slice pointer)
385        assert_eq!(result1.len(), result2.len());
386        assert_eq!(result2.len(), result3.len());
387        assert!(std::ptr::eq(result1, result2), "should return same slice");
388        assert!(std::ptr::eq(result2, result3), "should return same slice");
389    }
390
391    #[test]
392    fn test_native_roots_client_config() {
393        // Building client config succeeds if native roots are available
394        // (which they should be on most CI/dev systems)
395        // On systems without native certs, this returns Err (expected behavior)
396        let result = native_roots_client_config();
397
398        // Log the result for debugging in CI
399        match &result {
400            Ok(_) => tracing::debug!("native_roots_client_config succeeded"),
401            Err(e) => {
402                tracing::debug!(error = %e, "native_roots_client_config failed (expected on minimal containers)");
403            }
404        }
405
406        // We don't assert success because CI containers may not have OS certs.
407        // The important thing is it doesn't panic.
408    }
409
410    /// `webpki_roots_client_config()` must always build successfully — the
411    /// trust store comes from a static `webpki-roots::TLS_SERVER_ROOTS` slice
412    /// that is non-empty on every supported platform. Catches a silent
413    /// regression if the `webpki-roots` crate ever renames its constant or
414    /// changes its `extend`-able item type.
415    ///
416    /// Asserts behavioural properties this crate actually controls:
417    ///   1. The config carries the crypto provider returned by
418    ///      `get_crypto_provider()` (non-empty cipher-suite list).
419    ///   2. The provider's cipher-suite set is non-empty — proves we did
420    ///      not accidentally build a config whose negotiation would fail
421    ///      with `NoCipherSuitesInCommon` against every peer.
422    ///   3. The provider's kx-group list is non-empty — same reasoning.
423    ///
424    /// (Asserting `alpn_protocols.len() == 0` was a rustls default we do
425    /// not control; replaced.)
426    #[test]
427    fn test_webpki_roots_client_config_builds() {
428        let cfg = webpki_roots_client_config().expect("webpki roots must always build");
429        let provider = cfg.crypto_provider();
430        assert!(
431            !provider.cipher_suites.is_empty(),
432            "TLS client config must carry a non-empty cipher-suite list"
433        );
434        assert!(
435            !provider.kx_groups.is_empty(),
436            "TLS client config must carry a non-empty kx-group list"
437        );
438    }
439
440    /// When built with `--features fips`, `build_client_config` MUST:
441    ///   1. Set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5)
442    ///   2. Make `config.fips()` return true (full FIPS chain)
443    ///
444    /// Without this, an Apple-corecrypto-backed FIPS build silently advertises
445    /// `config.fips() == false` because rustls's stock `require_ems` default
446    /// is gated on rustls's *own* `fips` feature — which we deliberately keep
447    /// off on macOS to avoid pulling the AWS-LC FIPS gear.
448    ///
449    /// Exercised via the public `webpki_roots_client_config()` (which routes
450    /// through `build_client_config`); calling it avoids a hard dependency on
451    /// the OS keychain that `native_roots_client_config` carries.
452    ///
453    /// Run via `cargo test -p cf-gears-toolkit-http --features fips`.
454    /// `build_client_config` auto-installs the platform FIPS provider in
455    /// test mode (see `fips_test_provider` gear) so this test does not
456    /// need its own explicit install.
457    #[test]
458    #[cfg(feature = "fips")]
459    fn fips_client_config_requires_ems_and_advertises_fips() {
460        let cfg = webpki_roots_client_config().expect("build under fips");
461        assert!(cfg.require_ems, "fips build must set require_ems = true");
462        assert!(
463            cfg.fips(),
464            "fips build must yield ClientConfig::fips() == true (full provider chain)"
465        );
466    }
467}