use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use rcgen::{
CertificateRevocationListParams, Issuer, KeyIdMethod, RevocationReason, RevokedCertParams,
SerialNumber,
};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use tokio::sync::Mutex;
use tracing::info;
use super::CLOCK_SKEW_ALLOWANCE;
use super::key::CaSigningKey;
use crate::signer::{CrlPruner, SignerError};
#[derive(Clone)]
pub(super) struct CrlPaths {
pub(super) crl_path: PathBuf,
pub(super) revoked_path: PathBuf,
}
impl CrlPaths {
pub(super) fn state_key(&self) -> String {
format!("local_ca.ledger:{}", self.crl_path.display())
}
}
pub(super) struct RevokedLedger {
pub(super) entries: Vec<RevokedEntry>,
pub(super) crl_number: u64,
pub(super) crl_der: Vec<u8>,
}
impl RevokedLedger {
pub(super) fn next_crl_number(&mut self) -> u64 {
self.crl_number += 1;
self.crl_number
}
pub(super) fn sidecar_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&SidecarRef {
version: SIDECAR_VERSION,
crl_number: self.crl_number,
entries: &self.entries,
})
}
}
pub(super) struct LedgerStore {
pub(super) revoked: Arc<Mutex<RevokedLedger>>,
issuer: Arc<Issuer<'static, CaSigningKey>>,
paths: Option<CrlPaths>,
}
impl LedgerStore {
pub(super) fn new(
revoked: Arc<Mutex<RevokedLedger>>,
issuer: Arc<Issuer<'static, CaSigningKey>>,
paths: Option<CrlPaths>,
) -> Self {
Self {
revoked,
issuer,
paths,
}
}
pub(super) async fn rebuild_and_persist(
&self,
ledger: &mut RevokedLedger,
) -> Result<(), SignerError> {
let number = ledger.next_crl_number();
let ledger_json = ledger
.sidecar_json()
.map_err(|error| SignerError::Internal(error.to_string()))?;
let entries = ledger.entries.clone();
let issuer = self.issuer.clone();
let paths = self.paths.clone();
let crl_der = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
let crl = build_crl(&entries, number, &issuer)?;
let crl_der = crl.der().to_vec();
if let Some(paths) = &paths {
let crl_pem = crl.pem()?;
crate::pemfile::write_atomic(&paths.revoked_path, ledger_json.as_bytes(), 0o600)?;
crate::pemfile::write_atomic(&paths.crl_path, crl_pem.as_bytes(), 0o644)?;
}
Ok(crl_der)
})
.await
.map_err(|error| SignerError::Internal(format!("revocation persist panicked: {error}")))?
.map_err(|error| SignerError::Internal(error.to_string()))?;
ledger.crl_der = crl_der;
Ok(())
}
}
#[async_trait]
impl CrlPruner for LedgerStore {
fn state_key(&self) -> String {
self.paths.as_ref().map_or_else(
|| "local_ca.ledger:<memory>".to_string(),
CrlPaths::state_key,
)
}
async fn prune_expired(&self) -> Result<usize, SignerError> {
let mut ledger = self.revoked.lock().await;
let before = ledger.entries.clone();
let removed = prune_expired(&mut ledger.entries, OffsetDateTime::now_utc());
if removed == 0 {
return Ok(0);
}
if let Err(error) = self.rebuild_and_persist(&mut ledger).await {
ledger.entries = before;
return Err(error);
}
Ok(removed)
}
}
#[derive(Serialize, Deserialize, Clone)]
pub(super) struct RevokedEntry {
pub(super) serial_hex: String,
pub(super) revoked_at: i64,
pub(super) reason: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) not_after: Option<i64>,
}
const SIDECAR_VERSION: u32 = 2;
#[derive(Deserialize)]
struct Sidecar {
#[allow(dead_code)]
version: u32,
crl_number: u64,
entries: Vec<RevokedEntry>,
}
#[derive(Serialize)]
struct SidecarRef<'a> {
version: u32,
crl_number: u64,
entries: &'a [RevokedEntry],
}
const CRL_VALIDITY_DAYS: i64 = 7;
pub(super) fn build_crl(
entries: &[RevokedEntry],
crl_number: u64,
issuer: &Issuer<'static, CaSigningKey>,
) -> anyhow::Result<rcgen::CertificateRevocationList> {
let revoked_certs = entries
.iter()
.enumerate()
.map(|(index, e)| {
let serial = hex::decode(&e.serial_hex).map_err(|error| {
anyhow::anyhow!(
"revoked ledger entry {index}: serial `{}` is not hex: {error}",
e.serial_hex
)
})?;
Ok(RevokedCertParams {
serial_number: SerialNumber::from_slice(&serial),
revocation_time: OffsetDateTime::from_unix_timestamp(e.revoked_at)
.unwrap_or_else(|_| OffsetDateTime::now_utc()),
reason_code: e.reason.and_then(reason_from_u32),
invalidity_date: None,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let now = OffsetDateTime::now_utc();
CertificateRevocationListParams {
this_update: now - CLOCK_SKEW_ALLOWANCE,
next_update: now + Duration::days(CRL_VALIDITY_DAYS),
crl_number: SerialNumber::from(crl_number),
issuing_distribution_point: None,
revoked_certs,
key_identifier_method: KeyIdMethod::Sha256,
}
.signed_by(issuer)
.map_err(Into::into)
}
pub(super) fn reason_from_u32(code: u32) -> Option<RevocationReason> {
match code {
0 => Some(RevocationReason::Unspecified),
1 => Some(RevocationReason::KeyCompromise),
2 => Some(RevocationReason::CaCompromise),
3 => Some(RevocationReason::AffiliationChanged),
4 => Some(RevocationReason::Superseded),
5 => Some(RevocationReason::CessationOfOperation),
6 => Some(RevocationReason::CertificateHold),
8 => Some(RevocationReason::RemoveFromCrl),
9 => Some(RevocationReason::PrivilegeWithdrawn),
10 => Some(RevocationReason::AaCompromise),
_ => None,
}
}
pub(super) fn prune_expired(entries: &mut Vec<RevokedEntry>, now: OffsetDateTime) -> usize {
let cutoff = (now - CLOCK_SKEW_ALLOWANCE).unix_timestamp();
let before = entries.len();
entries.retain(|entry| match entry.not_after {
Some(not_after) => not_after >= cutoff,
None => true,
});
before - entries.len()
}
fn load_sidecar(path: &Path) -> anyhow::Result<(Vec<RevokedEntry>, u64)> {
let value: serde_json::Value = serde_json::from_str(&fs::read_to_string(path)?)?;
if value.is_array() {
let entries: Vec<RevokedEntry> = serde_json::from_value(value)?;
let crl_number = entries.len() as u64 + 1;
return Ok((entries, crl_number));
}
let sidecar: Sidecar = serde_json::from_value(value)?;
Ok((sidecar.entries, sidecar.crl_number))
}
pub(super) fn init_ledger(
paths: Option<&CrlPaths>,
issuer: &Issuer<'static, CaSigningKey>,
) -> anyhow::Result<RevokedLedger> {
let (entries, crl_number) = match paths {
Some(p) if p.revoked_path.exists() => load_sidecar(&p.revoked_path)?,
_ => (Vec::new(), 0),
};
let mut ledger = RevokedLedger {
entries,
crl_number,
crl_der: Vec::new(),
};
let removed = prune_expired(&mut ledger.entries, OffsetDateTime::now_utc());
if removed > 0 {
info!(
event = "local_ca_crl_pruned",
outcome = "success",
rows_removed = removed,
"dropped revocation entries whose certificates have expired"
);
}
let number = ledger.next_crl_number();
let crl = build_crl(&ledger.entries, number, issuer)?;
if let Some(p) = paths {
crate::pemfile::write_atomic(&p.revoked_path, ledger.sidecar_json()?.as_bytes(), 0o600)?;
crate::pemfile::write_atomic(&p.crl_path, crl.pem()?.as_bytes(), 0o644)?;
}
ledger.crl_der = crl.der().to_vec();
Ok(ledger)
}