use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, LazyLock, OnceLock};
use rustls::ServerConfig;
use rustls::crypto::aws_lc_rs as provider;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::{ClientHello, NoServerSessionStorage, ProducesTickets, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use sha2::{Digest, Sha256};
use crate::error::ControlError;
use crate::manifest::{ClientAuth, Resumption, TlsCert};
use crate::stek::SharedStekTicketer;
#[derive(Debug)]
struct SniResolver {
by_host: HashMap<String, Arc<CertifiedKey>>,
default: Option<Arc<CertifiedKey>>,
}
impl ResolvesServerCert for SniResolver {
fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
hello
.server_name()
.and_then(|name| {
if name.bytes().any(|b| b.is_ascii_uppercase()) {
self.by_host.get(&name.to_ascii_lowercase())
} else {
self.by_host.get(name)
}
})
.cloned()
.or_else(|| self.default.clone())
}
}
fn shared_ticketer() -> Result<Arc<dyn ProducesTickets>, ControlError> {
static TICKETER: OnceLock<Arc<dyn ProducesTickets>> = OnceLock::new();
if let Some(ticketer) = TICKETER.get() {
return Ok(ticketer.clone());
}
let fresh = provider::Ticketer::new().map_err(|e| ControlError::TlsCert {
host: None,
path: String::new(),
reason: format!("session-ticket key init: {e}"),
})?;
Ok(TICKETER.get_or_init(|| fresh).clone())
}
fn client_auth_ticketer(ca_bytes: &[u8]) -> Result<Arc<dyn ProducesTickets>, ControlError> {
type ByCaDigest = HashMap<[u8; 32], Arc<dyn ProducesTickets>>;
static BY_CA: LazyLock<parking_lot::Mutex<ByCaDigest>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
let digest: [u8; 32] = Sha256::digest(ca_bytes).into();
let mut by_ca = BY_CA.lock();
if let Some(ticketer) = by_ca.get(&digest) {
return Ok(ticketer.clone());
}
let fresh = provider::Ticketer::new().map_err(|e| ControlError::TlsCert {
host: None,
path: String::new(),
reason: format!("client-auth session-ticket key init: {e}"),
})?;
by_ca.insert(digest, fresh.clone());
Ok(fresh)
}
pub(crate) struct TlsConfigs {
pub(crate) tcp: Arc<ServerConfig>,
pub(crate) quic: Arc<ServerConfig>,
}
pub(crate) fn build_server_configs(
entries: &[TlsCert],
resumption: Option<&Resumption>,
client_auth: Option<(&ClientAuth, &[u8])>,
base_dir: &Path,
) -> Result<Option<TlsConfigs>, ControlError> {
if let (Some(resumption), Some(_)) = (resumption, client_auth) {
return Err(ControlError::Stek {
path: resumption.stek_file.clone(),
reason: "[resumption] shared STEK cannot be combined with [listen.client_auth]: a \
cross-replica ticket would resume without re-running client-certificate \
verification (ADR 000062 (b) / 000078) — drop [resumption] to keep \
per-node resumption"
.to_string(),
});
}
if entries.is_empty() {
if let Some(resumption) = resumption {
return Err(ControlError::Stek {
path: resumption.stek_file.clone(),
reason: "[resumption] requires at least one [[tls]] cert (ticket keys bind to \
the cert set, ADR 000062)"
.to_string(),
});
}
if let Some((auth, _)) = client_auth {
return Err(ControlError::ClientAuthCa {
path: auth.ca_path.clone(),
reason: "[listen.client_auth] requires at least one [[tls]] cert — a plain-HTTP \
listener has no handshake to verify a client certificate in"
.to_string(),
});
}
return Ok(None);
}
let mut by_host: HashMap<String, Arc<CertifiedKey>> = HashMap::new();
let mut default: Option<Arc<CertifiedKey>> = None;
let mut all_certified: Vec<Arc<CertifiedKey>> = Vec::with_capacity(entries.len());
for entry in entries {
let certified = Arc::new(load_certified_key(entry, base_dir)?);
all_certified.push(certified.clone());
match &entry.host {
Some(host) => {
if by_host
.insert(host.to_ascii_lowercase(), certified)
.is_some()
{
return Err(tls_err(entry, "duplicate cert for this SNI host"));
}
}
None => {
if default.replace(certified).is_some() {
return Err(tls_err(entry, "more than one default (host-less) cert"));
}
}
}
}
let resolver = Arc::new(SniResolver { by_host, default });
let ticketer: Arc<dyn ProducesTickets> = match (resumption, client_auth) {
(Some(resumption), _) => SharedStekTicketer::from_manifest(
resumption,
base_dir,
cert_set_binding(&all_certified, resumption)?,
)?,
(None, Some((_, ca_bytes))) => client_auth_ticketer(ca_bytes)?,
(None, None) => shared_ticketer()?,
};
let client_verifier = client_auth
.map(|(auth, ca_bytes)| build_client_verifier(auth, ca_bytes))
.transpose()?;
let tcp_builder = ServerConfig::builder_with_provider(Arc::new(provider::default_provider()))
.with_safe_default_protocol_versions()
.map_err(provider_init_err)?;
let mut tcp = match &client_verifier {
Some(verifier) => tcp_builder.with_client_cert_verifier(verifier.clone()),
None => tcp_builder.with_no_client_auth(),
}
.with_cert_resolver(resolver.clone());
tcp.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let quic_builder = ServerConfig::builder_with_provider(Arc::new(provider::default_provider()))
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(provider_init_err)?;
let mut quic = match &client_verifier {
Some(verifier) => quic_builder.with_client_cert_verifier(verifier.clone()),
None => quic_builder.with_no_client_auth(),
}
.with_cert_resolver(resolver);
quic.alpn_protocols = vec![b"h3".to_vec()];
for config in [&mut tcp, &mut quic] {
config.ticketer = ticketer.clone();
config.session_storage = Arc::new(NoServerSessionStorage {});
}
Ok(Some(TlsConfigs {
tcp: Arc::new(tcp),
quic: Arc::new(quic),
}))
}
fn build_client_verifier(
auth: &ClientAuth,
ca_bytes: &[u8],
) -> Result<Arc<dyn rustls::server::danger::ClientCertVerifier>, ControlError> {
let err = |reason: String| ControlError::ClientAuthCa {
path: auth.ca_path.clone(),
reason,
};
let certs = CertificateDer::pem_slice_iter(ca_bytes)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| err(format!("bad CA PEM: {e}")))?;
if certs.is_empty() {
return Err(err("no certificates in CA PEM".to_string()));
}
let mut roots = rustls::RootCertStore::empty();
let (added, _ignored) = roots.add_parsable_certificates(certs);
if added == 0 {
return Err(err("no usable trust anchor in CA PEM".to_string()));
}
rustls::server::WebPkiClientVerifier::builder_with_provider(
Arc::new(roots),
Arc::new(provider::default_provider()),
)
.build()
.map_err(|e| err(format!("client verifier: {e}")))
}
pub(crate) fn build_upstream_client_config(
upstream_name: &str,
tls: &crate::manifest::UpstreamTls,
base_dir: &Path,
) -> Result<Arc<rustls::ClientConfig>, ControlError> {
let mut roots = rustls::RootCertStore::empty();
match &tls.ca_path {
Some(ca_path) => {
let err = |reason: String| ControlError::UpstreamTlsCa {
upstream: upstream_name.to_string(),
path: ca_path.clone(),
reason,
};
let bytes = std::fs::read(base_dir.join(ca_path))
.map_err(|e| err(format!("read failed: {e}")))?;
let certs = CertificateDer::pem_slice_iter(&bytes)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| err(format!("bad CA PEM: {e}")))?;
if certs.is_empty() {
return Err(err("no certificates in CA PEM".to_string()));
}
let (added, _ignored) = roots.add_parsable_certificates(certs);
if added == 0 {
return Err(err("no usable root certificate in CA PEM".to_string()));
}
}
None => {
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
}
let builder =
rustls::ClientConfig::builder_with_provider(Arc::new(provider::default_provider()))
.with_safe_default_protocol_versions()
.map_err(provider_init_err)?
.with_root_certificates(roots);
let config = match (&tls.client_cert_path, &tls.client_key_path) {
(None, None) => builder.with_no_client_auth(),
(Some(cert_path), Some(key_path)) => {
let cerr = |path: &str, reason: String| ControlError::UpstreamClientCert {
upstream: upstream_name.to_string(),
path: path.to_string(),
reason,
};
let cert_bytes = std::fs::read(base_dir.join(cert_path))
.map_err(|e| cerr(cert_path, format!("read failed: {e}")))?;
let chain = CertificateDer::pem_slice_iter(&cert_bytes)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| cerr(cert_path, format!("bad cert PEM: {e}")))?;
if chain.is_empty() {
return Err(cerr(
cert_path,
"no certificates in client cert PEM".to_string(),
));
}
let key_file = base_dir.join(key_path);
owner_only(&key_file).map_err(|reason| cerr(key_path, reason))?;
let key_bytes = zeroize::Zeroizing::new(
std::fs::read(&key_file)
.map_err(|e| cerr(key_path, format!("read failed: {e}")))?,
);
let key = PrivateKeyDer::from_pem_slice(&key_bytes)
.map_err(|e| cerr(key_path, format!("bad key PEM: {e}")))?;
builder
.with_client_auth_cert(chain, key)
.map_err(|e| cerr(cert_path, format!("client cert/key rejected: {e}")))?
}
(Some(path), None) | (None, Some(path)) => {
return Err(ControlError::UpstreamClientCert {
upstream: upstream_name.to_string(),
path: path.clone(),
reason: "client_cert_path and client_key_path must be set together — half an \
identity cannot be presented (fail-closed)"
.to_string(),
});
}
};
Ok(Arc::new(config))
}
fn owner_only(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)
.map_err(|e| format!("read failed: {e}"))?
.permissions()
.mode();
if mode & 0o077 != 0 {
return Err(format!(
"file mode {:o} is readable by group/other — chmod 600 (owner-only) required",
mode & 0o777
));
}
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
pub(crate) fn parse_upstream_sni(
upstream_name: &str,
sni: &str,
) -> Result<rustls::pki_types::ServerName<'static>, ControlError> {
rustls::pki_types::ServerName::try_from(sni.to_string()).map_err(|e| {
ControlError::UpstreamTlsSni {
upstream: upstream_name.to_string(),
sni: sni.to_string(),
reason: e.to_string(),
}
})
}
fn cert_set_binding(
certified: &[Arc<CertifiedKey>],
resumption: &Resumption,
) -> Result<[u8; 32], ControlError> {
use sha2::{Digest, Sha256};
let mut fingerprints: Vec<[u8; 32]> = Vec::with_capacity(certified.len());
for certified_key in certified {
let spki = certified_key
.key
.public_key()
.ok_or_else(|| ControlError::Stek {
path: resumption.stek_file.clone(),
reason:
"a [[tls]] key's SPKI is unavailable from the provider; shared STEK cannot \
bind tickets to the cert set (ADR 000062 (a))"
.to_string(),
})?;
fingerprints.push(Sha256::digest(spki.as_ref()).into());
}
fingerprints.sort_unstable();
fingerprints.dedup();
let mut hasher = Sha256::new();
for fingerprint in &fingerprints {
hasher.update(fingerprint);
}
Ok(hasher.finalize().into())
}
fn provider_init_err(e: rustls::Error) -> ControlError {
ControlError::TlsCert {
host: None,
path: String::new(),
reason: format!("rustls provider init: {e}"),
}
}
fn load_certified_key(entry: &TlsCert, base_dir: &Path) -> Result<CertifiedKey, ControlError> {
let cert_chain = read_certs(entry, base_dir)?;
if cert_chain.is_empty() {
return Err(tls_err(entry, "no certificates in cert_path PEM"));
}
let key = read_key(entry, base_dir)?;
let signing_key = provider::sign::any_supported_type(&key)
.map_err(|e| tls_err(entry, &format!("unsupported private key: {e}")))?;
let certified = CertifiedKey::new(cert_chain, signing_key);
match certified.keys_match() {
Ok(()) | Err(rustls::Error::InconsistentKeys(rustls::InconsistentKeys::Unknown)) => {}
Err(e) => return Err(tls_err(entry, &format!("cert/key mismatch: {e}"))),
}
Ok(certified)
}
fn read_certs(
entry: &TlsCert,
base_dir: &Path,
) -> Result<Vec<CertificateDer<'static>>, ControlError> {
let bytes = std::fs::read(base_dir.join(&entry.cert_path))
.map_err(|e| tls_err_path(entry, &entry.cert_path, &format!("read failed: {e}")))?;
CertificateDer::pem_slice_iter(&bytes)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| tls_err_path(entry, &entry.cert_path, &format!("bad cert PEM: {e}")))
}
fn read_key(entry: &TlsCert, base_dir: &Path) -> Result<PrivateKeyDer<'static>, ControlError> {
let bytes = zeroize::Zeroizing::new(
std::fs::read(base_dir.join(&entry.key_path))
.map_err(|e| tls_err_path(entry, &entry.key_path, &format!("read failed: {e}")))?,
);
PrivateKeyDer::from_pem_slice(&bytes)
.map_err(|e| tls_err_path(entry, &entry.key_path, &format!("bad key PEM: {e}")))
}
fn tls_err(entry: &TlsCert, reason: &str) -> ControlError {
tls_err_path(entry, &entry.cert_path, reason)
}
fn tls_err_path(entry: &TlsCert, path: &str, reason: &str) -> ControlError {
ControlError::TlsCert {
host: entry.host.clone(),
path: path.to_string(),
reason: reason.to_string(),
}
}
impl crate::Control {
pub fn tls_config(&self) -> Option<Arc<ServerConfig>> {
self.active.load().tls.clone()
}
pub fn quic_tls_config(&self) -> Option<Arc<ServerConfig>> {
self.active.load().quic_tls.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resumption_entry(dir: &Path, fill: u8) -> Resumption {
let path = dir.join("stek.key");
std::fs::write(&path, [fill; crate::stek::STEK_FILE_LEN]).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
Resumption {
stek_file: path.to_str().unwrap().to_string(),
max_age_hours: 24,
}
}
fn default_cert_entry() -> (tempfile::TempDir, TlsCert) {
let generated = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, generated.cert.pem()).unwrap();
std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
let entry = TlsCert {
host: None,
cert_path: cert_path.to_str().unwrap().to_string(),
key_path: key_path.to_str().unwrap().to_string(),
};
(dir, entry)
}
#[test]
fn builds_tcp_and_quic_configs_with_distinct_alpn() {
let (dir, entry) = default_cert_entry();
let configs = build_server_configs(&[entry], None, None, dir.path())
.unwrap()
.expect("a cert entry yields TCP + QUIC configs");
assert_eq!(
configs.tcp.alpn_protocols,
vec![b"h2".to_vec(), b"http/1.1".to_vec()],
"TCP ALPN is h2 then http/1.1"
);
assert_eq!(
configs.quic.alpn_protocols,
vec![b"h3".to_vec()],
"QUIC ALPN is h3 only"
);
}
#[test]
fn stateless_resumption_invariants() {
let (dir, entry) = default_cert_entry();
let configs = build_server_configs(&[entry], None, None, dir.path())
.unwrap()
.expect("a cert entry yields TCP + QUIC configs");
for (label, config) in [("tcp", &configs.tcp), ("quic", &configs.quic)] {
assert!(
config.ticketer.enabled(),
"{label}: stateless session tickets are issued (ADR 000052)"
);
assert_eq!(
config.ticketer.lifetime(),
12 * 60 * 60,
"{label}: 12h acceptance window (6h rotation, current + previous key)"
);
assert!(
!config.session_storage.can_cache(),
"{label}: no stateful session cache — per-session server memory stays zero"
);
assert_eq!(
config.max_early_data_size, 0,
"{label}: 0-RTT stays refused (ADR 000016 / RFC 8470), independent of the \
ticketer-exclusivity rustls happens to enforce today"
);
}
}
#[test]
fn ticket_key_is_process_wide_across_paths_and_builds() {
let (dir, entry) = default_cert_entry();
let a = build_server_configs(std::slice::from_ref(&entry), None, None, dir.path())
.unwrap()
.unwrap();
let b = build_server_configs(&[entry], None, None, dir.path())
.unwrap()
.unwrap();
assert!(
Arc::ptr_eq(&a.tcp.ticketer, &a.quic.ticketer),
"TCP and QUIC share one ticket producer"
);
assert!(
Arc::ptr_eq(&a.tcp.ticketer, &b.tcp.ticketer),
"a rebuild (reload) keeps the process-lifetime ticket key"
);
}
#[test]
fn no_tls_entries_yields_none() {
assert!(
build_server_configs(&[], None, None, std::path::Path::new("."))
.unwrap()
.is_none(),
"no [[tls]] means no TLS/QUIC configs (plain HTTP/1.1, no h3)"
);
}
#[test]
fn mismatched_cert_and_key_is_rejected() {
let a = rcgen::generate_simple_self_signed(vec!["a.example".to_string()]).unwrap();
let b = rcgen::generate_simple_self_signed(vec!["b.example".to_string()]).unwrap();
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, a.cert.pem()).unwrap(); std::fs::write(&key_path, b.key_pair.serialize_pem()).unwrap(); let entry = TlsCert {
host: None,
cert_path: cert_path.to_str().unwrap().to_string(),
key_path: key_path.to_str().unwrap().to_string(),
};
let err = match build_server_configs(&[entry], None, None, dir.path()) {
Err(e) => e,
Ok(_) => panic!("a mismatched cert/key pair must be rejected at build"),
};
assert!(matches!(err, ControlError::TlsCert { .. }));
}
#[test]
fn shared_stek_keeps_the_resumption_invariants() {
let (dir, entry) = default_cert_entry();
let resumption = resumption_entry(dir.path(), 7);
let configs = build_server_configs(&[entry], Some(&resumption), None, dir.path())
.unwrap()
.unwrap();
for (label, config) in [("tcp", &configs.tcp), ("quic", &configs.quic)] {
assert!(config.ticketer.enabled(), "{label}: tickets are issued");
assert_eq!(
config.ticketer.lifetime(),
24 * 60 * 60,
"{label}: the hint is max_age_hours, not the per-node 12h"
);
assert!(!config.session_storage.can_cache(), "{label}: no cache");
assert_eq!(
config.max_early_data_size, 0,
"{label}: 0-RTT stays refused"
);
}
assert!(
Arc::ptr_eq(&configs.tcp.ticketer, &configs.quic.ticketer),
"TCP and QUIC share the one shared-STEK producer (same cert set → same keys)"
);
}
#[test]
fn shared_stek_rebuilds_derive_interchangeable_keys() {
let (dir, entry) = default_cert_entry();
let resumption = resumption_entry(dir.path(), 7);
let a = build_server_configs(
std::slice::from_ref(&entry),
Some(&resumption),
None,
dir.path(),
)
.unwrap()
.unwrap();
let b = build_server_configs(&[entry], Some(&resumption), None, dir.path())
.unwrap()
.unwrap();
assert!(
!Arc::ptr_eq(&a.tcp.ticketer, &b.tcp.ticketer),
"sanity: rebuilds construct distinct ticketer objects"
);
let ticket = a.tcp.ticketer.encrypt(b"session-state").unwrap();
assert_eq!(
b.tcp.ticketer.decrypt(&ticket).as_deref(),
Some(&b"session-state"[..]),
"a reload (or another replica) re-derives the same keys"
);
}
#[test]
fn shared_stek_binds_tickets_to_the_cert_set() {
let (dir_a, entry_a) = default_cert_entry();
let (dir_b, entry_b) = default_cert_entry(); let resumption = resumption_entry(dir_a.path(), 7);
let a = build_server_configs(&[entry_a], Some(&resumption), None, dir_a.path())
.unwrap()
.unwrap();
let b = build_server_configs(&[entry_b], Some(&resumption), None, dir_b.path())
.unwrap()
.unwrap();
let ticket = a.tcp.ticketer.encrypt(b"session-state").unwrap();
assert_eq!(
b.tcp.ticketer.decrypt(&ticket),
None,
"a deployment with a different cert set must not accept the ticket"
);
}
#[test]
fn resumption_without_tls_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let resumption = resumption_entry(dir.path(), 7);
let err = match build_server_configs(&[], Some(&resumption), None, dir.path()) {
Err(e) => e,
Ok(_) => panic!("[resumption] with no [[tls]] must fail the build"),
};
assert!(matches!(err, ControlError::Stek { .. }));
}
#[test]
fn shared_stek_bad_file_fails_the_build_closed() {
let (dir, entry) = default_cert_entry();
let mut resumption = resumption_entry(dir.path(), 7);
std::fs::write(&resumption.stek_file, [7u8; 48]).unwrap();
let err = match build_server_configs(
std::slice::from_ref(&entry),
Some(&resumption),
None,
dir.path(),
) {
Err(e) => e,
Ok(_) => panic!("a 48-byte file must be rejected"),
};
assert!(matches!(err, ControlError::Stek { .. }));
resumption = resumption_entry(dir.path(), 7);
resumption.max_age_hours = 169;
let err = match build_server_configs(&[entry], Some(&resumption), None, dir.path()) {
Err(e) => e,
Ok(_) => panic!("max_age_hours over the RFC 8446 cap must be rejected"),
};
assert!(matches!(err, ControlError::Stek { .. }));
}
fn client_auth_entry(dir: &Path, ca_pem: &[u8]) -> ClientAuth {
let path = dir.join("client-ca.pem");
std::fs::write(&path, ca_pem).unwrap();
ClientAuth {
ca_path: path.to_str().unwrap().to_string(),
}
}
fn some_ca_pem() -> Vec<u8> {
rcgen::generate_simple_self_signed(vec!["client-ca".to_string()])
.unwrap()
.cert
.pem()
.into_bytes()
}
fn upstream_client_identity(dir: &Path) -> (String, String) {
let generated = rcgen::generate_simple_self_signed(vec!["plecto".to_string()]).unwrap();
let cert_path = dir.join("client-cert.pem");
let key_path = dir.join("client-key.pem");
std::fs::write(&cert_path, generated.cert.pem()).unwrap();
std::fs::write(&key_path, generated.key_pair.serialize_pem()).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
(
cert_path.to_str().unwrap().to_string(),
key_path.to_str().unwrap().to_string(),
)
}
fn upstream_tls_with_identity(
cert: Option<String>,
key: Option<String>,
) -> crate::manifest::UpstreamTls {
crate::manifest::UpstreamTls {
client_cert_path: cert,
client_key_path: key,
..Default::default()
}
}
#[test]
fn upstream_client_cert_without_key_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let (cert, _key) = upstream_client_identity(dir.path());
let tls = upstream_tls_with_identity(Some(cert), None);
let err = match build_upstream_client_config("u", &tls, dir.path()) {
Err(e) => e,
Ok(_) => panic!("client_cert_path without client_key_path must fail the build"),
};
assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
}
#[test]
fn upstream_client_key_without_cert_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let (_cert, key) = upstream_client_identity(dir.path());
let tls = upstream_tls_with_identity(None, Some(key));
let err = match build_upstream_client_config("u", &tls, dir.path()) {
Err(e) => e,
Ok(_) => panic!("client_key_path without client_cert_path must fail the build"),
};
assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
}
#[test]
fn upstream_client_identity_is_loaded_into_the_config() {
let dir = tempfile::tempdir().unwrap();
let (cert, key) = upstream_client_identity(dir.path());
let tls = upstream_tls_with_identity(Some(cert), Some(key));
let config = build_upstream_client_config("u", &tls, dir.path()).unwrap();
assert!(
config.client_auth_cert_resolver.has_certs(),
"the declared client identity must be presented when the upstream requests one"
);
}
#[cfg(unix)]
#[test]
fn upstream_client_key_readable_by_group_fails_closed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let (cert, key) = upstream_client_identity(dir.path());
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o640)).unwrap();
let tls = upstream_tls_with_identity(Some(cert), Some(key));
let err = match build_upstream_client_config("u", &tls, dir.path()) {
Err(e) => e,
Ok(_) => panic!("a group-readable client key must fail the build (ADR 000062 (d))"),
};
assert!(matches!(err, ControlError::UpstreamClientCert { .. }));
}
#[test]
fn client_auth_ticketer_is_isolated_and_rotates_with_the_ca() {
let (dir, entry) = default_cert_entry();
let without = build_server_configs(std::slice::from_ref(&entry), None, None, dir.path())
.unwrap()
.unwrap();
let ca = some_ca_pem();
let auth = client_auth_entry(dir.path(), &ca);
let with = build_server_configs(
std::slice::from_ref(&entry),
None,
Some((&auth, ca.as_slice())),
dir.path(),
)
.unwrap()
.unwrap();
assert!(
!Arc::ptr_eq(&without.tcp.ticketer, &with.tcp.ticketer),
"client_auth must not reuse the anonymous process-wide ticketer"
);
let again = build_server_configs(
std::slice::from_ref(&entry),
None,
Some((&auth, ca.as_slice())),
dir.path(),
)
.unwrap()
.unwrap();
assert!(
Arc::ptr_eq(&with.tcp.ticketer, &again.tcp.ticketer),
"an unchanged CA keeps tickets valid across rebuilds (reload continuity)"
);
let rotated_ca = some_ca_pem();
let rotated = build_server_configs(
&[entry],
None,
Some((&auth, rotated_ca.as_slice())),
dir.path(),
)
.unwrap()
.unwrap();
assert!(
!Arc::ptr_eq(&with.tcp.ticketer, &rotated.tcp.ticketer),
"a CA rotation re-keys the ticketer (tickets cannot outlive the verifier's roots)"
);
assert!(
Arc::ptr_eq(&with.tcp.ticketer, &with.quic.ticketer),
"TCP and QUIC still share one ticketer within a single build"
);
}
#[test]
fn client_auth_with_shared_stek_fails_closed() {
let (dir, entry) = default_cert_entry();
let resumption = resumption_entry(dir.path(), 7);
let ca = some_ca_pem();
let auth = client_auth_entry(dir.path(), &ca);
let err = match build_server_configs(
&[entry],
Some(&resumption),
Some((&auth, ca.as_slice())),
dir.path(),
) {
Err(e) => e,
Ok(_) => {
panic!("[listen.client_auth] with [resumption] shared STEK must fail the build")
}
};
assert!(matches!(err, ControlError::Stek { .. }));
}
fn pump_tls(client: &mut rustls::ClientConnection, server: &mut rustls::ServerConnection) {
loop {
let mut progressed = false;
while client.wants_write() {
let mut buf = Vec::new();
let n = client.write_tls(&mut buf).unwrap();
if n == 0 {
break;
}
server.read_tls(&mut &buf[..]).unwrap();
server.process_new_packets().unwrap();
progressed = true;
}
while server.wants_write() {
let mut buf = Vec::new();
let n = server.write_tls(&mut buf).unwrap();
if n == 0 {
break;
}
client.read_tls(&mut &buf[..]).unwrap();
client.process_new_packets().unwrap();
progressed = true;
}
if !progressed {
break;
}
}
}
#[test]
fn client_auth_peer_certificates_survive_per_node_resumption() {
let (dir, entry) = default_cert_entry();
let client_id =
rcgen::generate_simple_self_signed(vec!["plecto-client".to_string()]).unwrap();
let ca = client_id.cert.pem().into_bytes();
let auth = client_auth_entry(dir.path(), &ca);
let configs = build_server_configs(
std::slice::from_ref(&entry),
None,
Some((&auth, ca.as_slice())),
dir.path(),
)
.unwrap()
.expect("client-auth listener yields TCP + QUIC configs");
let server_pem = std::fs::read(&entry.cert_path).unwrap();
let server_certs: Vec<CertificateDer<'static>> =
CertificateDer::pem_slice_iter(&server_pem)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(server_certs[0].clone()).unwrap();
let client_config = Arc::new(
rustls::ClientConfig::builder_with_provider(Arc::new(provider::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(roots)
.with_client_auth_cert(
vec![CertificateDer::from(client_id.cert.der().to_vec())],
PrivateKeyDer::try_from(client_id.key_pair.serialize_der()).unwrap(),
)
.unwrap(),
);
let server_name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
let mut client =
rustls::ClientConnection::new(client_config.clone(), server_name.clone()).unwrap();
let mut server = rustls::ServerConnection::new(configs.tcp.clone()).unwrap();
for _ in 0..64 {
pump_tls(&mut client, &mut server);
if !client.is_handshaking() && !server.is_handshaking() {
break;
}
}
assert!(
!client.is_handshaking() && !server.is_handshaking(),
"full handshake must complete"
);
for _ in 0..16 {
pump_tls(&mut client, &mut server);
}
let full_chain = server
.peer_certificates()
.expect("full handshake must expose the verified client chain")
.to_vec();
assert!(
!full_chain.is_empty(),
"verified client chain must be non-empty"
);
assert_eq!(
client.handshake_kind(),
Some(rustls::HandshakeKind::Full),
"first connection is a full handshake"
);
let mut client2 = rustls::ClientConnection::new(client_config, server_name).unwrap();
let mut server2 = rustls::ServerConnection::new(configs.tcp).unwrap();
for _ in 0..64 {
pump_tls(&mut client2, &mut server2);
if !client2.is_handshaking() && !server2.is_handshaking() {
break;
}
}
assert_eq!(
client2.handshake_kind(),
Some(rustls::HandshakeKind::Resumed),
"second connection must resume with the per-node ticket"
);
let resumed_chain = server2
.peer_certificates()
.expect("resumed handshake must restore peer_certificates from the ticket")
.to_vec();
assert_eq!(
resumed_chain, full_chain,
"ticket-restored identity must match the originally verified chain"
);
}
#[test]
fn client_auth_without_tls_entries_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let ca = some_ca_pem();
let auth = client_auth_entry(dir.path(), &ca);
let err = match build_server_configs(&[], None, Some((&auth, ca.as_slice())), dir.path()) {
Err(e) => e,
Ok(_) => panic!("[listen.client_auth] with no [[tls]] must fail the build"),
};
assert!(matches!(err, ControlError::ClientAuthCa { .. }));
}
#[test]
fn client_auth_ca_with_no_usable_root_fails_closed() {
let (dir, entry) = default_cert_entry();
let auth = client_auth_entry(dir.path(), b"not a pem at all");
let err = match build_server_configs(
&[entry],
None,
Some((&auth, b"not a pem at all".as_slice())),
dir.path(),
) {
Err(e) => e,
Ok(_) => panic!("an unusable client-auth CA bundle must fail the build"),
};
assert!(matches!(err, ControlError::ClientAuthCa { .. }));
}
}