use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use x509_cert::Certificate;
use crate::der_utils::{
find_tagged_value, integer_bodies_equal, parse_integer_body, parse_tlv, parse_tlv_with_rest,
parse_x509_time,
};
use crate::error::LtvError;
use crate::ltv::status::{RevocationReason, RevocationSource, ValidationStatus};
use crate::net::AttestedHttpClient;
#[derive(Debug, Clone)]
struct CrlCacheEntry {
der: Vec<u8>,
fetched_at: Instant,
}
#[derive(Debug, Clone)]
pub struct CrlClient {
http_client: AttestedHttpClient,
timeout: Duration,
cache: Arc<Mutex<HashMap<String, CrlCacheEntry>>>,
grace_period: Duration,
max_body_size: usize,
freshness: CrlFreshness,
}
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
fn default_http_client() -> Result<AttestedHttpClient, LtvError> {
crate::net::hardened_http_client().map_err(LtvError::from)
}
impl CrlClient {
pub fn new() -> Result<Self, LtvError> {
Ok(Self {
http_client: default_http_client()?,
timeout: Duration::from_secs(30),
cache: Arc::new(Mutex::new(HashMap::new())),
grace_period: Duration::from_secs(3600),
max_body_size: MAX_BODY_SIZE,
freshness: CrlFreshness::default(),
})
}
pub fn http_client(mut self, client: AttestedHttpClient) -> Self {
self.http_client = client;
self
}
#[cfg(not(feature = "fips"))]
pub fn unverified_http_client(mut self, client: reqwest::Client) -> Result<Self, LtvError> {
self.http_client = crate::net::unverified_http_client(client)?;
Ok(self)
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn grace_period(mut self, grace: Duration) -> Self {
self.grace_period = grace;
self
}
pub fn freshness(mut self, freshness: CrlFreshness) -> Self {
self.freshness = freshness;
self
}
pub fn max_body_size(mut self, max: usize) -> Self {
self.max_body_size = max;
self
}
async fn validate_url(url: &str) -> Result<(), LtvError> {
crate::net::validate_fetch_url(url)
.await
.map_err(|e| LtvError::Crl(format!("URL rejected: {e}")))
}
pub fn extract_crl_urls(cert: &Certificate) -> Vec<String> {
let mut urls = Vec::new();
let crl_dp_oid = const_oid::ObjectIdentifier::new_unwrap("2.5.29.31");
if let Some(extensions) = &cert.tbs_certificate.extensions {
for ext in extensions.iter() {
if ext.extn_id == crl_dp_oid {
if let Ok(urls_from_ext) = parse_crl_dp_extension(ext.extn_value.as_bytes()) {
urls.extend(urls_from_ext);
}
}
}
}
urls
}
pub async fn fetch_crl(&self, url: &str) -> Result<Vec<u8>, LtvError> {
self.fetch_crl_with_freshness(url, &self.freshness).await
}
pub async fn fetch_crl_with_freshness(
&self,
url: &str,
freshness: &CrlFreshness,
) -> Result<Vec<u8>, LtvError> {
{
let cache = self
.cache
.lock()
.map_err(|e| LtvError::Crl(format!("cache lock poisoned: {e}")))?;
if let Some(entry) = cache.get(url) {
if entry.fetched_at.elapsed() < self.grace_period
&& crl_is_current(&entry.der, chrono::Utc::now(), freshness)
{
log::debug!("CRL cache hit for {url}");
return Ok(entry.der.clone());
}
log::debug!("CRL cache entry for {url} is stale or expired; re-fetching");
}
}
Self::validate_url(url).await?;
log::debug!("Fetching CRL from {url}");
let response = self
.http_client
.client()
.get(url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| LtvError::Crl(format!("CRL fetch from {url} failed: {e}")))?;
if !response.status().is_success() {
return Err(LtvError::Crl(format!(
"CRL fetch from {url} returned HTTP {}",
response.status()
)));
}
if let Some(len) = response.content_length() {
if len > self.max_body_size as u64 {
return Err(LtvError::Crl(format!(
"CRL from {url} exceeds max body size ({len} > {})",
self.max_body_size
)));
}
}
let mut crl_bytes: Vec<u8> = Vec::new();
let mut response = response;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| LtvError::Crl(format!("failed to read CRL response body: {e}")))?;
let Some(chunk) = chunk else { break };
if crl_bytes.len() + chunk.len() > self.max_body_size {
return Err(LtvError::Crl(format!(
"CRL from {url} exceeds max body size (> {})",
self.max_body_size
)));
}
crl_bytes.extend_from_slice(&chunk);
}
if crl_bytes.is_empty() || crl_bytes[0] != 0x30 {
return Err(LtvError::Crl(format!(
"CRL from {url} does not appear to be DER-encoded"
)));
}
log::debug!("CRL from {url}: {} bytes", crl_bytes.len());
{
let mut cache = self
.cache
.lock()
.map_err(|e| LtvError::Crl(format!("cache lock poisoned: {e}")))?;
cache.insert(
url.to_string(),
CrlCacheEntry {
der: crl_bytes.clone(),
fetched_at: Instant::now(),
},
);
}
Ok(crl_bytes)
}
pub async fn fetch_crls_for_cert(&self, cert: &Certificate) -> Result<Vec<Vec<u8>>, LtvError> {
self.fetch_crls_for_cert_with_freshness(cert, &self.freshness)
.await
}
pub async fn fetch_crls_for_cert_with_freshness(
&self,
cert: &Certificate,
freshness: &CrlFreshness,
) -> Result<Vec<Vec<u8>>, LtvError> {
let urls = Self::extract_crl_urls(cert);
let mut stale_fallback: Option<Vec<u8>> = None;
for url in &urls {
match self.fetch_crl_with_freshness(url, freshness).await {
Ok(crl) => {
if crl_is_current(&crl, chrono::Utc::now(), freshness) {
return Ok(vec![crl]);
}
log::warn!("CRL from {url} is stale; trying other distribution points");
stale_fallback.get_or_insert(crl);
}
Err(e) => {
log::warn!("Failed to fetch CRL from {url}: {e}");
}
}
}
Ok(stale_fallback.into_iter().collect())
}
pub fn clear_cache(&self) {
if let Ok(mut cache) = self.cache.lock() {
cache.clear();
}
}
}
fn parse_crl_dp_extension(der_bytes: &[u8]) -> Result<Vec<String>, String> {
let mut urls = Vec::new();
let (tag, body) = parse_tlv(der_bytes)?;
if tag != 0x30 {
return Err(format!("expected SEQUENCE, got 0x{tag:02x}"));
}
let mut pos = &body[..];
while !pos.is_empty() {
let (dp_tag, dp_body, rest) = parse_tlv_with_rest(pos)?;
if dp_tag == 0x30 {
if !dp_body.is_empty() {
if let Ok((inner_tag, inner_body, _)) = parse_tlv_with_rest(dp_body) {
if inner_tag == 0xA0 {
if let Ok((fn_tag, fn_body, _)) = parse_tlv_with_rest(inner_body) {
if fn_tag == 0xA0 {
let mut gn_pos = fn_body;
while !gn_pos.is_empty() {
if let Ok((gn_tag, gn_body, gn_rest)) =
parse_tlv_with_rest(gn_pos)
{
if gn_tag == 0x86 {
if let Ok(uri) = std::str::from_utf8(gn_body) {
urls.push(uri.to_string());
}
}
gn_pos = gn_rest;
} else {
break;
}
}
}
}
}
}
}
}
pos = rest;
}
Ok(urls)
}
#[derive(Debug, Clone)]
pub struct RevokedEntry {
pub serial_number: Vec<u8>,
pub revocation_time: chrono::DateTime<chrono::Utc>,
pub reason: RevocationReason,
}
#[derive(Debug)]
pub struct ParsedCrl {
pub tbs_bytes: Vec<u8>,
pub signature_algorithm: spki::AlgorithmIdentifierOwned,
pub signature_bytes: Vec<u8>,
pub issuer_der: Vec<u8>,
pub this_update: chrono::DateTime<chrono::Utc>,
pub next_update: Option<chrono::DateTime<chrono::Utc>>,
pub revoked_entries: Vec<RevokedEntry>,
}
const DELTA_CRL_INDICATOR_OID: &[u8] = &[0x55, 0x1D, 0x1B];
const ISSUING_DIST_POINT_OID: &[u8] = &[0x55, 0x1D, 0x1C];
fn extensions_contain_oid(extensions_body: &[u8], target_oid: &[u8]) -> Result<bool, String> {
let mut pos = extensions_body;
while !pos.is_empty() {
let (ext_tag, ext_value, rest) = parse_tlv_with_rest(pos)?;
if ext_tag != 0x30 {
return Err(format!(
"expected Extension SEQUENCE (0x30), got 0x{ext_tag:02x}"
));
}
let (oid_tag, oid_body, _ext_rest) = parse_tlv_with_rest(ext_value)?;
if oid_tag == 0x06 && oid_body == target_oid {
return Ok(true);
}
pos = rest;
}
Ok(false)
}
pub fn parse_crl(crl_der: &[u8]) -> Result<ParsedCrl, LtvError> {
let (outer_tag, outer_body) =
parse_tlv(crl_der).map_err(|e| LtvError::Crl(format!("CRL outer SEQUENCE: {e}")))?;
if outer_tag != 0x30 {
return Err(LtvError::Crl(format!(
"expected CRL SEQUENCE (0x30), got 0x{outer_tag:02x}"
)));
}
let (tbs_tag, tbs_value, rest) = parse_tlv_with_rest(&outer_body)
.map_err(|e| LtvError::Crl(format!("CRL tbsCertList: {e}")))?;
if tbs_tag != 0x30 {
return Err(LtvError::Crl(format!(
"expected tbsCertList SEQUENCE, got 0x{tbs_tag:02x}"
)));
}
let tbs_start = crl_der.len() - outer_body.len();
let tbs_end = crl_der.len() - outer_body.len() + (outer_body.len() - rest.len());
let tbs_bytes = crl_der[tbs_start..tbs_end].to_vec();
use der::Decode as _;
let sig_alg_input = rest;
let (sig_alg_tag, _sig_alg_body, rest) = parse_tlv_with_rest(sig_alg_input)
.map_err(|e| LtvError::Crl(format!("CRL sigAlg: {e}")))?;
if sig_alg_tag != 0x30 {
return Err(LtvError::Crl(format!(
"expected signatureAlgorithm SEQUENCE, got 0x{sig_alg_tag:02x}"
)));
}
let sig_alg_der = &sig_alg_input[..sig_alg_input.len() - rest.len()];
let signature_algorithm = spki::AlgorithmIdentifierOwned::from_der(sig_alg_der)
.map_err(|e| LtvError::Crl(format!("CRL signatureAlgorithm decode: {e}")))?;
let (sig_val_tag, sig_val_body, _) =
parse_tlv_with_rest(rest).map_err(|e| LtvError::Crl(format!("CRL sigValue: {e}")))?;
if sig_val_tag != 0x03 {
return Err(LtvError::Crl(format!(
"expected signatureValue BIT STRING (0x03), got 0x{sig_val_tag:02x}"
)));
}
if sig_val_body.is_empty() {
return Err(LtvError::Crl("empty signature BIT STRING".into()));
}
let signature_bytes = sig_val_body[1..].to_vec();
let mut tbs_pos = tbs_value;
if !tbs_pos.is_empty() && tbs_pos[0] == 0x02 {
let (_, _, r) =
parse_tlv_with_rest(tbs_pos).map_err(|e| LtvError::Crl(format!("CRL version: {e}")))?;
tbs_pos = r;
}
if !tbs_pos.is_empty() {
let (tag, _, r) = parse_tlv_with_rest(tbs_pos)
.map_err(|e| LtvError::Crl(format!("CRL inner sigAlg: {e}")))?;
if tag == 0x30 {
tbs_pos = r;
}
}
let (issuer_tag, _issuer_body, rest_after_issuer) =
parse_tlv_with_rest(tbs_pos).map_err(|e| LtvError::Crl(format!("CRL issuer: {e}")))?;
if issuer_tag != 0x30 {
return Err(LtvError::Crl(format!(
"expected issuer SEQUENCE, got 0x{issuer_tag:02x}"
)));
}
let issuer_len = tbs_pos.len() - rest_after_issuer.len();
let issuer_der = tbs_pos[..issuer_len].to_vec();
tbs_pos = rest_after_issuer;
let (time_tag, time_body, rest_after_this) =
parse_tlv_with_rest(tbs_pos).map_err(|e| LtvError::Crl(format!("CRL thisUpdate: {e}")))?;
let this_update = parse_x509_time(time_tag, time_body)
.map_err(|e| LtvError::Crl(format!("CRL thisUpdate parse: {e}")))?;
tbs_pos = rest_after_this;
let mut next_update = None;
if !tbs_pos.is_empty() && (tbs_pos[0] == 0x17 || tbs_pos[0] == 0x18) {
let (nt_tag, nt_body, r) = parse_tlv_with_rest(tbs_pos)
.map_err(|e| LtvError::Crl(format!("CRL nextUpdate: {e}")))?;
next_update = Some(
parse_x509_time(nt_tag, nt_body)
.map_err(|e| LtvError::Crl(format!("CRL nextUpdate parse: {e}")))?,
);
tbs_pos = r;
}
let mut revoked_entries = Vec::new();
if !tbs_pos.is_empty() && tbs_pos[0] == 0x30 {
let (rc_tag, rc_body, r) = parse_tlv_with_rest(tbs_pos)
.map_err(|e| LtvError::Crl(format!("CRL revokedCertificates: {e}")))?;
if rc_tag == 0x30 {
parse_revoked_certificates(rc_body, &mut revoked_entries)?;
}
tbs_pos = r;
}
if !tbs_pos.is_empty() && tbs_pos[0] == 0xA0 {
let (wrap_tag, wrap_body, _) = parse_tlv_with_rest(tbs_pos)
.map_err(|e| LtvError::Crl(format!("crlExtensions: {e}")))?;
debug_assert_eq!(wrap_tag, 0xA0);
let (seq_tag, extensions_body, _) = parse_tlv_with_rest(wrap_body)
.map_err(|e| LtvError::Crl(format!("crlExtensions SEQUENCE: {e}")))?;
if seq_tag != 0x30 {
return Err(LtvError::Crl(format!(
"crlExtensions: expected Extensions SEQUENCE (0x30), got 0x{seq_tag:02x}"
)));
}
let has_delta = extensions_contain_oid(extensions_body, DELTA_CRL_INDICATOR_OID)
.map_err(|e| LtvError::Crl(format!("crlExtensions scan: {e}")))?;
if has_delta {
return Err(LtvError::Crl(
"delta CRL (2.5.29.27) not supported; only full CRLs are accepted".into(),
));
}
let has_idp = extensions_contain_oid(extensions_body, ISSUING_DIST_POINT_OID)
.map_err(|e| LtvError::Crl(format!("crlExtensions scan: {e}")))?;
if has_idp {
return Err(LtvError::Crl(
"partitioned CRL (IssuingDistributionPoint) not supported; only full CRLs are accepted".into(),
));
}
}
Ok(ParsedCrl {
tbs_bytes,
signature_algorithm,
signature_bytes,
issuer_der,
this_update,
next_update,
revoked_entries,
})
}
fn parse_revoked_certificates(
body: &[u8],
entries: &mut Vec<RevokedEntry>,
) -> Result<(), LtvError> {
let mut pos = body;
while !pos.is_empty() {
let (entry_tag, entry_body, rest) =
parse_tlv_with_rest(pos).map_err(|e| LtvError::Crl(format!("revoked entry: {e}")))?;
if entry_tag != 0x30 {
return Err(LtvError::Crl(format!(
"expected revoked entry SEQUENCE, got 0x{entry_tag:02x}"
)));
}
let (serial_tag, serial_body, entry_rest) = parse_tlv_with_rest(entry_body)
.map_err(|e| LtvError::Crl(format!("revoked serial: {e}")))?;
if serial_tag != 0x02 {
return Err(LtvError::Crl(format!(
"expected serial INTEGER (0x02), got 0x{serial_tag:02x}"
)));
}
let serial_number = parse_integer_body(serial_body);
let (time_tag, time_body, entry_rest2) = parse_tlv_with_rest(entry_rest)
.map_err(|e| LtvError::Crl(format!("revocation date: {e}")))?;
let revocation_time = parse_x509_time(time_tag, time_body)
.map_err(|e| LtvError::Crl(format!("revocation date parse: {e}")))?;
let reason = parse_revocation_reason(entry_rest2);
entries.push(RevokedEntry {
serial_number,
revocation_time,
reason,
});
pos = rest;
}
Ok(())
}
fn parse_revocation_reason(extensions_area: &[u8]) -> RevocationReason {
if extensions_area.is_empty() {
return RevocationReason::Unspecified;
}
let Ok((tag, ext_body, _)) = parse_tlv_with_rest(extensions_area) else {
return RevocationReason::Unspecified;
};
if tag != 0x30 {
return RevocationReason::Unspecified;
}
let reason_oid_bytes: &[u8] = &[0x55, 0x1D, 0x15];
let mut pos = ext_body;
while !pos.is_empty() {
let Ok((ext_tag, ext_value, rest)) = parse_tlv_with_rest(pos) else {
break;
};
if ext_tag == 0x30 {
if let Some(oid_body) = find_tagged_value(ext_value, 0x06) {
if oid_body == reason_oid_bytes {
if let Some(octet_body) = find_tagged_value(ext_value, 0x04) {
if let Some(enum_body) = find_tagged_value(octet_body, 0x0A) {
if !enum_body.is_empty() {
return RevocationReason::from_code(enum_body[0]);
}
}
}
}
}
}
pos = rest;
}
RevocationReason::Unspecified
}
pub fn verify_crl_signature(parsed_crl: &ParsedCrl, issuer: &Certificate) -> Result<(), LtvError> {
verify_crl_signature_with_policy(
parsed_crl,
issuer,
&crate::crypto::verify::SignaturePolicy::default(),
)
}
pub fn verify_crl_signature_with_policy(
parsed_crl: &ParsedCrl,
issuer: &Certificate,
policy: &crate::crypto::verify::SignaturePolicy,
) -> Result<(), LtvError> {
use der::Encode;
if let Some(ku) = crate::ltv::x509_ext::check_key_usage(issuer)? {
if !ku.crl_sign {
return Err(LtvError::Crl(format!(
"CRL issuer certificate keyUsage does not assert cRLSign (has: {ku})"
)));
}
}
let spki_der = issuer
.tbs_certificate
.subject_public_key_info
.to_der()
.map_err(|e| LtvError::Crl(format!("issuer SPKI encode failed: {e}")))?;
crate::crypto::verify::verify_signature_by_algid_with_policy(
&parsed_crl.tbs_bytes,
&parsed_crl.signature_bytes,
&spki_der,
&parsed_crl.signature_algorithm,
policy,
)
.map_err(|e| LtvError::Crl(format!("CRL signature verification failed: {e}")))
}
#[derive(Debug, Clone)]
pub struct CrlFreshness {
pub clock_skew: chrono::Duration,
pub max_age_without_next_update: chrono::Duration,
}
impl Default for CrlFreshness {
fn default() -> Self {
Self {
clock_skew: chrono::Duration::minutes(5),
max_age_without_next_update: chrono::Duration::hours(24),
}
}
}
fn validate_crl_freshness(
parsed: &ParsedCrl,
now: chrono::DateTime<chrono::Utc>,
freshness: &CrlFreshness,
) -> Result<(), LtvError> {
let skew = freshness.clock_skew;
match parsed.next_update {
Some(next_update) => {
if next_update < parsed.this_update {
return Err(LtvError::Crl(format!(
"CRL has nextUpdate ({next_update}) before thisUpdate ({})",
parsed.this_update
)));
}
if now > next_update + skew {
return Err(LtvError::Crl(format!(
"CRL is stale: nextUpdate ({next_update}) is before validation time ({now})"
)));
}
}
None => {
let max_valid = parsed.this_update + freshness.max_age_without_next_update + skew;
if now > max_valid {
return Err(LtvError::Crl(format!(
"CRL without nextUpdate is too old: thisUpdate ({}), validation time ({now}), max age {}",
parsed.this_update, freshness.max_age_without_next_update
)));
}
}
}
Ok(())
}
fn crl_is_current(
der: &[u8],
now: chrono::DateTime<chrono::Utc>,
freshness: &CrlFreshness,
) -> bool {
let Ok(parsed) = parse_crl(der) else {
return false;
};
if now + freshness.clock_skew < parsed.this_update {
return false;
}
validate_crl_freshness(&parsed, now, freshness).is_ok()
}
pub fn check_revocation(
crl_der: &[u8],
cert: &Certificate,
issuer: &Certificate,
validation_time: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<ValidationStatus, LtvError> {
check_revocation_with_policy(
crl_der,
cert,
issuer,
validation_time,
&crate::crypto::verify::SignaturePolicy::default(),
)
}
pub fn check_revocation_with_policy(
crl_der: &[u8],
cert: &Certificate,
issuer: &Certificate,
validation_time: Option<chrono::DateTime<chrono::Utc>>,
policy: &crate::crypto::verify::SignaturePolicy,
) -> Result<ValidationStatus, LtvError> {
check_revocation_with_options(
crl_der,
cert,
issuer,
validation_time,
policy,
&CrlFreshness::default(),
)
}
pub fn check_revocation_with_options(
crl_der: &[u8],
cert: &Certificate,
issuer: &Certificate,
validation_time: Option<chrono::DateTime<chrono::Utc>>,
policy: &crate::crypto::verify::SignaturePolicy,
freshness: &CrlFreshness,
) -> Result<ValidationStatus, LtvError> {
let now = validation_time.unwrap_or_else(chrono::Utc::now);
let parsed = parse_crl(crl_der)?;
verify_crl_signature_with_policy(&parsed, issuer, policy)?;
validate_crl_freshness(&parsed, now, freshness)?;
let cert_issuer_der = get_cert_issuer_der(cert)?;
if parsed.issuer_der != cert_issuer_der {
return Err(LtvError::Crl(
"CRL issuer does not match certificate issuer".into(),
));
}
let cert_serial = get_cert_serial_body(cert);
for entry in &parsed.revoked_entries {
if integer_bodies_equal(&entry.serial_number, &cert_serial) {
if entry.revocation_time > now {
log::debug!(
"cert serial found in CRL but revocation_time ({}) is in the future relative to validation_time ({})",
entry.revocation_time, now
);
return Ok(ValidationStatus::Valid {
source: RevocationSource::Crl,
checked_at: now,
});
}
return Ok(ValidationStatus::Revoked {
source: RevocationSource::Crl,
reason: entry.reason,
revocation_time: entry.revocation_time,
});
}
}
Ok(ValidationStatus::Valid {
source: RevocationSource::Crl,
checked_at: now,
})
}
fn get_cert_issuer_der(cert: &Certificate) -> Result<Vec<u8>, LtvError> {
use der::Encode;
cert.tbs_certificate
.issuer
.to_der()
.map_err(|e| LtvError::Crl(format!("cert issuer DER encode failed: {e}")))
}
fn get_cert_serial_body(cert: &Certificate) -> Vec<u8> {
let serial = &cert.tbs_certificate.serial_number;
let bytes = serial.as_bytes();
parse_integer_body(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::der_utils::{
encode_integer_u64, encode_sequence_from_parts, encode_sequence_raw, encode_tlv,
};
use der::{Decode, Encode};
#[test]
fn test_crl_client_default() {
let client = CrlClient::new().unwrap();
assert_eq!(client.grace_period, Duration::from_secs(3600));
}
#[test]
fn test_crl_client_builder() {
let client = CrlClient::new()
.unwrap()
.timeout(Duration::from_secs(10))
.grace_period(Duration::from_secs(7200));
assert_eq!(client.grace_period, Duration::from_secs(7200));
}
#[test]
fn test_extract_crl_urls_no_extensions() {
let cert_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ca_cert.pem"
));
let pem_data = pem_rfc7468::decode_vec(cert_pem.as_bytes());
if let Ok((_label, der)) = pem_data {
if let Ok(cert) = Certificate::from_der(&der) {
let urls = CrlClient::extract_crl_urls(&cert);
let _ = urls;
}
}
}
fn build_test_crl(
issuer_cert: &Certificate,
issuer_key_pem: &str,
revoked_serials: &[(Vec<u8>, &str)], ) -> Vec<u8> {
build_test_crl_with_window(
issuer_cert,
issuer_key_pem,
revoked_serials,
"260101000000Z",
Some("270101000000Z"),
)
}
fn build_test_crl_with_window(
issuer_cert: &Certificate,
issuer_key_pem: &str,
revoked_serials: &[(Vec<u8>, &str)],
this_update: &str,
next_update: Option<&str>,
) -> Vec<u8> {
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey;
use rsa::signature::SignatureEncoding;
use rsa::signature::Signer;
use sha2::Sha256;
let mut tbs_body = Vec::new();
tbs_body.extend_from_slice(&encode_integer_u64(1));
let sha256_rsa_oid: &[u8] = &[
0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
];
let alg_id = encode_sequence_from_parts(&[sha256_rsa_oid, &[0x05, 0x00]]);
tbs_body.extend_from_slice(&alg_id);
let issuer_name_der = issuer_cert.tbs_certificate.subject.to_der().unwrap();
tbs_body.extend_from_slice(&issuer_name_der);
let this_update_utc = encode_tlv(0x17, this_update.as_bytes());
tbs_body.extend_from_slice(&this_update_utc);
if let Some(next_update) = next_update {
let next_update_utc = encode_tlv(0x17, next_update.as_bytes());
tbs_body.extend_from_slice(&next_update_utc);
}
if !revoked_serials.is_empty() {
let mut revoked_body = Vec::new();
for (serial, time_str) in revoked_serials {
let serial_tlv = encode_tlv(0x02, serial);
let time_tlv = encode_tlv(0x17, time_str.as_bytes());
let entry = encode_sequence_from_parts(&[&serial_tlv, &time_tlv]);
revoked_body.extend_from_slice(&entry);
}
let revoked_seq = encode_sequence_raw(&revoked_body);
tbs_body.extend_from_slice(&revoked_seq);
}
let tbs_der = encode_sequence_raw(&tbs_body);
let key_der = pem_rfc7468::decode_vec(issuer_key_pem.as_bytes())
.unwrap()
.1;
let private_key = rsa::RsaPrivateKey::from_pkcs8_der(&key_der).unwrap();
let signing_key = SigningKey::<Sha256>::new(private_key);
let signature: rsa::pkcs1v15::Signature = signing_key.sign(&tbs_der);
let sig_bytes = signature.to_vec();
let outer_alg_id = alg_id.clone();
let mut bit_string_value = vec![0x00];
bit_string_value.extend_from_slice(&sig_bytes);
let sig_bit_string = encode_tlv(0x03, &bit_string_value);
encode_sequence_from_parts(&[&tbs_der, &outer_alg_id, &sig_bit_string])
}
fn load_test_cert(pem_str: &str) -> Certificate {
let (_, der) = pem_rfc7468::decode_vec(pem_str.as_bytes()).unwrap();
Certificate::from_der(&der).unwrap()
}
fn intermediate_ca_cert() -> Certificate {
let pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/intermediate_ca_cert.pem"
));
load_test_cert(pem)
}
fn intermediate_ca_key_pem() -> &'static str {
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/intermediate_ca_key.pem"
)
}
fn signer_cert() -> Certificate {
let pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/signer_cert.pem"
));
load_test_cert(pem)
}
#[test]
fn test_parse_crl_empty_revoked_list() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!(
"skipping test: intermediate_ca_key.pem not found (run gen-test-fixtures.sh)"
);
return;
};
let issuer = intermediate_ca_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let parsed = parse_crl(&crl_der).unwrap();
assert!(parsed.revoked_entries.is_empty());
assert!(parsed.next_update.is_some());
assert_eq!(parsed.this_update.to_rfc3339(), "2026-01-01T00:00:00+00:00");
}
#[test]
fn test_parse_crl_with_revoked_entries() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let revoked = vec![
(vec![0x01], "250601120000Z"),
(vec![0x00, 0xFF], "250701120000Z"),
];
let crl_der = build_test_crl(&issuer, &key_pem, &revoked);
let parsed = parse_crl(&crl_der).unwrap();
assert_eq!(parsed.revoked_entries.len(), 2);
assert_eq!(parsed.revoked_entries[0].serial_number, vec![0x01]);
assert_eq!(parsed.revoked_entries[1].serial_number, vec![0xFF]);
}
fn build_crl_with_extension(ext_oid: &[u8]) -> Vec<u8> {
let oid_tlv = encode_tlv(0x06, ext_oid);
let value_tlv = encode_tlv(0x04, &[]); let extension = encode_sequence_from_parts(&[&oid_tlv, &value_tlv]);
build_crl_with_extensions_body(&extension)
}
fn build_crl_with_extensions_body(extensions_body: &[u8]) -> Vec<u8> {
let mut tbs_body = Vec::new();
tbs_body.extend_from_slice(&encode_integer_u64(1));
let sha256_rsa_oid: &[u8] = &[
0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
];
let alg_id = encode_sequence_from_parts(&[sha256_rsa_oid, &[0x05, 0x00]]);
tbs_body.extend_from_slice(&alg_id);
tbs_body.extend_from_slice(&encode_sequence_raw(&[]));
tbs_body.extend_from_slice(&encode_tlv(0x17, b"260101000000Z"));
tbs_body.extend_from_slice(&encode_tlv(0x17, b"270101000000Z"));
let extensions_seq = encode_sequence_raw(extensions_body);
tbs_body.extend_from_slice(&encode_tlv(0xA0, &extensions_seq));
let tbs_der = encode_sequence_raw(&tbs_body);
let sig_bit_string = encode_tlv(0x03, &[0x00, 0xDE, 0xAD]);
encode_sequence_from_parts(&[&tbs_der, &alg_id, &sig_bit_string])
}
#[test]
fn test_parse_crl_rejects_delta_crl() {
let crl_der = build_crl_with_extension(&[0x55, 0x1D, 0x1B]);
let err = parse_crl(&crl_der).expect_err("delta CRL must be rejected");
let msg = format!("{err}");
assert!(msg.contains("delta CRL"), "unexpected error: {msg}");
}
#[test]
fn test_parse_crl_rejects_partitioned_crl() {
let crl_der = build_crl_with_extension(&[0x55, 0x1D, 0x1C]);
let err = parse_crl(&crl_der).expect_err("partitioned CRL must be rejected");
let msg = format!("{err}");
assert!(msg.contains("partitioned CRL"), "unexpected error: {msg}");
}
#[test]
fn test_parse_crl_accepts_benign_extension() {
let crl_der = build_crl_with_extension(&[0x55, 0x1D, 0x14]);
let parsed = parse_crl(&crl_der).expect("benign extension must parse");
assert!(parsed.revoked_entries.is_empty());
assert!(parsed.next_update.is_some());
}
#[test]
fn test_parse_crl_rejects_delta_with_missing_extnvalue() {
let ext = encode_sequence_from_parts(&[&encode_tlv(0x06, &[0x55, 0x1D, 0x1B])]); let crl_der = build_crl_with_extensions_body(&ext);
let err = parse_crl(&crl_der).expect_err("delta marker w/o extnValue must be rejected");
assert!(
format!("{err}").contains("delta CRL"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_crl_rejects_non_sequence_crl_extensions() {
let mut tbs_body = Vec::new();
tbs_body.extend_from_slice(&encode_integer_u64(1));
let sha256_rsa_oid: &[u8] = &[
0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
];
let alg_id = encode_sequence_from_parts(&[sha256_rsa_oid, &[0x05, 0x00]]);
tbs_body.extend_from_slice(&alg_id);
tbs_body.extend_from_slice(&encode_sequence_raw(&[]));
tbs_body.extend_from_slice(&encode_tlv(0x17, b"260101000000Z"));
tbs_body.extend_from_slice(&encode_tlv(0x17, b"270101000000Z"));
tbs_body.extend_from_slice(&encode_tlv(0xA0, &encode_integer_u64(7)));
let tbs_der = encode_sequence_raw(&tbs_body);
let sig = encode_tlv(0x03, &[0x00, 0xDE, 0xAD]);
let crl_der = encode_sequence_from_parts(&[&tbs_der, &alg_id, &sig]);
let err = parse_crl(&crl_der).expect_err("non-SEQUENCE crlExtensions must be rejected");
assert!(
format!("{err}").contains("Extensions SEQUENCE"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_crl_rejects_malformed_extension_list() {
let benign = encode_sequence_from_parts(&[
&encode_tlv(0x06, &[0x55, 0x1D, 0x14]),
&encode_tlv(0x04, &[]),
]);
let mut extensions_body = benign;
extensions_body.extend_from_slice(&[0x30, 0x05, 0x06, 0x03]); let crl_der = build_crl_with_extensions_body(&extensions_body);
let err = parse_crl(&crl_der).expect_err("malformed crlExtensions must be rejected");
assert!(
format!("{err}").contains("crlExtensions scan"),
"unexpected error: {err}"
);
}
#[test]
fn test_verify_crl_signature() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let parsed = parse_crl(&crl_der).unwrap();
let result = verify_crl_signature(&parsed, &issuer);
assert!(result.is_ok(), "CRL signature should verify: {result:?}");
}
#[test]
fn test_verify_crl_signature_wrong_issuer() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let parsed = parse_crl(&crl_der).unwrap();
let wrong_issuer = signer_cert();
let result = verify_crl_signature(&parsed, &wrong_issuer);
assert!(result.is_err(), "wrong issuer should fail verification");
}
#[test]
fn test_verify_crl_signature_rejects_issuer_without_crl_sign() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let parsed = parse_crl(&crl_der).unwrap();
let non_crl_signer = signer_cert();
let err = verify_crl_signature(&parsed, &non_crl_signer)
.expect_err("issuer without cRLSign keyUsage must be rejected");
assert!(
format!("{err}").contains("cRLSign"),
"expected cRLSign rejection, got: {err}"
);
}
#[test]
fn test_check_revocation_not_revoked() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let cert = signer_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let validation_time = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let status = check_revocation(&crl_der, &cert, &issuer, Some(validation_time)).unwrap();
assert!(status.is_valid(), "should be valid: {status}");
}
#[test]
fn test_check_revocation_cert_is_revoked() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let cert = signer_cert();
let serial = get_cert_serial_body(&cert);
let revoked = vec![(serial, "250601120000Z")];
let crl_der = build_test_crl(&issuer, &key_pem, &revoked);
let validation_time = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let status = check_revocation(&crl_der, &cert, &issuer, Some(validation_time)).unwrap();
assert!(status.is_revoked(), "should be revoked: {status}");
}
#[test]
fn test_check_revocation_time_aware_future_revocation() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let cert = signer_cert();
let serial = get_cert_serial_body(&cert);
let revoked = vec![(serial, "270101120000Z")];
let crl_der = build_test_crl(&issuer, &key_pem, &revoked);
let validation_time = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let status = check_revocation(&crl_der, &cert, &issuer, Some(validation_time)).unwrap();
assert!(
status.is_valid(),
"should be valid (revocation in future): {status}"
);
}
#[test]
fn test_check_revocation_stale_crl_rejected() {
let key_path = intermediate_ca_key_pem();
let Ok(key_pem) = std::fs::read_to_string(key_path) else {
eprintln!("skipping test: intermediate_ca_key.pem not found");
return;
};
let issuer = intermediate_ca_cert();
let cert = signer_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
let validation_time = chrono::DateTime::parse_from_rfc3339("2027-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let result = check_revocation(&crl_der, &cert, &issuer, Some(validation_time));
assert!(
result.is_err(),
"stale CRL must fail closed (Err → Invalid), got {result:?}"
);
}
fn parse_test_crl() -> Option<ParsedCrl> {
let key_pem = std::fs::read_to_string(intermediate_ca_key_pem()).ok()?;
let issuer = intermediate_ca_cert();
let crl_der = build_test_crl(&issuer, &key_pem, &[]);
Some(parse_crl(&crl_der).unwrap())
}
fn instant(s: &str) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::parse_from_rfc3339(s)
.unwrap()
.with_timezone(&chrono::Utc)
}
#[test]
fn test_validate_crl_freshness_within_window() {
let Some(parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let res = validate_crl_freshness(
&parsed,
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default(),
);
assert!(res.is_ok(), "in-window CRL should be fresh: {res:?}");
}
#[test]
fn test_validate_crl_freshness_stale() {
let Some(parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let res = validate_crl_freshness(
&parsed,
instant("2027-01-01T00:10:00Z"),
&CrlFreshness::default(),
);
assert!(res.is_err(), "CRL past nextUpdate must be stale");
}
#[test]
fn test_validate_crl_freshness_skew_grace() {
let Some(parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let res = validate_crl_freshness(
&parsed,
instant("2027-01-01T00:02:00Z"),
&CrlFreshness::default(),
);
assert!(res.is_ok(), "within-skew CRL should be accepted: {res:?}");
}
#[test]
fn test_validate_crl_freshness_later_collected_evidence() {
let Some(parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let res = validate_crl_freshness(
&parsed,
instant("2025-06-01T00:00:00Z"),
&CrlFreshness::default(),
);
assert!(res.is_ok(), "later-collected CRL should be kept: {res:?}");
}
#[test]
fn test_validate_crl_freshness_no_next_update_within_max_age() {
let Some(mut parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
parsed.next_update = None;
let res = validate_crl_freshness(
&parsed,
instant("2026-01-01T12:00:00Z"),
&CrlFreshness::default(),
);
assert!(res.is_ok(), "within max-age should be fresh: {res:?}");
}
#[test]
fn test_validate_crl_freshness_no_next_update_too_old() {
let Some(mut parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
parsed.next_update = None;
let res = validate_crl_freshness(
&parsed,
instant("2026-01-03T00:00:00Z"),
&CrlFreshness::default(),
);
assert!(
res.is_err(),
"beyond max-age without nextUpdate must be rejected"
);
}
#[test]
fn test_validate_crl_freshness_malformed_window() {
let Some(mut parsed) = parse_test_crl() else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
parsed.next_update = Some(parsed.this_update - chrono::Duration::hours(1));
let res = validate_crl_freshness(
&parsed,
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default(),
);
assert!(
res.is_err(),
"nextUpdate before thisUpdate must be rejected"
);
}
fn build_crl_or_skip(this_update: &str, next_update: Option<&str>) -> Option<Vec<u8>> {
let key_pem = std::fs::read_to_string(intermediate_ca_key_pem()).ok()?;
let issuer = intermediate_ca_cert();
Some(build_test_crl_with_window(
&issuer,
&key_pem,
&[],
this_update,
next_update,
))
}
fn utctime_from_now(days_offset: i64) -> String {
(chrono::Utc::now() + chrono::Duration::days(days_offset))
.format("%y%m%d%H%M%SZ")
.to_string()
}
#[test]
fn test_crl_is_current_within_window() {
let Some(der) = build_crl_or_skip("260101000000Z", Some("270101000000Z")) else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
assert!(crl_is_current(
&der,
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default()
));
}
#[test]
fn test_crl_is_current_stale_window() {
let Some(der) = build_crl_or_skip("200101000000Z", Some("210101000000Z")) else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
assert!(!crl_is_current(
&der,
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default()
));
}
#[test]
fn test_crl_is_current_future_window_not_current() {
let Some(der) = build_crl_or_skip("300101000000Z", Some("310101000000Z")) else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
assert!(!crl_is_current(
&der,
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default()
));
}
#[test]
fn test_crl_is_current_unparseable() {
assert!(!crl_is_current(
&[0x04, 0x00],
instant("2026-06-01T00:00:00Z"),
&CrlFreshness::default()
));
}
#[test]
fn test_is_disallowed_ip_classification() {
use crate::net::is_disallowed_ip;
use std::net::IpAddr;
let blocked = [
"127.0.0.1",
"169.254.169.254", "10.0.0.1",
"172.16.5.5",
"192.168.1.1",
"0.0.0.0",
"100.64.0.1", "::1",
"fc00::1", "fe80::1", "::ffff:127.0.0.1", ];
for s in blocked {
let ip: IpAddr = s.parse().unwrap();
assert!(is_disallowed_ip(ip), "{s} should be disallowed");
}
let allowed = [
"8.8.8.8",
"1.1.1.1",
"93.184.216.34",
"2606:4700:4700::1111",
];
for s in allowed {
let ip: IpAddr = s.parse().unwrap();
assert!(!is_disallowed_ip(ip), "{s} should be allowed");
}
}
#[tokio::test]
async fn test_validate_url_rejects_non_http_scheme() {
let err = CrlClient::validate_url("ftp://example.com/x.crl")
.await
.expect_err("non-http scheme must be rejected");
assert!(format!("{err}").contains("scheme not allowed"));
}
#[tokio::test]
async fn test_validate_url_blocks_each_internal_target() {
for url in [
"http://127.0.0.1/x.crl",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/x.crl",
"http://192.168.1.1/x.crl",
"http://[::1]/x.crl",
] {
let err = CrlClient::validate_url(url)
.await
.expect_err("internal address must be refused");
assert!(
format!("{err}").contains("non-public"),
"unexpected error for {url}: {err}"
);
}
}
#[tokio::test]
async fn test_validate_url_allows_public_literal() {
CrlClient::validate_url("http://1.1.1.1/x.crl")
.await
.expect("public address must be allowed");
}
#[tokio::test]
async fn test_fetch_crl_serves_fresh_cache_entry() {
let this_update = utctime_from_now(-1);
let next_update = utctime_from_now(365);
let Some(der) = build_crl_or_skip(&this_update, Some(&next_update)) else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let client = CrlClient::new().unwrap();
let url = "http://crl.invalid.example/fresh.crl";
client.cache.lock().unwrap().insert(
url.to_string(),
CrlCacheEntry {
der: der.clone(),
fetched_at: Instant::now(),
},
);
let got = client
.fetch_crl(url)
.await
.expect("fresh cached CRL should be served");
assert_eq!(got, der, "served bytes should be the cached CRL");
}
#[tokio::test]
async fn test_fetch_crl_skips_stale_cache_entry() {
let this_update = utctime_from_now(-730);
let next_update = utctime_from_now(-365);
let Some(stale) = build_crl_or_skip(&this_update, Some(&next_update)) else {
eprintln!("skipping: intermediate_ca_key.pem not found");
return;
};
let client = CrlClient::new()
.unwrap()
.timeout(Duration::from_millis(200));
let url = "http://crl.invalid.example/stale.crl";
client.cache.lock().unwrap().insert(
url.to_string(),
CrlCacheEntry {
der: stale,
fetched_at: Instant::now(), },
);
let res = client.fetch_crl(url).await;
assert!(
res.is_err(),
"stale cache entry must not be served; expected a (failed) re-fetch"
);
}
#[test]
fn test_parse_crl_invalid_data() {
let result = parse_crl(&[0x04, 0x00]); assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("SEQUENCE"));
}
#[test]
fn test_parse_revocation_reason_unspecified() {
let reason = parse_revocation_reason(&[]);
assert_eq!(reason, RevocationReason::Unspecified);
}
}