use std::time::Duration;
use chrono::{DateTime, Utc};
use x509_cert::Certificate;
use crate::error::LtvError;
use crate::ltv::crl::{self, CrlClient};
use crate::ltv::ocsp::{self, OcspClient};
use crate::ltv::status::{resolve_priority, ValidationStatus};
#[derive(Debug, Clone)]
pub struct RevocationConfig {
pub prefer_ocsp: bool,
pub require_revocation_check: bool,
pub ocsp_timeout: Duration,
pub crl_timeout: Duration,
pub use_ocsp_nonce: bool,
pub max_ocsp_recursion: usize,
pub per_cert_timeout: Duration,
pub signature_policy: crate::crypto::verify::SignaturePolicy,
pub ocsp_freshness: crate::ltv::ocsp::OcspFreshness,
pub crl_freshness: crate::ltv::crl::CrlFreshness,
}
impl Default for RevocationConfig {
fn default() -> Self {
Self {
prefer_ocsp: true,
require_revocation_check: true,
ocsp_timeout: Duration::from_secs(3),
crl_timeout: Duration::from_secs(7),
use_ocsp_nonce: true,
max_ocsp_recursion: 1,
per_cert_timeout: Duration::from_secs(10),
signature_policy: crate::crypto::verify::SignaturePolicy::default(),
ocsp_freshness: crate::ltv::ocsp::OcspFreshness::default(),
crl_freshness: crate::ltv::crl::CrlFreshness::default(),
}
}
}
impl RevocationConfig {
pub fn disabled() -> Self {
Self {
require_revocation_check: false,
..Default::default()
}
}
pub fn strict() -> Self {
Self {
require_revocation_check: true,
ocsp_timeout: Duration::from_secs(2),
crl_timeout: Duration::from_secs(5),
per_cert_timeout: Duration::from_secs(8),
..Default::default()
}
}
pub fn allow_legacy_signatures(mut self) -> Self {
self.signature_policy = crate::crypto::verify::SignaturePolicy::allow_legacy();
self
}
pub fn with_ocsp_freshness(mut self, freshness: crate::ltv::ocsp::OcspFreshness) -> Self {
self.ocsp_freshness = freshness;
self
}
pub fn with_crl_freshness(mut self, freshness: crate::ltv::crl::CrlFreshness) -> Self {
self.crl_freshness = freshness;
self
}
}
pub async fn check_certificate_revocation(
cert: &Certificate,
issuer: &Certificate,
config: &RevocationConfig,
crl_client: &CrlClient,
ocsp_client: &OcspClient,
validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
let ocsp_fut = run_ocsp_check(
cert,
issuer,
config,
crl_client,
ocsp_client,
validation_time,
);
let crl_fut = run_crl_check(cert, issuer, config, crl_client, validation_time);
let result = tokio::time::timeout(config.per_cert_timeout, async {
tokio::join!(ocsp_fut, crl_fut)
})
.await;
let (ocsp_status, crl_status) = match result {
Ok((ocsp, crl)) => (ocsp, crl),
Err(_elapsed) => {
log::warn!(
"per-certificate revocation check timed out after {:?}",
config.per_cert_timeout
);
(
ValidationStatus::Unknown {
reason: "OCSP check timed out".into(),
},
ValidationStatus::Unknown {
reason: "CRL check timed out".into(),
},
)
}
};
log::debug!("OCSP result: {ocsp_status}, CRL result: {crl_status}");
let merged = resolve_priority(ocsp_status, crl_status);
enforce_revocation_policy(merged, config.require_revocation_check)
}
fn enforce_revocation_policy(
status: ValidationStatus,
require_revocation_check: bool,
) -> ValidationStatus {
if !require_revocation_check {
return status;
}
match status {
ValidationStatus::Unknown { reason } => ValidationStatus::Invalid {
reason: format!(
"revocation check required but status could not be established: {reason}"
),
},
other => other,
}
}
fn ocsp_check_error_to_status(e: LtvError) -> ValidationStatus {
match e {
LtvError::OcspResponderStatus(_) => {
log::warn!("OCSP responder returned a non-successful status: {e}");
ValidationStatus::Unknown {
reason: format!("OCSP responder unavailable: {e}"),
}
}
other => {
log::warn!("OCSP response failed validation: {other}");
ValidationStatus::Invalid {
reason: format!("OCSP response failed validation: {other}"),
}
}
}
}
#[cfg(feature = "blocking")]
pub fn check_certificate_revocation_blocking(
cert: &Certificate,
issuer: &Certificate,
config: &RevocationConfig,
crl_client: &CrlClient,
ocsp_client: &OcspClient,
validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create tokio runtime");
rt.block_on(check_certificate_revocation(
cert,
issuer,
config,
crl_client,
ocsp_client,
validation_time,
))
}
async fn run_ocsp_check(
cert: &Certificate,
issuer: &Certificate,
config: &RevocationConfig,
crl_client: &CrlClient,
ocsp_client: &OcspClient,
validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
let urls = OcspClient::extract_ocsp_urls(cert);
if urls.is_empty() {
log::debug!("no OCSP responder URL in certificate AIA extension");
return ValidationStatus::Unknown {
reason: "no OCSP responder URL available".into(),
};
}
let result = tokio::time::timeout(config.ocsp_timeout, async {
let fetched = if config.use_ocsp_nonce {
ocsp_client
.fetch_ocsp_response_with_nonce(cert, issuer)
.await
.map(|(der, nonce)| (der, Some(nonce)))
} else {
ocsp_client
.fetch_ocsp_response(cert, issuer)
.await
.map(|der| (der, None))
};
let (response_der, nonce) = match fetched {
Ok(v) => v,
Err(e) => {
log::warn!("OCSP fetch failed: {e}");
return ValidationStatus::Unknown {
reason: format!("OCSP fetch failed: {e}"),
};
}
};
let outcome = match ocsp::check_revocation_detailed(
&response_der,
cert,
issuer,
nonce.as_deref(),
validation_time,
&config.signature_policy,
&config.ocsp_freshness,
) {
Ok(outcome) => outcome,
Err(e) => return ocsp_check_error_to_status(e),
};
if let Some(responder) = &outcome.delegated_responder {
if config.max_ocsp_recursion == 0 {
return ValidationStatus::Invalid {
reason: "delegated OCSP responder lacks id-pkix-ocsp-nocheck and \
responder-revocation checking is disabled (max_ocsp_recursion = 0)"
.into(),
};
}
let responder_status: ValidationStatus =
Box::pin(check_delegated_responder_revocation(
responder,
issuer,
config,
crl_client,
ocsp_client,
validation_time,
))
.await;
if responder_status.is_revoked() || responder_status.is_invalid() {
return ValidationStatus::Invalid {
reason: format!(
"delegated OCSP responder certificate could not be confirmed \
unrevoked: {responder_status}"
),
};
}
}
outcome.status
})
.await;
match result {
Ok(status) => status,
Err(_elapsed) => {
log::warn!("OCSP check timed out after {:?}", config.ocsp_timeout);
ValidationStatus::Unknown {
reason: format!("OCSP check timed out after {:?}", config.ocsp_timeout),
}
}
}
}
async fn check_delegated_responder_revocation(
responder: &Certificate,
ca_issuer: &Certificate,
config: &RevocationConfig,
crl_client: &CrlClient,
ocsp_client: &OcspClient,
validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
let sub_config = RevocationConfig {
max_ocsp_recursion: config.max_ocsp_recursion.saturating_sub(1),
require_revocation_check: config.require_revocation_check,
..config.clone()
};
let ocsp_fut = run_ocsp_check(
responder,
ca_issuer,
&sub_config,
crl_client,
ocsp_client,
validation_time,
);
let crl_fut = run_crl_check(
responder,
ca_issuer,
&sub_config,
crl_client,
validation_time,
);
let (ocsp_status, crl_status) = match tokio::time::timeout(config.per_cert_timeout, async {
tokio::join!(ocsp_fut, crl_fut)
})
.await
{
Ok(pair) => pair,
Err(_) => (
ValidationStatus::Unknown {
reason: "responder OCSP check timed out".into(),
},
ValidationStatus::Unknown {
reason: "responder CRL check timed out".into(),
},
),
};
let merged = resolve_priority(ocsp_status, crl_status);
enforce_revocation_policy(merged, sub_config.require_revocation_check)
}
async fn run_crl_check(
cert: &Certificate,
issuer: &Certificate,
config: &RevocationConfig,
crl_client: &CrlClient,
validation_time: Option<DateTime<Utc>>,
) -> ValidationStatus {
let urls = CrlClient::extract_crl_urls(cert);
if urls.is_empty() {
log::debug!("no CRL distribution points in certificate");
return ValidationStatus::Unknown {
reason: "no CRL distribution points available".into(),
};
}
let result = tokio::time::timeout(config.crl_timeout, async {
match crl_client
.fetch_crls_for_cert_with_freshness(cert, &config.crl_freshness)
.await
{
Ok(crls) => {
if crls.is_empty() {
return ValidationStatus::Unknown {
reason: "no CRLs could be fetched".into(),
};
}
match crl::check_revocation_with_options(
&crls[0],
cert,
issuer,
validation_time,
&config.signature_policy,
&config.crl_freshness,
) {
Ok(status) => status,
Err(e) => {
log::warn!("CRL revocation check failed: {e}");
ValidationStatus::Invalid {
reason: format!("CRL validation failed: {e}"),
}
}
}
}
Err(e) => {
log::warn!("CRL fetch failed: {e}");
ValidationStatus::Unknown {
reason: format!("CRL fetch failed: {e}"),
}
}
}
})
.await;
match result {
Ok(status) => status,
Err(_elapsed) => {
log::warn!("CRL check timed out after {:?}", config.crl_timeout);
ValidationStatus::Unknown {
reason: format!("CRL check timed out after {:?}", config.crl_timeout),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ltv::status::RevocationSource;
#[test]
fn test_default_config() {
let config = RevocationConfig::default();
assert!(config.prefer_ocsp);
assert!(config.require_revocation_check);
assert!(config.use_ocsp_nonce);
assert_eq!(config.ocsp_timeout, Duration::from_secs(3));
assert_eq!(config.crl_timeout, Duration::from_secs(7));
assert_eq!(config.max_ocsp_recursion, 1);
assert_eq!(config.per_cert_timeout, Duration::from_secs(10));
}
#[test]
fn test_disabled_config() {
let config = RevocationConfig::disabled();
assert!(!config.require_revocation_check);
assert!(config.prefer_ocsp); }
#[test]
fn test_strict_config() {
let config = RevocationConfig::strict();
assert!(config.require_revocation_check);
assert_eq!(config.ocsp_timeout, Duration::from_secs(2));
assert_eq!(config.crl_timeout, Duration::from_secs(5));
assert_eq!(config.per_cert_timeout, Duration::from_secs(8));
}
fn load_ca_and_intermediate() -> (Certificate, Certificate) {
let ca_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ca_cert.pem"
));
let intermediate_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/intermediate_ca_cert.pem"
));
let (_, ca_der) = pem_rfc7468::decode_vec(ca_pem.as_bytes()).unwrap();
let (_, inter_der) = pem_rfc7468::decode_vec(intermediate_pem.as_bytes()).unwrap();
let ca = der::Decode::from_der(&ca_der).unwrap();
let intermediate: Certificate = der::Decode::from_der(&inter_der).unwrap();
(ca, intermediate)
}
#[tokio::test]
async fn test_check_no_endpoints_strict_hard_fails() {
let (ca, intermediate) = load_ca_and_intermediate();
let config = RevocationConfig::default();
assert!(config.require_revocation_check);
let crl_client = CrlClient::new().unwrap();
let ocsp_client = OcspClient::new().unwrap();
let status = check_certificate_revocation(
&intermediate,
&ca,
&config,
&crl_client,
&ocsp_client,
None,
)
.await;
assert!(
status.is_invalid(),
"strict policy must hard-fail when no revocation source is usable, got: {status}"
);
assert!(
!status.is_unknown(),
"strict policy must not return Unknown (fail-open), got: {status}"
);
}
#[tokio::test]
async fn test_check_no_endpoints_disabled_returns_unknown() {
let (ca, intermediate) = load_ca_and_intermediate();
let config = RevocationConfig::disabled();
assert!(!config.require_revocation_check);
let crl_client = CrlClient::new().unwrap();
let ocsp_client = OcspClient::new().unwrap();
let status = check_certificate_revocation(
&intermediate,
&ca,
&config,
&crl_client,
&ocsp_client,
None,
)
.await;
assert!(
status.is_unknown(),
"disabled policy should return Unknown for certs without endpoints, got: {status}"
);
}
#[tokio::test]
async fn test_resolve_ocsp_valid_crl_unknown() {
let ocsp = ValidationStatus::Valid {
source: RevocationSource::Ocsp,
checked_at: Utc::now(),
};
let crl = ValidationStatus::Unknown {
reason: "no CRL endpoints".into(),
};
let result = resolve_priority(ocsp, crl);
assert!(result.is_valid());
}
#[tokio::test]
async fn test_resolve_ocsp_unknown_crl_revoked() {
let ocsp = ValidationStatus::Unknown {
reason: "timeout".into(),
};
let crl = ValidationStatus::Revoked {
source: RevocationSource::Crl,
reason: crate::ltv::status::RevocationReason::KeyCompromise,
revocation_time: Utc::now(),
};
let result = resolve_priority(ocsp, crl);
assert!(result.is_revoked());
}
#[tokio::test]
async fn test_resolve_both_valid_picks_first() {
let ocsp = ValidationStatus::Valid {
source: RevocationSource::Ocsp,
checked_at: Utc::now(),
};
let crl = ValidationStatus::Valid {
source: RevocationSource::Crl,
checked_at: Utc::now(),
};
let result = resolve_priority(ocsp, crl);
assert!(result.is_valid());
match result {
ValidationStatus::Valid { source, .. } => {
assert_eq!(source, RevocationSource::Ocsp);
}
_ => panic!("expected Valid"),
}
}
#[tokio::test]
async fn test_resolve_both_unknown() {
let a = ValidationStatus::Unknown {
reason: "no OCSP".into(),
};
let b = ValidationStatus::Unknown {
reason: "no CRL".into(),
};
let result = resolve_priority(a, b);
assert!(result.is_unknown());
}
#[tokio::test]
async fn test_check_with_signer_cert_strict_hard_fails() {
let signer_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/signer_cert.pem"
));
let intermediate_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/intermediate_ca_cert.pem"
));
let (_, signer_der) = pem_rfc7468::decode_vec(signer_pem.as_bytes()).unwrap();
let (_, inter_der) = pem_rfc7468::decode_vec(intermediate_pem.as_bytes()).unwrap();
let signer: Certificate = der::Decode::from_der(&signer_der).unwrap();
let intermediate: Certificate = der::Decode::from_der(&inter_der).unwrap();
let config = RevocationConfig {
per_cert_timeout: Duration::from_secs(2),
..Default::default()
};
let crl_client = CrlClient::new().unwrap();
let ocsp_client = OcspClient::new().unwrap();
let status = check_certificate_revocation(
&signer,
&intermediate,
&config,
&crl_client,
&ocsp_client,
None,
)
.await;
assert!(
status.is_invalid(),
"expected hard fail (Invalid) for signer cert without endpoints under strict policy, got: {status}"
);
}
#[test]
fn test_enforce_policy_upgrades_unknown_to_invalid_when_required() {
let unknown = ValidationStatus::Unknown {
reason: "both sources unreachable".into(),
};
let enforced = enforce_revocation_policy(unknown, true);
assert!(
enforced.is_invalid(),
"strict policy must upgrade Unknown to Invalid, got: {enforced}"
);
}
#[test]
fn test_enforce_policy_keeps_unknown_when_not_required() {
let unknown = ValidationStatus::Unknown {
reason: "offline".into(),
};
assert!(enforce_revocation_policy(unknown, false).is_unknown());
}
#[test]
fn test_enforce_policy_passes_valid_and_revoked_through() {
let valid = ValidationStatus::Valid {
source: RevocationSource::Ocsp,
checked_at: Utc::now(),
};
assert!(enforce_revocation_policy(valid, true).is_valid());
let revoked = ValidationStatus::Revoked {
source: RevocationSource::Crl,
reason: crate::ltv::status::RevocationReason::KeyCompromise,
revocation_time: Utc::now(),
};
assert!(enforce_revocation_policy(revoked, true).is_revoked());
}
#[test]
fn test_enforce_policy_keeps_invalid_blocking() {
let invalid = ValidationStatus::Invalid {
reason: "forged CRL".into(),
};
assert!(enforce_revocation_policy(invalid, true).is_invalid());
}
#[test]
fn test_ocsp_responder_status_maps_to_unknown_not_invalid() {
for msg in ["tryLater (3)", "internalError (2)", "unauthorized (6)"] {
let status = ocsp_check_error_to_status(LtvError::OcspResponderStatus(msg.into()));
assert!(
status.is_unknown(),
"responder status {msg} must map to Unknown, got: {status}"
);
}
}
#[test]
fn test_ocsp_integrity_failure_maps_to_invalid() {
for e in [
LtvError::Ocsp("OCSP signature verification failed".into()),
LtvError::Ocsp("nonce mismatch".into()),
LtvError::Ocsp("responder certificate not trusted".into()),
] {
let status = ocsp_check_error_to_status(e);
assert!(
status.is_invalid(),
"integrity failure must map to Invalid, got: {status}"
);
}
}
#[tokio::test]
async fn test_ocsp_outage_does_not_dominate_under_relaxed_policy() {
let ocsp = ocsp_check_error_to_status(LtvError::OcspResponderStatus("tryLater (3)".into()));
let crl = ValidationStatus::Unknown {
reason: "no CRL distribution points".into(),
};
let merged = resolve_priority(ocsp, crl);
assert!(
merged.is_unknown(),
"transient OCSP outage must not become Invalid"
);
assert!(enforce_revocation_policy(merged, false).is_unknown());
}
#[test]
fn test_forged_source_plus_unreachable_hard_fails_under_strict() {
let forged = ValidationStatus::Invalid {
reason: "forged CRL signature".into(),
};
let blocked = ValidationStatus::Unknown {
reason: "OCSP egress blocked".into(),
};
let merged = resolve_priority(forged, blocked);
assert!(merged.is_invalid());
assert!(enforce_revocation_policy(merged, true).is_invalid());
}
#[test]
fn test_config_custom() {
let config = RevocationConfig {
prefer_ocsp: false,
require_revocation_check: false,
ocsp_timeout: Duration::from_millis(500),
crl_timeout: Duration::from_millis(1000),
use_ocsp_nonce: false,
max_ocsp_recursion: 0,
per_cert_timeout: Duration::from_secs(2),
signature_policy: crate::crypto::verify::SignaturePolicy::default(),
ocsp_freshness: crate::ltv::ocsp::OcspFreshness::default(),
crl_freshness: crate::ltv::crl::CrlFreshness::default(),
};
assert!(!config.prefer_ocsp);
assert!(!config.require_revocation_check);
assert!(!config.use_ocsp_nonce);
assert_eq!(config.max_ocsp_recursion, 0);
}
}