1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "status", rename_all = "camelCase")]
44pub enum RevocationStatus {
45 Good,
47
48 Revoked {
50 #[serde(skip_serializing_if = "Option::is_none")]
52 reason: Option<RevocationReason>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 revocation_time: Option<String>,
56 },
57
58 Unknown,
60
61 Error {
63 message: String,
65 },
66}
67
68impl RevocationStatus {
69 #[must_use]
71 pub fn is_good(&self) -> bool {
72 matches!(self, Self::Good)
73 }
74
75 #[must_use]
77 pub fn is_revoked(&self) -> bool {
78 matches!(self, Self::Revoked { .. })
79 }
80
81 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
107#[serde(rename_all = "camelCase")]
108#[repr(u8)]
109pub enum RevocationReason {
110 #[strum(serialize = "unspecified")]
112 Unspecified = 0,
113 #[strum(serialize = "key compromise")]
115 KeyCompromise = 1,
116 #[strum(serialize = "CA compromise")]
118 CaCompromise = 2,
119 #[strum(serialize = "affiliation changed")]
121 AffiliationChanged = 3,
122 #[strum(serialize = "superseded")]
124 Superseded = 4,
125 #[strum(serialize = "cessation of operation")]
127 CessationOfOperation = 5,
128 #[strum(serialize = "certificate hold")]
130 CertificateHold = 6,
131 #[strum(serialize = "remove from CRL")]
133 RemoveFromCrl = 8,
134 #[strum(serialize = "privilege withdrawn")]
136 PrivilegeWithdrawn = 9,
137 #[strum(serialize = "AA compromise")]
139 AaCompromise = 10,
140}
141
142impl RevocationReason {
143 #[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 #[must_use]
163 pub const fn code(&self) -> u8 {
164 *self as u8
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct RevocationResult {
172 pub status: RevocationStatus,
174
175 pub method: RevocationMethod,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub responder_url: Option<String>,
181
182 pub checked_at: String,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub produced_at: Option<String>,
188
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub next_update: Option<String>,
192
193 pub serial_number: String,
195}
196
197impl RevocationResult {
198 #[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 #[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 #[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 #[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 #[must_use]
235 pub fn is_valid(&self) -> bool {
236 self.status.is_good()
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
242#[serde(rename_all = "camelCase")]
243pub enum RevocationMethod {
244 #[strum(serialize = "OCSP")]
246 Ocsp,
247 #[strum(serialize = "CRL")]
249 Crl,
250 #[strum(serialize = "OCSP Stapling")]
252 OcspStapling,
253}
254
255#[derive(Debug, Clone)]
257pub struct RevocationConfig {
258 pub timeout: Duration,
260
261 pub prefer_ocsp: bool,
263
264 pub use_stapling: bool,
266
267 pub strict_mode: bool,
270
271 pub max_crl_age: u64,
273
274 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, ocsp_responder: None,
287 }
288 }
289}
290
291impl RevocationConfig {
292 #[must_use]
294 pub fn new() -> Self {
295 Self::default()
296 }
297
298 #[must_use]
300 pub fn with_timeout(mut self, timeout: Duration) -> Self {
301 self.timeout = timeout;
302 self
303 }
304
305 #[must_use]
307 pub fn with_prefer_ocsp(mut self, prefer: bool) -> Self {
308 self.prefer_ocsp = prefer;
309 self
310 }
311
312 #[must_use]
314 pub fn with_strict_mode(mut self, strict: bool) -> Self {
315 self.strict_mode = strict;
316 self
317 }
318
319 #[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
327pub struct RevocationChecker {
332 config: RevocationConfig,
333 #[cfg(feature = "ocsp")]
334 client: reqwest::Client,
335}
336
337impl RevocationChecker {
338 #[cfg(feature = "ocsp")]
344 pub fn new() -> Result<Self, crate::Error> {
345 Self::with_config(RevocationConfig::default())
346 }
347
348 #[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 #[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 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 let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
395 let serial = bytes_to_hex(serial_bytes);
396
397 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 let request_body = build_ocsp_request(&cert, &issuer)?;
409
410 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 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 #[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 let cert = Certificate::from_der(cert_der)
465 .map_err(|e| invalid_certificate(format!("Failed to parse certificate: {e}")))?;
466
467 let serial_bytes = cert.tbs_certificate().serial_number().as_bytes();
469 let serial = bytes_to_hex(serial_bytes);
470
471 let crl_url = extract_crl_url(&cert)
473 .ok_or_else(|| invalid_certificate("No CRL distribution point found in certificate"))?;
474
475 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 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 #[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 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 _ => {} }
534 }
535
536 self.check_crl(cert_der).await
538 } else {
539 match self.check_crl(cert_der).await {
541 Ok(result) if !result.status.is_error() => Ok(result),
542 _ => {
543 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 #[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#[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 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 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 if ext.extn_id.to_string() == "2.5.29.31" {
622 let bytes = ext.extn_value.as_bytes();
625 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 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 if response.is_empty() {
668 return RevocationStatus::Error {
669 message: "Empty OCSP response".to_string(),
670 };
671 }
672
673 if response.len() > 2 {
676 if response.contains(&0x00) {
679 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 let crl = CertificateList::from_der(crl_data)
699 .map_err(|e| invalid_certificate(format!("Failed to parse CRL: {e}")))?;
700
701 if let Some(revoked_certs) = &crl.tbs_cert_list.revoked_certificates {
703 for revoked in revoked_certs {
704 if &revoked.serial_number == serial {
705 let reason = revoked.crl_entry_extensions.as_ref().and_then(|exts| {
707 exts.iter().find_map(|ext| {
708 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 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); 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}