@@ -63,7 +63,7 @@ pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
6363/// This avoids global state mutation and is safe to call from multiple
6464/// threads.
6565///
66- /// ## Two-providers caveat
66+ /// ## Two-providers caveat (non-FIPS only)
6767///
6868/// The fallback at step (3) does **not** call `install_default()`. If
6969/// `modkit::bootstrap::init_crypto_provider` (the canonical install
@@ -82,6 +82,16 @@ pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
8282/// Callers outside the bootstrap path (probe binaries, ad-hoc tests)
8383/// should invoke it first. The fallback here is a safety net for code
8484/// 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) ) ]
8595pub fn get_crypto_provider ( ) -> Arc < rustls:: crypto:: CryptoProvider > {
8696 rustls:: crypto:: CryptoProvider :: get_default ( )
8797 . cloned ( )
@@ -127,11 +137,72 @@ pub fn get_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
127137 } )
128138}
129139
130- /// Type alias for the fallible TLS-config builders in this module. Using a
131- /// boxed `dyn Error` (rather than `String`) preserves the source-error chain
132- /// from rustls and from `apply_fips_hardening`, so downstream
133- /// `HttpError::Tls(_)` carries a proper `.source()`.
134- pub type TlsConfigError = Box < dyn std:: error:: Error + Send + Sync + ' static > ;
140+ /// Error type returned by the fallible TLS-config builders in this module.
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]
147+ pub 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+ /// `modkit::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+ modkit::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 — typically a witness mismatch
165+ /// (`cyberware_rustls_corecrypto_provider::oe::fips_witness_ok` on macOS)
166+ /// or a missed `init_crypto_provider()` call.
167+ #[ error( "{0}" ) ]
168+ FipsHardeningFailed ( String ) ,
169+
170+ /// Catch-all for foreign errors (`rustls::Error`, etc.) propagated
171+ /// via `?`. Marked `#[error(transparent)]` so the `Display` and
172+ /// `source()` impls forward to the inner error unchanged.
173+ #[ error( transparent) ]
174+ Other ( #[ from] Box < dyn std:: error:: Error + Send + Sync + ' static > ) ,
175+ }
176+
177+ /// Test-only auto-install of the platform-appropriate FIPS crypto provider.
178+ ///
179+ /// Gated on `cfg(all(test, feature = "fips"))` — `cfg(test)` is only set when
180+ /// compiling the crate's own unit-test target, NOT when compiling the lib for
181+ /// use by integration tests under `tests/`. The regression test in
182+ /// `tests/no_crypto_provider_fips.rs` therefore sees a clean process state and
183+ /// can still assert `TlsConfigError::NoCryptoProvider`.
184+ ///
185+ /// Forced from inside `build_client_config` so every TLS-config-building
186+ /// entry point (`webpki_roots_client_config`, `native_roots_client_config`,
187+ /// and `HttpClientBuilder::build`) auto-installs uniformly. nextest's default
188+ /// "process-per-test" execution means a builder-level `LazyLock` would not
189+ /// cover tests that bypass the builder.
190+ #[ cfg( all( test, feature = "fips" ) ) ]
191+ mod fips_test_provider {
192+ use std:: sync:: LazyLock ;
193+
194+ pub ( super ) static INSTALL : LazyLock < ( ) > = LazyLock :: new ( || {
195+ // `install_default()` returns `Err` if a provider is already installed
196+ // (benign here); drop the result. `drop` rather than `let _ =`
197+ // satisfies clippy::let_underscore_must_use.
198+ #[ cfg( target_os = "macos" ) ]
199+ drop ( rustls_corecrypto_provider:: default_provider ( ) . install_default ( ) ) ;
200+ #[ cfg( target_os = "windows" ) ]
201+ drop ( rustls_cng_crypto:: fips_provider ( ) . install_default ( ) ) ;
202+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
203+ drop ( rustls:: crypto:: default_fips_provider ( ) . install_default ( ) ) ;
204+ } ) ;
205+ }
135206
136207/// Build a rustls `ClientConfig` from the given root store.
137208///
@@ -146,12 +217,38 @@ pub type TlsConfigError = Box<dyn std::error::Error + Send + Sync + 'static>;
146217fn build_client_config (
147218 root_store : rustls:: RootCertStore ,
148219) -> Result < rustls:: ClientConfig , TlsConfigError > {
220+ // Test-only: ensure the platform-appropriate FIPS crypto provider is
221+ // installed before this funnel runs. The fail-closed path below requires
222+ // `modkit::bootstrap::init_crypto_provider` to have run in production;
223+ // in-crate tests that don't go through bootstrap (most of them) would
224+ // otherwise hit `NoCryptoProvider`. Placed here rather than in the
225+ // builder so it covers every TLS-config-building entry point uniformly
226+ // (`webpki_roots_client_config`, `native_roots_client_config`, and any
227+ // future addition). `cfg(test)` is set only for in-crate unit tests, NOT
228+ // for integration tests under `tests/` — so
229+ // `tests/no_crypto_provider_fips.rs` still sees a clean process state.
230+ #[ cfg( all( test, feature = "fips" ) ) ]
231+ std:: sync:: LazyLock :: force ( & fips_test_provider:: INSTALL ) ;
232+
233+ // Under `--features fips` the crypto provider MUST have been installed
234+ // up-front via `modkit::bootstrap::init_crypto_provider` — otherwise
235+ // `get_crypto_provider()`'s fallback would mint an *uninstalled* provider
236+ // whose FIPS-validation status is indeterminate from the rustls global's
237+ // point of view. Fail closed instead.
238+ //
239+ // The non-FIPS branch preserves the historical infallible fallback
240+ // (see `get_crypto_provider` docstring).
241+ #[ cfg( feature = "fips" ) ]
242+ let provider = rustls:: crypto:: CryptoProvider :: get_default ( )
243+ . cloned ( )
244+ . ok_or ( TlsConfigError :: NoCryptoProvider ) ?;
245+ #[ cfg( not( feature = "fips" ) ) ]
149246 let provider = get_crypto_provider ( ) ;
150247
151248 #[ allow( unused_mut) ]
152249 let mut config = rustls:: ClientConfig :: builder_with_provider ( provider)
153250 . with_safe_default_protocol_versions ( )
154- . map_err ( TlsConfigError :: from ) ?
251+ . map_err ( |e| TlsConfigError :: Other ( Box :: new ( e ) ) ) ?
155252 . with_root_certificates ( root_store)
156253 . with_no_client_auth ( ) ;
157254
@@ -178,17 +275,16 @@ fn build_client_config(
178275fn apply_fips_hardening ( cfg : & mut rustls:: ClientConfig ) -> Result < ( ) , TlsConfigError > {
179276 cfg. require_ems = true ;
180277 if !cfg. fips ( ) {
181- return Err (
278+ return Err ( TlsConfigError :: FipsHardeningFailed (
182279 "TLS ClientConfig does not advertise FIPS after enabling require_ems. \
183- Either (a) init_crypto_provider() was not called before this TLS config was built, \
184- or (b) the runtime FIPS witness \
280+ The runtime FIPS witness \
185281 (cyberware_rustls_corecrypto_provider::oe::fips_witness_ok on macOS) \
186282 is reporting false -- typically because the running macOS major is \
187283 outside the active corecrypto CMVP cert OE. \
188284 Set CYBERWARE_FIPS_OE_OVERRIDE=1 (CI / pre-release only) to force the \
189285 witness to true; never set this in production."
190- . into ( ) ,
191- ) ;
286+ . to_owned ( ) ,
287+ ) ) ;
192288 }
193289 Ok ( ( ) )
194290}
@@ -209,7 +305,9 @@ pub fn native_roots_client_config() -> Result<rustls::ClientConfig, TlsConfigErr
209305 let mut root_store = rustls:: RootCertStore :: empty ( ) ;
210306
211307 if certs. is_empty ( ) {
212- return Err ( "no native root CA certificates found in OS certificate store" . into ( ) ) ;
308+ return Err ( TlsConfigError :: Other (
309+ "no native root CA certificates found in OS certificate store" . into ( ) ,
310+ ) ) ;
213311 }
214312
215313 let ( added, ignored) = root_store. add_parsable_certificates ( certs. iter ( ) . cloned ( ) ) ;
@@ -223,12 +321,14 @@ pub fn native_roots_client_config() -> Result<rustls::ClientConfig, TlsConfigErr
223321 }
224322
225323 if added == 0 {
226- return Err ( format ! (
227- "no valid native root CA certificates parsed (found {}, all {} failed to parse)" ,
228- certs. len( ) ,
229- ignored
230- )
231- . into ( ) ) ;
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+ ) ) ;
232332 }
233333
234334 build_client_config ( root_store)
@@ -350,7 +450,10 @@ mod tests {
350450 /// through `build_client_config`); calling it avoids a hard dependency on
351451 /// the OS keychain that `native_roots_client_config` carries.
352452 ///
353- /// Run via `cargo test -p cf-modkit-http --features fips`.
453+ /// Run via `cargo test -p cyberware-modkit-http --features fips`.
454+ /// `build_client_config` auto-installs the platform FIPS provider in
455+ /// test mode (see `fips_test_provider` module) so this test does not
456+ /// need its own explicit install.
354457 #[ test]
355458 #[ cfg( feature = "fips" ) ]
356459 fn fips_client_config_requires_ems_and_advertises_fips ( ) {
0 commit comments