1use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use tracing::{debug, instrument};
16use x509_parser::oid_registry::Oid;
17use x509_parser::prelude::*;
18
19use crate::caa::{self, CaaPolicy};
20use crate::dns::DnsResolver;
21use crate::error::{Result, SeerError};
22use crate::net::resolve_public_host;
23use crate::tls::PresentedChain;
24use crate::validation::normalize_host;
25
26const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
28
29const RSA_MIN_KEY_BITS: u32 = 2048;
31const EC_MIN_KEY_BITS: u32 = 256;
33const CERT_EXPIRING_SOON_DAYS: i64 = 30;
35
36fn derive_cert_warnings(
40 leaf: &CertDetail,
41 is_valid: bool,
42 hostname_verified: bool,
43 days_until_expiry: i64,
44) -> Vec<CertWarning> {
45 let mut warnings = Vec::new();
46 let critical = |message: String| CertWarning {
47 severity: CertWarningSeverity::Critical,
48 message,
49 };
50 let warn = |message: String| CertWarning {
51 severity: CertWarningSeverity::Warning,
52 message,
53 };
54
55 if let (Some(kt), Some(bits)) = (leaf.key_type.as_deref(), leaf.key_bits) {
57 match kt {
58 "RSA" if bits < RSA_MIN_KEY_BITS => warnings.push(critical(format!(
59 "RSA key size {bits} is below the {RSA_MIN_KEY_BITS}-bit minimum"
60 ))),
61 "EC" if bits < EC_MIN_KEY_BITS => warnings.push(critical(format!(
62 "EC key size {bits} is below the {EC_MIN_KEY_BITS}-bit minimum"
63 ))),
64 _ => {}
65 }
66 }
67
68 if let Some(sig) = leaf.signature_algorithm.as_deref() {
70 let lower = sig.to_lowercase();
71 if lower.contains("sha-1") || lower.contains("sha1") || lower.contains("md5") {
72 warnings.push(critical(format!(
73 "Certificate uses deprecated signature algorithm: {sig}"
74 )));
75 }
76 }
77
78 if leaf.subject == leaf.issuer {
80 warnings.push(warn(
81 "Certificate is self-signed (issuer equals subject)".to_string(),
82 ));
83 }
84
85 if leaf.is_ca {
87 warnings.push(warn(
88 "Leaf certificate is marked as a CA certificate".to_string(),
89 ));
90 }
91
92 if days_until_expiry < 0 {
94 warnings.push(critical(format!(
95 "Certificate expired {} day(s) ago",
96 -days_until_expiry
97 )));
98 } else if !is_valid {
99 warnings.push(critical(
101 "Certificate is not yet valid (notBefore is in the future)".to_string(),
102 ));
103 } else if days_until_expiry <= CERT_EXPIRING_SOON_DAYS {
104 warnings.push(warn(format!(
105 "Certificate expires in {days_until_expiry} day(s)"
106 )));
107 }
108
109 if !hostname_verified {
111 warnings.push(critical(
112 "Certificate does not match the requested hostname".to_string(),
113 ));
114 }
115
116 warnings
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SslReport {
122 pub domain: String,
124 pub chain: Vec<CertDetail>,
127 pub protocol_version: Option<String>,
129 pub san_names: Vec<String>,
131 pub is_valid: bool,
141 #[serde(default)]
149 pub hostname_verified: bool,
150 pub days_until_expiry: i64,
152 #[serde(skip_serializing_if = "Option::is_none")]
159 pub caa: Option<CaaPolicy>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub warnings: Vec<CertWarning>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum CertWarningSeverity {
175 Critical,
178 Warning,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct CertWarning {
186 pub severity: CertWarningSeverity,
188 pub message: String,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CertDetail {
195 pub subject: String,
197 pub issuer: String,
199 pub valid_from: DateTime<Utc>,
201 pub valid_until: DateTime<Utc>,
203 pub serial_number: String,
205 pub signature_algorithm: Option<String>,
207 pub is_ca: bool,
209 pub key_type: Option<String>,
211 pub key_bits: Option<u32>,
213}
214
215#[derive(Debug, Clone)]
217pub struct SslChecker {
218 dns_resolver: DnsResolver,
220 timeout: Duration,
222}
223
224impl Default for SslChecker {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230impl SslChecker {
231 pub fn new() -> Self {
233 Self {
234 dns_resolver: DnsResolver::new(),
235 timeout: DEFAULT_TIMEOUT,
236 }
237 }
238
239 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
249 Self {
250 dns_resolver: DnsResolver::from_config(config),
251 timeout: config.http_timeout(),
252 }
253 }
254
255 pub fn with_timeout(mut self, timeout: Duration) -> Self {
258 self.timeout = timeout;
259 self.dns_resolver = self.dns_resolver.with_timeout(timeout);
260 self
261 }
262
263 #[instrument(skip(self), fields(domain = %domain))]
275 pub async fn check(&self, domain: &str) -> Result<SslReport> {
276 let domain = normalize_host(domain)?;
279
280 debug!(domain = %domain, "Checking SSL certificate chain");
281
282 let caa_future = caa::lookup_caa(&self.dns_resolver, &domain, None);
286
287 let resolve_future = resolve_public_host(&domain, 443);
292
293 let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
294 let caa_policy = caa_policy.unwrap_or_else(|_| CaaPolicy::empty());
297 let socket_addrs = socket_addrs.map_err(|e| {
298 SeerError::SslError(format!(
299 "could not resolve {} for SSL inspection: {}",
300 domain, e
301 ))
302 })?;
303
304 let presented =
308 crate::tls::inspect(&domain, &socket_addrs, self.timeout, SeerError::SslError).await?;
309
310 build_report(domain, presented, caa_policy)
311 }
312}
313
314fn build_report(
317 domain: String,
318 presented: PresentedChain,
319 mut caa_policy: CaaPolicy,
320) -> Result<SslReport> {
321 let (_, x509) = X509Certificate::from_der(&presented.leaf)
323 .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
324
325 let san_names = extract_sans(&x509);
327 let leaf_detail = parse_cert_detail(&x509)?;
328
329 let now = Utc::now();
330 let days_until_expiry = crate::dates::days_until(leaf_detail.valid_until, now);
331 let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
332
333 let hostname_verified = crate::tls::cert_matches_host(&x509, &domain);
337
338 let wildcard = crate::tls::cert_names_wildcard(&x509);
342 caa_policy.issuer_match = Some(caa::classify_issuer(
343 &leaf_detail.issuer,
344 &caa_policy,
345 wildcard,
346 ));
347
348 let warnings =
350 derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
351
352 let mut chain = vec![leaf_detail];
356 chain.extend(presented.intermediates.iter().filter_map(|der| {
357 let detail = X509Certificate::from_der(der)
358 .ok()
359 .and_then(|(_, cert)| parse_cert_detail(&cert).ok());
360 if detail.is_none() {
361 debug!("skipping unparseable certificate in the presented chain");
362 }
363 detail
364 }));
365
366 Ok(SslReport {
367 domain,
368 chain,
369 protocol_version: presented.protocol,
370 san_names,
371 is_valid,
372 hostname_verified,
373 days_until_expiry,
374 caa: Some(caa_policy),
375 warnings,
376 })
377}
378
379fn extract_sans(cert: &X509Certificate) -> Vec<String> {
381 let mut sans = Vec::new();
382 if let Ok(Some(ext)) = cert.subject_alternative_name() {
383 for name in &ext.value.general_names {
384 match name {
385 GeneralName::DNSName(dns) => {
386 sans.push(dns.to_string());
387 }
388 GeneralName::IPAddress(ip_bytes) => {
389 sans.push(format_ip_san(ip_bytes));
390 }
391 _ => {}
392 }
393 }
394 }
395 sans
396}
397
398fn format_ip_san(ip_bytes: &[u8]) -> String {
404 crate::tls::san_ip(ip_bytes).map_or_else(|| format!("{:?}", ip_bytes), |ip| ip.to_string())
405}
406
407fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
409 let subject = cert.subject().to_string();
410 let issuer = cert.issuer().to_string();
411
412 let (valid_from, valid_until) = crate::tls::validity_window(cert)
413 .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))?;
414
415 let serial_number = cert.serial.to_str_radix(16);
416
417 let signature_algorithm = Some(oid_to_name(&cert.signature_algorithm.algorithm));
418
419 let is_ca = cert.is_ca();
420
421 let spki = cert.public_key();
423 let (key_type, key_bits) = extract_key_info(spki);
424
425 Ok(CertDetail {
426 subject,
427 issuer,
428 valid_from,
429 valid_until,
430 serial_number,
431 signature_algorithm,
432 is_ca,
433 key_type,
434 key_bits,
435 })
436}
437
438fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
440 use x509_parser::public_key::PublicKey;
441 let oid = &spki.algorithm.algorithm;
442 let key_type = Some(oid_to_key_type(oid));
443 let key_bits = match spki.parsed() {
444 Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
445 Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
446 _ => None,
447 };
448 (key_type, key_bits)
449}
450
451fn oid_to_name(oid: &Oid) -> String {
454 let name = match oid.to_id_string().as_str() {
455 "1.2.840.113549.1.1.11" => "SHA-256 with RSA",
456 "1.2.840.113549.1.1.12" => "SHA-384 with RSA",
457 "1.2.840.113549.1.1.13" => "SHA-512 with RSA",
458 "1.2.840.113549.1.1.5" => "SHA-1 with RSA",
459 "1.2.840.113549.1.1.4" => "MD5 with RSA",
462 "1.2.840.10045.4.1" => "ECDSA with SHA-1",
463 "1.2.840.10040.4.3" => "DSA with SHA-1",
464 "1.2.840.113549.1.1.14" => "SHA-224 with RSA",
465 "1.2.840.10045.4.3.2" => "ECDSA with SHA-256",
466 "1.2.840.10045.4.3.3" => "ECDSA with SHA-384",
467 "1.2.840.10045.4.3.4" => "ECDSA with SHA-512",
468 "1.3.101.112" => "Ed25519",
469 "1.3.101.113" => "Ed448",
470 other => return other.to_string(),
471 };
472 name.to_string()
473}
474
475fn oid_to_key_type(oid: &Oid) -> String {
478 let name = match oid.to_id_string().as_str() {
479 "1.2.840.113549.1.1.1" => "RSA",
480 "1.2.840.10045.2.1" => "EC",
481 "1.3.101.112" => "Ed25519",
482 "1.3.101.113" => "Ed448",
483 other => return other.to_string(),
484 };
485 name.to_string()
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn test_ssl_checker_creation() {
494 let _checker = SslChecker::new();
495 let _default_checker = SslChecker::default();
496 }
497
498 #[tokio::test]
501 async fn with_timeout_keeps_the_configured_resolver() {
502 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
503
504 let port = spawn_mock_dns(MockMode::Zone).await;
506 let checker = SslChecker {
507 dns_resolver: mock_dns_resolver_default(port),
508 ..SslChecker::new()
509 }
510 .with_timeout(Duration::from_secs(3));
511 assert_eq!(checker.dns_resolver.timeout(), Duration::from_secs(3));
512 let policy = caa::lookup_caa(&checker.dns_resolver, "seer.test", None)
513 .await
514 .unwrap();
515 assert!(policy.has_policy, "the configured resolver must be kept");
516 }
517
518 #[test]
519 fn from_config_applies_http_timeout() {
520 let mut config = crate::config::SeerConfig::default();
521 config.timeouts.http_secs = 77;
522 let checker = SslChecker::from_config(&config);
523 assert_eq!(checker.timeout, Duration::from_secs(77));
524 }
525
526 #[test]
527 fn test_oid_to_name() {
528 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
529 assert_eq!(oid_to_name(&oid), "SHA-256 with RSA");
530 }
531
532 #[test]
533 fn test_oid_to_key_type() {
534 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
535 assert_eq!(oid_to_key_type(&oid), "RSA");
536 }
537
538 #[tokio::test]
544 #[ignore = "requires network — performs a real TLS handshake"]
545 async fn check_live_example_com_succeeds() {
546 let report = SslChecker::new().check("example.com").await.unwrap();
547 assert_eq!(report.domain, "example.com");
548 assert!(!report.chain.is_empty(), "expected at least a leaf cert");
549 assert!(
550 report.is_valid,
551 "example.com's leaf cert should be currently valid"
552 );
553 }
554
555 #[tokio::test]
558 async fn report_carries_the_presented_chain_and_protocol() {
559 use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
560
561 let addr = serve_once(&[LEAF_DER, CA_DER], &[&rustls::version::TLS12]).await;
562 let presented = crate::tls::inspect(
563 "chain.test",
564 &[addr],
565 Duration::from_secs(5),
566 SeerError::SslError,
567 )
568 .await
569 .unwrap();
570 let report = build_report("chain.test".to_string(), presented, CaaPolicy::empty()).unwrap();
571
572 let subjects: Vec<&str> = report.chain.iter().map(|c| c.subject.as_str()).collect();
573 assert_eq!(subjects, ["CN=chain.test", "CN=Seer Test CA"]);
574 assert!(report.chain[1].is_ca);
575 assert_eq!(report.protocol_version.as_deref(), Some("TLSv1.2"));
576 assert!(report.hostname_verified);
577 }
578
579 #[tokio::test]
582 async fn an_rsa_1024_leaf_is_inspected_and_flagged() {
583 use crate::tls::test_support::{serve_once, RSA_1024_LEAF};
584
585 let addr = serve_once(&[RSA_1024_LEAF], rustls::DEFAULT_VERSIONS).await;
586 let presented = crate::tls::inspect(
587 "weak-rsa.test",
588 &[addr],
589 Duration::from_secs(5),
590 SeerError::SslError,
591 )
592 .await
593 .unwrap();
594 let report =
595 build_report("weak-rsa.test".to_string(), presented, CaaPolicy::empty()).unwrap();
596
597 assert_eq!(report.chain[0].key_type.as_deref(), Some("RSA"));
598 assert_eq!(report.chain[0].key_bits, Some(1024));
599 assert!(
600 report
601 .warnings
602 .iter()
603 .any(|w| w.severity == CertWarningSeverity::Critical
604 && w.message == "RSA key size 1024 is below the 2048-bit minimum"),
605 "got {:?}",
606 report.warnings
607 );
608 }
609
610 #[test]
611 fn test_ssl_report_serialization() {
612 let report = SslReport {
613 domain: "example.com".to_string(),
614 chain: vec![CertDetail {
615 subject: "CN=example.com".to_string(),
616 issuer: "CN=R3, O=Let's Encrypt".to_string(),
617 valid_from: Utc::now(),
618 valid_until: Utc::now(),
619 serial_number: "abc123".to_string(),
620 signature_algorithm: Some("SHA-256 with RSA".to_string()),
621 is_ca: false,
622 key_type: Some("RSA".to_string()),
623 key_bits: Some(2048),
624 }],
625 protocol_version: None,
626 san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
627 is_valid: true,
628 hostname_verified: true,
629 days_until_expiry: 90,
630 caa: None,
631 warnings: vec![],
632 };
633 let json = serde_json::to_string(&report).unwrap();
634 assert!(json.contains("example.com"));
635 assert!(json.contains("SHA-256 with RSA"));
636 assert!(json.contains("\"is_valid\":true"));
637 assert!(json.contains("\"hostname_verified\":true"));
638 }
639
640 #[test]
641 fn format_ip_san_renders_canonical_addresses() {
642 assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
644 let mut loopback = [0u8; 16];
646 loopback[15] = 1;
647 assert_eq!(format_ip_san(&loopback), "::1");
648 let mut addr = [0u8; 16];
651 addr[0] = 0x20;
652 addr[1] = 0x01;
653 addr[2] = 0x0d;
654 addr[3] = 0xb8;
655 addr[15] = 1;
656 assert_eq!(format_ip_san(&addr), "2001:db8::1");
657 }
658
659 fn sample_leaf() -> CertDetail {
660 CertDetail {
661 subject: "CN=example.com".to_string(),
662 issuer: "CN=R3, O=Let's Encrypt".to_string(),
663 valid_from: Utc::now(),
664 valid_until: Utc::now(),
665 serial_number: "abc123".to_string(),
666 signature_algorithm: Some("SHA-256 with RSA".to_string()),
667 is_ca: false,
668 key_type: Some("RSA".to_string()),
669 key_bits: Some(2048),
670 }
671 }
672
673 #[test]
674 fn derive_cert_warnings_clean_cert_has_none() {
675 let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
676 assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
677 }
678
679 #[test]
680 fn derive_cert_warnings_flags_weak_rsa_key() {
681 let mut leaf = sample_leaf();
682 leaf.key_bits = Some(1024);
683 let w = derive_cert_warnings(&leaf, true, true, 90);
684 assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
685 && x.severity == CertWarningSeverity::Critical));
686 }
687
688 #[test]
689 fn derive_cert_warnings_flags_sha1_signature() {
690 let mut leaf = sample_leaf();
691 leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
692 let w = derive_cert_warnings(&leaf, true, true, 90);
693 assert!(w
694 .iter()
695 .any(|x| x.message.to_lowercase().contains("deprecated signature")));
696 }
697
698 #[test]
699 fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
700 let mut leaf = sample_leaf();
701 leaf.issuer = leaf.subject.clone();
702 let w = derive_cert_warnings(&leaf, false, false, -3);
703 assert!(w.iter().any(|x| x.message.contains("self-signed")));
704 assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
705 assert!(w.iter().any(|x| x.message.contains("does not match")));
706 }
707
708 #[test]
709 fn derive_cert_warnings_flags_expiring_soon_as_warning() {
710 let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
711 assert_eq!(w.len(), 1);
712 assert_eq!(w[0].severity, CertWarningSeverity::Warning);
713 assert!(w[0].message.contains("expires in 10 day(s)"));
714 }
715
716 #[test]
717 fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
718 let now = Utc::now();
719 let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
720 let w = derive_cert_warnings(&sample_leaf(), false, true, days);
721 assert!(
722 w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
723 "a just-expired cert must be reported expired: {w:?}"
724 );
725 assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
726 }
727
728 #[test]
729 fn deprecated_signature_oids_are_named_and_flagged() {
730 for (arcs, name) in [
731 (&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
732 (&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
733 (&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
734 ] {
735 let oid = Oid::from(arcs).unwrap();
736 assert_eq!(oid_to_name(&oid), name);
737 let mut leaf = sample_leaf();
738 leaf.signature_algorithm = Some(oid_to_name(&oid));
739 let w = derive_cert_warnings(&leaf, true, true, 90);
740 assert!(
741 w.iter().any(|x| x.message.contains("deprecated signature")),
742 "{name} must be flagged: {w:?}"
743 );
744 }
745 }
746
747 #[test]
748 fn derive_cert_warnings_flags_ca_marked_leaf() {
749 let mut leaf = sample_leaf();
750 leaf.is_ca = true;
751 let w = derive_cert_warnings(&leaf, true, true, 90);
752 assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
753 }
754
755 #[test]
759 fn ip_only_san_still_falls_back_to_the_cn() {
760 use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP};
761
762 let presented = PresentedChain {
763 leaf: cert(CN_VICTIM_SAN_IP),
764 intermediates: vec![],
765 protocol: None,
766 };
767 let report =
768 build_report("victim.example".to_string(), presented, CaaPolicy::empty()).unwrap();
769 assert!(report.hostname_verified);
770 assert_eq!(report.san_names, ["203.0.113.7"]);
771 }
772}