use std::path::{Path, PathBuf};
use std::sync::Arc;
use rustls::crypto::CryptoProvider;
use rustls::{ClientConfig, NamedGroup, ProtocolVersion, RootCertStore};
use rustls_pki_types::CertificateDer;
use rustls_pki_types::pem::PemObject;
use crate::crypto::{CryptoProfile, PqcMode, TlsFloor};
#[derive(Debug, thiserror::Error)]
pub enum TlsError {
#[error("TLS: cannot read certificate file {path}: {reason}")]
PemRead {
path: PathBuf,
reason: String,
},
#[error("TLS: no certificates parsed from {path}")]
NoCertsFound {
path: PathBuf,
},
#[error(
"TLS: trust store is empty -- no roots from native/webpki/extra sources \
(check `native_roots`/`webpki_roots`/`extra_roots`, or `exclusive` with no files)"
)]
EmptyTrustStore,
#[error("TLS: failed to build client config: {0}")]
Build(String),
}
#[derive(Debug, Clone)]
pub struct TlsTrust {
pub native_roots: bool,
pub webpki_roots: bool,
pub extra_roots: Vec<PathBuf>,
pub extra_intermediates: Vec<PathBuf>,
pub exclusive: bool,
}
impl Default for TlsTrust {
fn default() -> Self {
Self {
native_roots: true,
webpki_roots: false,
extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: false,
}
}
}
impl TlsTrust {
#[must_use]
pub fn private_ca(pem_path: impl Into<PathBuf>) -> Self {
Self {
native_roots: false,
webpki_roots: false,
extra_roots: vec![pem_path.into()],
extra_intermediates: Vec::new(),
exclusive: true,
}
}
}
pub enum TlsConfigSource {
Explicit(Arc<ClientConfig>),
Trust(TlsTrust),
}
pub fn add_pem_file_certs(store: &mut RootCertStore, path: &Path) -> Result<usize, TlsError> {
if !path.is_file() {
return Err(TlsError::PemRead {
path: path.to_path_buf(),
reason: "not a readable file".to_string(),
});
}
let iter = CertificateDer::pem_file_iter(path).map_err(|e| TlsError::PemRead {
path: path.to_path_buf(),
reason: e.to_string(),
})?;
let certs: Vec<CertificateDer<'static>> = iter.filter_map(Result::ok).collect();
let (added, _ignored) = store.add_parsable_certificates(certs);
if added == 0 {
return Err(TlsError::NoCertsFound {
path: path.to_path_buf(),
});
}
Ok(added)
}
pub fn build_root_store(trust: &TlsTrust) -> Result<RootCertStore, TlsError> {
let mut store = RootCertStore::empty();
if !trust.exclusive {
if trust.native_roots {
let result = rustls_native_certs::load_native_certs();
let (_added, _ignored) = store.add_parsable_certificates(result.certs);
for err in result.errors {
tracing::warn!(error = %err, "TLS: error loading a native root (continuing)");
}
}
if trust.webpki_roots {
store
.roots
.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
}
for path in &trust.extra_roots {
add_pem_file_certs(&mut store, path)?;
}
for path in &trust.extra_intermediates {
add_pem_file_certs(&mut store, path)?;
}
if store.is_empty() {
return Err(TlsError::EmptyTrustStore);
}
Ok(store)
}
pub fn build_client_config(source: TlsConfigSource) -> Result<Arc<ClientConfig>, TlsError> {
build_client_config_with(CryptoProfile::default(), source)
}
pub fn build_client_config_with(
profile: CryptoProfile,
source: TlsConfigSource,
) -> Result<Arc<ClientConfig>, TlsError> {
match source {
TlsConfigSource::Explicit(cfg) => Ok(cfg),
TlsConfigSource::Trust(trust) => {
let roots = build_root_store(&trust)?;
let provider = posture_provider(profile.pqc());
let builder = ClientConfig::builder_with_provider(provider);
let versioned = match profile.tls_floor() {
TlsFloor::V1_2 => builder.with_safe_default_protocol_versions(),
TlsFloor::V1_3 => builder.with_protocol_versions(&[&rustls::version::TLS13]),
};
let cfg = versioned
.map_err(|e| TlsError::Build(e.to_string()))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Arc::new(cfg))
}
}
}
fn is_hybrid_pqc(group: NamedGroup) -> bool {
matches!(group, NamedGroup::X25519MLKEM768)
}
fn posture_provider(pqc: PqcMode) -> Arc<CryptoProvider> {
let mut provider = rustls::crypto::aws_lc_rs::default_provider();
match pqc {
PqcMode::Prefer => provider
.kx_groups
.sort_by_key(|g| u8::from(!is_hybrid_pqc(g.name()))),
PqcMode::Require => provider.kx_groups.retain(|g| is_hybrid_pqc(g.name())),
PqcMode::Off => provider.kx_groups.retain(|g| !is_hybrid_pqc(g.name())),
}
Arc::new(provider)
}
#[derive(Debug, Clone)]
pub struct TlsParts {
pub min_version: &'static str,
pub curves: Vec<&'static str>,
pub cipher_string: &'static str,
pub ca_paths: Vec<PathBuf>,
pub verify: bool,
}
#[must_use]
pub fn tls_parts(profile: CryptoProfile, trust: &TlsTrust) -> TlsParts {
let curves = match profile.pqc() {
PqcMode::Require => vec!["X25519MLKEM768"],
PqcMode::Prefer => vec!["X25519MLKEM768", "P-384", "X25519"],
PqcMode::Off => vec!["P-384", "X25519"],
};
TlsParts {
min_version: match profile.tls_floor() {
TlsFloor::V1_2 => "1.2",
TlsFloor::V1_3 => "1.3",
},
curves,
cipher_string: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384",
ca_paths: trust.extra_roots.clone(),
verify: true,
}
}
pub fn warn_if_downgraded(
profile: CryptoProfile,
negotiated_version: Option<ProtocolVersion>,
negotiated_group: Option<NamedGroup>,
peer: &str,
) {
if !profile.warn_on_downgrade() {
return;
}
if profile.pqc() == PqcMode::Prefer && negotiated_group.is_some_and(|g| !is_hybrid_pqc(g)) {
tracing::warn!(
peer,
group = ?negotiated_group,
"TLS negotiated a classical key exchange (no post-quantum protection); peer does not offer hybrid ML-KEM"
);
}
if negotiated_version == Some(ProtocolVersion::TLSv1_2) {
tracing::warn!(
peer,
"TLS negotiated 1.2, below the preferred 1.3 (commercial floor)"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn gen_ca_bundle(n: usize) -> String {
let mut bundle = String::new();
for i in 0..n {
let cert = rcgen::generate_simple_self_signed(vec![format!("ca-{i}.test")])
.expect("rcgen self-signed");
bundle.push_str(&cert.cert.pem());
}
bundle
}
fn write_temp(contents: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("temp file");
f.write_all(contents.as_bytes()).expect("write");
f.flush().expect("flush");
f
}
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
use rustls::{ClientConnection, ServerConfig, ServerConnection};
fn gen_server_cert() -> (CertificateDer<'static>, PrivateKeyDer<'static>, String) {
let ck = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let pem = ck.cert.pem();
let cert = ck.cert.der().clone();
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(ck.signing_key.serialize_der()));
(cert, key, pem)
}
fn server_config_with(
pqc: PqcMode,
cert: &CertificateDer<'static>,
key: &PrivateKeyDer<'static>,
) -> Arc<ServerConfig> {
Arc::new(
ServerConfig::builder_with_provider(posture_provider(pqc))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert.clone()], key.clone_key())
.unwrap(),
)
}
fn pump(
client: &mut ClientConnection,
server: &mut ServerConnection,
) -> Result<(), rustls::Error> {
for _ in 0..40 {
let mut c2s: Vec<u8> = Vec::new();
while client.wants_write() {
client.write_tls(&mut c2s).unwrap();
}
let mut rd: &[u8] = &c2s;
while !rd.is_empty() {
server.read_tls(&mut rd).unwrap();
server.process_new_packets()?;
}
let mut s2c: Vec<u8> = Vec::new();
while server.wants_write() {
server.write_tls(&mut s2c).unwrap();
}
let mut rd2: &[u8] = &s2c;
while !rd2.is_empty() {
client.read_tls(&mut rd2).unwrap();
client.process_new_packets()?;
}
if !client.is_handshaking() && !server.is_handshaking() {
return Ok(());
}
}
Err(rustls::Error::General(
"handshake did not complete within 40 pump rounds".to_string(),
))
}
fn client_for(profile: CryptoProfile, ca_path: &Path) -> Arc<ClientConfig> {
build_client_config_with(
profile,
TlsConfigSource::Trust(TlsTrust::private_ca(ca_path)),
)
.unwrap()
}
#[test]
fn prod_negotiates_pqc_hybrid() {
let (cert, key, pem) = gen_server_cert();
let ca = write_temp(&pem);
let mut client = ClientConnection::new(
client_for(CryptoProfile::Prod, ca.path()),
ServerName::try_from("localhost").unwrap(),
)
.unwrap();
let mut server =
ServerConnection::new(server_config_with(PqcMode::Prefer, &cert, &key)).unwrap();
pump(&mut client, &mut server).expect("handshake completes");
assert_eq!(
client.negotiated_key_exchange_group().map(|g| g.name()),
Some(NamedGroup::X25519MLKEM768),
"prod prefers and actually negotiates the hybrid PQC group"
);
assert_eq!(client.protocol_version(), Some(ProtocolVersion::TLSv1_3));
}
#[test]
fn highsec_require_fails_against_classical_only_peer() {
let (cert, key, pem) = gen_server_cert();
let ca = write_temp(&pem);
let mut client = ClientConnection::new(
client_for(CryptoProfile::HighSec, ca.path()), ServerName::try_from("localhost").unwrap(),
)
.unwrap();
let mut server =
ServerConnection::new(server_config_with(PqcMode::Off, &cert, &key)).unwrap(); assert!(
pump(&mut client, &mut server).is_err(),
"highsec REQUIRES post-quantum kx; a classical-only peer must fail, not silently downgrade"
);
}
#[test]
fn posture_provider_selects_groups() {
let all_hybrid = |p: &CryptoProvider| {
!p.kx_groups.is_empty() && p.kx_groups.iter().all(|g| is_hybrid_pqc(g.name()))
};
let any_hybrid = |p: &CryptoProvider| p.kx_groups.iter().any(|g| is_hybrid_pqc(g.name()));
let prefer = posture_provider(PqcMode::Prefer);
assert!(
prefer
.kx_groups
.first()
.is_some_and(|g| is_hybrid_pqc(g.name())),
"prefer puts the hybrid first"
);
assert!(
all_hybrid(&posture_provider(PqcMode::Require)),
"require keeps only hybrid"
);
assert!(
!any_hybrid(&posture_provider(PqcMode::Off)),
"off drops the hybrid"
);
}
#[test]
fn require_keeps_a_nonempty_group_set() {
let require = posture_provider(PqcMode::Require);
assert!(
!require.kx_groups.is_empty(),
"PqcMode::Require retained no key-exchange groups at all -- \
is_hybrid_pqc matches nothing the provider offers, so HighSec \
cannot negotiate with any peer"
);
let off = posture_provider(PqcMode::Off);
assert!(
!off.kx_groups.is_empty(),
"PqcMode::Off retained no key-exchange groups at all"
);
}
#[test]
fn highsec_completes_handshake_with_pqc_peer() {
let (cert, key, pem) = gen_server_cert();
let ca = write_temp(&pem);
let mut client = ClientConnection::new(
client_for(CryptoProfile::HighSec, ca.path()),
ServerName::try_from("localhost").unwrap(),
)
.unwrap();
let mut server =
ServerConnection::new(server_config_with(PqcMode::Require, &cert, &key)).unwrap();
pump(&mut client, &mut server)
.expect("HighSec must still complete a handshake with a PQC-capable peer");
assert_eq!(
client.negotiated_key_exchange_group().map(|g| g.name()),
Some(NamedGroup::X25519MLKEM768),
"highsec negotiates the hybrid PQC group"
);
assert_eq!(client.protocol_version(), Some(ProtocolVersion::TLSv1_3));
}
#[test]
fn tls_parts_reflect_profile() {
let f = write_temp(&gen_ca_bundle(1));
let trust = TlsTrust::private_ca(f.path());
let prod = tls_parts(CryptoProfile::Prod, &trust);
assert_eq!(prod.min_version, "1.2");
assert_eq!(prod.curves.first(), Some(&"X25519MLKEM768"));
assert_eq!(prod.ca_paths, vec![f.path().to_path_buf()]);
let hs = tls_parts(CryptoProfile::HighSec, &trust);
assert_eq!(hs.min_version, "1.3");
assert_eq!(hs.curves, vec!["X25519MLKEM768"]);
}
#[test]
fn add_pem_counts_multi_cert_bundle() {
let f = write_temp(&gen_ca_bundle(3));
let mut store = RootCertStore::empty();
let added = add_pem_file_certs(&mut store, f.path()).unwrap();
assert_eq!(added, 3, "all three certs in the bundle are added");
}
#[test]
fn add_pem_is_lenient_with_junk_plus_valid() {
let mut contents = String::from("this is not a PEM block\ngarbage line\n");
contents.push_str(&gen_ca_bundle(1));
contents.push_str("\ntrailing junk\n");
let f = write_temp(&contents);
let mut store = RootCertStore::empty();
let added = add_pem_file_certs(&mut store, f.path()).unwrap();
assert_eq!(added, 1, "junk is skipped, the valid cert still loads");
}
#[test]
fn add_pem_zero_certs_is_error() {
let f = write_temp("no certificates here at all\n");
let mut store = RootCertStore::empty();
let err = add_pem_file_certs(&mut store, f.path()).unwrap_err();
assert!(matches!(err, TlsError::NoCertsFound { .. }));
}
#[test]
fn add_pem_unreadable_path_is_error() {
let mut store = RootCertStore::empty();
let err = add_pem_file_certs(&mut store, Path::new("/nonexistent/nope.pem")).unwrap_err();
assert!(matches!(err, TlsError::PemRead { .. }));
}
#[test]
fn build_store_augments_native_with_extra() {
let f = write_temp(&gen_ca_bundle(1));
let trust = TlsTrust {
native_roots: true,
webpki_roots: false,
extra_roots: vec![f.path().to_path_buf()],
extra_intermediates: Vec::new(),
exclusive: false,
};
let store = build_root_store(&trust).unwrap();
assert!(!store.is_empty());
}
#[test]
fn build_store_exclusive_uses_only_extra() {
let f = write_temp(&gen_ca_bundle(2));
let trust = TlsTrust::private_ca(f.path());
let store = build_root_store(&trust).unwrap();
assert_eq!(
store.roots.len(),
2,
"exclusive store holds exactly the private-CA certs, no native roots"
);
}
#[test]
fn build_store_exclusive_with_no_files_is_error() {
let trust = TlsTrust {
native_roots: true, webpki_roots: true, extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: true,
};
let err = build_root_store(&trust).unwrap_err();
assert!(matches!(err, TlsError::EmptyTrustStore));
}
#[test]
fn build_store_no_sources_is_empty_error() {
let trust = TlsTrust {
native_roots: false,
webpki_roots: false,
extra_roots: Vec::new(),
extra_intermediates: Vec::new(),
exclusive: false,
};
let err = build_root_store(&trust).unwrap_err();
assert!(matches!(err, TlsError::EmptyTrustStore));
}
#[test]
fn build_client_config_from_private_ca() {
let f = write_temp(&gen_ca_bundle(1));
let cfg =
build_client_config(TlsConfigSource::Trust(TlsTrust::private_ca(f.path()))).unwrap();
assert!(Arc::strong_count(&cfg) >= 1);
}
}