Files
Yuedong Wu ff028ce0df feat(server): support TLS certificate hot-reload (#1870)
* feat(server): support TLS certificate hot-reload

Signed-off-by: Yuedong Wu <dwcn22@outlook.com>

* refactor(server): extract shared TLS test utilities

Signed-off-by: Yuedong Wu <dwcn22@outlook.com>

---------

Signed-off-by: Yuedong Wu <dwcn22@outlook.com>
2026-06-16 16:16:13 -07:00

909 lines
36 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! TLS support using tokio-rustls.
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use arc_swap::ArcSwap;
use notify::event::EventKind;
use notify::{Event, RecursiveMode, Watcher};
use openshell_core::{Error, Result};
use openshell_ocsf::{
ConfigStateChangeBuilder, OCSF_TARGET, SandboxContext, SeverityId, StateId, StatusId,
};
use rustls::ServerConfig;
use rustls::crypto::ring::sign;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::WebPkiClientVerifier;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info, warn};
/// TLS acceptor for wrapping connections.
///
/// Uses `ArcSwap` internally so the active `ServerConfig` can be atomically
/// swapped by a background reload worker without blocking TLS handshakes.
///
/// Stores the cert/key/CA paths from construction so that `reload()` can
/// re-read from the same files without the caller tracking them separately.
#[derive(Clone)]
pub struct TlsAcceptor {
config: Arc<ArcSwap<ServerConfig>>,
cert_path: PathBuf,
key_path: PathBuf,
client_ca_path: Option<PathBuf>,
require_client_auth: bool,
reload_spawned: Arc<AtomicBool>,
}
impl TlsAcceptor {
/// Create a new TLS acceptor from certificate and key files.
///
/// When `client_ca_path` is `Some` and `require_client_auth` is `true`,
/// the TLS handshake rejects connections that do not present a valid
/// client certificate signed by the given CA.
///
/// When `client_ca_path` is `Some` and `require_client_auth` is `false`,
/// client certificates are validated against the CA but not required.
/// Clients may connect without a certificate; presented certs from an
/// unknown CA are still rejected.
///
/// When `client_ca_path` is `None`, the server does not request client
/// certificates at all (HTTPS-only).
///
/// # Errors
///
/// Returns an error if the certificate, key, or CA files cannot be read or parsed.
pub fn from_files(
cert_path: &Path,
key_path: &Path,
client_ca_path: Option<&Path>,
require_client_auth: bool,
) -> Result<Self> {
let config = build_server_config(cert_path, key_path, client_ca_path, require_client_auth)?;
Ok(Self {
config: Arc::new(ArcSwap::from(config)),
cert_path: cert_path.to_path_buf(),
key_path: key_path.to_path_buf(),
client_ca_path: client_ca_path.map(Path::to_path_buf),
require_client_auth,
reload_spawned: Arc::new(AtomicBool::new(false)),
})
}
/// Re-read certificates from the same paths used at construction and
/// atomically swap the active config.
///
/// Returns `Ok(())` when the new config was built and swapped successfully.
/// Returns `Err(...)` if cert/key loading fails — the old config is preserved.
pub fn reload(&self) -> Result<()> {
let new_config = build_server_config(
&self.cert_path,
&self.key_path,
self.client_ca_path.as_deref(),
self.require_client_auth,
)?;
self.config.store(new_config);
let event = ConfigStateChangeBuilder::new(&tls_ocsf_ctx())
.severity(SeverityId::Informational)
.status(StatusId::Success)
.state(StateId::Enabled, "reloaded")
.message("TLS certificate config reloaded successfully")
.build();
info!(
target: OCSF_TARGET,
sandbox_id = "",
message = %event.format_shorthand()
);
Ok(())
}
/// Return a fresh `tokio_rustls::TlsAcceptor` backed by the current config
/// snapshot. Each call clones the active `Arc<ServerConfig>` so it remains
/// alive for the duration of the TLS handshake.
#[must_use]
pub fn acceptor(&self) -> tokio_rustls::TlsAcceptor {
tokio_rustls::TlsAcceptor::from(self.config.load_full())
}
/// Spawn a background worker that watches the parent directories of the
/// cert, key, and CA files and calls [`reload`](Self::reload) when changes
/// are detected.
///
/// A 1-second debounce window coalesces rapid filesystem events (such as
/// Kubernetes Secret volume atomic swaps) into a single reload. If reload
/// fails, the old config is preserved and a warning is logged.
///
/// The worker exits when the `shutdown` watch channel fires, allowing the
/// gateway to perform a graceful shutdown without orphaned reload
/// tasks.
pub fn spawn_reload_worker(
&self,
mut shutdown: watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
if self.reload_spawned.swap(true, Ordering::Relaxed) {
warn!("TLS certificate reload worker already spawned, ignoring duplicate call");
return tokio::spawn(async {});
}
let this = self.clone();
// Collect unique parent directories to watch.
let cert_dir = self.cert_path.parent().unwrap_or_else(|| Path::new("."));
let key_dir = self.key_path.parent().unwrap_or_else(|| Path::new("."));
let mut dirs = vec![cert_dir.to_path_buf()];
if key_dir != cert_dir {
dirs.push(key_dir.to_path_buf());
}
if let Some(ref ca) = self.client_ca_path {
let ca_dir = ca.parent().unwrap_or_else(|| Path::new("."));
if ca_dir != cert_dir && ca_dir != key_dir {
dirs.push(ca_dir.to_path_buf());
}
}
let debounce = Duration::from_secs(1);
tokio::spawn(async move {
let (tx, mut rx) = mpsc::unbounded_channel();
// recommended_watcher runs its own thread; we bridge events into
// the tokio runtime via the unbounded mpsc channel.
let mut watcher = match notify::recommended_watcher(
move |res: std::result::Result<Event, notify::Error>| {
if let Ok(event) = res
&& matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_))
{
let _ = tx.send(());
}
},
) {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "Failed to start TLS cert file watcher, hot-reload disabled");
return;
}
};
for dir in &dirs {
if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) {
warn!(error = %e, dir = %dir.display(), "Failed to watch TLS cert directory, hot-reload disabled");
return;
}
}
info!(?dirs, "TLS certificate file watcher started");
// Event loop with manual debounce.
// When the watcher fires, we drain any follow-up events that
// arrive within the debounce window before calling reload().
// This handles Kubernetes Secret atomic-swap patterns where
// kubelet writes a new ..data directory and swaps symlinks in
// rapid succession.
'outer: loop {
// Wait for the first event (or shutdown).
let got_event = tokio::select! {
r = rx.recv() => r.is_some(),
_ = shutdown.changed() => break 'outer,
};
if !got_event {
warn!("TLS cert file watcher disconnected, hot-reload stopping");
break 'outer;
}
// Debounce: keep draining events for the debounce duration.
loop {
tokio::select! {
() = tokio::time::sleep(debounce) => {
// Debounce window elapsed — reload now.
if let Err(e) = this.reload() {
let event = ConfigStateChangeBuilder::new(&tls_ocsf_ctx())
.severity(SeverityId::Medium)
.status(StatusId::Failure)
.state(StateId::Enabled, "reload_failed")
.message(format!(
"TLS certificate reload failed: {e}"
))
.build();
info!(
target: OCSF_TARGET,
sandbox_id = "",
message = %event.format_shorthand()
);
warn!(error = %e, "TLS certificate reload failed, keeping existing config");
}
break;
}
r = rx.recv() => {
if r.is_some() {
// Another event arrived — reset debounce.
continue;
}
warn!("TLS cert file watcher disconnected, hot-reload stopping");
break 'outer;
}
_ = shutdown.changed() => {
debug!("TLS certificate reload worker stopped");
break 'outer;
}
}
}
}
// `watcher` dropped here — stops the underlying watcher thread.
})
}
}
/// Build a `ServerConfig` from certificate, key, and optional client CA files.
fn build_server_config(
cert_path: &Path,
key_path: &Path,
client_ca_path: Option<&Path>,
require_client_auth: bool,
) -> Result<Arc<ServerConfig>> {
let certs = load_certs(cert_path)?;
let key = load_key(key_path)?;
// Validate the key type early — rustls defers this to handshake time,
// which produces a cryptic error. A bad key type surfaces clearly here.
sign::any_supported_type(&key)
.map_err(|e| Error::tls(format!("unsupported private key type: {e}")))?;
let mut config = if let Some(ca_path) = client_ca_path {
let ca_certs = load_certs(ca_path)?;
let mut root_store = rustls::RootCertStore::empty();
for cert in ca_certs {
root_store
.add(cert)
.map_err(|e| Error::tls(format!("failed to add CA certificate: {e}")))?;
}
let verifier_builder = WebPkiClientVerifier::builder(Arc::new(root_store));
let verifier = if require_client_auth {
verifier_builder
} else {
verifier_builder.allow_unauthenticated()
}
.build()
.map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?;
ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?
} else {
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?
};
config
.alpn_protocols
.extend([b"h2".to_vec(), b"http/1.1".to_vec()]);
Ok(Arc::new(config))
}
/// Load certificates from a PEM file.
fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
let file =
File::open(path).map_err(|e| Error::tls(format!("failed to open cert file: {e}")))?;
let mut reader = BufReader::new(file);
let certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| Error::tls(format!("failed to parse certificates: {e}")))?;
if certs.is_empty() {
return Err(Error::tls("no certificates found in file"));
}
Ok(certs)
}
/// Load a private key from a PEM file.
fn load_key(path: &Path) -> Result<PrivateKeyDer<'static>> {
let file = File::open(path).map_err(|e| Error::tls(format!("failed to open key file: {e}")))?;
let mut reader = BufReader::new(file);
loop {
let item = rustls_pemfile::read_one(&mut reader)
.map_err(|e| Error::tls(format!("failed to parse key file: {e}")))?;
match item {
Some(rustls_pemfile::Item::Pkcs1Key(key)) => return Ok(key.into()),
Some(rustls_pemfile::Item::Pkcs8Key(key)) => return Ok(key.into()),
Some(rustls_pemfile::Item::Sec1Key(key)) => return Ok(key.into()),
None => break,
_ => {}
}
}
Err(Error::tls("no private key found in file"))
}
/// Build an OCSF context for gateway-level (non-sandbox) events.
fn tls_ocsf_ctx() -> SandboxContext {
SandboxContext {
sandbox_id: String::new(),
sandbox_name: String::new(),
container_image: "openshell/gateway".to_string(),
hostname: "openshell-gateway".to_string(),
product_version: openshell_core::VERSION.to_string(),
proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
proxy_port: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tls_test_utils::{
generate_test_certs_with_ca, install_rustls_provider, write_test_file,
};
use rcgen::{CertificateParams, IsCa, KeyPair, KeyUsagePurpose};
use tokio::net::{TcpListener, TcpStream};
/// Generate a new server cert + key in `dir`, signed by the given CA.
/// Overwrites `server-cert.pem` and `server-key.pem`.
fn generate_server_cert(ca_cert: &rcgen::Certificate, ca_key: &KeyPair, dir: &Path) {
let server_params = CertificateParams::new(vec!["localhost".to_string()])
.expect("failed to create server params");
let server_key = KeyPair::generate().expect("failed to generate server key");
let server_cert = server_params
.signed_by(&server_key, ca_cert, ca_key)
.expect("failed to sign server cert");
write_test_file(dir, "server-cert.pem", server_cert.pem().as_bytes());
write_test_file(dir, "server-key.pem", server_key.serialize_pem().as_bytes());
}
fn build_test_client_config(ca_path: &Path) -> Arc<rustls::ClientConfig> {
let ca_certs = load_certs(ca_path).expect("failed to load CA certs");
let mut root_store = rustls::RootCertStore::empty();
for cert in ca_certs {
root_store
.add(cert)
.expect("failed to add CA to root store");
}
Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth(),
)
}
/// Extract the DER-encoded server certificate from a TLS session.
fn peer_cert_der(tls: &tokio_rustls::client::TlsStream<TcpStream>) -> Vec<u8> {
tls.get_ref().1.peer_certificates().expect("no peer certs")[0]
.as_ref()
.to_vec()
}
#[test]
fn test_build_server_config() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let _ = generate_test_certs_with_ca(dir.path());
let config = build_server_config(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build server config");
assert!(config.alpn_protocols.contains(&b"h2".to_vec()));
assert!(config.alpn_protocols.contains(&b"http/1.1".to_vec()));
}
#[test]
fn test_reload_success() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
generate_server_cert(&ca_cert, &ca_key, dir.path());
acceptor.reload().expect("reload should succeed");
}
#[test]
fn test_reload_invalid_preserves_old() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
// Snapshot the current config before corrupting files.
let acceptor_before = acceptor.acceptor();
std::fs::write(dir.path().join("server-cert.pem"), b"garbage")
.expect("failed to write garbage");
assert!(
acceptor.reload().is_err(),
"reload with garbage cert should fail"
);
// Old config must still be accessible after a failed reload.
drop(acceptor_before);
let _ = acceptor.acceptor(); // does not panic
}
#[tokio::test(flavor = "multi_thread")]
async fn test_concurrent_handshake_and_reload() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
let client_config = build_test_client_config(&dir.path().join("ca.pem"));
let connector = tokio_rustls::TlsConnector::from(client_config);
let server_name =
rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name");
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let listen_addr = listener.local_addr().expect("failed to get local addr");
let acceptor_for_server = acceptor.clone();
let (server_done_tx, mut server_done_rx) = watch::channel(false);
let server_handle = tokio::spawn(async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _)) => {
let acc = acceptor_for_server.clone();
tokio::spawn(async move {
let _ = acc.acceptor().accept(stream).await;
});
}
Err(_) => break,
}
}
_ = server_done_rx.changed() => break,
}
}
});
let num_clients = 10;
let cycles_per_client = 5;
let mut client_handles = Vec::with_capacity(num_clients);
for i in 0..num_clients {
let connector = connector.clone();
let name = server_name.clone();
let addr = listen_addr;
client_handles.push(tokio::spawn(async move {
for _ in 0..cycles_per_client {
let tcp = TcpStream::connect(addr)
.await
.expect("client connect failed");
let _tls = connector
.connect(name.clone(), tcp)
.await
.expect("client TLS handshake failed");
}
i
}));
}
let reload_handle = tokio::spawn(async move {
for _ in 0..20 {
generate_server_cert(&ca_cert, &ca_key, dir.path());
acceptor
.reload()
.expect("reload with valid cert should succeed");
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
let mut client_failures = 0;
for handle in client_handles {
match handle.await {
Err(e) if e.is_panic() => client_failures += 1,
_ => {}
}
}
assert_eq!(client_failures, 0, "some client tasks panicked");
reload_handle.await.expect("reload task panicked");
let _ = server_done_tx.send(true);
let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await;
}
#[tokio::test]
async fn test_reload_serves_new_cert() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
let client_config = build_test_client_config(&dir.path().join("ca.pem"));
let connector = tokio_rustls::TlsConnector::from(client_config);
let server_name =
rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name");
// Connection 1: original cert
let listener1 = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr1 = listener1.local_addr().expect("failed to get local addr");
let acc1 = acceptor.clone();
let server_task_1 = tokio::spawn(async move {
let (stream, _) = listener1.accept().await.expect("accept failed");
acc1.acceptor()
.accept(stream)
.await
.expect("TLS accept failed");
});
let tcp_1 = TcpStream::connect(addr1).await.expect("connect failed");
let tls_1 = connector
.connect(server_name.clone(), tcp_1)
.await
.expect("TLS handshake failed");
let server_cert_1 = peer_cert_der(&tls_1);
server_task_1.await.expect("server task 1 failed");
assert!(!server_cert_1.is_empty());
// Generate new server cert + reload
generate_server_cert(&ca_cert, &ca_key, dir.path());
acceptor.reload().expect("reload should succeed");
// Connection 2: new cert on a fresh listener
let listener2 = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let addr2 = listener2.local_addr().expect("failed to get local addr");
let acc2 = acceptor.clone();
let server_task_2 = tokio::spawn(async move {
let (stream, _) = listener2.accept().await.expect("accept failed");
acc2.acceptor()
.accept(stream)
.await
.expect("TLS accept failed");
});
let tcp_2 = TcpStream::connect(addr2).await.expect("connect failed");
let tls_2 = connector
.connect(server_name.clone(), tcp_2)
.await
.expect("TLS handshake failed");
let server_cert_2 = peer_cert_der(&tls_2);
server_task_2.await.expect("server task 2 failed");
assert!(!server_cert_2.is_empty());
assert_ne!(
server_cert_1, server_cert_2,
"served cert should change after reload"
);
}
#[tokio::test]
async fn test_reload_worker_shutdown() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = acceptor.spawn_reload_worker(shutdown_rx);
// Give the watcher time to start
tokio::time::sleep(Duration::from_millis(200)).await;
// Send shutdown signal and verify the worker exits promptly.
// shutdown.changed() is checked in both the outer select (waiting
// for first event) and the inner debounce loop — no file change is
// needed to exercise the shutdown path.
let _ = shutdown_tx.send(true);
tokio::time::timeout(Duration::from_secs(2), handle)
.await
.expect("reload worker should exit after shutdown signal")
.expect("reload worker should not panic");
}
#[tokio::test]
async fn test_reload_worker_detects_file_change() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
false,
)
.expect("failed to build acceptor");
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = acceptor.spawn_reload_worker(shutdown_rx);
// Give the watcher time to start
tokio::time::sleep(Duration::from_millis(200)).await;
// Capture the original cert DER so we can distinguish a stale
// handshake from a genuinely reloaded config.
let original_der = load_certs(&dir.path().join("server-cert.pem"))
.expect("failed to load original cert")[0]
.as_ref()
.to_vec();
// Generate new server cert — the file watcher should detect this
generate_server_cert(&ca_cert, &ca_key, dir.path());
// Retry TLS handshake until the watcher picks up the new cert or
// the deadline expires. Follows the wait_for_status pattern
// (health_endpoint_integration.rs).
let client_config = build_test_client_config(&dir.path().join("ca.pem"));
let connector = tokio_rustls::TlsConnector::from(client_config);
let server_name =
rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name");
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let listen_addr = listener.local_addr().expect("failed to get local addr");
let acc = acceptor.clone();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept failed");
acc.acceptor().accept(stream).await
});
let tcp = TcpStream::connect(listen_addr)
.await
.expect("connect failed");
let handshake_result = tokio::time::timeout(
Duration::from_millis(500),
connector.connect(server_name.clone(), tcp),
)
.await;
if let Ok(Ok(tls)) = handshake_result {
let served_der = peer_cert_der(&tls);
server_task
.await
.expect("server task panicked")
.expect("TLS accept should succeed");
if served_der != original_der {
break;
}
// Handshake succeeded but cert still matches original —
// watcher hasn't reloaded yet, keep retrying.
} else {
server_task.abort();
}
assert!(
tokio::time::Instant::now() < deadline,
"watcher did not detect cert change within 10s"
);
tokio::time::sleep(Duration::from_millis(200)).await;
}
// Clean shutdown
let _ = shutdown_tx.send(true);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn test_reload_mtls_ca_rotation() {
install_rustls_provider();
let dir = tempfile::tempdir().expect("failed to create tempdir");
let (initial_ca_cert, initial_ca_key) = generate_test_certs_with_ca(dir.path());
let acceptor = TlsAcceptor::from_files(
&dir.path().join("server-cert.pem"),
&dir.path().join("server-key.pem"),
Some(&dir.path().join("ca.pem")),
true, // require mTLS
)
.expect("failed to build acceptor with mTLS");
// Generate new CA and overwrite ca.pem
let mut new_ca_params =
CertificateParams::new(Vec::<String>::new()).expect("failed to create CA params");
new_ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
new_ca_params
.distinguished_name
.push(rcgen::DnType::CommonName, "new-ca");
let new_ca_key = KeyPair::generate().expect("failed to generate new CA key");
let new_ca_cert = new_ca_params
.self_signed(&new_ca_key)
.expect("failed to sign new CA cert");
std::fs::write(dir.path().join("ca.pem"), new_ca_cert.pem().as_bytes())
.expect("failed to write new CA");
// Generate new server cert signed by new CA and reload
generate_server_cert(&new_ca_cert, &new_ca_key, dir.path());
acceptor
.reload()
.expect("reload with new CA should succeed");
// Generate client cert signed by new CA, write to files
let client_key = KeyPair::generate().expect("failed to generate client key");
let mut client_params =
CertificateParams::new(Vec::<String>::new()).expect("failed to create client params");
client_params
.distinguished_name
.push(rcgen::DnType::CommonName, "test-client");
client_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let client_cert = client_params
.signed_by(&client_key, &new_ca_cert, &new_ca_key)
.expect("failed to sign client cert");
// Write client cert + key as PEM files and load via load_certs/load_key
let client_cert_path = dir.path().join("client-cert.pem");
let client_key_path = dir.path().join("client-key.pem");
std::fs::write(&client_cert_path, client_cert.pem().as_bytes())
.expect("failed to write client cert");
std::fs::write(&client_key_path, client_key.serialize_pem().as_bytes())
.expect("failed to write client key");
let client_cert_chain = load_certs(&client_cert_path).expect("failed to load client cert");
let client_private_key = load_key(&client_key_path).expect("failed to load client key");
let mut root_store = rustls::RootCertStore::empty();
root_store
.add(CertificateDer::from(new_ca_cert.der().to_vec()))
.expect("failed to add new CA to root store");
let new_ca_client_config = Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_client_auth_cert(client_cert_chain, client_private_key)
.expect("failed to set client auth cert"),
);
// Verify a real handshake succeeds with the new CA
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let listen_addr = listener.local_addr().expect("failed to get local addr");
let acceptor_srv = acceptor.clone();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept failed");
acceptor_srv
.acceptor()
.accept(stream)
.await
.expect("TLS accept with new mTLS CA failed");
});
let connector = tokio_rustls::TlsConnector::from(new_ca_client_config);
let server_name =
rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name");
let tcp = TcpStream::connect(listen_addr)
.await
.expect("connect failed");
connector
.connect(server_name.clone(), tcp)
.await
.expect("mTLS handshake with new CA should succeed");
server_task.await.expect("server task failed");
// Verify old CA is no longer trusted: a client cert signed by the
// initial CA should be rejected after rotation.
let old_client_key = KeyPair::generate().expect("failed to generate old client key");
let mut old_client_params = CertificateParams::new(Vec::<String>::new())
.expect("failed to create old client params");
old_client_params
.distinguished_name
.push(rcgen::DnType::CommonName, "old-client");
old_client_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let old_client_cert = old_client_params
.signed_by(&old_client_key, &initial_ca_cert, &initial_ca_key)
.expect("failed to sign old client cert");
let old_cert_path = dir.path().join("old-client-cert.pem");
let old_key_path = dir.path().join("old-client-key.pem");
std::fs::write(&old_cert_path, old_client_cert.pem().as_bytes())
.expect("failed to write old client cert");
std::fs::write(&old_key_path, old_client_key.serialize_pem().as_bytes())
.expect("failed to write old client key");
let old_cert_chain = load_certs(&old_cert_path).expect("failed to load old client cert");
let old_key_der = load_key(&old_key_path).expect("failed to load old client key");
let mut old_root_store = rustls::RootCertStore::empty();
old_root_store
.add(CertificateDer::from(new_ca_cert.der().to_vec()))
.expect("failed to add new CA to root store");
let old_ca_client_config = Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(old_root_store)
.with_client_auth_cert(old_cert_chain, old_key_der)
.expect("failed to set old client auth cert"),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("failed to bind");
let listen_addr = listener.local_addr().expect("failed to get local addr");
let acceptor_srv = acceptor.clone();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept failed");
let result = acceptor_srv.acceptor().accept(stream).await;
assert!(
result.is_err(),
"TLS accept should reject client cert signed by old CA"
);
});
// Drive the client side so the server has something to accept.
// tokio_rustls::TlsConnector::connect() may return Ok even when the
// server rejects the client cert (TLS 1.3 post-handshake auth),
// so the authoritative check is the server-side accept result above.
let connector = tokio_rustls::TlsConnector::from(old_ca_client_config);
let tcp = TcpStream::connect(listen_addr)
.await
.expect("connect failed");
let _ = connector.connect(server_name, tcp).await;
server_task.await.expect("server task failed");
}
}