use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::sync::{Arc, RwLock};
use tracing::{info, warn};
#[derive(Debug, Clone, Default)]
pub struct CrlStore {
revoked_serials: HashSet<String>,
loaded_at_us: u64,
reload_count: u64,
}
impl CrlStore {
pub fn new() -> Self {
Self::default()
}
pub fn load_from_file(path: &Path) -> Result<Self, CrlError> {
let pem_data = fs::read(path).map_err(|e| CrlError::IoError {
path: path.display().to_string(),
detail: e.to_string(),
})?;
Self::load_from_pem(&pem_data)
}
pub fn load_from_pem(pem_data: &[u8]) -> Result<Self, CrlError> {
let mut revoked_serials = HashSet::new();
for pem in pem::parse_many(pem_data).map_err(|e| CrlError::ParseError {
detail: format!("PEM parse failed: {e}"),
})? {
if pem.tag() != "X509 CRL" {
continue;
}
let serials = parse_crl_der(pem.contents())?;
revoked_serials.extend(serials);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64;
info!(revoked_count = revoked_serials.len(), "CRL loaded");
Ok(Self {
revoked_serials,
loaded_at_us: now,
reload_count: 0,
})
}
pub fn is_revoked(&self, serial_hex: &str) -> bool {
self.revoked_serials.contains(serial_hex)
}
pub fn revoked_count(&self) -> usize {
self.revoked_serials.len()
}
pub fn loaded_at_us(&self) -> u64 {
self.loaded_at_us
}
pub fn reload_count(&self) -> u64 {
self.reload_count
}
pub fn reload_from_file(&mut self, path: &Path) -> Result<(), CrlError> {
let new = Self::load_from_file(path)?;
self.revoked_serials = new.revoked_serials;
self.loaded_at_us = new.loaded_at_us;
self.reload_count += 1;
Ok(())
}
}
fn parse_crl_der(der: &[u8]) -> Result<Vec<String>, CrlError> {
use x509_parser::prelude::FromDer;
use x509_parser::revocation_list::CertificateRevocationList;
let (_, crl) = CertificateRevocationList::from_der(der).map_err(|e| CrlError::ParseError {
detail: format!("CRL DER parse failed: {e}"),
})?;
let mut serials = Vec::new();
for revoked in crl.iter_revoked_certificates() {
let serial = revoked.raw_serial_as_string();
serials.push(serial.to_uppercase());
}
Ok(serials)
}
pub type SharedCrlStore = Arc<RwLock<CrlStore>>;
pub fn load_shared_crl(crl_path: Option<&Path>) -> Result<Option<SharedCrlStore>, CrlError> {
match crl_path {
Some(path) => {
let store = CrlStore::load_from_file(path)?;
Ok(Some(Arc::new(RwLock::new(store))))
}
None => Ok(None),
}
}
pub fn spawn_crl_reload_task(
crl_store: SharedCrlStore,
crl_path: std::path::PathBuf,
interval_secs: u64,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let interval = std::time::Duration::from_secs(interval_secs);
info!(
interval_secs,
path = %crl_path.display(),
"CRL reload task started"
);
loop {
tokio::select! {
_ = tokio::time::sleep(interval) => {}
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("CRL reload task stopped");
return;
}
}
}
let mut store = crl_store.write().unwrap_or_else(|p| {
warn!("CRL store lock poisoned, recovering");
p.into_inner()
});
if let Err(e) = store.reload_from_file(&crl_path) {
warn!(
error = %e,
path = %crl_path.display(),
"CRL reload failed"
);
}
}
})
}
pub fn check_revocation(crl_store: &SharedCrlStore, serial_hex: &str) -> bool {
match crl_store.read() {
Ok(store) => store.is_revoked(serial_hex),
Err(_) => {
warn!("CRL store lock poisoned — failing open (allowing connection)");
false
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum CrlError {
#[error("CRL I/O error ({path}): {detail}")]
IoError { path: String, detail: String },
#[error("CRL parse error: {detail}")]
ParseError { detail: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_crl_store() {
let store = CrlStore::new();
assert_eq!(store.revoked_count(), 0);
assert!(!store.is_revoked("DEADBEEF"));
}
#[test]
fn manual_serial_check() {
let mut store = CrlStore::new();
store.revoked_serials.insert("0A".into());
store.revoked_serials.insert("FF".into());
assert!(store.is_revoked("0A"));
assert!(store.is_revoked("FF"));
assert!(!store.is_revoked("0B"));
}
#[test]
fn shared_crl_no_path() {
let result = load_shared_crl(None).unwrap();
assert!(result.is_none());
}
#[test]
fn shared_crl_nonexistent_path() {
let result = load_shared_crl(Some(Path::new("/nonexistent/crl.pem")));
assert!(result.is_err());
}
#[test]
fn revocation_check_thread_safe() {
let store = Arc::new(RwLock::new(CrlStore::new()));
store.write().unwrap().revoked_serials.insert("AA".into());
assert!(check_revocation(&store, "AA"));
assert!(!check_revocation(&store, "BB"));
}
}