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 let ocsp_err = 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 Ok(result) => Some(format!("OCSP returned error: {}", result.status)),
533 Err(e) => Some(format!("OCSP check failed: {e}")),
534 }
535 } else {
536 None
537 };
538
539 match self.check_crl(cert_der).await {
541 Ok(result) if !result.status.is_error() => Ok(result),
542 Ok(result) => Err(crate::Error::InvalidCertificate {
543 reason: format!(
544 "CRL returned error: {}{}",
545 result.status,
546 ocsp_err
547 .as_ref()
548 .map_or(String::new(), |e| format!("; prior {e}"))
549 ),
550 }),
551 Err(crl_err) => Err(crate::Error::InvalidCertificate {
552 reason: format!(
553 "CRL check failed: {crl_err}{}",
554 ocsp_err
555 .as_ref()
556 .map_or(String::new(), |e| format!("; prior {e}"))
557 ),
558 }),
559 }
560 } else {
561 let crl_err = match self.check_crl(cert_der).await {
563 Ok(result) if !result.status.is_error() => return Ok(result),
564 Ok(result) => Some(format!("CRL returned error: {}", result.status)),
565 Err(e) => Some(format!("CRL check failed: {e}")),
566 };
567
568 if let Some(issuer) = issuer_der {
570 match self.check_ocsp(cert_der, issuer).await {
571 Ok(result) if !result.status.is_error() => Ok(result),
572 Ok(result) => Err(crate::Error::InvalidCertificate {
573 reason: format!(
574 "OCSP returned error: {}{}",
575 result.status,
576 crl_err
577 .as_ref()
578 .map_or(String::new(), |e| format!("; prior {e}"))
579 ),
580 }),
581 Err(ocsp_err) => Err(crate::Error::InvalidCertificate {
582 reason: format!(
583 "OCSP check failed: {ocsp_err}{}",
584 crl_err
585 .as_ref()
586 .map_or(String::new(), |e| format!("; prior {e}"))
587 ),
588 }),
589 }
590 } else {
591 Err(crate::Error::InvalidCertificate {
592 reason: format!(
593 "No issuer provided for OCSP fallback{}",
594 crl_err
595 .as_ref()
596 .map_or(String::new(), |e| format!("; prior {e}"))
597 ),
598 })
599 }
600 }
601 }
602
603 #[must_use]
605 pub fn config(&self) -> &RevocationConfig {
606 &self.config
607 }
608}
609
610impl std::fmt::Debug for RevocationChecker {
611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612 f.debug_struct("RevocationChecker")
613 .field("config", &self.config)
614 .finish_non_exhaustive()
615 }
616}
617
618#[cfg(feature = "ocsp")]
622fn bytes_to_hex(bytes: &[u8]) -> String {
623 use std::fmt::Write;
624 bytes
625 .iter()
626 .fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
627 let _ = write!(acc, "{b:02X}");
628 acc
629 })
630}
631
632#[cfg(feature = "ocsp")]
633fn extract_ocsp_url(cert: &x509_cert::Certificate) -> Option<String> {
634 use x509_cert::ext::pkix::AuthorityInfoAccessSyntax;
635
636 let extensions = cert.tbs_certificate().extensions()?;
637
638 for ext in extensions {
639 if ext.extn_id.to_string() == "1.3.6.1.5.5.7.1.1" {
641 if let Ok(aia) =
642 <AuthorityInfoAccessSyntax as der::Decode>::from_der(ext.extn_value.as_bytes())
643 {
644 for access_desc in &aia.0 {
645 if access_desc.access_method.to_string() == "1.3.6.1.5.5.7.48.1" {
647 if let x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(
648 uri,
649 ) = &access_desc.access_location
650 {
651 return Some(uri.to_string());
652 }
653 }
654 }
655 }
656 }
657 }
658
659 None
660}
661
662#[cfg(feature = "ocsp")]
663fn extract_crl_url(cert: &x509_cert::Certificate) -> Option<String> {
664 let extensions = cert.tbs_certificate().extensions()?;
665
666 for ext in extensions {
667 if ext.extn_id.to_string() == "2.5.29.31" {
669 let bytes = ext.extn_value.as_bytes();
672 if let Ok(s) = std::str::from_utf8(bytes) {
674 if let Some(start) = s.find("http://").or_else(|| s.find("https://")) {
675 let end = s[start..]
676 .find(|c: char| c.is_control() || c == '\0')
677 .map_or(s.len(), |e| start + e);
678 return Some(s[start..end].to_string());
679 }
680 }
681 }
682 }
683
684 None
685}
686
687#[cfg(feature = "ocsp")]
688fn build_ocsp_request(
689 _cert: &x509_cert::Certificate,
690 _issuer: &x509_cert::Certificate,
691) -> Result<Vec<u8>, crate::Error> {
692 Err(crate::Error::NotImplemented {
701 feature: "Full OCSP request building requires ocsp-rs or similar crate".to_string(),
702 })
703}
704
705#[cfg(feature = "ocsp")]
706fn parse_ocsp_response(_response: &[u8]) -> RevocationStatus {
707 RevocationStatus::Error {
716 message: "OCSP response parsing not yet implemented".to_string(),
717 }
718}
719
720#[cfg(feature = "ocsp")]
721fn check_crl_for_serial(
722 crl_data: &[u8],
723 serial: &x509_cert::serial_number::SerialNumber,
724) -> Result<RevocationStatus, crate::Error> {
725 use der::Decode;
726 use x509_cert::crl::CertificateList;
727
728 let crl = CertificateList::from_der(crl_data)
730 .map_err(|e| invalid_certificate(format!("Failed to parse CRL: {e}")))?;
731
732 if let Some(revoked_certs) = &crl.tbs_cert_list.revoked_certificates {
734 for revoked in revoked_certs {
735 if &revoked.serial_number == serial {
736 let reason = revoked.crl_entry_extensions.as_ref().and_then(|exts| {
738 exts.iter().find_map(|ext| {
739 if ext.extn_id.to_string() == "2.5.29.21" {
741 let bytes = ext.extn_value.as_bytes();
742 if bytes.len() >= 3 {
743 RevocationReason::from_code(bytes[2])
744 } else {
745 None
746 }
747 } else {
748 None
749 }
750 })
751 });
752
753 return Ok(RevocationStatus::Revoked {
754 reason,
755 revocation_time: Some(revoked.revocation_date.to_string()),
756 });
757 }
758 }
759 }
760
761 Ok(RevocationStatus::Good)
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn test_revocation_status_display() {
771 assert_eq!(RevocationStatus::Good.to_string(), "good");
772 assert_eq!(RevocationStatus::Unknown.to_string(), "unknown");
773 assert_eq!(
774 RevocationStatus::Revoked {
775 reason: Some(RevocationReason::KeyCompromise),
776 revocation_time: None,
777 }
778 .to_string(),
779 "revoked (key compromise)"
780 );
781 }
782
783 #[test]
784 fn test_revocation_status_checks() {
785 assert!(RevocationStatus::Good.is_good());
786 assert!(!RevocationStatus::Good.is_revoked());
787
788 let revoked = RevocationStatus::Revoked {
789 reason: None,
790 revocation_time: None,
791 };
792 assert!(!revoked.is_good());
793 assert!(revoked.is_revoked());
794
795 let error = RevocationStatus::Error {
796 message: "test".to_string(),
797 };
798 assert!(error.is_error());
799 }
800
801 #[test]
802 fn test_revocation_reason_from_code() {
803 assert_eq!(
804 RevocationReason::from_code(0),
805 Some(RevocationReason::Unspecified)
806 );
807 assert_eq!(
808 RevocationReason::from_code(1),
809 Some(RevocationReason::KeyCompromise)
810 );
811 assert_eq!(
812 RevocationReason::from_code(5),
813 Some(RevocationReason::CessationOfOperation)
814 );
815 assert_eq!(RevocationReason::from_code(7), None); assert_eq!(RevocationReason::from_code(255), None);
817 }
818
819 #[test]
820 fn test_revocation_reason_code() {
821 assert_eq!(RevocationReason::Unspecified.code(), 0);
822 assert_eq!(RevocationReason::KeyCompromise.code(), 1);
823 assert_eq!(RevocationReason::AaCompromise.code(), 10);
824 }
825
826 #[test]
827 fn test_revocation_reason_display() {
828 assert_eq!(
829 RevocationReason::KeyCompromise.to_string(),
830 "key compromise"
831 );
832 assert_eq!(
833 RevocationReason::CessationOfOperation.to_string(),
834 "cessation of operation"
835 );
836 }
837
838 #[test]
839 fn test_revocation_config_default() {
840 let config = RevocationConfig::default();
841 assert_eq!(config.timeout, Duration::from_secs(10));
842 assert!(config.prefer_ocsp);
843 assert!(config.use_stapling);
844 assert!(!config.strict_mode);
845 assert!(config.ocsp_responder.is_none());
846 }
847
848 #[test]
849 fn test_revocation_config_builder() {
850 let config = RevocationConfig::new()
851 .with_timeout(Duration::from_secs(30))
852 .with_prefer_ocsp(false)
853 .with_strict_mode(true)
854 .with_ocsp_responder("http://ocsp.example.com");
855
856 assert_eq!(config.timeout, Duration::from_secs(30));
857 assert!(!config.prefer_ocsp);
858 assert!(config.strict_mode);
859 assert_eq!(
860 config.ocsp_responder,
861 Some("http://ocsp.example.com".to_string())
862 );
863 }
864
865 #[test]
866 fn test_revocation_result_new() {
867 let result = RevocationResult::new(
868 RevocationStatus::Good,
869 RevocationMethod::Ocsp,
870 "1234ABCD".to_string(),
871 );
872
873 assert!(result.is_valid());
874 assert_eq!(result.method, RevocationMethod::Ocsp);
875 assert_eq!(result.serial_number, "1234ABCD");
876 assert!(result.responder_url.is_none());
877 }
878
879 #[test]
880 fn test_revocation_result_builder() {
881 let result = RevocationResult::new(
882 RevocationStatus::Good,
883 RevocationMethod::Ocsp,
884 "1234".to_string(),
885 )
886 .with_responder("http://ocsp.example.com")
887 .with_produced_at("2024-01-01T00:00:00Z")
888 .with_next_update("2024-01-02T00:00:00Z");
889
890 assert_eq!(
891 result.responder_url,
892 Some("http://ocsp.example.com".to_string())
893 );
894 assert_eq!(result.produced_at, Some("2024-01-01T00:00:00Z".to_string()));
895 assert_eq!(result.next_update, Some("2024-01-02T00:00:00Z".to_string()));
896 }
897
898 #[test]
899 fn test_revocation_method_display() {
900 assert_eq!(RevocationMethod::Ocsp.to_string(), "OCSP");
901 assert_eq!(RevocationMethod::Crl.to_string(), "CRL");
902 assert_eq!(RevocationMethod::OcspStapling.to_string(), "OCSP Stapling");
903 }
904
905 #[test]
906 fn test_revocation_status_serialization() {
907 let good = RevocationStatus::Good;
908 let json = serde_json::to_string(&good).unwrap();
909 assert!(json.contains("\"status\":\"good\""));
910
911 let revoked = RevocationStatus::Revoked {
912 reason: Some(RevocationReason::KeyCompromise),
913 revocation_time: Some("2024-01-01T00:00:00Z".to_string()),
914 };
915 let json = serde_json::to_string(&revoked).unwrap();
916 assert!(json.contains("\"status\":\"revoked\""));
917 assert!(json.contains("\"reason\":\"keyCompromise\""));
918 }
919}