Skip to content

Commit 48ec109

Browse files
authored
Merge pull request #1982 from asmith987/fix/modkit-http-fail-closed-under-fips
fix(modkit-http): fail closed when rustls crypto provider not installed under FIPS
2 parents 32e03fb + e06ab43 commit 48ec109

5 files changed

Lines changed: 189 additions & 26 deletions

File tree

Makefile

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,20 @@ test-db: test-sqlite test-pg test-mysql
390390
test-users-info-pg: install-tools
391391
cargo nextest run -p users-info --features "integration"
392392

393-
## Run FIPS-mode integration tests (cyberware-modkit, requires Go for aws-lc-fips-sys)
393+
## Run FIPS-mode integration tests (requires Go for aws-lc-fips-sys).
394+
## Covers:
395+
## - cyberware-modkit : bootstrap + init_crypto_provider dispatch
396+
## - cyberware-modkit-http : TLS client fail-closed path (NoCryptoProvider,
397+
## apply_fips_hardening, builder/client FIPS-feature
398+
## test surface). See issue #1935.
399+
##
400+
## Per-package `pkg/feat` syntax is required because `bootstrap` exists only
401+
## on `cyberware-modkit` and the two crates have independent FIPS feature
402+
## spaces (modkit doesn't depend on modkit-http). Single invocation so the
403+
## shared FIPS dep graph compiles once.
394404
test-fips: install-tools
395-
cargo nextest run -p cyberware-modkit --features bootstrap,fips
405+
cargo nextest run -p cyberware-modkit -p cyberware-modkit-http \
406+
--features cyberware-modkit/bootstrap,cyberware-modkit/fips,cyberware-modkit-http/fips
396407

397408
## Cross-compile gate for the Windows+FIPS path (Windows handshake
398409
## verification is the manual runbook in cyberware-fips-probe/README.md). Catches

libs/modkit-http/src/builder.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -528,14 +528,15 @@ fn build_https_connector(
528528
// Both branches build a `ClientConfig` ourselves (rather than using
529529
// `with_provider_and_webpki_roots`) so we can apply `require_ems = true`
530530
// under the `fips` feature — see `tls::build_client_config`. The
531-
// functions now return `tls::TlsConfigError` (= `Box<dyn Error + Send + Sync>`),
532-
// which is exactly what `HttpError::Tls` wraps — no string conversion,
533-
// source chain preserved.
531+
// functions return `tls::TlsConfigError` (an enum implementing
532+
// `std::error::Error`); boxing it into `HttpError::Tls`'s
533+
// `Box<dyn Error + Send + Sync>` preserves the source chain via
534+
// `TlsConfigError`'s own `Error::source()` impl.
534535
let client_config = match tls_roots {
535536
TlsRootConfig::WebPki => tls::webpki_roots_client_config(),
536537
TlsRootConfig::Native => tls::native_roots_client_config(),
537538
}
538-
.map_err(HttpError::Tls)?;
539+
.map_err(|e| HttpError::Tls(Box::new(e)))?;
539540

540541
let builder = hyper_rustls::HttpsConnectorBuilder::new().with_tls_config(client_config);
541542
let connector = if allow_http {

libs/modkit-http/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,4 @@ pub use layers::{
6868
};
6969
pub use request::RequestBuilder;
7070
pub use response::{HttpResponse, LimitedBody, ResponseBody};
71+
pub use tls::TlsConfigError;

libs/modkit-http/src/tls.rs

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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))]
8595
pub 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>;
146217
fn 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(
178275
fn 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() {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#![cfg(feature = "fips")]
2+
3+
//! Regression for issue #1935: under `--features fips`, building a TLS client
4+
//! before `modkit::bootstrap::init_crypto_provider` has installed the
5+
//! process-wide rustls [`CryptoProvider`] MUST fail closed with
6+
//! [`TlsConfigError::NoCryptoProvider`] rather than silently constructing
7+
//! an uninstalled provider via the historical `unwrap_or_else` fallback.
8+
//!
9+
//! Lives in its own integration-test binary (separate process from
10+
//! `cargo test`'s in-crate unit tests) so the runtime state is guaranteed
11+
//! clean — no other test installs a default provider before this one runs.
12+
13+
use modkit_http::{HttpClient, HttpError, TlsConfigError};
14+
15+
#[test]
16+
fn build_client_without_init_crypto_provider_returns_no_crypto_provider() {
17+
// Precondition: this test binary has not installed any rustls provider.
18+
// If it had, the `get_default().is_some()` branch in `build_client_config`
19+
// would succeed and the test premise would be invalid.
20+
assert!(
21+
rustls::crypto::CryptoProvider::get_default().is_none(),
22+
"test precondition: no crypto provider must be installed before this test runs"
23+
);
24+
25+
// `let-else` instead of `.expect_err()` because `HttpClient` does not
26+
// implement `Debug` (clippy::manual_let_else points this pattern at us).
27+
let Err(err) = HttpClient::builder().build() else {
28+
panic!(
29+
"HttpClient::builder().build() must fail closed under --features fips \
30+
when no crypto provider is installed"
31+
);
32+
};
33+
34+
let inner = match err {
35+
HttpError::Tls(inner) => inner,
36+
other => panic!("expected HttpError::Tls(_), got {other:?}"),
37+
};
38+
39+
let tls_err = inner
40+
.downcast_ref::<TlsConfigError>()
41+
.expect("HttpError::Tls source must be a TlsConfigError");
42+
43+
assert!(
44+
matches!(tls_err, TlsConfigError::NoCryptoProvider),
45+
"expected TlsConfigError::NoCryptoProvider, got {tls_err:?}"
46+
);
47+
}

0 commit comments

Comments
 (0)