#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use rustls::RootCertStore;
use rustls::crypto::CryptoProvider;
use rustls::server::WebPkiClientVerifier;
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls_pki_types::pem::PemObject as _;
use rustls_pki_types::{CertificateDer, CertificateRevocationListDer, UnixTime};
use super::TlsError;
use crate::config::ClientAuthMode;
pub const REJECTED_METRIC: &str = "tls_client_auth_rejected_total";
const LOG_INTERVAL_SECS: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ClientIdentity {
pub subject: String,
pub issuer: String,
pub sans: Vec<String>,
pub fingerprint: String,
pub serial: String,
pub not_after_unix: i64,
common_name: Option<String>,
}
impl ClientIdentity {
pub fn from_der(der: &CertificateDer<'_>) -> Result<Self, TlsError> {
use x509_parser::prelude::FromDer as _;
let (_, cert) =
x509_parser::certificate::X509Certificate::from_der(der.as_ref()).map_err(|e| {
TlsError::ParsePeerCert {
detail: e.to_string(),
}
})?;
Ok(Self {
subject: cert.subject().to_string(),
issuer: cert.issuer().to_string(),
sans: collect_sans(&cert),
fingerprint: sha256_fingerprint(der.as_ref()),
serial: cert.raw_serial_as_string().replace(':', ""),
not_after_unix: cert.validity().not_after.timestamp(),
common_name: cert
.subject()
.iter_common_name()
.filter_map(|cn| cn.as_str().ok())
.last()
.map(str::to_owned),
})
}
#[must_use]
pub fn has_san(&self, san: &str) -> bool {
if let Some(host) = san.strip_prefix("DNS:") {
return self.sans.iter().any(|s| {
s.strip_prefix("DNS:")
.is_some_and(|have| have.eq_ignore_ascii_case(host))
});
}
self.sans.iter().any(|s| s == san)
}
#[must_use]
pub fn common_name(&self) -> Option<&str> {
self.common_name.as_deref()
}
#[must_use]
pub fn new_for_test(common_name: &str, sans: Vec<String>) -> Self {
Self {
subject: format!("CN={common_name}"),
issuer: "CN=test".to_owned(),
sans,
fingerprint: "sha256:test".to_owned(),
serial: "01".to_owned(),
not_after_unix: i64::MAX,
common_name: Some(common_name.to_owned()),
}
}
}
fn collect_sans(cert: &x509_parser::certificate::X509Certificate<'_>) -> Vec<String> {
use x509_parser::extensions::{GeneralName, ParsedExtension};
let mut out = Vec::new();
for ext in cert.extensions() {
let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() else {
continue;
};
for name in &san.general_names {
match name {
GeneralName::DNSName(v) => out.push(format!("DNS:{v}")),
GeneralName::URI(v) => out.push(format!("URI:{v}")),
GeneralName::RFC822Name(v) => out.push(format!("email:{v}")),
GeneralName::IPAddress(bytes) => {
if let Some(ip) = render_ip(bytes) {
out.push(format!("IP:{ip}"));
}
}
_ => {}
}
}
}
out
}
fn render_ip(bytes: &[u8]) -> Option<String> {
match bytes.len() {
4 => {
let octets: [u8; 4] = bytes.try_into().ok()?;
Some(std::net::Ipv4Addr::from(octets).to_string())
}
16 => {
let octets: [u8; 16] = bytes.try_into().ok()?;
Some(std::net::Ipv6Addr::from(octets).to_string())
}
_ => None,
}
}
fn sha256_fingerprint(der: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
let digest = Sha256::digest(der);
let mut out = String::with_capacity(7 + digest.len() * 2);
out.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
pub fn load_client_roots(path: &Path) -> Result<RootCertStore, TlsError> {
let mut roots = RootCertStore::empty();
for (idx, cert) in read_ca_cert_ders(path)?.into_iter().enumerate() {
roots
.add(cert)
.map_err(|source| TlsError::InvalidClientCa {
path: path.to_path_buf(),
position: idx + 1,
source: Box::new(source),
})?;
}
Ok(roots)
}
pub fn load_crls(path: &Path) -> Result<Vec<CertificateRevocationListDer<'static>>, TlsError> {
let mut crls = Vec::new();
for crl in CertificateRevocationListDer::pem_file_iter(path)
.map_err(|source| map_crl_pem_err(path, source))?
{
crls.push(crl.map_err(|source| map_crl_pem_err(path, source))?);
}
if crls.is_empty() {
return Err(TlsError::NoCrls {
path: path.to_path_buf(),
});
}
Ok(crls)
}
fn read_ca_cert_ders(ca_bundle_path: &Path) -> Result<Vec<CertificateDer<'static>>, TlsError> {
let mut certs = Vec::new();
for cert in CertificateDer::pem_file_iter(ca_bundle_path)
.map_err(|source| map_pem_err(ca_bundle_path, source))?
{
certs.push(cert.map_err(|source| map_pem_err(ca_bundle_path, source))?);
}
if certs.is_empty() {
return Err(TlsError::NoClientCas {
path: ca_bundle_path.to_path_buf(),
});
}
Ok(certs)
}
fn map_pem_err(path: &Path, source: rustls_pki_types::pem::Error) -> TlsError {
match source {
rustls_pki_types::pem::Error::Io(source) => TlsError::ReadClientCa {
path: path.to_path_buf(),
source,
},
other => TlsError::ParseClientCa {
path: path.to_path_buf(),
source: other,
},
}
}
fn map_crl_pem_err(path: &Path, source: rustls_pki_types::pem::Error) -> TlsError {
match source {
rustls_pki_types::pem::Error::Io(source) => TlsError::ReadCrl {
path: path.to_path_buf(),
source,
},
other => TlsError::ParseCrl {
path: path.to_path_buf(),
source: other,
},
}
}
pub fn crl_coverage_gaps(ca_bundle_path: &Path, crl_path: &Path) -> Result<Vec<String>, TlsError> {
load_client_roots(ca_bundle_path)?;
let crls = load_crls(crl_path)?;
let ca_certs = read_ca_cert_ders(ca_bundle_path)?;
crl_coverage_gaps_in(&ca_certs, &crls, ca_bundle_path, crl_path)
}
fn crl_coverage_gaps_in(
ca_certs: &[CertificateDer<'static>],
crls: &[CertificateRevocationListDer<'static>],
ca_bundle_path: &Path,
crl_path: &Path,
) -> Result<Vec<String>, TlsError> {
use x509_parser::prelude::FromDer as _;
let mut cas = Vec::with_capacity(ca_certs.len());
for (idx, cert) in ca_certs.iter().enumerate() {
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(cert.as_ref())
.map_err(|e| TlsError::ParseChainCert {
path: ca_bundle_path.to_path_buf(),
position: idx + 1,
detail: e.to_string(),
})?;
cas.push(parsed);
}
let mut parsed_crls = Vec::with_capacity(crls.len());
for (idx, crl) in crls.iter().enumerate() {
let (_, parsed) =
x509_parser::revocation_list::CertificateRevocationList::from_der(crl.as_ref())
.map_err(|e| TlsError::ParseCrlDer {
path: crl_path.to_path_buf(),
position: idx + 1,
detail: e.to_string(),
})?;
parsed_crls.push(parsed);
}
let issued_by_bundle_ca =
|crl: &x509_parser::revocation_list::CertificateRevocationList<'_>| {
cas.iter()
.any(|ca| ca.subject().as_raw() == crl.issuer().as_raw())
};
if !parsed_crls.iter().all(issued_by_bundle_ca) {
return Ok(Vec::new());
}
let is_parent = |ca: &x509_parser::certificate::X509Certificate<'_>| {
let parent = ca.subject().as_raw();
cas.iter().any(|child| {
let (subject, issuer) = (child.subject().as_raw(), child.issuer().as_raw());
subject != parent
&& issuer == parent
&& child.verify_signature(Some(ca.public_key())).is_ok()
})
};
Ok(cas
.iter()
.filter(|ca| !is_parent(ca))
.filter(|ca| {
!parsed_crls.iter().any(|crl| {
crl.issuer().as_raw() == ca.subject().as_raw()
&& crl.verify_signature(ca.public_key()).is_ok()
})
})
.map(|ca| ca.subject().to_string())
.collect())
}
pub fn ensure_crl_coverage(ca_bundle_path: &Path, crl_path: &Path) -> Result<(), TlsError> {
let gaps = crl_coverage_gaps(ca_bundle_path, crl_path)?;
if gaps.is_empty() {
Ok(())
} else {
Err(TlsError::CrlCoverageGap {
ca_bundle_path: ca_bundle_path.to_path_buf(),
crl_path: crl_path.to_path_buf(),
uncovered: gaps,
})
}
}
pub fn build_client_verifier(
roots: RootCertStore,
crls: Vec<CertificateRevocationListDer<'static>>,
mode: ClientAuthMode,
provider: Arc<CryptoProvider>,
) -> Result<Arc<dyn ClientCertVerifier>, TlsError> {
let mut builder = WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider);
if !crls.is_empty() {
builder = builder.with_crls(crls).only_check_end_entity_revocation();
}
if !mode.mandates_certificate() {
builder = builder.allow_unauthenticated();
}
builder
.build()
.map_err(|source| TlsError::BuildClientVerifier { source })
}
#[derive(Debug)]
pub struct ReloadableClientVerifier {
inner: RwLock<Arc<dyn ClientCertVerifier>>,
mode: ClientAuthMode,
}
impl ReloadableClientVerifier {
#[must_use]
pub fn new(initial: Arc<dyn ClientCertVerifier>, mode: ClientAuthMode) -> Self {
Self {
inner: RwLock::new(initial),
mode,
}
}
pub fn store(&self, next: Arc<dyn ClientCertVerifier>) {
let mut guard = self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = next;
}
#[must_use]
pub fn current(&self) -> Arc<dyn ClientCertVerifier> {
Arc::clone(
&self
.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
}
#[must_use]
pub const fn mode(&self) -> ClientAuthMode {
self.mode
}
}
impl ClientCertVerifier for ReloadableClientVerifier {
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&[]
}
fn verify_client_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
) -> Result<ClientCertVerified, rustls::Error> {
self.current()
.verify_client_cert(end_entity, intermediates, now)
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
self.current().verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
self.current().verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.current().supported_verify_schemes()
}
fn requires_raw_public_keys(&self) -> bool {
self.current().requires_raw_public_keys()
}
fn offer_client_auth(&self) -> bool {
self.mode.requests_certificate()
}
fn client_auth_mandatory(&self) -> bool {
self.mode.mandates_certificate()
}
}
pub struct ClientTrustReloader {
verifier: Arc<ReloadableClientVerifier>,
provider: Arc<CryptoProvider>,
ca_bundle_path: PathBuf,
crl_path: Option<PathBuf>,
mode: ClientAuthMode,
interval: std::time::Duration,
baseline: (Option<std::time::SystemTime>, Option<std::time::SystemTime>),
}
impl ClientTrustReloader {
pub fn load(
ca_bundle_path: PathBuf,
crl_path: Option<PathBuf>,
mode: ClientAuthMode,
provider: Arc<CryptoProvider>,
interval: std::time::Duration,
) -> Result<(Arc<ReloadableClientVerifier>, Self), TlsError> {
let baseline = trust_mtimes(&ca_bundle_path, crl_path.as_deref());
let built = build_from_paths(&ca_bundle_path, crl_path.as_deref(), mode, &provider)?;
let verifier = Arc::new(ReloadableClientVerifier::new(built, mode));
Ok((
Arc::clone(&verifier),
Self {
verifier,
provider,
ca_bundle_path,
crl_path,
mode,
interval,
baseline,
},
))
}
pub async fn run(mut self, shutdown: tokio_util::sync::CancellationToken) {
let interval = if self.interval.is_zero() {
std::time::Duration::from_secs(super::DEFAULT_RELOAD_INTERVAL_SECS)
} else {
self.interval
};
loop {
tokio::select! {
() = tokio::time::sleep(interval) => {}
() = shutdown.cancelled() => break,
}
self.poll_once().await;
}
}
async fn poll_once(&mut self) {
let bundle = self.ca_bundle_path.clone();
let crl = self.crl_path.clone();
let seen = match crate::time::spawn_blocking(move || trust_mtimes(&bundle, crl.as_deref()))
.await
{
Ok(mtimes) => mtimes,
Err(e) => {
tracing::warn!(
error = %e,
"mTLS trust reload: mtime read task failed; skipping tick"
);
return;
}
};
if seen == self.baseline {
return;
}
let bundle = self.ca_bundle_path.clone();
let crl = self.crl_path.clone();
let mode = self.mode;
let provider = Arc::clone(&self.provider);
let built = crate::time::spawn_blocking(move || {
build_from_paths(&bundle, crl.as_deref(), mode, &provider)
})
.await;
let built = match built {
Ok(result) => result,
Err(e) => {
tracing::warn!(
error = %e,
"mTLS trust reload: load task failed; skipping tick"
);
return;
}
};
match built {
Ok(next) => {
self.verifier.store(next);
self.baseline = seen;
tracing::info!(
ca_bundle = %self.ca_bundle_path.display(),
"reloaded the mTLS client trust store"
);
}
Err(e) => tracing::error!(
ca_bundle = %self.ca_bundle_path.display(),
error = %e,
"failed to reload the mTLS client trust store; keeping the previous one"
),
}
}
}
fn build_from_paths(
ca_bundle_path: &Path,
crl_path: Option<&Path>,
mode: ClientAuthMode,
provider: &Arc<CryptoProvider>,
) -> Result<Arc<dyn ClientCertVerifier>, TlsError> {
let ca_certs = read_ca_cert_ders(ca_bundle_path)?;
let crls = match crl_path {
Some(path) => load_crls(path)?,
None => Vec::new(),
};
let mut roots = RootCertStore::empty();
for (idx, cert) in ca_certs.iter().enumerate() {
roots
.add(cert.clone())
.map_err(|source| TlsError::InvalidClientCa {
path: ca_bundle_path.to_path_buf(),
position: idx + 1,
source: Box::new(source),
})?;
}
if let Some(path) = crl_path {
let gaps = crl_coverage_gaps_in(&ca_certs, &crls, ca_bundle_path, path)?;
if !gaps.is_empty() {
return Err(TlsError::CrlCoverageGap {
ca_bundle_path: ca_bundle_path.to_path_buf(),
crl_path: path.to_path_buf(),
uncovered: gaps,
});
}
}
build_client_verifier(roots, crls, mode, Arc::clone(provider))
}
fn trust_mtimes(
ca_bundle: &Path,
crl: Option<&Path>,
) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>) {
let mtime = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
(mtime(ca_bundle), crl.and_then(mtime))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RejectionReason {
NoCertificate,
UntrustedCa,
Expired,
NotYetValid,
Revoked,
UnknownRevocation,
Invalid,
}
impl RejectionReason {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NoCertificate => "no_certificate",
Self::UntrustedCa => "untrusted_ca",
Self::Expired => "expired",
Self::NotYetValid => "not_yet_valid",
Self::Revoked => "revoked",
Self::UnknownRevocation => "unknown_revocation",
Self::Invalid => "invalid",
}
}
pub const ALL: [Self; 7] = [
Self::NoCertificate,
Self::UntrustedCa,
Self::Expired,
Self::NotYetValid,
Self::Revoked,
Self::UnknownRevocation,
Self::Invalid,
];
const fn slot(self) -> usize {
match self {
Self::NoCertificate => 0,
Self::UntrustedCa => 1,
Self::Expired => 2,
Self::NotYetValid => 3,
Self::Revoked => 4,
Self::UnknownRevocation => 5,
Self::Invalid => 6,
}
}
#[must_use]
pub const fn classify(error: &rustls::Error) -> Option<Self> {
use rustls::CertificateError;
match error {
rustls::Error::NoCertificatesPresented => Some(Self::NoCertificate),
rustls::Error::InvalidCertificate(cert_error) => Some(match cert_error {
CertificateError::UnknownIssuer => Self::UntrustedCa,
CertificateError::Expired | CertificateError::ExpiredContext { .. } => {
Self::Expired
}
CertificateError::NotValidYet | CertificateError::NotValidYetContext { .. } => {
Self::NotYetValid
}
CertificateError::Revoked => Self::Revoked,
CertificateError::UnknownRevocationStatus
| CertificateError::ExpiredRevocationList
| CertificateError::ExpiredRevocationListContext { .. } => Self::UnknownRevocation,
_ => Self::Invalid,
}),
_ => None,
}
}
pub fn record(self, peer: Option<std::net::SocketAddr>) {
crate::metrics::counter(REJECTED_METRIC)
.with_label("reason", self.as_str())
.increment(1);
if let Some(suppressed) = self.take_log_slot() {
tracing::warn!(
reason = self.as_str(),
peer = peer.map(|p| p.to_string()).unwrap_or_default(),
suppressed,
"rejected an mTLS client certificate"
);
}
}
fn take_log_slot(self) -> Option<u64> {
static LAST_LOG_UNIX: [AtomicI64; RejectionReason::ALL.len()] =
[const { AtomicI64::new(i64::MIN) }; RejectionReason::ALL.len()];
static SUPPRESSED: [AtomicU64; RejectionReason::ALL.len()] =
[const { AtomicU64::new(0) }; RejectionReason::ALL.len()];
let slot = self.slot();
let now = super::now_unix();
let last = LAST_LOG_UNIX[slot].load(Ordering::Relaxed);
if now.saturating_sub(last) < LOG_INTERVAL_SECS {
SUPPRESSED[slot].fetch_add(1, Ordering::Relaxed);
return None;
}
if LAST_LOG_UNIX[slot]
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
SUPPRESSED[slot].fetch_add(1, Ordering::Relaxed);
return None;
}
Some(SUPPRESSED[slot].swap(0, Ordering::Relaxed))
}
}
pub const REQUIRED_REJECTION_STATUS: http::StatusCode = http::StatusCode::FORBIDDEN;
pub const REQUIRED_REJECTION_MESSAGE: &str =
"this route requires a verified client certificate (mTLS)";
#[derive(Debug, Clone)]
pub struct ClientCert(pub Arc<ClientIdentity>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for ClientCert {
type Rejection = crate::error::AutumnError;
async fn from_request_parts(
parts: &mut http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Arc<ClientIdentity>>()
.map(|id| Self(Arc::clone(id)))
.ok_or_else(|| crate::error::AutumnError::forbidden_msg(REQUIRED_REJECTION_MESSAGE))
}
}
#[derive(Debug, Clone)]
pub struct OptionalClientCert(pub Option<Arc<ClientIdentity>>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OptionalClientCert {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(Self(parts.extensions.get::<Arc<ClientIdentity>>().cloned()))
}
}
tokio::task_local! {
static CURRENT_CLIENT_IDENTITY: Option<Arc<ClientIdentity>>;
}
#[must_use]
pub fn current_client_identity() -> Option<Arc<ClientIdentity>> {
CURRENT_CLIENT_IDENTITY
.try_with(Clone::clone)
.unwrap_or_default()
}
pub async fn with_client_identity<F: Future>(
identity: Option<Arc<ClientIdentity>>,
future: F,
) -> F::Output {
CURRENT_CLIENT_IDENTITY.scope(identity, future).await
}
#[derive(Clone, Debug, Default)]
pub struct ClientIdentityLayer;
impl<S> tower::Layer<S> for ClientIdentityLayer {
type Service = ClientIdentityService<S>;
fn layer(&self, inner: S) -> Self::Service {
ClientIdentityService { inner }
}
}
#[derive(Clone, Debug)]
pub struct ClientIdentityService<S> {
inner: S,
}
impl<S, ReqBody> tower::Service<http::Request<ReqBody>> for ClientIdentityService<S>
where
S: tower::Service<http::Request<ReqBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: http::Request<ReqBody>) -> Self::Future {
let connect_info = req
.extensions()
.get::<axum::extract::ConnectInfo<super::TlsConnectInfo>>()
.map(|info| info.0.clone());
let identity = match connect_info {
Some(info) => {
req.extensions_mut()
.insert(axum::extract::ConnectInfo(info.peer));
info.client
}
None => None,
};
if let Some(identity) = identity.clone() {
req.extensions_mut().insert(identity);
}
let clone = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, clone);
Box::pin(CURRENT_CLIENT_IDENTITY.scope(identity, async move { inner.call(req).await }))
}
}
#[derive(Clone, Debug)]
pub struct RequireClientCertLayer {
scope: RequirementScope,
}
#[derive(Clone, Debug)]
enum RequirementScope {
AllRoutes,
Paths(Arc<Vec<String>>),
}
impl RequireClientCertLayer {
#[must_use]
pub const fn new() -> Self {
Self {
scope: RequirementScope::AllRoutes,
}
}
#[must_use]
pub fn for_paths(paths: Vec<String>) -> Self {
Self {
scope: RequirementScope::Paths(Arc::new(paths)),
}
}
#[must_use]
pub fn requires(&self, path: &str) -> bool {
match &self.scope {
RequirementScope::AllRoutes => true,
RequirementScope::Paths(paths) => path_matches_any(path, paths),
}
}
}
impl Default for RequireClientCertLayer {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn path_matches_any(path: &str, prefixes: &[String]) -> bool {
prefixes.iter().any(|prefix| {
let bare = prefix.strip_suffix('/').unwrap_or(prefix);
if path == bare {
true
} else if let Some(rest) = path.strip_prefix(bare) {
rest.starts_with('/')
} else {
false
}
})
}
impl<S> tower::Layer<S> for RequireClientCertLayer {
type Service = RequireClientCert<S>;
fn layer(&self, inner: S) -> Self::Service {
RequireClientCert {
inner,
scope: self.scope.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct RequireClientCert<S> {
inner: S,
scope: RequirementScope,
}
impl<S, ResBody> tower::Service<http::Request<axum::body::Body>> for RequireClientCert<S>
where
S: tower::Service<http::Request<axum::body::Body>, Response = http::Response<ResBody>>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
ResBody: Default + Send + 'static,
axum::body::Body: From<ResBody>,
{
type Response = http::Response<axum::body::Body>;
type Error = S::Error;
type Future = std::pin::Pin<
Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: http::Request<axum::body::Body>) -> Self::Future {
let required = match &self.scope {
RequirementScope::AllRoutes => true,
RequirementScope::Paths(paths) if paths.is_empty() => false,
RequirementScope::Paths(paths) => {
let raw = req.uri().path();
path_matches_any(raw, paths)
|| path_matches_any(crate::security::path::clean_path(raw).as_str(), paths)
}
};
let verified = req.extensions().get::<Arc<ClientIdentity>>().is_some();
if required && !verified {
crate::metrics::counter(ROUTE_REJECTED_METRIC).increment(1);
let response = axum::response::IntoResponse::into_response(
crate::error::AutumnError::forbidden_msg(REQUIRED_REJECTION_MESSAGE),
);
return Box::pin(async move { Ok(response) });
}
let future = self.inner.call(req);
Box::pin(async move {
let response = future.await?;
Ok(response.map(axum::body::Body::from))
})
}
}
pub const ROUTE_REJECTED_METRIC: &str = "tls_client_auth_route_rejected_total";
#[must_use]
pub fn record_handshake_rejection(error: &std::io::Error, peer: std::net::SocketAddr) -> bool {
let Some(rustls_error) = error
.get_ref()
.and_then(|inner| inner.downcast_ref::<rustls::Error>())
else {
return false;
};
RejectionReason::classify(rustls_error).is_some_and(|reason| {
reason.record(Some(peer));
true
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CaInspection {
pub subject: String,
pub not_before_unix: i64,
pub not_after_unix: i64,
}
impl CaInspection {
#[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
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct CrlInspection {
pub issuers: Vec<String>,
pub next_update_unix: Option<i64>,
pub revoked_count: usize,
}
impl CrlInspection {
#[must_use]
pub fn is_stale(&self, now_unix: i64) -> bool {
self.next_update_unix.is_some_and(|next| next <= now_unix)
}
}
pub fn inspect_client_ca_bundle(path: &Path) -> Result<Vec<CaInspection>, TlsError> {
use x509_parser::prelude::FromDer as _;
load_client_roots(path)?;
let mut out = Vec::new();
for (idx, cert) in CertificateDer::pem_file_iter(path)
.map_err(|source| map_pem_err(path, source))?
.enumerate()
{
let cert = cert.map_err(|source| map_pem_err(path, source))?;
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(cert.as_ref())
.map_err(|e| TlsError::ParseChainCert {
path: path.to_path_buf(),
position: idx + 1,
detail: e.to_string(),
})?;
out.push(CaInspection {
subject: parsed.subject().to_string(),
not_before_unix: parsed.validity().not_before.timestamp(),
not_after_unix: parsed.validity().not_after.timestamp(),
});
}
Ok(out)
}
pub fn inspect_crl(path: &Path) -> Result<CrlInspection, TlsError> {
use x509_parser::prelude::FromDer as _;
let ders = load_crls(path)?;
let mut inspection = CrlInspection::default();
for (idx, der) in ders.iter().enumerate() {
let (_, crl) =
x509_parser::revocation_list::CertificateRevocationList::from_der(der.as_ref())
.map_err(|e| TlsError::ParseCrlDer {
path: path.to_path_buf(),
position: idx + 1,
detail: e.to_string(),
})?;
inspection.revoked_count += crl.iter_revoked_certificates().count();
inspection.issuers.push(crl.issuer().to_string());
if let Some(next) = crl.next_update().map(|t| t.timestamp()) {
inspection.next_update_unix = Some(
inspection
.next_update_unix
.map_or(next, |cur| cur.min(next)),
);
}
}
Ok(inspection)
}
#[cfg(test)]
mod tests {
use super::*;
const CA_PEM: &str = include_str!("../../tests/fixtures/tls/client/ca.cert.pem");
const OTHER_CA_PEM: &str = include_str!("../../tests/fixtures/tls/client/other-ca.cert.pem");
const CLIENT_PEM: &str = include_str!("../../tests/fixtures/tls/client/client.cert.pem");
const CRL_PEM: &str = include_str!("../../tests/fixtures/tls/client/crl.pem");
const CRL_EMPTY_PEM: &str = include_str!("../../tests/fixtures/tls/client/crl-empty.pem");
const SPOOFED_CN_PEM: &str =
include_str!("../../tests/fixtures/tls/client/spoofed-cn.cert.pem");
fn write_temp(name: &str, contents: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, contents).expect("write fixture");
(dir, path)
}
fn first_der(pem: &str) -> CertificateDer<'static> {
CertificateDer::pem_slice_iter(pem.as_bytes())
.next()
.expect("a CERTIFICATE block")
.expect("parseable")
}
#[test]
fn identity_carries_subject_issuer_sans_and_fingerprint() {
let id = ClientIdentity::from_der(&first_der(CLIENT_PEM)).expect("parse client cert");
assert!(
id.subject.contains("CN=svc-orders"),
"subject DN should name the service: {}",
id.subject
);
assert!(
id.issuer.contains("CN=Autumn Test Client CA"),
"issuer DN should name the CA: {}",
id.issuer
);
assert!(id.has_san("DNS:svc-orders.internal"), "sans: {:?}", id.sans);
assert!(
id.has_san("URI:spiffe://autumn.test/svc/orders"),
"sans: {:?}",
id.sans
);
assert!(
id.has_san("email:orders@autumn.test"),
"sans: {:?}",
id.sans
);
assert_eq!(id.common_name(), Some("svc-orders"));
assert!(id.has_san("DNS:SVC-Orders.Internal"));
assert!(!id.has_san("URI:SPIFFE://autumn.test/svc/orders"));
let expected = {
use std::fmt::Write as _;
use sha2::{Digest as _, Sha256};
let digest = Sha256::digest(first_der(CLIENT_PEM).as_ref());
digest.iter().fold("sha256:".to_owned(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
};
assert_eq!(id.fingerprint, expected);
assert!(!id.serial.is_empty());
assert!(id.not_after_unix > 0);
}
#[test]
fn common_name_cannot_be_smuggled_through_another_attribute() {
let id = ClientIdentity::from_der(&first_der(SPOOFED_CN_PEM))
.expect("parse the spoofed certificate");
assert!(
id.subject.contains("CN=svc-payments"),
"fixture should render an injected CN: {}",
id.subject
);
assert_eq!(
id.common_name(),
Some("svc-lowpriv"),
"the real CN must win over one smuggled into another attribute"
);
}
#[test]
fn identity_rejects_bytes_that_are_not_a_certificate() {
let der = CertificateDer::from(vec![0x30, 0x00]);
let err = ClientIdentity::from_der(&der).expect_err("garbage is not a certificate");
assert!(matches!(err, TlsError::ParsePeerCert { .. }), "{err}");
}
#[test]
fn loads_a_multi_ca_bundle() {
let (_dir, path) = write_temp("ca.pem", &format!("{CA_PEM}{OTHER_CA_PEM}"));
let roots = load_client_roots(&path).expect("bundle loads");
assert_eq!(roots.len(), 2);
}
#[test]
fn rejects_a_missing_bundle() {
let err = load_client_roots(Path::new("/nonexistent/ca.pem"))
.expect_err("a missing bundle must fail fast");
assert!(matches!(err, TlsError::ReadClientCa { .. }), "{err}");
assert!(err.to_string().contains("/nonexistent/ca.pem"), "{err}");
}
#[test]
fn rejects_an_empty_bundle() {
let (_dir, path) = write_temp("ca.pem", "# no certificates here\n");
let err = load_client_roots(&path).expect_err("an empty bundle must fail fast");
assert!(matches!(err, TlsError::NoClientCas { .. }), "{err}");
}
#[test]
fn rejects_a_malformed_bundle() {
let (_dir, path) = write_temp(
"ca.pem",
"-----BEGIN CERTIFICATE-----\nnot base64 at all!\n-----END CERTIFICATE-----\n",
);
let err = load_client_roots(&path).expect_err("malformed PEM must fail fast");
assert!(matches!(err, TlsError::ParseClientCa { .. }), "{err}");
}
#[test]
fn rejects_a_missing_or_empty_crl() {
let err =
load_crls(Path::new("/nonexistent/crl.pem")).expect_err("a missing CRL must fail fast");
assert!(matches!(err, TlsError::ReadCrl { .. }), "{err}");
let (_dir, path) = write_temp("crl.pem", "\n");
let err = load_crls(&path).expect_err("an empty CRL file must fail fast");
assert!(matches!(err, TlsError::NoCrls { .. }), "{err}");
}
#[test]
fn loads_a_crl() {
let (_dir, path) = write_temp("crl.pem", CRL_PEM);
assert_eq!(load_crls(&path).expect("CRL loads").len(), 1);
}
#[test]
fn crl_coverage_is_full_when_every_ca_has_a_crl() {
let (_bundle_dir, bundle) = write_temp("ca.pem", CA_PEM);
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
assert_eq!(
crl_coverage_gaps(&bundle, &crl).expect("coverage check runs"),
Vec::<String>::new(),
"the fixture CRL is issued by the fixture CA"
);
}
#[test]
fn crl_coverage_names_the_ca_with_no_crl() {
let (_bundle_dir, bundle) = write_temp("ca.pem", &format!("{CA_PEM}{OTHER_CA_PEM}"));
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
let gaps = crl_coverage_gaps(&bundle, &crl).expect("coverage check runs");
assert_eq!(gaps.len(), 1, "exactly the uncovered CA is named: {gaps:?}");
assert!(
gaps[0].contains("Autumn Untrusted CA"),
"the gap names the CA with no CRL: {}",
gaps[0]
);
}
#[test]
fn a_crl_from_a_renewed_cas_old_key_does_not_cover_it() {
const RENEWED_CA_PEM: &str =
include_str!("../../tests/fixtures/tls/client/renewed-ca.cert.pem");
let (_bundle_dir, bundle) = write_temp("ca.pem", &format!("{CA_PEM}{RENEWED_CA_PEM}"));
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
let gaps = crl_coverage_gaps(&bundle, &crl).expect("coverage check runs");
assert_eq!(
gaps.len(),
1,
"only the old-key CA is covered by the old-key CRL: {gaps:?}"
);
assert!(gaps[0].contains("Autumn Test Client CA"), "{gaps:?}");
}
#[test]
fn an_intermediate_issued_crl_stands_the_bundle_check_down() {
const INTERMEDIATE_CRL_PEM: &str =
include_str!("../../tests/fixtures/tls/client/intermediate-crl.pem");
let (_bundle_dir, bundle) = write_temp("ca.pem", CA_PEM);
let (_crl_dir, crl) = write_temp("crl.pem", INTERMEDIATE_CRL_PEM);
assert_eq!(
crl_coverage_gaps(&bundle, &crl).expect("coverage check runs"),
Vec::<String>::new()
);
build_from_paths(
&bundle,
Some(&crl),
ClientAuthMode::Required,
&super::super::crypto_provider(),
)
.expect("an intermediate-based PKI still boots");
}
#[test]
fn a_bundled_root_that_signed_a_bundled_intermediate_needs_no_crl() {
const BUNDLED_INTERMEDIATE_PEM: &str =
include_str!("../../tests/fixtures/tls/client/bundled-intermediate.cert.pem");
const BUNDLED_INTERMEDIATE_CRL_PEM: &str =
include_str!("../../tests/fixtures/tls/client/bundled-intermediate-crl.pem");
let (_bundle_dir, bundle) =
write_temp("ca.pem", &format!("{CA_PEM}{BUNDLED_INTERMEDIATE_PEM}"));
let (_crl_dir, crl) = write_temp("crl.pem", BUNDLED_INTERMEDIATE_CRL_PEM);
assert_eq!(
crl_coverage_gaps(&bundle, &crl).expect("coverage check runs"),
Vec::<String>::new()
);
let (_old_crl_dir, old_crl) = write_temp("crl.pem", CRL_PEM);
let gaps = crl_coverage_gaps(&bundle, &old_crl).expect("coverage check runs");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert!(gaps[0].contains("Bundled Intermediate"), "{gaps:?}");
}
#[test]
fn ensure_crl_coverage_fails_fast_naming_the_uncovered_ca() {
let (_bundle_dir, bundle) = write_temp("ca.pem", &format!("{CA_PEM}{OTHER_CA_PEM}"));
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
let err = ensure_crl_coverage(&bundle, &crl).expect_err("a partial CRL set must fail fast");
assert!(matches!(err, TlsError::CrlCoverageGap { .. }), "{err}");
let message = err.to_string();
assert!(message.contains("Autumn Untrusted CA"), "{message}");
assert!(message.contains("refuses"), "{message}");
}
#[test]
fn the_startup_path_refuses_a_crl_set_that_covers_only_some_cas() {
let (_bundle_dir, bundle) = write_temp("ca.pem", &format!("{CA_PEM}{OTHER_CA_PEM}"));
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
let err = build_from_paths(
&bundle,
Some(&crl),
ClientAuthMode::Required,
&super::super::crypto_provider(),
)
.expect_err("boot must refuse a partial CRL set");
assert!(matches!(err, TlsError::CrlCoverageGap { .. }), "{err}");
}
#[test]
fn the_startup_path_builds_when_every_ca_has_a_crl() {
let (_bundle_dir, bundle) = write_temp("ca.pem", CA_PEM);
let (_crl_dir, crl) = write_temp("crl.pem", CRL_PEM);
build_from_paths(
&bundle,
Some(&crl),
ClientAuthMode::Required,
&super::super::crypto_provider(),
)
.expect("a fully-covered CRL set builds");
}
fn roots_from(pem: &str) -> RootCertStore {
let (_dir, path) = write_temp("ca.pem", pem);
load_client_roots(&path).expect("roots load")
}
#[test]
fn required_mode_mandates_a_certificate_and_optional_does_not() {
let provider = super::super::crypto_provider();
let required = build_client_verifier(
roots_from(CA_PEM),
Vec::new(),
ClientAuthMode::Required,
Arc::clone(&provider),
)
.expect("required verifier builds");
assert!(required.client_auth_mandatory());
let optional = build_client_verifier(
roots_from(CA_PEM),
Vec::new(),
ClientAuthMode::Optional,
provider,
)
.expect("optional verifier builds");
assert!(!optional.client_auth_mandatory());
}
#[test]
fn reloadable_verifier_reports_the_configured_mode() {
let provider = super::super::crypto_provider();
let inner = build_client_verifier(
roots_from(CA_PEM),
Vec::new(),
ClientAuthMode::Optional,
provider,
)
.expect("verifier builds");
let reloadable = ReloadableClientVerifier::new(inner, ClientAuthMode::Optional);
assert!(reloadable.offer_client_auth());
assert!(!reloadable.client_auth_mandatory());
assert_eq!(reloadable.mode(), ClientAuthMode::Optional);
assert!(reloadable.root_hint_subjects().is_empty());
}
#[test]
fn reloadable_verifier_swaps_the_inner_verifier() {
let provider = super::super::crypto_provider();
let first = build_client_verifier(
roots_from(CA_PEM),
Vec::new(),
ClientAuthMode::Required,
Arc::clone(&provider),
)
.expect("first verifier");
let reloadable =
ReloadableClientVerifier::new(Arc::clone(&first), ClientAuthMode::Required);
assert!(Arc::ptr_eq(&reloadable.current(), &first));
let second = build_client_verifier(
roots_from(OTHER_CA_PEM),
Vec::new(),
ClientAuthMode::Required,
provider,
)
.expect("second verifier");
reloadable.store(Arc::clone(&second));
assert!(Arc::ptr_eq(&reloadable.current(), &second));
}
#[tokio::test]
async fn reloader_swaps_when_the_bundle_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle = dir.path().join("ca.pem");
std::fs::write(&bundle, CA_PEM).expect("write bundle");
let (verifier, mut reloader) = ClientTrustReloader::load(
bundle.clone(),
None,
ClientAuthMode::Required,
super::super::crypto_provider(),
std::time::Duration::from_millis(10),
)
.expect("initial load");
let before = verifier.current();
std::fs::write(&bundle, format!("{CA_PEM}{OTHER_CA_PEM}")).expect("rotate");
bump_mtime(&bundle);
reloader.poll_once().await;
assert!(
!Arc::ptr_eq(&verifier.current(), &before),
"a changed bundle should swap the verifier"
);
}
#[tokio::test]
async fn reloader_keeps_the_previous_store_when_the_new_one_is_broken() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle = dir.path().join("ca.pem");
std::fs::write(&bundle, CA_PEM).expect("write bundle");
let (verifier, mut reloader) = ClientTrustReloader::load(
bundle.clone(),
None,
ClientAuthMode::Required,
super::super::crypto_provider(),
std::time::Duration::from_millis(10),
)
.expect("initial load");
let before = verifier.current();
std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\ngarbage\n").expect("corrupt");
bump_mtime(&bundle);
reloader.poll_once().await;
assert!(
Arc::ptr_eq(&verifier.current(), &before),
"a broken reload must keep the previous trust store"
);
}
#[tokio::test]
async fn reloader_swaps_when_only_the_crl_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle = dir.path().join("ca.pem");
let crl = dir.path().join("crl.pem");
std::fs::write(&bundle, CA_PEM).expect("write bundle");
std::fs::write(&crl, CRL_EMPTY_PEM).expect("write crl");
let (verifier, mut reloader) = ClientTrustReloader::load(
bundle,
Some(crl.clone()),
ClientAuthMode::Required,
super::super::crypto_provider(),
std::time::Duration::from_millis(10),
)
.expect("initial load");
let before = verifier.current();
std::fs::write(&crl, CRL_PEM).expect("publish a revocation");
bump_mtime(&crl);
reloader.poll_once().await;
assert!(
!Arc::ptr_eq(&verifier.current(), &before),
"a newly published revocation should swap the verifier"
);
}
#[tokio::test]
async fn reloader_is_quiet_when_nothing_changed() {
let dir = tempfile::tempdir().expect("tempdir");
let bundle = dir.path().join("ca.pem");
std::fs::write(&bundle, CA_PEM).expect("write bundle");
let (verifier, mut reloader) = ClientTrustReloader::load(
bundle,
None,
ClientAuthMode::Required,
super::super::crypto_provider(),
std::time::Duration::from_millis(10),
)
.expect("initial load");
let before = verifier.current();
reloader.poll_once().await;
assert!(
Arc::ptr_eq(&verifier.current(), &before),
"an unchanged bundle must not swap"
);
}
fn bump_mtime(path: &Path) {
let later = filetime::FileTime::from_unix_time(super::super::now_unix() + 60, 0);
filetime::set_file_mtime(path, later).expect("set mtime");
}
#[test]
fn a_required_prefix_covers_the_bare_path_but_not_a_sibling() {
let prefixes = vec!["/internal/".to_owned()];
assert!(path_matches_any("/internal", &prefixes));
assert!(path_matches_any("/internal/", &prefixes));
assert!(path_matches_any("/internal/keys", &prefixes));
assert!(!path_matches_any("/internal-tools", &prefixes));
assert!(!path_matches_any("/internals", &prefixes));
assert!(!path_matches_any("/", &prefixes));
let bare = vec!["/internal".to_owned()];
assert!(path_matches_any("/internal", &bare));
assert!(path_matches_any("/internal/keys", &bare));
assert!(!path_matches_any("/internal-tools", &bare));
}
#[test]
fn a_multi_crl_file_reports_the_earliest_next_update() {
let (_dir, path) = write_temp("crl.pem", &format!("{CRL_EMPTY_PEM}{CRL_PEM}"));
let crl = inspect_crl(&path).expect("multi-CRL file inspects");
assert_eq!(crl.issuers.len(), 2);
assert_eq!(crl.revoked_count, 1, "counts across every CRL in the file");
let earliest = crl.next_update_unix.expect("both fixtures carry one");
assert!(crl.is_stale(earliest + 1));
assert!(!crl.is_stale(earliest - 1));
}
#[test]
fn classifies_every_documented_rejection_reason() {
use rustls::CertificateError;
assert_eq!(
RejectionReason::classify(&rustls::Error::NoCertificatesPresented),
Some(RejectionReason::NoCertificate)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::UnknownIssuer
)),
Some(RejectionReason::UntrustedCa)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::Expired
)),
Some(RejectionReason::Expired)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::NotValidYet
)),
Some(RejectionReason::NotYetValid)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::Revoked
)),
Some(RejectionReason::Revoked)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::UnknownRevocationStatus
)),
Some(RejectionReason::UnknownRevocation)
);
assert_eq!(
RejectionReason::classify(&rustls::Error::InvalidCertificate(
CertificateError::BadSignature
)),
Some(RejectionReason::Invalid)
);
}
#[test]
fn leaves_ordinary_handshake_failures_unclassified() {
assert_eq!(
RejectionReason::classify(&rustls::Error::DecryptError),
None
);
assert_eq!(
RejectionReason::classify(&rustls::Error::NoApplicationProtocol),
None
);
}
#[test]
fn every_reason_has_a_distinct_tag_and_slot() {
let mut tags: Vec<&str> = RejectionReason::ALL.iter().map(|r| r.as_str()).collect();
tags.sort_unstable();
let count = tags.len();
tags.dedup();
assert_eq!(tags.len(), count, "reason tags must be distinct");
let mut slots: Vec<usize> = RejectionReason::ALL.iter().map(|r| r.slot()).collect();
slots.sort_unstable();
assert_eq!(slots, (0..count).collect::<Vec<_>>());
}
#[test]
fn inspects_every_ca_in_the_bundle() {
let (_dir, path) = write_temp("ca.pem", &format!("{CA_PEM}{OTHER_CA_PEM}"));
let cas = inspect_client_ca_bundle(&path).expect("bundle inspects");
assert_eq!(cas.len(), 2);
assert!(cas[0].subject.contains("CN=Autumn Test Client CA"));
assert!(!cas[0].is_expired(super::super::now_unix()));
assert!(cas[0].days_until_expiry(super::super::now_unix()) > 3650);
}
#[test]
fn inspects_a_crl_with_its_next_update_and_revocations() {
let (_dir, path) = write_temp("crl.pem", CRL_PEM);
let crl = inspect_crl(&path).expect("CRL inspects");
assert!(
crl.issuers[0].contains("CN=Autumn Test Client CA"),
"{crl:?}"
);
assert_eq!(crl.revoked_count, 1);
assert!(!crl.is_stale(super::super::now_unix()));
assert!(crl.is_stale(crl.next_update_unix.expect("fixture has nextUpdate") + 1));
}
#[test]
fn inspects_an_empty_crl_as_zero_revocations() {
let (_dir, path) = write_temp("crl.pem", CRL_EMPTY_PEM);
let crl = inspect_crl(&path).expect("CRL inspects");
assert_eq!(crl.revoked_count, 0);
}
#[test]
fn inspection_rejects_what_the_runtime_rejects() {
let (_dir, path) = write_temp("ca.pem", "");
assert!(matches!(
inspect_client_ca_bundle(&path),
Err(TlsError::NoClientCas { .. })
));
}
}