use crate::config::{ClientAuthConfig, TlsConfig, TlsVersion};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
use std::sync::{Arc, OnceLock};
static NATIVE_ROOTS_CACHE: OnceLock<Vec<CertificateDer<'static>>> = OnceLock::new();
#[cfg(test)]
static LOAD_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn load_native_certs_inner() -> Vec<CertificateDer<'static>> {
#[cfg(test)]
LOAD_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let result = rustls_native_certs::load_native_certs();
if !result.errors.is_empty() {
for err in &result.errors {
tracing::warn!(error = %err, "error loading native root certificate");
}
}
let certs: Vec<CertificateDer<'static>> = result.certs;
if certs.is_empty() {
tracing::warn!("no native root CA certificates found");
} else {
tracing::debug!(count = certs.len(), "loaded native root certificates");
}
certs
}
pub fn native_root_certs() -> &'static [CertificateDer<'static>] {
NATIVE_ROOTS_CACHE
.get_or_init(load_native_certs_inner)
.as_slice()
}
#[cfg_attr(feature = "fips", allow(dead_code))]
pub fn get_crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
rustls::crypto::CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| {
#[cfg(all(feature = "fips", target_os = "macos"))]
{
Arc::new(rustls_corecrypto_provider::default_provider())
}
#[cfg(all(feature = "fips", target_os = "windows"))]
{
let provider = rustls_cng_crypto::fips_provider();
assert!(
!provider.cipher_suites.is_empty(),
"Windows is not in FIPS mode (FipsAlgorithmPolicy != 1). \
Enable system-wide FIPS via Group Policy and reboot, \
or call toolkit::bootstrap::init_crypto_provider() first \
for the canonical fail-closed path."
);
Arc::new(provider)
}
#[cfg(all(feature = "fips", not(any(target_os = "macos", target_os = "windows"))))]
{
Arc::new(rustls::crypto::default_fips_provider())
}
#[cfg(not(feature = "fips"))]
{
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
}
})
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TlsConfigError {
#[error(
"rustls CryptoProvider has not been installed; call \
toolkit::bootstrap::init_crypto_provider() before building any TLS \
config under --features fips"
)]
NoCryptoProvider,
#[error("{0}")]
FipsHardeningFailed(String),
#[error(transparent)]
Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
}
#[cfg(all(test, feature = "fips"))]
mod fips_test_provider {
use std::sync::LazyLock;
pub(super) static INSTALL: LazyLock<()> = LazyLock::new(|| {
#[cfg(target_os = "macos")]
drop(rustls_corecrypto_provider::default_provider().install_default());
#[cfg(target_os = "windows")]
drop(rustls_cng_crypto::fips_provider().install_default());
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
drop(rustls::crypto::default_fips_provider().install_default());
});
}
fn build_client_config(
root_store: rustls::RootCertStore,
tls: &TlsConfig,
) -> Result<rustls::ClientConfig, TlsConfigError> {
#[cfg(all(test, feature = "fips"))]
std::sync::LazyLock::force(&fips_test_provider::INSTALL);
#[cfg(feature = "fips")]
let provider = rustls::crypto::CryptoProvider::get_default()
.cloned()
.ok_or(TlsConfigError::NoCryptoProvider)?;
#[cfg(not(feature = "fips"))]
let provider = get_crypto_provider();
let roots_builder = rustls::ClientConfig::builder_with_provider(provider)
.with_protocol_versions(protocol_versions(tls.min_version))
.map_err(|e| TlsConfigError::Other(Box::new(e)))?
.with_root_certificates(root_store);
#[allow(unused_mut)]
let mut config = match &tls.client_auth {
Some(auth) => {
let (cert_chain, key) = load_client_auth(auth)?;
roots_builder
.with_client_auth_cert(cert_chain, key)
.map_err(|e| TlsConfigError::Other(Box::new(e)))?
}
None => roots_builder.with_no_client_auth(),
};
#[cfg(feature = "fips")]
{
apply_fips_hardening(&mut config)?;
}
Ok(config)
}
fn protocol_versions(min: TlsVersion) -> &'static [&'static rustls::SupportedProtocolVersion] {
const TLS12_AND_13: &[&rustls::SupportedProtocolVersion] =
&[&rustls::version::TLS13, &rustls::version::TLS12];
const TLS13_ONLY: &[&rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
match min {
TlsVersion::Tls12 => TLS12_AND_13,
TlsVersion::Tls13 => TLS13_ONLY,
}
}
fn load_client_auth(
auth: &ClientAuthConfig,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), TlsConfigError> {
let cert_err = |e: rustls_pki_types::pem::Error| {
TlsConfigError::Other(
format!(
"failed to load client certificate chain from {}: {e}",
auth.cert_chain.display()
)
.into(),
)
};
let cert_chain: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(&auth.cert_chain)
.map_err(cert_err)?
.collect::<Result<Vec<_>, _>>()
.map_err(cert_err)?;
if cert_chain.is_empty() {
return Err(TlsConfigError::Other(
format!(
"client certificate chain at {} contained no certificates",
auth.cert_chain.display()
)
.into(),
));
}
let key = PrivateKeyDer::from_pem_file(&auth.key).map_err(|_| {
TlsConfigError::Other(
format!(
"failed to load client private key from {} (IO or PEM parse error)",
auth.key.display()
)
.into(),
)
})?;
Ok((cert_chain, key))
}
#[cfg(feature = "fips")]
fn apply_fips_hardening(cfg: &mut rustls::ClientConfig) -> Result<(), TlsConfigError> {
cfg.require_ems = true;
if !cfg.fips() {
return Err(TlsConfigError::FipsHardeningFailed(
"TLS ClientConfig does not advertise FIPS after enabling require_ems. \
The bootstrap assert in init_crypto_provider should have caught a \
non-FIPS provider at startup; if you see this error the provider is \
FIPS-OK but a per-config setting (protocol versions, require_ems) is \
preventing ClientConfig::fips() from reporting true. \
If init_crypto_provider was not called, call it before building any \
TLS config."
.to_owned(),
));
}
Ok(())
}
pub fn native_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
let certs = native_root_certs();
let mut root_store = rustls::RootCertStore::empty();
if certs.is_empty() {
return Err(TlsConfigError::Other(
"no native root CA certificates found in OS certificate store".into(),
));
}
let (added, ignored) = root_store.add_parsable_certificates(certs.iter().cloned());
if ignored > 0 {
tracing::warn!(
added = added,
ignored = ignored,
"some native root certificates could not be parsed"
);
}
if added == 0 {
return Err(TlsConfigError::Other(
format!(
"no valid native root CA certificates parsed (found {}, all {} failed to parse)",
certs.len(),
ignored
)
.into(),
));
}
build_client_config(root_store, tls)
}
pub fn webpki_roots_client_config(tls: &TlsConfig) -> Result<rustls::ClientConfig, TlsConfigError> {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
build_client_config(root_store, tls)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn test_native_roots_cached() {
let initial_count = LOAD_COUNT.load(Ordering::SeqCst);
let result1 = native_root_certs();
let result2 = native_root_certs();
let result3 = native_root_certs();
let final_count = LOAD_COUNT.load(Ordering::SeqCst);
assert!(
final_count <= initial_count + 1,
"loader should run at most once, but ran {} times since test start",
final_count - initial_count
);
assert_eq!(result1.len(), result2.len());
assert_eq!(result2.len(), result3.len());
assert!(std::ptr::eq(result1, result2), "should return same slice");
assert!(std::ptr::eq(result2, result3), "should return same slice");
}
#[test]
fn test_native_roots_client_config() {
let result = native_roots_client_config(&TlsConfig::default());
match &result {
Ok(_) => tracing::debug!("native_roots_client_config succeeded"),
Err(e) => {
tracing::debug!(error = %e, "native_roots_client_config failed (expected on minimal containers)");
}
}
}
#[test]
fn test_webpki_roots_client_config_builds() {
let cfg = webpki_roots_client_config(&TlsConfig::default())
.expect("webpki roots must always build");
let provider = cfg.crypto_provider();
assert!(
!provider.cipher_suites.is_empty(),
"TLS client config must carry a non-empty cipher-suite list"
);
assert!(
!provider.kx_groups.is_empty(),
"TLS client config must carry a non-empty kx-group list"
);
}
#[test]
#[cfg(feature = "fips")]
fn fips_client_config_requires_ems_and_advertises_fips() {
let cfg = webpki_roots_client_config(&TlsConfig::default()).expect("build under fips");
assert!(cfg.require_ems, "fips build must set require_ems = true");
assert!(
cfg.fips(),
"fips build must yield ClientConfig::fips() == true (full provider chain)"
);
}
#[test]
fn protocol_versions_maps_min_version() {
let v12 = protocol_versions(TlsVersion::Tls12);
assert_eq!(v12.len(), 2, "Tls12 floor must advertise TLS 1.2 and 1.3");
assert!(
v12.iter()
.any(|v| v.version == rustls::ProtocolVersion::TLSv1_2)
);
assert!(
v12.iter()
.any(|v| v.version == rustls::ProtocolVersion::TLSv1_3)
);
let v13 = protocol_versions(TlsVersion::Tls13);
assert_eq!(v13.len(), 1, "Tls13 floor must advertise TLS 1.3 only");
assert_eq!(v13[0].version, rustls::ProtocolVersion::TLSv1_3);
}
#[test]
fn webpki_client_config_builds_with_tls13_floor() {
let tls = TlsConfig {
min_version: TlsVersion::Tls13,
..TlsConfig::default()
};
let cfg = webpki_roots_client_config(&tls).expect("tls 1.3 floor must build");
assert!(!cfg.crypto_provider().cipher_suites.is_empty());
}
#[test]
fn client_auth_missing_files_errors() {
let tls = TlsConfig {
client_auth: Some(ClientAuthConfig::new(
"/nonexistent/toolkit-http/cert.pem",
"/nonexistent/toolkit-http/key.pem",
)),
..TlsConfig::default()
};
let err =
webpki_roots_client_config(&tls).expect_err("missing client-auth files must error");
let msg = err.to_string();
assert!(
msg.contains("client certificate chain") && msg.contains("cert.pem"),
"error should name the missing cert path, got: {msg}"
);
}
fn write_self_signed_identity() -> (tempfile::TempDir, ClientAuthConfig) {
use std::io::Write;
let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
.expect("generate ECDSA P-256 key pair");
let cert = rcgen::CertificateParams::new(vec!["client.test".to_owned()])
.expect("certificate params")
.self_signed(&key_pair)
.expect("self-sign certificate");
let dir = tempfile::tempdir().expect("create temp dir");
let cert_path = dir.path().join("client_cert.pem");
let key_path = dir.path().join("client_key.pem");
std::fs::File::create(&cert_path)
.and_then(|mut f| f.write_all(cert.pem().as_bytes()))
.expect("write cert pem");
std::fs::File::create(&key_path)
.and_then(|mut f| f.write_all(key_pair.serialize_pem().as_bytes()))
.expect("write key pem");
(dir, ClientAuthConfig::new(cert_path, key_path))
}
#[test]
#[cfg(not(feature = "fips"))]
fn client_auth_pem_round_trips() {
let (_dir, client_auth) = write_self_signed_identity();
let tls = TlsConfig {
client_auth: Some(client_auth),
..TlsConfig::default()
};
let result = webpki_roots_client_config(&tls);
assert!(
result.is_ok(),
"client-auth config from valid PEM must build: {:?}",
result.err()
);
}
#[test]
#[cfg(feature = "fips")]
fn client_auth_under_fips_fails_closed() {
let (_dir, client_auth) = write_self_signed_identity();
let tls = TlsConfig {
client_auth: Some(client_auth),
..TlsConfig::default()
};
match webpki_roots_client_config(&tls) {
Ok(cfg) => assert!(
cfg.fips(),
"fips build returned Ok but ClientConfig::fips() == false: \
an mTLS config slipped past the FIPS witness"
),
Err(TlsConfigError::FipsHardeningFailed(_)) => {}
Err(other) => {
panic!("unexpected non-fail-closed error on fips mTLS path: {other}")
}
}
}
}