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 crate::config::{ClientAuthConfig, TlsConfig, TlsVersion};
7use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
8use std::sync::{Arc, OnceLock};
9
10/// Cached native root certificates.
11/// Always stores Ok; empty vec means no certs found (warned, not errored).
12static NATIVE_ROOTS_CACHE: OnceLock<Vec<CertificateDer<'static>>> = OnceLock::new();
13
14/// Counter for test verification that the loader only runs once.
15#[cfg(test)]
16static LOAD_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
17
18/// Load native root certificates from the OS certificate store.
19///
20/// This function is called once and the result is cached for subsequent calls.
21/// Returns Ok with potentially empty vec; missing certs are warned, not errored.
22fn load_native_certs_inner() -> Vec<CertificateDer<'static>> {
23 #[cfg(test)]
24 LOAD_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
25
26 let result = rustls_native_certs::load_native_certs();
27
28 // Log any errors encountered during loading
29 if !result.errors.is_empty() {
30 for err in &result.errors {
31 tracing::warn!(error = %err, "error loading native root certificate");
32 }
33 }
34
35 let certs: Vec<CertificateDer<'static>> = result.certs;
36
37 if certs.is_empty() {
38 tracing::warn!("no native root CA certificates found");
39 } else {
40 tracing::debug!(count = certs.len(), "loaded native root certificates");
41 }
42
43 certs
44}
45
46/// Get cached native root certificates.
47///
48/// Returns a reference to the cached certificates (may be empty).
49/// The certificates are loaded lazily on first call and cached for all subsequent calls.
50pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
51 NATIVE_ROOTS_CACHE
52 .get_or_init(load_native_certs_inner)
53 .as_slice()
54}
55
56/// Get the crypto provider for TLS connections.
57///
58/// This function follows the reqwest pattern:
59/// 1. Check if a default provider is already installed globally
60/// 2. If yes, use that (respects user configuration)
61/// 3. If no, create a new aws-lc-rs / corecrypto / AWS-LC FIPS provider
62/// **without** installing it globally
63///
64/// This avoids global state mutation and is safe to call from multiple
65/// threads.
66///
67/// ## Two-providers caveat (non-FIPS only)
68///
69/// The fallback at step (3) does **not** call `install_default()`. If
70/// `toolkit::bootstrap::init_crypto_provider` (the canonical install
71/// site) has not yet run, every call into `get_crypto_provider()` will
72/// rebuild a provider — and `CryptoProvider::get_default()` continues
73/// to observe the absence. In practice this is benign because each
74/// build returns an `Arc` over the same gear-level statics inside
75/// the underlying provider crate (corecrypto's `default_provider()`
76/// itself caches a process-wide `Arc<CryptoProvider>`, so the
77/// "concurrent providers" are byte-identical handles), but it is **not
78/// the same** as having a global default installed: code paths that
79/// later call `get_default()` (e.g. rustls internals that re-detect)
80/// will still see `None`.
81///
82/// **The canonical entry point is `toolkit::bootstrap::init_crypto_provider`**.
83/// Callers outside the bootstrap path (probe binaries, ad-hoc tests)
84/// should invoke it first. The fallback here is a safety net for code
85/// that genuinely cannot run bootstrap, not a substitute for it.
86///
87/// Under `--features fips`, `build_client_config` bypasses this fallback
88/// entirely and instead returns [`TlsConfigError::NoCryptoProvider`] when
89/// no global provider is installed — silently constructing an uninstalled
90/// FIPS provider would mask a misconfigured bootstrap.
91// Under `--features fips`, `build_client_config` no longer routes through this
92// function — it goes straight to `CryptoProvider::get_default()` and fails
93// closed when none is installed. The function still exists for the non-FIPS
94// path; suppress the unused-function lint for the FIPS build.
95#[cfg_attr(feature = "fips", allow(dead_code))]
96pub fn get_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
97 rustls::crypto::CryptoProvider::get_default()
98 .cloned()
99 .unwrap_or_else(|| {
100 // Provider selection mirrors `toolkit::bootstrap::init_crypto_provider`:
101 // - fips + macOS → Apple corecrypto, TLS 1.3-only (via the
102 // corecrypto-provider's own `fips` feature
103 // forwarded by toolkit-http's `fips`).
104 // `default_provider()` under `feature = "fips"`
105 // is aliased to `fips_provider()` semantics by
106 // the corecrypto crate itself — same single-
107 // entry-point pattern as `rustls-cng-crypto`.
108 // - fips + Windows → Windows CNG (FIPS-Approved set).
109 // No `fips::enabled()` re-check here: that
110 // gate is owned by `init_crypto_provider`
111 // and runs once at startup.
112 // - fips + other → AWS-LC FIPS.
113 // - non-fips → AWS-LC default.
114 #[cfg(all(feature = "fips", target_os = "macos"))]
115 {
116 Arc::new(rustls_corecrypto_provider::default_provider())
117 }
118 #[cfg(all(feature = "fips", target_os = "windows"))]
119 {
120 let provider = rustls_cng_crypto::fips_provider();
121 assert!(
122 !provider.cipher_suites.is_empty(),
123 "Windows is not in FIPS mode (FipsAlgorithmPolicy != 1). \
124 Enable system-wide FIPS via Group Policy and reboot, \
125 or call toolkit::bootstrap::init_crypto_provider() first \
126 for the canonical fail-closed path."
127 );
128 Arc::new(provider)
129 }
130 #[cfg(all(feature = "fips", not(any(target_os = "macos", target_os = "windows"))))]
131 {
132 Arc::new(rustls::crypto::default_fips_provider())
133 }
134 #[cfg(not(feature = "fips"))]
135 {
136 Arc::new(rustls::crypto::aws_lc_rs::default_provider())
137 }
138 })
139}
140
141/// Error type returned by the fallible TLS-config builders in this gear.
142///
143/// The `Other` variant carries a boxed `dyn Error` so the source-error chain
144/// from rustls (and any future foreign error) is preserved end-to-end —
145/// downstream `HttpError::Tls(_)` wraps this and reports a proper `.source()`.
146#[derive(Debug, thiserror::Error)]
147#[non_exhaustive]
148pub enum TlsConfigError {
149 /// `rustls::crypto::CryptoProvider::get_default()` returned `None` when a
150 /// FIPS-mode TLS config was requested. Under `--features fips` the
151 /// crypto provider must be installed up-front via
152 /// `toolkit::bootstrap::init_crypto_provider`; falling back to an
153 /// uninstalled provider here would silently bypass the canonical
154 /// install path and yield a config whose FIPS-validation status is
155 /// indeterminate.
156 #[error(
157 "rustls CryptoProvider has not been installed; call \
158 toolkit::bootstrap::init_crypto_provider() before building any TLS \
159 config under --features fips"
160 )]
161 NoCryptoProvider,
162
163 /// `apply_fips_hardening` rejected the freshly-built `ClientConfig`
164 /// because `ClientConfig::fips()` reported `false`. Carries the
165 /// human-readable diagnostic. Under normal bootstrap flow the
166 /// provider-level witness is already asserted by
167 /// `init_crypto_provider`; this error surfaces per-config issues
168 /// (e.g. missing `require_ems`) or a missed `init_crypto_provider()`
169 /// call.
170 #[error("{0}")]
171 FipsHardeningFailed(String),
172
173 /// Catch-all for foreign errors (`rustls::Error`, etc.) propagated
174 /// via `?`. Marked `#[error(transparent)]` so the `Display` and
175 /// `source()` impls forward to the inner error unchanged.
176 #[error(transparent)]
177 Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
178}
179
180/// Test-only auto-install of the platform-appropriate FIPS crypto provider.
181///
182/// Gated on `cfg(all(test, feature = "fips"))` — `cfg(test)` is only set when
183/// compiling the crate's own unit-test target, NOT when compiling the lib for
184/// use by integration tests under `tests/`. The regression test in
185/// `tests/no_crypto_provider_fips.rs` therefore sees a clean process state and
186/// can still assert `TlsConfigError::NoCryptoProvider`.
187///
188/// Forced from inside `build_client_config` so every TLS-config-building
189/// entry point (`webpki_roots_client_config`, `native_roots_client_config`,
190/// and `HttpClientBuilder::build`) auto-installs uniformly. nextest's default
191/// "process-per-test" execution means a builder-level `LazyLock` would not
192/// cover tests that bypass the builder.
193#[cfg(all(test, feature = "fips"))]
194mod fips_test_provider {
195 use std::sync::LazyLock;
196
197 pub(super) static INSTALL: LazyLock<()> = LazyLock::new(|| {
198 // `install_default()` returns `Err` if a provider is already installed
199 // (benign here); drop the result. `drop` rather than `let _ =`
200 // satisfies clippy::let_underscore_must_use.
201 #[cfg(target_os = "macos")]
202 drop(rustls_corecrypto_provider::default_provider().install_default());
203 #[cfg(target_os = "windows")]
204 drop(rustls_cng_crypto::fips_provider().install_default());
205 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
206 drop(rustls::crypto::default_fips_provider().install_default());
207 });
208}
209
210/// Build a rustls `ClientConfig` from the given root store and [`TlsConfig`].
211///
212/// `tls.min_version` selects the advertised protocol versions (see
213/// [`protocol_versions`]) and `tls.client_auth`, when set, installs a
214/// mutual-TLS client certificate (see [`load_client_auth`]).
215///
216/// Under the `fips` feature, applies [`apply_fips_hardening`] which:
217/// 1. forces `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5), and
218/// 2. verifies `config.fips() == true` and returns `Err` otherwise.
219///
220/// The bootstrap `assert!` in `init_crypto_provider` is the primary
221/// guard against a non-FIPS provider; this TLS-layer check is
222/// defence-in-depth for per-config settings (`require_ems`, protocol
223/// versions) that also affect `ClientConfig::fips()`.
224fn build_client_config(
225 root_store: rustls::RootCertStore,
226 tls: &TlsConfig,
227) -> Result<rustls::ClientConfig, TlsConfigError> {
228 // Test-only: ensure the platform-appropriate FIPS crypto provider is
229 // installed before this funnel runs. The fail-closed path below requires
230 // `toolkit::bootstrap::init_crypto_provider` to have run in production;
231 // in-crate tests that don't go through bootstrap (most of them) would
232 // otherwise hit `NoCryptoProvider`. Placed here rather than in the
233 // builder so it covers every TLS-config-building entry point uniformly
234 // (`webpki_roots_client_config`, `native_roots_client_config`, and any
235 // future addition). `cfg(test)` is set only for in-crate unit tests, NOT
236 // for integration tests under `tests/` — so
237 // `tests/no_crypto_provider_fips.rs` still sees a clean process state.
238 #[cfg(all(test, feature = "fips"))]
239 std::sync::LazyLock::force(&fips_test_provider::INSTALL);
240
241 // Under `--features fips` the crypto provider MUST have been installed
242 // up-front via `toolkit::bootstrap::init_crypto_provider` — otherwise
243 // `get_crypto_provider()`'s fallback would mint an *uninstalled* provider
244 // whose FIPS-validation status is indeterminate from the rustls global's
245 // point of view. Fail closed instead.
246 //
247 // The non-FIPS branch preserves the historical infallible fallback
248 // (see `get_crypto_provider` docstring).
249 #[cfg(feature = "fips")]
250 let provider = rustls::crypto::CryptoProvider::get_default()
251 .cloned()
252 .ok_or(TlsConfigError::NoCryptoProvider)?;
253 #[cfg(not(feature = "fips"))]
254 let provider = get_crypto_provider();
255
256 let roots_builder = rustls::ClientConfig::builder_with_provider(provider)
257 .with_protocol_versions(protocol_versions(tls.min_version))
258 .map_err(|e| TlsConfigError::Other(Box::new(e)))?
259 .with_root_certificates(root_store);
260
261 // Plumb optional mutual-TLS client identity. PEM material is read and
262 // parsed here (not at config-construction time) so IO/parse failures
263 // surface as a recoverable `TlsConfigError` at client-build time.
264 #[allow(unused_mut)]
265 let mut config = match &tls.client_auth {
266 Some(auth) => {
267 let (cert_chain, key) = load_client_auth(auth)?;
268 roots_builder
269 .with_client_auth_cert(cert_chain, key)
270 .map_err(|e| TlsConfigError::Other(Box::new(e)))?
271 }
272 None => roots_builder.with_no_client_auth(),
273 };
274
275 #[cfg(feature = "fips")]
276 {
277 apply_fips_hardening(&mut config)?;
278 }
279
280 Ok(config)
281}
282
283/// Map a [`TlsVersion`] floor onto the rustls protocol-version slice.
284///
285/// `Tls12` advertises both TLS 1.2 and TLS 1.3 (equivalent to the previous
286/// `with_safe_default_protocol_versions()`); `Tls13` advertises TLS 1.3 only.
287/// The returned slices are `const` items and therefore inherently `'static` —
288/// every element is a reference to a `rustls::version::*` static.
289fn protocol_versions(min: TlsVersion) -> &'static [&'static rustls::SupportedProtocolVersion] {
290 const TLS12_AND_13: &[&rustls::SupportedProtocolVersion] =
291 &[&rustls::version::TLS13, &rustls::version::TLS12];
292 const TLS13_ONLY: &[&rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
293 match min {
294 TlsVersion::Tls12 => TLS12_AND_13,
295 TlsVersion::Tls13 => TLS13_ONLY,
296 }
297}
298
299/// Load a PEM-encoded client certificate chain and private key for mutual TLS.
300///
301/// Reads both files referenced by [`ClientAuthConfig`], returning a
302/// [`TlsConfigError::Other`] (boxed, with path context) on any IO or parse
303/// failure or when the certificate chain is empty.
304fn load_client_auth(
305 auth: &ClientAuthConfig,
306) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), TlsConfigError> {
307 // `pem_file_iter` reports file-open errors eagerly and per-section parse
308 // errors lazily from the iterator; map both to the same path-context error.
309 let cert_err = |e: rustls_pki_types::pem::Error| {
310 TlsConfigError::Other(
311 format!(
312 "failed to load client certificate chain from {}: {e}",
313 auth.cert_chain.display()
314 )
315 .into(),
316 )
317 };
318 let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(&auth.cert_chain)
319 .map_err(cert_err)?
320 .collect::<Result<Vec<_>, _>>()
321 .map_err(cert_err)?;
322
323 if cert_chain.is_empty() {
324 return Err(TlsConfigError::Other(
325 format!(
326 "client certificate chain at {} contained no certificates",
327 auth.cert_chain.display()
328 )
329 .into(),
330 ));
331 }
332
333 // Deliberately do NOT interpolate the underlying parse error here: it is
334 // produced from private-key bytes and must never reach logs. The path is
335 // safe to include and is sufficient to diagnose IO/format problems.
336 let key = PrivateKeyDer::from_pem_file(&auth.key).map_err(|_| {
337 TlsConfigError::Other(
338 format!(
339 "failed to load client private key from {} (IO or PEM parse error)",
340 auth.key.display()
341 )
342 .into(),
343 )
344 })?;
345
346 Ok((cert_chain, key))
347}
348
349/// Apply FIPS-mode hardening to a freshly-built `ClientConfig`:
350/// * set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5 — required for
351/// `ClientConfig::fips()` to consider TLS 1.2 sessions FIPS-compliant
352/// when the EMS extension is honoured by the peer).
353/// * verify the full FIPS chain reports `fips() == true`. If not, return
354/// `Err` — the bootstrap `assert!` in `init_crypto_provider` already
355/// guarantees the provider reports `fips() == true`, so a failure here
356/// indicates a per-config issue (e.g. missing `require_ems` or
357/// restricted protocol versions) rather than a provider-level problem.
358#[cfg(feature = "fips")]
359fn apply_fips_hardening(cfg: &mut rustls::ClientConfig) -> Result<(), TlsConfigError> {
360 cfg.require_ems = true;
361 if !cfg.fips() {
362 return Err(TlsConfigError::FipsHardeningFailed(
363 "TLS ClientConfig does not advertise FIPS after enabling require_ems. \
364 The bootstrap assert in init_crypto_provider should have caught a \
365 non-FIPS provider at startup; if you see this error the provider is \
366 FIPS-OK but a per-config setting (protocol versions, require_ems) is \
367 preventing ClientConfig::fips() from reporting true. \
368 If init_crypto_provider was not called, call it before building any \
369 TLS config."
370 .to_owned(),
371 ));
372 }
373 Ok(())
374}
375
376/// Build a rustls `ClientConfig` using the cached native root certificates.
377///
378/// # Errors
379///
380/// Returns an error if no valid root certificates are available:
381/// - OS certificate store is empty
382/// - All certificates failed to parse
383///
384/// This fail-fast behavior ensures TLS configuration errors are caught at client
385/// construction time rather than failing later during TLS handshakes.
386pub fn native_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
387 let certs = native_root_certs();
388
389 let mut root_store = rustls::RootCertStore::empty();
390
391 if certs.is_empty() {
392 return Err(TlsConfigError::Other(
393 "no native root CA certificates found in OS certificate store".into(),
394 ));
395 }
396
397 let (added, ignored) = root_store.add_parsable_certificates(certs.iter().cloned());
398
399 if ignored > 0 {
400 tracing::warn!(
401 added = added,
402 ignored = ignored,
403 "some native root certificates could not be parsed"
404 );
405 }
406
407 if added == 0 {
408 return Err(TlsConfigError::Other(
409 format!(
410 "no valid native root CA certificates parsed (found {}, all {} failed to parse)",
411 certs.len(),
412 ignored
413 )
414 .into(),
415 ));
416 }
417
418 build_client_config(root_store, tls)
419}
420
421/// Build a rustls `ClientConfig` using Mozilla's webpki-roots trust anchors.
422///
423/// Under the `fips` feature, `require_ems` is forced on (see
424/// [`build_client_config`]). This is the FIPS-conformant counterpart to the
425/// `hyper_rustls::HttpsConnectorBuilder::with_provider_and_webpki_roots`
426/// one-liner — we must build the config ourselves so we can flip the EMS bit.
427///
428/// # Errors
429///
430/// See [`custom_roots_client_config`].
431pub fn webpki_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
432 let mut root_store = rustls::RootCertStore::empty();
433 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
434 build_client_config(root_store, tls)
435}
436
437/// Build a rustls `ClientConfig` trusting exactly `root_store` (e.g. a
438/// private CA for internal upstreams).
439///
440/// Same provider selection and FIPS hardening as the other builders.
441///
442/// # Errors
443///
444/// [`TlsConfigError::NoCryptoProvider`] under `fips` when no provider was
445/// installed, [`TlsConfigError::FipsHardeningFailed`] when the config does
446/// not report FIPS mode, or [`TlsConfigError::Other`] for an invalid
447/// protocol-version or client-certificate setup.
448pub fn custom_roots_client_config(
449 root_store: rustls::RootCertStore,
450 tls: &TlsConfig,
451) -> Result<rustls::ClientConfig, TlsConfigError> {
452 build_client_config(root_store, tls)
453}
454
455#[cfg(test)]
456#[cfg_attr(coverage_nightly, coverage(off))]
457mod tests {
458 use super::*;
459 use std::sync::atomic::Ordering;
460
461 /// Test that native root certs are cached after the first load.
462 ///
463 /// NOTE: This test verifies "at most one load" rather than "exactly one load"
464 /// because `LOAD_COUNT` is a global atomic shared across all tests. If another
465 /// test (or parallel test) calls `native_root_certs()` before this test runs,
466 /// the cache will already be initialized and `final_count - initial_count`
467 /// will be 0. The assertion handles this correctly.
468 #[test]
469 fn test_native_roots_cached() {
470 // Capture count before our calls (may be non-zero if cache already initialized)
471 let initial_count = LOAD_COUNT.load(Ordering::SeqCst);
472
473 // First call - loads if not cached, otherwise uses existing cache
474 let result1 = native_root_certs();
475
476 // Second call should use cache
477 let result2 = native_root_certs();
478
479 // Third call should also use cache
480 let result3 = native_root_certs();
481
482 // Verify loader was called at most once more than initial (0 if already cached, 1 if we triggered the load)
483 let final_count = LOAD_COUNT.load(Ordering::SeqCst);
484 assert!(
485 final_count <= initial_count + 1,
486 "loader should run at most once, but ran {} times since test start",
487 final_count - initial_count
488 );
489
490 // Results should be consistent (same slice pointer)
491 assert_eq!(result1.len(), result2.len());
492 assert_eq!(result2.len(), result3.len());
493 assert!(std::ptr::eq(result1, result2), "should return same slice");
494 assert!(std::ptr::eq(result2, result3), "should return same slice");
495 }
496
497 #[test]
498 fn test_native_roots_client_config() {
499 // Building client config succeeds if native roots are available
500 // (which they should be on most CI/dev systems)
501 // On systems without native certs, this returns Err (expected behavior)
502 let result = native_roots_client_config(&TlsConfig::default());
503
504 // Log the result for debugging in CI
505 match &result {
506 Ok(_) => tracing::debug!("native_roots_client_config succeeded"),
507 Err(e) => {
508 tracing::debug!(error = %e, "native_roots_client_config failed (expected on minimal containers)");
509 }
510 }
511
512 // We don't assert success because CI containers may not have OS certs.
513 // The important thing is it doesn't panic.
514 }
515
516 /// `webpki_roots_client_config()` must always build successfully — the
517 /// trust store comes from a static `webpki-roots::TLS_SERVER_ROOTS` slice
518 /// that is non-empty on every supported platform. Catches a silent
519 /// regression if the `webpki-roots` crate ever renames its constant or
520 /// changes its `extend`-able item type.
521 ///
522 /// Asserts behavioural properties this crate actually controls:
523 /// 1. The config carries the crypto provider returned by
524 /// `get_crypto_provider()` (non-empty cipher-suite list).
525 /// 2. The provider's cipher-suite set is non-empty — proves we did
526 /// not accidentally build a config whose negotiation would fail
527 /// with `NoCipherSuitesInCommon` against every peer.
528 /// 3. The provider's kx-group list is non-empty — same reasoning.
529 ///
530 /// (Asserting `alpn_protocols.len() == 0` was a rustls default we do
531 /// not control; replaced.)
532 #[test]
533 fn test_webpki_roots_client_config_builds() {
534 let cfg = webpki_roots_client_config(&TlsConfig::default())
535 .expect("webpki roots must always build");
536 let provider = cfg.crypto_provider();
537 assert!(
538 !provider.cipher_suites.is_empty(),
539 "TLS client config must carry a non-empty cipher-suite list"
540 );
541 assert!(
542 !provider.kx_groups.is_empty(),
543 "TLS client config must carry a non-empty kx-group list"
544 );
545 }
546
547 /// When built with `--features fips`, `build_client_config` MUST:
548 /// 1. Set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5)
549 /// 2. Make `config.fips()` return true (full FIPS chain)
550 ///
551 /// Without this, an Apple-corecrypto-backed FIPS build silently advertises
552 /// `config.fips() == false` because rustls's stock `require_ems` default
553 /// is gated on rustls's *own* `fips` feature — which we deliberately keep
554 /// off on macOS to avoid pulling the AWS-LC FIPS gear.
555 ///
556 /// Exercised via the public `webpki_roots_client_config()` (which routes
557 /// through `build_client_config`); calling it avoids a hard dependency on
558 /// the OS keychain that `native_roots_client_config` carries.
559 ///
560 /// Run via `cargo test -p cf-gears-toolkit-http --features fips`.
561 /// `build_client_config` auto-installs the platform FIPS provider in
562 /// test mode (see `fips_test_provider` gear) so this test does not
563 /// need its own explicit install.
564 #[test]
565 #[cfg(feature = "fips")]
566 fn fips_client_config_requires_ems_and_advertises_fips() {
567 let cfg = webpki_roots_client_config(&TlsConfig::default()).expect("build under fips");
568 assert!(cfg.require_ems, "fips build must set require_ems = true");
569 assert!(
570 cfg.fips(),
571 "fips build must yield ClientConfig::fips() == true (full provider chain)"
572 );
573 }
574
575 /// `protocol_versions` maps the `min_version` knob onto the rustls
576 /// protocol-version slice: `Tls12` advertises both 1.2 and 1.3 (the
577 /// previous `with_safe_default_protocol_versions()` behaviour) and `Tls13`
578 /// advertises 1.3 only.
579 #[test]
580 fn protocol_versions_maps_min_version() {
581 let v12 = protocol_versions(TlsVersion::Tls12);
582 assert_eq!(v12.len(), 2, "Tls12 floor must advertise TLS 1.2 and 1.3");
583 assert!(
584 v12.iter()
585 .any(|v| v.version == rustls::ProtocolVersion::TLSv1_2)
586 );
587 assert!(
588 v12.iter()
589 .any(|v| v.version == rustls::ProtocolVersion::TLSv1_3)
590 );
591
592 let v13 = protocol_versions(TlsVersion::Tls13);
593 assert_eq!(v13.len(), 1, "Tls13 floor must advertise TLS 1.3 only");
594 assert_eq!(v13[0].version, rustls::ProtocolVersion::TLSv1_3);
595 }
596
597 /// A `min_version: Tls13` config still builds a valid `ClientConfig`
598 /// through the public webpki entry point.
599 #[test]
600 fn webpki_client_config_builds_with_tls13_floor() {
601 let tls = TlsConfig {
602 min_version: TlsVersion::Tls13,
603 ..TlsConfig::default()
604 };
605 let cfg = webpki_roots_client_config(&tls).expect("tls 1.3 floor must build");
606 assert!(!cfg.crypto_provider().cipher_suites.is_empty());
607 }
608
609 /// A `client_auth` pointing at non-existent PEM files must fail closed at
610 /// build time with a `TlsConfigError` whose message names the missing path
611 /// — not panic, and not silently fall back to no-client-auth.
612 #[test]
613 fn client_auth_missing_files_errors() {
614 let tls = TlsConfig {
615 client_auth: Some(ClientAuthConfig::new(
616 "/nonexistent/toolkit-http/cert.pem",
617 "/nonexistent/toolkit-http/key.pem",
618 )),
619 ..TlsConfig::default()
620 };
621 let err =
622 webpki_roots_client_config(&tls).expect_err("missing client-auth files must error");
623 let msg = err.to_string();
624 assert!(
625 msg.contains("client certificate chain") && msg.contains("cert.pem"),
626 "error should name the missing cert path, got: {msg}"
627 );
628 }
629
630 /// Generate a self-signed ECDSA P-256 / SHA-256 identity and write it to a
631 /// fresh temp directory as PEM. Returns the [`tempfile::TempDir`] (which the
632 /// caller must keep alive — dropping it removes the files, even on panic)
633 /// and a [`ClientAuthConfig`] pointing at the written cert-chain and key.
634 ///
635 /// ECDSA P-256 / SHA-256 is an explicitly FIPS-approved signature class, so
636 /// the same identity drives both the non-FIPS and FIPS code paths.
637 fn write_self_signed_identity() -> (tempfile::TempDir, ClientAuthConfig) {
638 use std::io::Write;
639
640 let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
641 .expect("generate ECDSA P-256 key pair");
642 let cert = rcgen::CertificateParams::new(vec!["client.test".to_owned()])
643 .expect("certificate params")
644 .self_signed(&key_pair)
645 .expect("self-sign certificate");
646
647 let dir = tempfile::tempdir().expect("create temp dir");
648 let cert_path = dir.path().join("client_cert.pem");
649 let key_path = dir.path().join("client_key.pem");
650
651 std::fs::File::create(&cert_path)
652 .and_then(|mut f| f.write_all(cert.pem().as_bytes()))
653 .expect("write cert pem");
654 std::fs::File::create(&key_path)
655 .and_then(|mut f| f.write_all(key_pair.serialize_pem().as_bytes()))
656 .expect("write key pem");
657
658 (dir, ClientAuthConfig::new(cert_path, key_path))
659 }
660
661 /// Happy-path mutual TLS: a freshly generated self-signed cert + key load
662 /// from PEM files and produce a `ClientConfig` carrying a client-auth
663 /// resolver. Gated off under `fips`; the FIPS path is pinned separately by
664 /// `client_auth_under_fips_fails_closed`.
665 #[test]
666 #[cfg(not(feature = "fips"))]
667 fn client_auth_pem_round_trips() {
668 // `_dir` keeps the temp directory (and its PEM files) alive for the
669 // duration of the test; it is removed on drop, even if an assert panics.
670 let (_dir, client_auth) = write_self_signed_identity();
671 let tls = TlsConfig {
672 client_auth: Some(client_auth),
673 ..TlsConfig::default()
674 };
675
676 let result = webpki_roots_client_config(&tls);
677 assert!(
678 result.is_ok(),
679 "client-auth config from valid PEM must build: {:?}",
680 result.err()
681 );
682 }
683
684 /// Mutual TLS under `--features fips` must fail *closed*, never panic. Both
685 /// outcomes below are FIPS-correct and which one occurs is provider-
686 /// dependent (macOS corecrypto / Windows CNG / Linux AWS-LC):
687 /// * `Ok(cfg)` with `cfg.fips() == true` — the active provider witnessed
688 /// the ECDSA P-256 client-auth signer as FIPS-approved; or
689 /// * `Err(TlsConfigError::FipsHardeningFailed(_))` — `apply_fips_hardening`
690 /// rejected a config it could not prove `ClientConfig::fips()` for.
691 ///
692 /// Any other result (panic, a different error variant, or `Ok` with
693 /// `fips() == false`) is a regression in the FIPS mTLS path.
694 #[test]
695 #[cfg(feature = "fips")]
696 fn client_auth_under_fips_fails_closed() {
697 let (_dir, client_auth) = write_self_signed_identity();
698 let tls = TlsConfig {
699 client_auth: Some(client_auth),
700 ..TlsConfig::default()
701 };
702
703 match webpki_roots_client_config(&tls) {
704 Ok(cfg) => assert!(
705 cfg.fips(),
706 "fips build returned Ok but ClientConfig::fips() == false: \
707 an mTLS config slipped past the FIPS witness"
708 ),
709 // Acceptable: failed closed because fips() could not be proven.
710 Err(TlsConfigError::FipsHardeningFailed(_)) => {}
711 Err(other) => {
712 panic!("unexpected non-fail-closed error on fips mTLS path: {other}")
713 }
714 }
715 }
716}