Skip to main content

cdx_core/security/
revocation.rs

1//! Certificate revocation checking via OCSP and CRL.
2//!
3//! This module provides online revocation checking for X.509 certificates
4//! using OCSP (Online Certificate Status Protocol) and CRL (Certificate
5//! Revocation Lists).
6//!
7//! # Feature Flag
8//!
9//! This module requires the `ocsp` feature:
10//!
11//! ```toml
12//! [dependencies]
13//! cdx-core = { version = "0.1", features = ["ocsp"] }
14//! ```
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use cdx_core::security::revocation::{RevocationChecker, RevocationStatus};
20//!
21//! let checker = RevocationChecker::new();
22//! let status = checker.check_ocsp(certificate_der, issuer_der).await?;
23//!
24//! match status {
25//!     RevocationStatus::Good => println!("Certificate is valid"),
26//!     RevocationStatus::Revoked { reason, time } => {
27//!         println!("Certificate revoked: {:?}", reason);
28//!     }
29//!     RevocationStatus::Unknown => println!("Status unknown"),
30//! }
31//! ```
32
33use std::fmt;
34use std::time::Duration;
35
36use serde::{Deserialize, Serialize};
37
38#[cfg(feature = "ocsp")]
39use crate::error::{invalid_certificate, network_error};
40
41/// Revocation status of a certificate.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "status", rename_all = "camelCase")]
44pub enum RevocationStatus {
45    /// Certificate is not revoked.
46    Good,
47
48    /// Certificate has been revoked.
49    Revoked {
50        /// Reason for revocation.
51        #[serde(skip_serializing_if = "Option::is_none")]
52        reason: Option<RevocationReason>,
53        /// Time of revocation (ISO 8601).
54        #[serde(skip_serializing_if = "Option::is_none")]
55        revocation_time: Option<String>,
56    },
57
58    /// Revocation status is unknown.
59    Unknown,
60
61    /// Revocation check failed.
62    Error {
63        /// Error message.
64        message: String,
65    },
66}
67
68impl RevocationStatus {
69    /// Check if the certificate is known to be good.
70    #[must_use]
71    pub fn is_good(&self) -> bool {
72        matches!(self, Self::Good)
73    }
74
75    /// Check if the certificate is revoked.
76    #[must_use]
77    pub fn is_revoked(&self) -> bool {
78        matches!(self, Self::Revoked { .. })
79    }
80
81    /// Check if there was an error checking revocation.
82    #[must_use]
83    pub fn is_error(&self) -> bool {
84        matches!(self, Self::Error { .. })
85    }
86}
87
88impl fmt::Display for RevocationStatus {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::Good => write!(f, "good"),
92            Self::Revoked { reason, .. } => {
93                if let Some(r) = reason {
94                    write!(f, "revoked ({r})")
95                } else {
96                    write!(f, "revoked")
97                }
98            }
99            Self::Unknown => write!(f, "unknown"),
100            Self::Error { message } => write!(f, "error: {message}"),
101        }
102    }
103}
104
105/// Reason for certificate revocation (RFC 5280).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
107#[serde(rename_all = "camelCase")]
108#[repr(u8)]
109pub enum RevocationReason {
110    /// Unspecified reason.
111    #[strum(serialize = "unspecified")]
112    Unspecified = 0,
113    /// Key has been compromised.
114    #[strum(serialize = "key compromise")]
115    KeyCompromise = 1,
116    /// CA key has been compromised.
117    #[strum(serialize = "CA compromise")]
118    CaCompromise = 2,
119    /// Affiliation has changed.
120    #[strum(serialize = "affiliation changed")]
121    AffiliationChanged = 3,
122    /// Certificate has been superseded.
123    #[strum(serialize = "superseded")]
124    Superseded = 4,
125    /// Certificate is no longer needed.
126    #[strum(serialize = "cessation of operation")]
127    CessationOfOperation = 5,
128    /// Certificate is on hold.
129    #[strum(serialize = "certificate hold")]
130    CertificateHold = 6,
131    /// Removed from CRL (not revoked).
132    #[strum(serialize = "remove from CRL")]
133    RemoveFromCrl = 8,
134    /// Privilege has been withdrawn.
135    #[strum(serialize = "privilege withdrawn")]
136    PrivilegeWithdrawn = 9,
137    /// AA has been compromised.
138    #[strum(serialize = "AA compromise")]
139    AaCompromise = 10,
140}
141
142impl RevocationReason {
143    /// Create from RFC 5280 reason code.
144    #[must_use]
145    pub fn from_code(code: u8) -> Option<Self> {
146        match code {
147            0 => Some(Self::Unspecified),
148            1 => Some(Self::KeyCompromise),
149            2 => Some(Self::CaCompromise),
150            3 => Some(Self::AffiliationChanged),
151            4 => Some(Self::Superseded),
152            5 => Some(Self::CessationOfOperation),
153            6 => Some(Self::CertificateHold),
154            8 => Some(Self::RemoveFromCrl),
155            9 => Some(Self::PrivilegeWithdrawn),
156            10 => Some(Self::AaCompromise),
157            _ => None,
158        }
159    }
160
161    /// Get the RFC 5280 reason code.
162    #[must_use]
163    pub const fn code(&self) -> u8 {
164        *self as u8
165    }
166}
167
168/// Result of a revocation check.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct RevocationResult {
172    /// The revocation status.
173    pub status: RevocationStatus,
174
175    /// Method used for the check.
176    pub method: RevocationMethod,
177
178    /// URL of the responder or CRL.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub responder_url: Option<String>,
181
182    /// When this check was performed (ISO 8601).
183    pub checked_at: String,
184
185    /// When the response was produced (ISO 8601).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub produced_at: Option<String>,
188
189    /// When this response should be considered stale (ISO 8601).
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub next_update: Option<String>,
192
193    /// Certificate serial number that was checked.
194    pub serial_number: String,
195}
196
197impl RevocationResult {
198    /// Create a new revocation result.
199    #[must_use]
200    pub fn new(status: RevocationStatus, method: RevocationMethod, serial_number: String) -> Self {
201        Self {
202            status,
203            method,
204            responder_url: None,
205            checked_at: chrono::Utc::now().to_rfc3339(),
206            produced_at: None,
207            next_update: None,
208            serial_number,
209        }
210    }
211
212    /// Set the responder URL.
213    #[must_use]
214    pub fn with_responder(mut self, url: impl Into<String>) -> Self {
215        self.responder_url = Some(url.into());
216        self
217    }
218
219    /// Set the produced_at time.
220    #[must_use]
221    pub fn with_produced_at(mut self, time: impl Into<String>) -> Self {
222        self.produced_at = Some(time.into());
223        self
224    }
225
226    /// Set the next_update time.
227    #[must_use]
228    pub fn with_next_update(mut self, time: impl Into<String>) -> Self {
229        self.next_update = Some(time.into());
230        self
231    }
232
233    /// Check if the result indicates the certificate is valid.
234    #[must_use]
235    pub fn is_valid(&self) -> bool {
236        self.status.is_good()
237    }
238}
239
240/// Method used for revocation checking.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
242#[serde(rename_all = "camelCase")]
243pub enum RevocationMethod {
244    /// OCSP (Online Certificate Status Protocol).
245    #[strum(serialize = "OCSP")]
246    Ocsp,
247    /// CRL (Certificate Revocation List).
248    #[strum(serialize = "CRL")]
249    Crl,
250    /// OCSP stapled in TLS handshake.
251    #[strum(serialize = "OCSP Stapling")]
252    OcspStapling,
253}
254
255/// Configuration for revocation checking.
256#[derive(Debug, Clone)]
257pub struct RevocationConfig {
258    /// Timeout for network requests.
259    pub timeout: Duration,
260
261    /// Whether to prefer OCSP over CRL.
262    pub prefer_ocsp: bool,
263
264    /// Whether to use OCSP stapling when available.
265    pub use_stapling: bool,
266
267    /// Whether to require a valid revocation response.
268    /// If false, unknown status is treated as valid.
269    pub strict_mode: bool,
270
271    /// Maximum age of cached CRL responses in seconds.
272    pub max_crl_age: u64,
273
274    /// Custom OCSP responder URL (overrides AIA).
275    pub ocsp_responder: Option<String>,
276}
277
278impl Default for RevocationConfig {
279    fn default() -> Self {
280        Self {
281            timeout: Duration::from_secs(10),
282            prefer_ocsp: true,
283            use_stapling: true,
284            strict_mode: false,
285            max_crl_age: 86400, // 24 hours
286            ocsp_responder: None,
287        }
288    }
289}
290
291impl RevocationConfig {
292    /// Create a new configuration with default settings.
293    #[must_use]
294    pub fn new() -> Self {
295        Self::default()
296    }
297
298    /// Set the network timeout.
299    #[must_use]
300    pub fn with_timeout(mut self, timeout: Duration) -> Self {
301        self.timeout = timeout;
302        self
303    }
304
305    /// Set whether to prefer OCSP over CRL.
306    #[must_use]
307    pub fn with_prefer_ocsp(mut self, prefer: bool) -> Self {
308        self.prefer_ocsp = prefer;
309        self
310    }
311
312    /// Set strict mode (require valid revocation response).
313    #[must_use]
314    pub fn with_strict_mode(mut self, strict: bool) -> Self {
315        self.strict_mode = strict;
316        self
317    }
318
319    /// Set a custom OCSP responder URL.
320    #[must_use]
321    pub fn with_ocsp_responder(mut self, url: impl Into<String>) -> Self {
322        self.ocsp_responder = Some(url.into());
323        self
324    }
325}
326
327/// Certificate revocation checker.
328///
329/// This provides methods for checking certificate revocation status
330/// using OCSP and CRL protocols.
331pub struct RevocationChecker {
332    config: RevocationConfig,
333    #[cfg(feature = "ocsp")]
334    client: reqwest::Client,
335}
336
337impl RevocationChecker {
338    /// Create a new revocation checker with default configuration.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if the HTTP client cannot be initialized.
343    #[cfg(feature = "ocsp")]
344    pub fn new() -> Result<Self, crate::Error> {
345        Self::with_config(RevocationConfig::default())
346    }
347
348    /// Create a new revocation checker with custom configuration.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the HTTP client cannot be initialized.
353    #[cfg(feature = "ocsp")]
354    pub fn with_config(config: RevocationConfig) -> Result<Self, crate::Error> {
355        let client = reqwest::Client::builder()
356            .timeout(config.timeout)
357            .build()
358            .map_err(|e| network_error(format!("Failed to create HTTP client: {e}")))?;
359
360        Ok(Self { config, client })
361    }
362
363    /// Check certificate revocation status via OCSP.
364    ///
365    /// # Arguments
366    ///
367    /// * `cert_der` - DER-encoded certificate to check
368    /// * `issuer_der` - DER-encoded issuer certificate
369    ///
370    /// # Returns
371    ///
372    /// The revocation status of the certificate.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the OCSP check fails.
377    #[cfg(feature = "ocsp")]
378    pub async fn check_ocsp(
379        &self,
380        cert_der: &[u8],
381        issuer_der: &[u8],
382    ) -> Result<RevocationResult, crate::Error> {
383        use der::Decode;
384        use x509_cert::Certificate;
385
386        // Parse the certificates
387        let cert = Certificate::from_der(cert_der)
388            .map_err(|e| invalid_certificate(format!("Failed to parse certificate: {e}")))?;
389
390        let issuer = Certificate::from_der(issuer_der)
391            .map_err(|e| invalid_certificate(format!("Failed to parse issuer certificate: {e}")))?;
392
393        // Get serial number as hex string
394        let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
395        let serial = bytes_to_hex(serial_bytes);
396
397        // Get OCSP responder URL
398        let responder_url = self
399            .config
400            .ocsp_responder
401            .clone()
402            .or_else(|| extract_ocsp_url(&cert))
403            .ok_or_else(|| crate::Error::InvalidCertificate {
404                reason: "No OCSP responder URL found in certificate".to_string(),
405            })?;
406
407        // Build OCSP request
408        let request_body = build_ocsp_request(&cert, &issuer)?;
409
410        // Send OCSP request
411        let response = self
412            .client
413            .post(&responder_url)
414            .header("Content-Type", "application/ocsp-request")
415            .body(request_body)
416            .send()
417            .await
418            .map_err(|e| network_error(format!("OCSP request failed: {e}")))?;
419
420        if !response.status().is_success() {
421            return Ok(RevocationResult::new(
422                RevocationStatus::Error {
423                    message: format!("OCSP responder returned status {}", response.status()),
424                },
425                RevocationMethod::Ocsp,
426                serial,
427            )
428            .with_responder(&responder_url));
429        }
430
431        let response_body = response
432            .bytes()
433            .await
434            .map_err(|e| network_error(format!("Failed to read OCSP response: {e}")))?;
435
436        // Parse OCSP response
437        let status = parse_ocsp_response(&response_body);
438
439        Ok(
440            RevocationResult::new(status, RevocationMethod::Ocsp, serial)
441                .with_responder(&responder_url),
442        )
443    }
444
445    /// Check certificate revocation status via CRL.
446    ///
447    /// # Arguments
448    ///
449    /// * `cert_der` - DER-encoded certificate to check
450    ///
451    /// # Returns
452    ///
453    /// The revocation status of the certificate.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if the CRL check fails.
458    #[cfg(feature = "ocsp")]
459    pub async fn check_crl(&self, cert_der: &[u8]) -> Result<RevocationResult, crate::Error> {
460        use der::Decode;
461        use x509_cert::Certificate;
462
463        // Parse the certificate
464        let cert = Certificate::from_der(cert_der)
465            .map_err(|e| invalid_certificate(format!("Failed to parse certificate: {e}")))?;
466
467        // Get serial number as hex string
468        let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
469        let serial = bytes_to_hex(serial_bytes);
470
471        // Get CRL distribution point
472        let crl_url = extract_crl_url(&cert)
473            .ok_or_else(|| invalid_certificate("No CRL distribution point found in certificate"))?;
474
475        // Fetch CRL
476        let response = self
477            .client
478            .get(&crl_url)
479            .send()
480            .await
481            .map_err(|e| network_error(format!("CRL fetch failed: {e}")))?;
482
483        if !response.status().is_success() {
484            return Ok(RevocationResult::new(
485                RevocationStatus::Error {
486                    message: format!("CRL server returned status {}", response.status()),
487                },
488                RevocationMethod::Crl,
489                serial,
490            )
491            .with_responder(&crl_url));
492        }
493
494        let crl_data = response
495            .bytes()
496            .await
497            .map_err(|e| network_error(format!("Failed to read CRL: {e}")))?;
498
499        // Parse and check CRL
500        let status = check_crl_for_serial(&crl_data, cert.tbs_certificate().serial_number())?;
501
502        Ok(RevocationResult::new(status, RevocationMethod::Crl, serial).with_responder(&crl_url))
503    }
504
505    /// Check certificate revocation status using the preferred method.
506    ///
507    /// Tries OCSP first if configured, falls back to CRL.
508    ///
509    /// # Arguments
510    ///
511    /// * `cert_der` - DER-encoded certificate to check
512    /// * `issuer_der` - DER-encoded issuer certificate (for OCSP)
513    ///
514    /// # Returns
515    ///
516    /// The revocation status of the certificate.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if all revocation checks fail.
521    #[cfg(feature = "ocsp")]
522    pub async fn check(
523        &self,
524        cert_der: &[u8],
525        issuer_der: Option<&[u8]>,
526    ) -> Result<RevocationResult, crate::Error> {
527        if self.config.prefer_ocsp {
528            // Try OCSP first
529            if let Some(issuer) = issuer_der {
530                match self.check_ocsp(cert_der, issuer).await {
531                    Ok(result) if !result.status.is_error() => return Ok(result),
532                    _ => {} // Fall through to CRL
533                }
534            }
535
536            // Fall back to CRL
537            self.check_crl(cert_der).await
538        } else {
539            // Try CRL first
540            match self.check_crl(cert_der).await {
541                Ok(result) if !result.status.is_error() => Ok(result),
542                _ => {
543                    // Fall back to OCSP
544                    if let Some(issuer) = issuer_der {
545                        self.check_ocsp(cert_der, issuer).await
546                    } else {
547                        Err(crate::Error::InvalidCertificate {
548                            reason: "CRL check failed and no issuer provided for OCSP".to_string(),
549                        })
550                    }
551                }
552            }
553        }
554    }
555
556    /// Get the current configuration.
557    #[must_use]
558    pub fn config(&self) -> &RevocationConfig {
559        &self.config
560    }
561}
562
563impl std::fmt::Debug for RevocationChecker {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        f.debug_struct("RevocationChecker")
566            .field("config", &self.config)
567            .finish_non_exhaustive()
568    }
569}
570
571// Helper functions for OCSP
572
573/// Convert bytes to uppercase hex string.
574#[cfg(feature = "ocsp")]
575fn bytes_to_hex(bytes: &[u8]) -> String {
576    use std::fmt::Write;
577    bytes
578        .iter()
579        .fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
580            let _ = write!(acc, "{b:02X}");
581            acc
582        })
583}
584
585#[cfg(feature = "ocsp")]
586fn extract_ocsp_url(cert: &x509_cert::Certificate) -> Option<String> {
587    use x509_cert::ext::pkix::AuthorityInfoAccessSyntax;
588
589    let extensions = cert.tbs_certificate().extensions()?;
590
591    for ext in extensions {
592        // OID for Authority Information Access: 1.3.6.1.5.5.7.1.1
593        if ext.extn_id.to_string() == "1.3.6.1.5.5.7.1.1" {
594            if let Ok(aia) =
595                <AuthorityInfoAccessSyntax as der::Decode>::from_der(ext.extn_value.as_bytes())
596            {
597                for access_desc in &aia.0 {
598                    // OID for OCSP: 1.3.6.1.5.5.7.48.1
599                    if access_desc.access_method.to_string() == "1.3.6.1.5.5.7.48.1" {
600                        if let x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(
601                            uri,
602                        ) = &access_desc.access_location
603                        {
604                            return Some(uri.to_string());
605                        }
606                    }
607                }
608            }
609        }
610    }
611
612    None
613}
614
615#[cfg(feature = "ocsp")]
616fn extract_crl_url(cert: &x509_cert::Certificate) -> Option<String> {
617    let extensions = cert.tbs_certificate().extensions()?;
618
619    for ext in extensions {
620        // OID for CRL Distribution Points: 2.5.29.31
621        if ext.extn_id.to_string() == "2.5.29.31" {
622            // Parse the CRL distribution points
623            // This is a simplified extraction - in practice you'd fully parse the ASN.1
624            let bytes = ext.extn_value.as_bytes();
625            // Look for http:// or https:// in the extension value
626            if let Ok(s) = std::str::from_utf8(bytes) {
627                if let Some(start) = s.find("http://").or_else(|| s.find("https://")) {
628                    let end = s[start..]
629                        .find(|c: char| c.is_control() || c == '\0')
630                        .map_or(s.len(), |e| start + e);
631                    return Some(s[start..end].to_string());
632                }
633            }
634        }
635    }
636
637    None
638}
639
640#[cfg(feature = "ocsp")]
641fn build_ocsp_request(
642    _cert: &x509_cert::Certificate,
643    _issuer: &x509_cert::Certificate,
644) -> Result<Vec<u8>, crate::Error> {
645    // Build a minimal OCSP request
646    // In a full implementation, this would:
647    // 1. Hash the issuer's name and key
648    // 2. Include the certificate serial number
649    // 3. Optionally add a nonce for replay protection
650
651    // For now, return a placeholder that indicates this needs full implementation
652    // with a proper ASN.1 OCSP request builder
653    Err(crate::Error::NotImplemented {
654        feature: "Full OCSP request building requires ocsp-rs or similar crate".to_string(),
655    })
656}
657
658#[cfg(feature = "ocsp")]
659fn parse_ocsp_response(response: &[u8]) -> RevocationStatus {
660    // Parse OCSP response
661    // A full implementation would:
662    // 1. Check the response status (successful, malformed, etc.)
663    // 2. Verify the response signature
664    // 3. Extract the certificate status
665
666    // Check for basic response structure
667    if response.is_empty() {
668        return RevocationStatus::Error {
669            message: "Empty OCSP response".to_string(),
670        };
671    }
672
673    // OCSP response status is the first byte after the sequence tag
674    // 0 = successful, 1 = malformed, 2 = internal error, etc.
675    if response.len() > 2 {
676        // This is a simplified check - full parsing would use proper ASN.1
677        // Look for common success indicators
678        if response.contains(&0x00) {
679            // Likely successful response - would need full parsing to determine status
680            return RevocationStatus::Unknown;
681        }
682    }
683
684    RevocationStatus::Error {
685        message: "Failed to parse OCSP response".to_string(),
686    }
687}
688
689#[cfg(feature = "ocsp")]
690fn check_crl_for_serial(
691    crl_data: &[u8],
692    serial: &x509_cert::serial_number::SerialNumber,
693) -> Result<RevocationStatus, crate::Error> {
694    use der::Decode;
695    use x509_cert::crl::CertificateList;
696
697    // Parse the CRL
698    let crl = CertificateList::from_der(crl_data)
699        .map_err(|e| invalid_certificate(format!("Failed to parse CRL: {e}")))?;
700
701    // Check if the serial number is in the revoked list
702    if let Some(revoked_certs) = &crl.tbs_cert_list.revoked_certificates {
703        for revoked in revoked_certs {
704            if &revoked.serial_number == serial {
705                // Found in CRL - certificate is revoked
706                let reason = revoked.crl_entry_extensions.as_ref().and_then(|exts| {
707                    exts.iter().find_map(|ext| {
708                        // OID for CRL Reason: 2.5.29.21
709                        if ext.extn_id.to_string() == "2.5.29.21" {
710                            let bytes = ext.extn_value.as_bytes();
711                            if bytes.len() >= 3 {
712                                RevocationReason::from_code(bytes[2])
713                            } else {
714                                None
715                            }
716                        } else {
717                            None
718                        }
719                    })
720                });
721
722                return Ok(RevocationStatus::Revoked {
723                    reason,
724                    revocation_time: Some(revoked.revocation_date.to_string()),
725                });
726            }
727        }
728    }
729
730    // Not found in CRL - certificate is good
731    Ok(RevocationStatus::Good)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_revocation_status_display() {
740        assert_eq!(RevocationStatus::Good.to_string(), "good");
741        assert_eq!(RevocationStatus::Unknown.to_string(), "unknown");
742        assert_eq!(
743            RevocationStatus::Revoked {
744                reason: Some(RevocationReason::KeyCompromise),
745                revocation_time: None,
746            }
747            .to_string(),
748            "revoked (key compromise)"
749        );
750    }
751
752    #[test]
753    fn test_revocation_status_checks() {
754        assert!(RevocationStatus::Good.is_good());
755        assert!(!RevocationStatus::Good.is_revoked());
756
757        let revoked = RevocationStatus::Revoked {
758            reason: None,
759            revocation_time: None,
760        };
761        assert!(!revoked.is_good());
762        assert!(revoked.is_revoked());
763
764        let error = RevocationStatus::Error {
765            message: "test".to_string(),
766        };
767        assert!(error.is_error());
768    }
769
770    #[test]
771    fn test_revocation_reason_from_code() {
772        assert_eq!(
773            RevocationReason::from_code(0),
774            Some(RevocationReason::Unspecified)
775        );
776        assert_eq!(
777            RevocationReason::from_code(1),
778            Some(RevocationReason::KeyCompromise)
779        );
780        assert_eq!(
781            RevocationReason::from_code(5),
782            Some(RevocationReason::CessationOfOperation)
783        );
784        assert_eq!(RevocationReason::from_code(7), None); // No reason code 7
785        assert_eq!(RevocationReason::from_code(255), None);
786    }
787
788    #[test]
789    fn test_revocation_reason_code() {
790        assert_eq!(RevocationReason::Unspecified.code(), 0);
791        assert_eq!(RevocationReason::KeyCompromise.code(), 1);
792        assert_eq!(RevocationReason::AaCompromise.code(), 10);
793    }
794
795    #[test]
796    fn test_revocation_reason_display() {
797        assert_eq!(
798            RevocationReason::KeyCompromise.to_string(),
799            "key compromise"
800        );
801        assert_eq!(
802            RevocationReason::CessationOfOperation.to_string(),
803            "cessation of operation"
804        );
805    }
806
807    #[test]
808    fn test_revocation_config_default() {
809        let config = RevocationConfig::default();
810        assert_eq!(config.timeout, Duration::from_secs(10));
811        assert!(config.prefer_ocsp);
812        assert!(config.use_stapling);
813        assert!(!config.strict_mode);
814        assert!(config.ocsp_responder.is_none());
815    }
816
817    #[test]
818    fn test_revocation_config_builder() {
819        let config = RevocationConfig::new()
820            .with_timeout(Duration::from_secs(30))
821            .with_prefer_ocsp(false)
822            .with_strict_mode(true)
823            .with_ocsp_responder("http://ocsp.example.com");
824
825        assert_eq!(config.timeout, Duration::from_secs(30));
826        assert!(!config.prefer_ocsp);
827        assert!(config.strict_mode);
828        assert_eq!(
829            config.ocsp_responder,
830            Some("http://ocsp.example.com".to_string())
831        );
832    }
833
834    #[test]
835    fn test_revocation_result_new() {
836        let result = RevocationResult::new(
837            RevocationStatus::Good,
838            RevocationMethod::Ocsp,
839            "1234ABCD".to_string(),
840        );
841
842        assert!(result.is_valid());
843        assert_eq!(result.method, RevocationMethod::Ocsp);
844        assert_eq!(result.serial_number, "1234ABCD");
845        assert!(result.responder_url.is_none());
846    }
847
848    #[test]
849    fn test_revocation_result_builder() {
850        let result = RevocationResult::new(
851            RevocationStatus::Good,
852            RevocationMethod::Ocsp,
853            "1234".to_string(),
854        )
855        .with_responder("http://ocsp.example.com")
856        .with_produced_at("2024-01-01T00:00:00Z")
857        .with_next_update("2024-01-02T00:00:00Z");
858
859        assert_eq!(
860            result.responder_url,
861            Some("http://ocsp.example.com".to_string())
862        );
863        assert_eq!(result.produced_at, Some("2024-01-01T00:00:00Z".to_string()));
864        assert_eq!(result.next_update, Some("2024-01-02T00:00:00Z".to_string()));
865    }
866
867    #[test]
868    fn test_revocation_method_display() {
869        assert_eq!(RevocationMethod::Ocsp.to_string(), "OCSP");
870        assert_eq!(RevocationMethod::Crl.to_string(), "CRL");
871        assert_eq!(RevocationMethod::OcspStapling.to_string(), "OCSP Stapling");
872    }
873
874    #[test]
875    fn test_revocation_status_serialization() {
876        let good = RevocationStatus::Good;
877        let json = serde_json::to_string(&good).unwrap();
878        assert!(json.contains("\"status\":\"good\""));
879
880        let revoked = RevocationStatus::Revoked {
881            reason: Some(RevocationReason::KeyCompromise),
882            revocation_time: Some("2024-01-01T00:00:00Z".to_string()),
883        };
884        let json = serde_json::to_string(&revoked).unwrap();
885        assert!(json.contains("\"status\":\"revoked\""));
886        assert!(json.contains("\"reason\":\"keyCompromise\""));
887    }
888}