use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[cfg(feature = "ocsp")]
use crate::error::{invalid_certificate, network_error};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RevocationStatus {
Good,
Revoked {
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<RevocationReason>,
#[serde(skip_serializing_if = "Option::is_none")]
revocation_time: Option<String>,
},
Unknown,
Error {
message: String,
},
}
impl RevocationStatus {
#[must_use]
pub fn is_good(&self) -> bool {
matches!(self, Self::Good)
}
#[must_use]
pub fn is_revoked(&self) -> bool {
matches!(self, Self::Revoked { .. })
}
#[must_use]
pub fn is_error(&self) -> bool {
matches!(self, Self::Error { .. })
}
}
impl fmt::Display for RevocationStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Good => write!(f, "good"),
Self::Revoked { reason, .. } => {
if let Some(r) = reason {
write!(f, "revoked ({r})")
} else {
write!(f, "revoked")
}
}
Self::Unknown => write!(f, "unknown"),
Self::Error { message } => write!(f, "error: {message}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "camelCase")]
#[repr(u8)]
pub enum RevocationReason {
#[strum(serialize = "unspecified")]
Unspecified = 0,
#[strum(serialize = "key compromise")]
KeyCompromise = 1,
#[strum(serialize = "CA compromise")]
CaCompromise = 2,
#[strum(serialize = "affiliation changed")]
AffiliationChanged = 3,
#[strum(serialize = "superseded")]
Superseded = 4,
#[strum(serialize = "cessation of operation")]
CessationOfOperation = 5,
#[strum(serialize = "certificate hold")]
CertificateHold = 6,
#[strum(serialize = "remove from CRL")]
RemoveFromCrl = 8,
#[strum(serialize = "privilege withdrawn")]
PrivilegeWithdrawn = 9,
#[strum(serialize = "AA compromise")]
AaCompromise = 10,
}
impl RevocationReason {
#[must_use]
pub fn from_code(code: u8) -> Option<Self> {
match code {
0 => Some(Self::Unspecified),
1 => Some(Self::KeyCompromise),
2 => Some(Self::CaCompromise),
3 => Some(Self::AffiliationChanged),
4 => Some(Self::Superseded),
5 => Some(Self::CessationOfOperation),
6 => Some(Self::CertificateHold),
8 => Some(Self::RemoveFromCrl),
9 => Some(Self::PrivilegeWithdrawn),
10 => Some(Self::AaCompromise),
_ => None,
}
}
#[must_use]
pub const fn code(&self) -> u8 {
*self as u8
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevocationResult {
pub status: RevocationStatus,
pub method: RevocationMethod,
#[serde(skip_serializing_if = "Option::is_none")]
pub responder_url: Option<String>,
pub checked_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub produced_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_update: Option<String>,
pub serial_number: String,
}
impl RevocationResult {
#[must_use]
pub fn new(status: RevocationStatus, method: RevocationMethod, serial_number: String) -> Self {
Self {
status,
method,
responder_url: None,
checked_at: chrono::Utc::now().to_rfc3339(),
produced_at: None,
next_update: None,
serial_number,
}
}
#[must_use]
pub fn with_responder(mut self, url: impl Into<String>) -> Self {
self.responder_url = Some(url.into());
self
}
#[must_use]
pub fn with_produced_at(mut self, time: impl Into<String>) -> Self {
self.produced_at = Some(time.into());
self
}
#[must_use]
pub fn with_next_update(mut self, time: impl Into<String>) -> Self {
self.next_update = Some(time.into());
self
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.status.is_good()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "camelCase")]
pub enum RevocationMethod {
#[strum(serialize = "OCSP")]
Ocsp,
#[strum(serialize = "CRL")]
Crl,
#[strum(serialize = "OCSP Stapling")]
OcspStapling,
}
#[derive(Debug, Clone)]
pub struct RevocationConfig {
pub timeout: Duration,
pub prefer_ocsp: bool,
pub use_stapling: bool,
pub strict_mode: bool,
pub max_crl_age: u64,
pub ocsp_responder: Option<String>,
}
impl Default for RevocationConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(10),
prefer_ocsp: true,
use_stapling: true,
strict_mode: false,
max_crl_age: 86400, ocsp_responder: None,
}
}
}
impl RevocationConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_prefer_ocsp(mut self, prefer: bool) -> Self {
self.prefer_ocsp = prefer;
self
}
#[must_use]
pub fn with_strict_mode(mut self, strict: bool) -> Self {
self.strict_mode = strict;
self
}
#[must_use]
pub fn with_ocsp_responder(mut self, url: impl Into<String>) -> Self {
self.ocsp_responder = Some(url.into());
self
}
}
pub struct RevocationChecker {
config: RevocationConfig,
#[cfg(feature = "ocsp")]
client: reqwest::Client,
}
impl RevocationChecker {
#[cfg(feature = "ocsp")]
pub fn new() -> Result<Self, crate::Error> {
Self::with_config(RevocationConfig::default())
}
#[cfg(feature = "ocsp")]
pub fn with_config(config: RevocationConfig) -> Result<Self, crate::Error> {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.build()
.map_err(|e| network_error(format!("Failed to create HTTP client: {e}")))?;
Ok(Self { config, client })
}
#[cfg(feature = "ocsp")]
pub async fn check_ocsp(
&self,
cert_der: &[u8],
issuer_der: &[u8],
) -> Result<RevocationResult, crate::Error> {
use der::Decode;
use x509_cert::Certificate;
let cert = Certificate::from_der(cert_der)
.map_err(|e| invalid_certificate(format!("Failed to parse certificate: {e}")))?;
let issuer = Certificate::from_der(issuer_der)
.map_err(|e| invalid_certificate(format!("Failed to parse issuer certificate: {e}")))?;
let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
let serial = bytes_to_hex(serial_bytes);
let responder_url = self
.config
.ocsp_responder
.clone()
.or_else(|| extract_ocsp_url(&cert))
.ok_or_else(|| crate::Error::InvalidCertificate {
reason: "No OCSP responder URL found in certificate".to_string(),
})?;
let request_body = build_ocsp_request(&cert, &issuer)?;
let response = self
.client
.post(&responder_url)
.header("Content-Type", "application/ocsp-request")
.body(request_body)
.send()
.await
.map_err(|e| network_error(format!("OCSP request failed: {e}")))?;
if !response.status().is_success() {
return Ok(RevocationResult::new(
RevocationStatus::Error {
message: format!("OCSP responder returned status {}", response.status()),
},
RevocationMethod::Ocsp,
serial,
)
.with_responder(&responder_url));
}
let response_body = response
.bytes()
.await
.map_err(|e| network_error(format!("Failed to read OCSP response: {e}")))?;
let status = parse_ocsp_response(&response_body);
Ok(
RevocationResult::new(status, RevocationMethod::Ocsp, serial)
.with_responder(&responder_url),
)
}
#[cfg(feature = "ocsp")]
pub async fn check_crl(&self, cert_der: &[u8]) -> Result<RevocationResult, crate::Error> {
use der::Decode;
use x509_cert::Certificate;
let cert = Certificate::from_der(cert_der)
.map_err(|e| invalid_certificate(format!("Failed to parse certificate: {e}")))?;
let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
let serial = bytes_to_hex(serial_bytes);
let crl_url = extract_crl_url(&cert)
.ok_or_else(|| invalid_certificate("No CRL distribution point found in certificate"))?;
let response = self
.client
.get(&crl_url)
.send()
.await
.map_err(|e| network_error(format!("CRL fetch failed: {e}")))?;
if !response.status().is_success() {
return Ok(RevocationResult::new(
RevocationStatus::Error {
message: format!("CRL server returned status {}", response.status()),
},
RevocationMethod::Crl,
serial,
)
.with_responder(&crl_url));
}
let crl_data = response
.bytes()
.await
.map_err(|e| network_error(format!("Failed to read CRL: {e}")))?;
let status = check_crl_for_serial(&crl_data, cert.tbs_certificate().serial_number())?;
Ok(RevocationResult::new(status, RevocationMethod::Crl, serial).with_responder(&crl_url))
}
#[cfg(feature = "ocsp")]
pub async fn check(
&self,
cert_der: &[u8],
issuer_der: Option<&[u8]>,
) -> Result<RevocationResult, crate::Error> {
if self.config.prefer_ocsp {
let ocsp_err = if let Some(issuer) = issuer_der {
match self.check_ocsp(cert_der, issuer).await {
Ok(result) if !result.status.is_error() => return Ok(result),
Ok(result) => Some(format!("OCSP returned error: {}", result.status)),
Err(e) => Some(format!("OCSP check failed: {e}")),
}
} else {
None
};
match self.check_crl(cert_der).await {
Ok(result) if !result.status.is_error() => Ok(result),
Ok(result) => Err(crate::Error::InvalidCertificate {
reason: format!(
"CRL returned error: {}{}",
result.status,
ocsp_err
.as_ref()
.map_or(String::new(), |e| format!("; prior {e}"))
),
}),
Err(crl_err) => Err(crate::Error::InvalidCertificate {
reason: format!(
"CRL check failed: {crl_err}{}",
ocsp_err
.as_ref()
.map_or(String::new(), |e| format!("; prior {e}"))
),
}),
}
} else {
let crl_err = match self.check_crl(cert_der).await {
Ok(result) if !result.status.is_error() => return Ok(result),
Ok(result) => Some(format!("CRL returned error: {}", result.status)),
Err(e) => Some(format!("CRL check failed: {e}")),
};
if let Some(issuer) = issuer_der {
match self.check_ocsp(cert_der, issuer).await {
Ok(result) if !result.status.is_error() => Ok(result),
Ok(result) => Err(crate::Error::InvalidCertificate {
reason: format!(
"OCSP returned error: {}{}",
result.status,
crl_err
.as_ref()
.map_or(String::new(), |e| format!("; prior {e}"))
),
}),
Err(ocsp_err) => Err(crate::Error::InvalidCertificate {
reason: format!(
"OCSP check failed: {ocsp_err}{}",
crl_err
.as_ref()
.map_or(String::new(), |e| format!("; prior {e}"))
),
}),
}
} else {
Err(crate::Error::InvalidCertificate {
reason: format!(
"No issuer provided for OCSP fallback{}",
crl_err
.as_ref()
.map_or(String::new(), |e| format!("; prior {e}"))
),
})
}
}
}
#[must_use]
pub fn config(&self) -> &RevocationConfig {
&self.config
}
}
impl std::fmt::Debug for RevocationChecker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RevocationChecker")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
#[cfg(feature = "ocsp")]
fn bytes_to_hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
let _ = write!(acc, "{b:02X}");
acc
})
}
#[cfg(feature = "ocsp")]
fn extract_ocsp_url(cert: &x509_cert::Certificate) -> Option<String> {
use x509_cert::ext::pkix::AuthorityInfoAccessSyntax;
let extensions = cert.tbs_certificate().extensions()?;
for ext in extensions {
if ext.extn_id.to_string() == "1.3.6.1.5.5.7.1.1" {
if let Ok(aia) =
<AuthorityInfoAccessSyntax as der::Decode>::from_der(ext.extn_value.as_bytes())
{
for access_desc in &aia.0 {
if access_desc.access_method.to_string() == "1.3.6.1.5.5.7.48.1" {
if let x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(
uri,
) = &access_desc.access_location
{
return Some(uri.to_string());
}
}
}
}
}
}
None
}
#[cfg(feature = "ocsp")]
fn extract_crl_url(cert: &x509_cert::Certificate) -> Option<String> {
let extensions = cert.tbs_certificate().extensions()?;
for ext in extensions {
if ext.extn_id.to_string() == "2.5.29.31" {
let bytes = ext.extn_value.as_bytes();
if let Ok(s) = std::str::from_utf8(bytes) {
if let Some(start) = s.find("http://").or_else(|| s.find("https://")) {
let end = s[start..]
.find(|c: char| c.is_control() || c == '\0')
.map_or(s.len(), |e| start + e);
return Some(s[start..end].to_string());
}
}
}
}
None
}
#[cfg(feature = "ocsp")]
fn build_ocsp_request(
_cert: &x509_cert::Certificate,
_issuer: &x509_cert::Certificate,
) -> Result<Vec<u8>, crate::Error> {
Err(crate::Error::NotImplemented {
feature: "Full OCSP request building requires ocsp-rs or similar crate".to_string(),
})
}
#[cfg(feature = "ocsp")]
fn parse_ocsp_response(_response: &[u8]) -> RevocationStatus {
RevocationStatus::Error {
message: "OCSP response parsing not yet implemented".to_string(),
}
}
#[cfg(feature = "ocsp")]
fn check_crl_for_serial(
crl_data: &[u8],
serial: &x509_cert::serial_number::SerialNumber,
) -> Result<RevocationStatus, crate::Error> {
use der::Decode;
use x509_cert::crl::CertificateList;
let crl = CertificateList::from_der(crl_data)
.map_err(|e| invalid_certificate(format!("Failed to parse CRL: {e}")))?;
if let Some(revoked_certs) = &crl.tbs_cert_list.revoked_certificates {
for revoked in revoked_certs {
if &revoked.serial_number == serial {
let reason = revoked.crl_entry_extensions.as_ref().and_then(|exts| {
exts.iter().find_map(|ext| {
if ext.extn_id.to_string() == "2.5.29.21" {
let bytes = ext.extn_value.as_bytes();
if bytes.len() >= 3 {
RevocationReason::from_code(bytes[2])
} else {
None
}
} else {
None
}
})
});
return Ok(RevocationStatus::Revoked {
reason,
revocation_time: Some(revoked.revocation_date.to_string()),
});
}
}
}
Ok(RevocationStatus::Good)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_revocation_status_display() {
assert_eq!(RevocationStatus::Good.to_string(), "good");
assert_eq!(RevocationStatus::Unknown.to_string(), "unknown");
assert_eq!(
RevocationStatus::Revoked {
reason: Some(RevocationReason::KeyCompromise),
revocation_time: None,
}
.to_string(),
"revoked (key compromise)"
);
}
#[test]
fn test_revocation_status_checks() {
assert!(RevocationStatus::Good.is_good());
assert!(!RevocationStatus::Good.is_revoked());
let revoked = RevocationStatus::Revoked {
reason: None,
revocation_time: None,
};
assert!(!revoked.is_good());
assert!(revoked.is_revoked());
let error = RevocationStatus::Error {
message: "test".to_string(),
};
assert!(error.is_error());
}
#[test]
fn test_revocation_reason_from_code() {
assert_eq!(
RevocationReason::from_code(0),
Some(RevocationReason::Unspecified)
);
assert_eq!(
RevocationReason::from_code(1),
Some(RevocationReason::KeyCompromise)
);
assert_eq!(
RevocationReason::from_code(5),
Some(RevocationReason::CessationOfOperation)
);
assert_eq!(RevocationReason::from_code(7), None); assert_eq!(RevocationReason::from_code(255), None);
}
#[test]
fn test_revocation_reason_code() {
assert_eq!(RevocationReason::Unspecified.code(), 0);
assert_eq!(RevocationReason::KeyCompromise.code(), 1);
assert_eq!(RevocationReason::AaCompromise.code(), 10);
}
#[test]
fn test_revocation_reason_display() {
assert_eq!(
RevocationReason::KeyCompromise.to_string(),
"key compromise"
);
assert_eq!(
RevocationReason::CessationOfOperation.to_string(),
"cessation of operation"
);
}
#[test]
fn test_revocation_config_default() {
let config = RevocationConfig::default();
assert_eq!(config.timeout, Duration::from_secs(10));
assert!(config.prefer_ocsp);
assert!(config.use_stapling);
assert!(!config.strict_mode);
assert!(config.ocsp_responder.is_none());
}
#[test]
fn test_revocation_config_builder() {
let config = RevocationConfig::new()
.with_timeout(Duration::from_secs(30))
.with_prefer_ocsp(false)
.with_strict_mode(true)
.with_ocsp_responder("http://ocsp.example.com");
assert_eq!(config.timeout, Duration::from_secs(30));
assert!(!config.prefer_ocsp);
assert!(config.strict_mode);
assert_eq!(
config.ocsp_responder,
Some("http://ocsp.example.com".to_string())
);
}
#[test]
fn test_revocation_result_new() {
let result = RevocationResult::new(
RevocationStatus::Good,
RevocationMethod::Ocsp,
"1234ABCD".to_string(),
);
assert!(result.is_valid());
assert_eq!(result.method, RevocationMethod::Ocsp);
assert_eq!(result.serial_number, "1234ABCD");
assert!(result.responder_url.is_none());
}
#[test]
fn test_revocation_result_builder() {
let result = RevocationResult::new(
RevocationStatus::Good,
RevocationMethod::Ocsp,
"1234".to_string(),
)
.with_responder("http://ocsp.example.com")
.with_produced_at("2024-01-01T00:00:00Z")
.with_next_update("2024-01-02T00:00:00Z");
assert_eq!(
result.responder_url,
Some("http://ocsp.example.com".to_string())
);
assert_eq!(result.produced_at, Some("2024-01-01T00:00:00Z".to_string()));
assert_eq!(result.next_update, Some("2024-01-02T00:00:00Z".to_string()));
}
#[test]
fn test_revocation_method_display() {
assert_eq!(RevocationMethod::Ocsp.to_string(), "OCSP");
assert_eq!(RevocationMethod::Crl.to_string(), "CRL");
assert_eq!(RevocationMethod::OcspStapling.to_string(), "OCSP Stapling");
}
#[test]
fn test_revocation_status_serialization() {
let good = RevocationStatus::Good;
let json = serde_json::to_string(&good).unwrap();
assert!(json.contains("\"status\":\"good\""));
let revoked = RevocationStatus::Revoked {
reason: Some(RevocationReason::KeyCompromise),
revocation_time: Some("2024-01-01T00:00:00Z".to_string()),
};
let json = serde_json::to_string(&revoked).unwrap();
assert!(json.contains("\"status\":\"revoked\""));
assert!(json.contains("\"reason\":\"keyCompromise\""));
}
}