use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use rustls::crypto::CryptoProvider;
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use rustls_pki_types::pem::PemObject as _;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
pub const DEFAULT_RELOAD_INTERVAL_SECS: u64 = 60;
#[derive(Debug, thiserror::Error)]
pub enum TlsError {
#[error("failed to read TLS certificate file `{path}`: {source}")]
ReadCert {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to read TLS private key file `{path}`: {source}")]
ReadKey {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse a PEM certificate in `{path}`: {source}")]
ParseCert {
path: PathBuf,
source: rustls_pki_types::pem::Error,
},
#[error("no certificates found in `{path}` (expected at least one PEM CERTIFICATE block)")]
NoCertificates {
path: PathBuf,
},
#[error("failed to parse a PEM private key in `{path}`: {source}")]
ParseKey {
path: PathBuf,
source: rustls_pki_types::pem::Error,
},
#[error(
"the TLS private key `{key}` is invalid or does not match the leaf certificate `{cert}`: \
{source}"
)]
InvalidKeyPair {
cert: PathBuf,
key: PathBuf,
source: rustls::Error,
},
#[error("failed to parse the leaf certificate in `{path}` for expiry inspection: {detail}")]
ParseLeaf {
path: PathBuf,
detail: String,
},
#[error("certificate #{position} in the chain in `{path}` is malformed: {detail}")]
ParseChainCert {
path: PathBuf,
position: usize,
detail: String,
},
#[error("the leaf certificate in `{path}` expired at {not_after} (UNIX {not_after_unix})")]
Expired {
path: PathBuf,
not_after: String,
not_after_unix: i64,
},
#[error(
"the leaf certificate in `{path}` is not valid until {not_before} (UNIX {not_before_unix})"
)]
NotYetValid {
path: PathBuf,
not_before: String,
not_before_unix: i64,
},
#[error(
"certificate #{position} in the chain in `{path}` expired at {not_after} \
(UNIX {not_after_unix})"
)]
ExpiredChainCert {
path: PathBuf,
position: usize,
not_after: String,
not_after_unix: i64,
},
#[error(
"certificate #{position} in the chain in `{path}` is not yet valid until {not_before} \
(UNIX {not_before_unix})"
)]
NotYetValidChainCert {
path: PathBuf,
position: usize,
not_before: String,
not_before_unix: i64,
},
#[error("failed to build the rustls server configuration: {source}")]
BuildConfig {
source: rustls::Error,
},
}
#[must_use]
pub fn crypto_provider() -> Arc<CryptoProvider> {
Arc::new(rustls::crypto::ring::default_provider())
}
fn read_cert_chain(cert_path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let pem = std::fs::read(cert_path).map_err(|source| TlsError::ReadCert {
path: cert_path.to_path_buf(),
source,
})?;
let mut chain = Vec::new();
for cert in CertificateDer::pem_slice_iter(&pem) {
let cert = cert.map_err(|source| TlsError::ParseCert {
path: cert_path.to_path_buf(),
source,
})?;
chain.push(cert);
}
if chain.is_empty() {
return Err(TlsError::NoCertificates {
path: cert_path.to_path_buf(),
});
}
Ok(chain)
}
fn read_private_key(key_path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
let pem = std::fs::read(key_path).map_err(|source| TlsError::ReadKey {
path: key_path.to_path_buf(),
source,
})?;
PrivateKeyDer::from_pem_slice(&pem).map_err(|source| TlsError::ParseKey {
path: key_path.to_path_buf(),
source,
})
}
fn leaf_validity_unix(cert_path: &Path, leaf: &CertificateDer<'_>) -> Result<(i64, i64), TlsError> {
use x509_parser::prelude::FromDer as _;
let (_, parsed) =
x509_parser::certificate::X509Certificate::from_der(leaf.as_ref()).map_err(|e| {
TlsError::ParseLeaf {
path: cert_path.to_path_buf(),
detail: e.to_string(),
}
})?;
let validity = parsed.validity();
Ok((
validity.not_before.timestamp(),
validity.not_after.timestamp(),
))
}
fn validate_chain_certs(
cert_path: &Path,
chain: &[CertificateDer<'_>],
now_unix: i64,
) -> Result<(), TlsError> {
use x509_parser::prelude::FromDer as _;
for (idx, cert) in chain.iter().enumerate().skip(1) {
let position = idx + 1;
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(cert.as_ref())
.map_err(|e| TlsError::ParseChainCert {
path: cert_path.to_path_buf(),
position,
detail: e.to_string(),
})?;
let validity = parsed.validity();
let not_before = validity.not_before.timestamp();
let not_after = validity.not_after.timestamp();
if now_unix < not_before {
return Err(TlsError::NotYetValidChainCert {
path: cert_path.to_path_buf(),
position,
not_before: render_unix(not_before),
not_before_unix: not_before,
});
}
if not_after < now_unix {
return Err(TlsError::ExpiredChainCert {
path: cert_path.to_path_buf(),
position,
not_after: render_unix(not_after),
not_after_unix: not_after,
});
}
}
Ok(())
}
pub fn load_certified_key(
cert_path: &Path,
key_path: &Path,
provider: &CryptoProvider,
now_unix: i64,
) -> Result<Arc<CertifiedKey>, TlsError> {
let chain = read_cert_chain(cert_path)?;
let key = read_private_key(key_path)?;
let (not_before, not_after) = leaf_validity_unix(cert_path, &chain[0])?;
if now_unix < not_before {
return Err(TlsError::NotYetValid {
path: cert_path.to_path_buf(),
not_before: render_unix(not_before),
not_before_unix: not_before,
});
}
if not_after < now_unix {
return Err(TlsError::Expired {
path: cert_path.to_path_buf(),
not_after: render_unix(not_after),
not_after_unix: not_after,
});
}
validate_chain_certs(cert_path, &chain, now_unix)?;
let certified = CertifiedKey::from_der(chain, key, provider).map_err(|source| {
TlsError::InvalidKeyPair {
cert: cert_path.to_path_buf(),
key: key_path.to_path_buf(),
source,
}
})?;
Ok(Arc::new(certified))
}
fn render_unix(secs: i64) -> String {
x509_parser::time::ASN1Time::from_timestamp(secs)
.ok()
.map_or_else(|| format!("UNIX {secs}"), |t| t.to_string())
}
#[derive(Debug)]
pub struct ReloadableCertResolver {
current: RwLock<Arc<CertifiedKey>>,
}
impl ReloadableCertResolver {
#[must_use]
pub const fn new(initial: Arc<CertifiedKey>) -> Self {
Self {
current: RwLock::new(initial),
}
}
pub fn store(&self, next: Arc<CertifiedKey>) {
let mut guard = self
.current
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = next;
}
#[must_use]
pub fn current(&self) -> Arc<CertifiedKey> {
let guard = self
.current
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(&guard)
}
}
impl ResolvesServerCert for ReloadableCertResolver {
fn resolve(&self, _client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
Some(self.current())
}
}
pub fn build_server_config(
provider: Arc<CryptoProvider>,
resolver: Arc<ReloadableCertResolver>,
) -> Result<Arc<rustls::ServerConfig>, TlsError> {
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|source| TlsError::BuildConfig { source })?
.with_no_client_auth()
.with_cert_resolver(resolver);
Ok(Arc::new(config))
}
const MAX_CONCURRENT_HANDSHAKES: usize = 256;
const READY_CONN_CHANNEL_CAPACITY: usize = 1024;
pub struct TlsListener {
rx: tokio::sync::mpsc::Receiver<(
tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
std::net::SocketAddr,
)>,
local_addr: std::net::SocketAddr,
shutdown: tokio_util::sync::CancellationToken,
}
impl TlsListener {
#[must_use]
pub fn new(
tcp: tokio::net::TcpListener,
config: Arc<rustls::ServerConfig>,
handshake_timeout: std::time::Duration,
shutdown: tokio_util::sync::CancellationToken,
) -> Self {
let local_addr = tcp
.local_addr()
.expect("bound TLS listener must have a local address");
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let (tx, rx) = tokio::sync::mpsc::channel(READY_CONN_CHANNEL_CAPACITY);
let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_HANDSHAKES));
let acceptor_shutdown = shutdown.clone();
tokio::spawn(async move {
run_acceptor(
tcp,
acceptor,
handshake_timeout,
semaphore,
tx,
acceptor_shutdown,
)
.await;
});
Self {
rx,
local_addr,
shutdown,
}
}
}
async fn run_acceptor(
tcp: tokio::net::TcpListener,
acceptor: tokio_rustls::TlsAcceptor,
handshake_timeout: std::time::Duration,
semaphore: Arc<tokio::sync::Semaphore>,
tx: tokio::sync::mpsc::Sender<(
tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
std::net::SocketAddr,
)>,
shutdown: tokio_util::sync::CancellationToken,
) {
loop {
let (stream, peer) = tokio::select! {
() = shutdown.cancelled() => break,
result = tcp.accept() => match result {
Ok(pair) => pair,
Err(e) => {
if is_transient_connection_error(&e) {
continue;
}
tracing::error!(error = %e, "TLS listener accept error");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
},
};
let Ok(permit) = Arc::clone(&semaphore).acquire_owned().await else {
break;
};
let acceptor = acceptor.clone();
let tx = tx.clone();
tokio::spawn(async move {
let _permit = permit;
match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await {
Ok(Ok(tls)) => {
let _ = tx.send((tls, peer)).await;
}
Ok(Err(e)) => {
tracing::debug!(
peer = %peer,
error = %e,
"TLS handshake failed; dropping connection"
);
}
Err(_elapsed) => {
tracing::debug!(peer = %peer, "TLS handshake timed out");
}
}
});
}
}
fn is_transient_connection_error(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
)
}
impl axum::serve::Listener for TlsListener {
type Io = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
type Addr = std::net::SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
if let Some(conn) = self.rx.recv().await {
return conn;
}
self.shutdown.cancelled().await;
std::future::pending().await
}
fn local_addr(&self) -> std::io::Result<Self::Addr> {
Ok(self.local_addr)
}
}
#[derive(Debug, Clone, Copy)]
pub struct LeafInspection {
pub not_before_unix: i64,
pub not_after_unix: i64,
}
impl LeafInspection {
#[must_use]
pub const fn is_not_yet_valid(&self, now_unix: i64) -> bool {
now_unix < self.not_before_unix
}
#[must_use]
pub const fn days_until_expiry(&self, now_unix: i64) -> i64 {
(self.not_after_unix - now_unix) / 86_400
}
#[must_use]
pub const fn is_expired(&self, now_unix: i64) -> bool {
self.not_after_unix <= now_unix
}
}
pub fn inspect_leaf(
cert_path: &Path,
key_path: &Path,
now_unix: i64,
) -> Result<LeafInspection, TlsError> {
let chain = read_cert_chain(cert_path)?;
let key = read_private_key(key_path)?;
let (not_before, not_after) = leaf_validity_unix(cert_path, &chain[0])?;
validate_chain_certs(cert_path, &chain, now_unix)?;
let provider = crypto_provider();
CertifiedKey::from_der(chain, key, &provider).map_err(|source| TlsError::InvalidKeyPair {
cert: cert_path.to_path_buf(),
key: key_path.to_path_buf(),
source,
})?;
Ok(LeafInspection {
not_before_unix: not_before,
not_after_unix: not_after,
})
}
#[cfg(feature = "acme")]
pub fn certified_key_from_pem(
chain_pem: &[u8],
key_pem: &[u8],
provider: &CryptoProvider,
) -> Result<Arc<CertifiedKey>, String> {
let mut chain = Vec::new();
for cert in CertificateDer::pem_slice_iter(chain_pem) {
chain.push(cert.map_err(|e| format!("failed to parse ACME certificate PEM: {e}"))?);
}
if chain.is_empty() {
return Err("ACME certificate chain contained no PEM CERTIFICATE blocks".to_owned());
}
let key = PrivateKeyDer::from_pem_slice(key_pem)
.map_err(|e| format!("failed to parse ACME private key PEM: {e}"))?;
let certified = CertifiedKey::from_der(chain, key, provider)
.map_err(|e| format!("ACME private key does not match the issued certificate: {e}"))?;
Ok(Arc::new(certified))
}
#[cfg(feature = "acme")]
pub fn leaf_not_after_from_pem(chain_pem: &[u8]) -> Result<i64, String> {
let leaf = CertificateDer::pem_slice_iter(chain_pem)
.next()
.ok_or_else(|| "certificate chain contained no PEM CERTIFICATE blocks".to_owned())?
.map_err(|e| format!("failed to parse certificate PEM: {e}"))?;
leaf_validity_unix(Path::new("<memory>"), &leaf)
.map(|(_, not_after)| not_after)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
const CERT_PEM: &str = include_str!("../tests/fixtures/tls/localhost.cert.pem");
const KEY_PEM: &str = include_str!("../tests/fixtures/tls/localhost.key.pem");
const MISMATCHED_KEY_PEM: &str = include_str!("../tests/fixtures/tls/other.key.pem");
const EXPIRED_CERT_PEM: &str = include_str!("../tests/fixtures/tls/expired.cert.pem");
const EXPIRED_KEY_PEM: &str = include_str!("../tests/fixtures/tls/expired.key.pem");
const MALFORMED_CERT_PEM: &str = "\
-----BEGIN CERTIFICATE-----
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
-----END CERTIFICATE-----
";
fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(contents.as_bytes()).unwrap();
path
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.try_into()
.unwrap()
}
#[test]
fn load_valid_pair_succeeds() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let ck = load_certified_key(&cert, &key, &provider, now()).expect("valid pair loads");
assert!(!ck.cert.is_empty());
}
#[test]
fn malformed_intermediate_in_chain_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let chain_pem = format!("{CERT_PEM}\n{MALFORMED_CERT_PEM}");
let cert = write_temp(dir.path(), "chain.pem", &chain_pem);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let err = load_certified_key(&cert, &key, &provider, now()).unwrap_err();
assert!(
matches!(err, TlsError::ParseChainCert { position: 2, .. }),
"a malformed intermediate must fail fast naming its chain position, got {err:?}"
);
assert!(
err.to_string().contains("#2"),
"error must name the offending position, got: {err}"
);
}
#[test]
fn valid_leaf_and_intermediate_chain_loads() {
let dir = tempfile::tempdir().unwrap();
let chain_pem = format!("{CERT_PEM}\n{CERT_PEM}");
let cert = write_temp(dir.path(), "chain.pem", &chain_pem);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let ck = load_certified_key(&cert, &key, &provider, now())
.expect("a valid leaf + well-formed intermediate must load");
assert_eq!(ck.cert.len(), 2, "both chain certificates are retained");
}
#[test]
fn expired_intermediate_in_chain_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let chain_pem = format!("{CERT_PEM}\n{EXPIRED_CERT_PEM}");
let path = write_temp(dir.path(), "chain.pem", &chain_pem);
let chain = read_cert_chain(&path).unwrap();
let err = validate_chain_certs(&path, &chain, now()).unwrap_err();
assert!(
matches!(err, TlsError::ExpiredChainCert { position: 2, .. }),
"an expired intermediate must fail fast at position 2, got {err:?}"
);
assert!(
err.to_string().contains("#2"),
"error must name the offending position, got: {err}"
);
assert!(
err.to_string().contains("expired"),
"error must report expiry, got: {err}"
);
}
#[test]
fn not_yet_valid_intermediate_in_chain_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let leaf = write_temp(dir.path(), "leaf.pem", CERT_PEM);
let leaf_key = write_temp(dir.path(), "leaf.key.pem", KEY_PEM);
let not_before = inspect_leaf(&leaf, &leaf_key, now())
.unwrap()
.not_before_unix;
let chain_pem = format!("{CERT_PEM}\n{CERT_PEM}");
let path = write_temp(dir.path(), "chain.pem", &chain_pem);
let chain = read_cert_chain(&path).unwrap();
let err = validate_chain_certs(&path, &chain, not_before - 3600).unwrap_err();
assert!(
matches!(err, TlsError::NotYetValidChainCert { position: 2, .. }),
"a not-yet-valid intermediate must fail fast at position 2, got {err:?}"
);
assert!(
err.to_string().contains("#2"),
"error must name the offending position, got: {err}"
);
assert!(
err.to_string().contains("not yet valid"),
"error must report the not-yet-valid window, got: {err}"
);
}
#[test]
fn valid_intermediate_in_chain_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let chain_pem = format!("{CERT_PEM}\n{CERT_PEM}");
let path = write_temp(dir.path(), "chain.pem", &chain_pem);
let chain = read_cert_chain(&path).unwrap();
validate_chain_certs(&path, &chain, now())
.expect("a valid, in-window intermediate must be accepted");
}
#[test]
fn missing_cert_file_names_the_path() {
let dir = tempfile::tempdir().unwrap();
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let missing = dir.path().join("does-not-exist.pem");
let provider = crypto_provider();
let err = load_certified_key(&missing, &key, &provider, now()).unwrap_err();
assert!(matches!(err, TlsError::ReadCert { .. }));
assert!(err.to_string().contains("does-not-exist.pem"));
}
#[test]
fn unparseable_cert_pem_is_reported() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", "not a pem file");
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let err = load_certified_key(&cert, &key, &provider, now()).unwrap_err();
assert!(matches!(
err,
TlsError::NoCertificates { .. } | TlsError::ParseCert { .. }
));
}
#[test]
fn mismatched_key_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", MISMATCHED_KEY_PEM);
let provider = crypto_provider();
let err = load_certified_key(&cert, &key, &provider, now()).unwrap_err();
assert!(
matches!(err, TlsError::InvalidKeyPair { .. }),
"got {err:?}"
);
}
#[test]
fn expired_leaf_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", EXPIRED_CERT_PEM);
let key = write_temp(dir.path(), "k.pem", EXPIRED_KEY_PEM);
let provider = crypto_provider();
let err = load_certified_key(&cert, &key, &provider, now()).unwrap_err();
assert!(matches!(err, TlsError::Expired { .. }), "got {err:?}");
}
#[test]
fn not_yet_valid_leaf_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let not_before = inspect_leaf(&cert, &key, now()).unwrap().not_before_unix;
let err = load_certified_key(&cert, &key, &provider, not_before - 3600).unwrap_err();
assert!(matches!(err, TlsError::NotYetValid { .. }), "got {err:?}");
load_certified_key(&cert, &key, &provider, not_before + 1)
.expect("cert loads once notBefore has passed");
}
#[test]
fn inspect_reports_not_yet_valid_window() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let inspection = inspect_leaf(&cert, &key, now()).expect("pair inspects");
assert!(inspection.is_not_yet_valid(inspection.not_before_unix - 1));
assert!(!inspection.is_not_yet_valid(inspection.not_before_unix));
assert!(!inspection.is_not_yet_valid(inspection.not_after_unix));
}
#[test]
fn inspect_reports_future_expiry() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let inspection = inspect_leaf(&cert, &key, now()).expect("valid pair inspects");
assert!(
inspection.days_until_expiry(now()) > 30,
"fixture should be valid far into the future"
);
}
#[test]
fn inspect_still_reports_expiry_for_expired_cert() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", EXPIRED_CERT_PEM);
let key = write_temp(dir.path(), "k.pem", EXPIRED_KEY_PEM);
let inspection = inspect_leaf(&cert, &key, now()).expect("expired pair still inspects");
assert!(inspection.days_until_expiry(now()) < 0);
}
#[test]
fn reloadable_resolver_swaps_certificate() {
let dir = tempfile::tempdir().unwrap();
let cert = write_temp(dir.path(), "c.pem", CERT_PEM);
let key = write_temp(dir.path(), "k.pem", KEY_PEM);
let provider = crypto_provider();
let first = load_certified_key(&cert, &key, &provider, now()).unwrap();
let resolver = ReloadableCertResolver::new(Arc::clone(&first));
assert!(Arc::ptr_eq(&resolver.current(), &first));
let second = load_certified_key(&cert, &key, &provider, now()).unwrap();
resolver.store(Arc::clone(&second));
assert!(Arc::ptr_eq(&resolver.current(), &second));
}
}