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 {
257 self.timeout = timeout;
258 self.dns_resolver = DnsResolver::new().with_timeout(timeout);
259 self
260 }
261
262 #[instrument(skip(self), fields(domain = %domain))]
274 pub async fn check(&self, domain: &str) -> Result<SslReport> {
275 let domain = normalize_host(domain)?;
278
279 debug!(domain = %domain, "Checking SSL certificate chain");
280
281 let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
285
286 let resolve_future = resolve_public_host(&domain, 443);
291
292 let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
293 let socket_addrs = socket_addrs.map_err(|e| {
294 SeerError::SslError(format!(
295 "could not resolve {} for SSL inspection: {}",
296 domain, e
297 ))
298 })?;
299
300 let presented =
304 crate::tls::inspect(&domain, &socket_addrs, self.timeout, SeerError::SslError).await?;
305
306 build_report(domain, presented, caa_policy)
307 }
308}
309
310fn build_report(
313 domain: String,
314 presented: PresentedChain,
315 mut caa_policy: CaaPolicy,
316) -> Result<SslReport> {
317 let (_, x509) = X509Certificate::from_der(&presented.leaf)
319 .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
320
321 let san_names = extract_sans(&x509);
323 let leaf_detail = parse_cert_detail(&x509)?;
324
325 let now = Utc::now();
326 let days_until_expiry = crate::dates::days_until(leaf_detail.valid_until, now);
327 let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
328
329 let hostname_verified = crate::tls::cert_matches_host(&x509, &domain);
333
334 caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
337
338 let warnings =
340 derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
341
342 let mut chain = vec![leaf_detail];
346 chain.extend(presented.intermediates.iter().filter_map(|der| {
347 let detail = X509Certificate::from_der(der)
348 .ok()
349 .and_then(|(_, cert)| parse_cert_detail(&cert).ok());
350 if detail.is_none() {
351 debug!("skipping unparseable certificate in the presented chain");
352 }
353 detail
354 }));
355
356 Ok(SslReport {
357 domain,
358 chain,
359 protocol_version: presented.protocol,
360 san_names,
361 is_valid,
362 hostname_verified,
363 days_until_expiry,
364 caa: Some(caa_policy),
365 warnings,
366 })
367}
368
369fn extract_sans(cert: &X509Certificate) -> Vec<String> {
371 let mut sans = Vec::new();
372 if let Ok(Some(ext)) = cert.subject_alternative_name() {
373 for name in &ext.value.general_names {
374 match name {
375 GeneralName::DNSName(dns) => {
376 sans.push(dns.to_string());
377 }
378 GeneralName::IPAddress(ip_bytes) => {
379 sans.push(format_ip_san(ip_bytes));
380 }
381 _ => {}
382 }
383 }
384 }
385 sans
386}
387
388fn format_ip_san(ip_bytes: &[u8]) -> String {
394 crate::tls::san_ip(ip_bytes).map_or_else(|| format!("{:?}", ip_bytes), |ip| ip.to_string())
395}
396
397fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
399 let subject = cert.subject().to_string();
400 let issuer = cert.issuer().to_string();
401
402 let (valid_from, valid_until) = crate::tls::validity_window(cert)
403 .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))?;
404
405 let serial_number = cert.serial.to_str_radix(16);
406
407 let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
408
409 let is_ca = cert.is_ca();
410
411 let spki = cert.public_key();
413 let (key_type, key_bits) = extract_key_info(spki);
414
415 Ok(CertDetail {
416 subject,
417 issuer,
418 valid_from,
419 valid_until,
420 serial_number,
421 signature_algorithm,
422 is_ca,
423 key_type,
424 key_bits,
425 })
426}
427
428fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
430 use x509_parser::public_key::PublicKey;
431 let oid = &spki.algorithm.algorithm;
432 let key_type = oid_to_key_type(oid);
433 let key_bits = match spki.parsed() {
434 Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
435 Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
436 _ => None,
437 };
438 (key_type, key_bits)
439}
440
441fn oid_to_name(oid: &Oid) -> Option<String> {
443 let oid_str = format!("{}", oid);
444 match oid_str.as_str() {
445 "1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
446 "1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
447 "1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
448 "1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
449 "1.2.840.113549.1.1.4" => Some("MD5 with RSA".to_string()),
452 "1.2.840.10045.4.1" => Some("ECDSA with SHA-1".to_string()),
453 "1.2.840.10040.4.3" => Some("DSA with SHA-1".to_string()),
454 "1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
455 "1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
456 "1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
457 "1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
458 "1.3.101.112" => Some("Ed25519".to_string()),
459 "1.3.101.113" => Some("Ed448".to_string()),
460 _ => Some(oid_str),
461 }
462}
463
464fn oid_to_key_type(oid: &Oid) -> Option<String> {
466 let oid_str = format!("{}", oid);
467 match oid_str.as_str() {
468 "1.2.840.113549.1.1.1" => Some("RSA".to_string()),
469 "1.2.840.10045.2.1" => Some("EC".to_string()),
470 "1.3.101.112" => Some("Ed25519".to_string()),
471 "1.3.101.113" => Some("Ed448".to_string()),
472 _ => Some(oid_str),
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn test_ssl_checker_creation() {
482 let _checker = SslChecker::new();
483 let _default_checker = SslChecker::default();
484 }
485
486 #[test]
487 fn from_config_applies_http_timeout() {
488 let mut config = crate::config::SeerConfig::default();
489 config.timeouts.http_secs = 77;
490 let checker = SslChecker::from_config(&config);
491 assert_eq!(checker.timeout, Duration::from_secs(77));
492 }
493
494 #[test]
495 fn test_oid_to_name() {
496 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
497 assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
498 }
499
500 #[test]
501 fn test_oid_to_key_type() {
502 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
503 assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
504 }
505
506 #[tokio::test]
512 #[ignore = "requires network — performs a real TLS handshake"]
513 async fn check_live_example_com_succeeds() {
514 let report = SslChecker::new().check("example.com").await.unwrap();
515 assert_eq!(report.domain, "example.com");
516 assert!(!report.chain.is_empty(), "expected at least a leaf cert");
517 assert!(
518 report.is_valid,
519 "example.com's leaf cert should be currently valid"
520 );
521 }
522
523 #[tokio::test]
526 async fn report_carries_the_presented_chain_and_protocol() {
527 use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
528
529 let addr = serve_once(&[LEAF_DER, CA_DER], &[&rustls::version::TLS12]).await;
530 let presented = crate::tls::inspect(
531 "chain.test",
532 &[addr],
533 Duration::from_secs(5),
534 SeerError::SslError,
535 )
536 .await
537 .unwrap();
538 let report = build_report("chain.test".to_string(), presented, CaaPolicy::empty()).unwrap();
539
540 let subjects: Vec<&str> = report.chain.iter().map(|c| c.subject.as_str()).collect();
541 assert_eq!(subjects, ["CN=chain.test", "CN=Seer Test CA"]);
542 assert!(report.chain[1].is_ca);
543 assert_eq!(report.protocol_version.as_deref(), Some("TLSv1.2"));
544 assert!(report.hostname_verified);
545 }
546
547 #[tokio::test]
550 async fn an_rsa_1024_leaf_is_inspected_and_flagged() {
551 use crate::tls::test_support::{serve_once, RSA_1024_LEAF};
552
553 let addr = serve_once(&[RSA_1024_LEAF], rustls::DEFAULT_VERSIONS).await;
554 let presented = crate::tls::inspect(
555 "weak-rsa.test",
556 &[addr],
557 Duration::from_secs(5),
558 SeerError::SslError,
559 )
560 .await
561 .unwrap();
562 let report =
563 build_report("weak-rsa.test".to_string(), presented, CaaPolicy::empty()).unwrap();
564
565 assert_eq!(report.chain[0].key_type.as_deref(), Some("RSA"));
566 assert_eq!(report.chain[0].key_bits, Some(1024));
567 assert!(
568 report
569 .warnings
570 .iter()
571 .any(|w| w.severity == CertWarningSeverity::Critical
572 && w.message == "RSA key size 1024 is below the 2048-bit minimum"),
573 "got {:?}",
574 report.warnings
575 );
576 }
577
578 #[test]
579 fn test_ssl_report_serialization() {
580 let report = SslReport {
581 domain: "example.com".to_string(),
582 chain: vec![CertDetail {
583 subject: "CN=example.com".to_string(),
584 issuer: "CN=R3, O=Let's Encrypt".to_string(),
585 valid_from: Utc::now(),
586 valid_until: Utc::now(),
587 serial_number: "abc123".to_string(),
588 signature_algorithm: Some("SHA-256 with RSA".to_string()),
589 is_ca: false,
590 key_type: Some("RSA".to_string()),
591 key_bits: Some(2048),
592 }],
593 protocol_version: None,
594 san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
595 is_valid: true,
596 hostname_verified: true,
597 days_until_expiry: 90,
598 caa: None,
599 warnings: vec![],
600 };
601 let json = serde_json::to_string(&report).unwrap();
602 assert!(json.contains("example.com"));
603 assert!(json.contains("SHA-256 with RSA"));
604 assert!(json.contains("\"is_valid\":true"));
605 assert!(json.contains("\"hostname_verified\":true"));
606 }
607
608 #[test]
609 fn format_ip_san_renders_canonical_addresses() {
610 assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
612 let mut loopback = [0u8; 16];
614 loopback[15] = 1;
615 assert_eq!(format_ip_san(&loopback), "::1");
616 let mut addr = [0u8; 16];
619 addr[0] = 0x20;
620 addr[1] = 0x01;
621 addr[2] = 0x0d;
622 addr[3] = 0xb8;
623 addr[15] = 1;
624 assert_eq!(format_ip_san(&addr), "2001:db8::1");
625 }
626
627 fn sample_leaf() -> CertDetail {
628 CertDetail {
629 subject: "CN=example.com".to_string(),
630 issuer: "CN=R3, O=Let's Encrypt".to_string(),
631 valid_from: Utc::now(),
632 valid_until: Utc::now(),
633 serial_number: "abc123".to_string(),
634 signature_algorithm: Some("SHA-256 with RSA".to_string()),
635 is_ca: false,
636 key_type: Some("RSA".to_string()),
637 key_bits: Some(2048),
638 }
639 }
640
641 #[test]
642 fn derive_cert_warnings_clean_cert_has_none() {
643 let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
644 assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
645 }
646
647 #[test]
648 fn derive_cert_warnings_flags_weak_rsa_key() {
649 let mut leaf = sample_leaf();
650 leaf.key_bits = Some(1024);
651 let w = derive_cert_warnings(&leaf, true, true, 90);
652 assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
653 && x.severity == CertWarningSeverity::Critical));
654 }
655
656 #[test]
657 fn derive_cert_warnings_flags_sha1_signature() {
658 let mut leaf = sample_leaf();
659 leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
660 let w = derive_cert_warnings(&leaf, true, true, 90);
661 assert!(w
662 .iter()
663 .any(|x| x.message.to_lowercase().contains("deprecated signature")));
664 }
665
666 #[test]
667 fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
668 let mut leaf = sample_leaf();
669 leaf.issuer = leaf.subject.clone();
670 let w = derive_cert_warnings(&leaf, false, false, -3);
671 assert!(w.iter().any(|x| x.message.contains("self-signed")));
672 assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
673 assert!(w.iter().any(|x| x.message.contains("does not match")));
674 }
675
676 #[test]
677 fn derive_cert_warnings_flags_expiring_soon_as_warning() {
678 let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
679 assert_eq!(w.len(), 1);
680 assert_eq!(w[0].severity, CertWarningSeverity::Warning);
681 assert!(w[0].message.contains("expires in 10 day(s)"));
682 }
683
684 #[test]
685 fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
686 let now = Utc::now();
687 let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
688 let w = derive_cert_warnings(&sample_leaf(), false, true, days);
689 assert!(
690 w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
691 "a just-expired cert must be reported expired: {w:?}"
692 );
693 assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
694 }
695
696 #[test]
697 fn deprecated_signature_oids_are_named_and_flagged() {
698 for (arcs, name) in [
699 (&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
700 (&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
701 (&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
702 ] {
703 let oid = Oid::from(arcs).unwrap();
704 assert_eq!(oid_to_name(&oid).as_deref(), Some(name));
705 let mut leaf = sample_leaf();
706 leaf.signature_algorithm = oid_to_name(&oid);
707 let w = derive_cert_warnings(&leaf, true, true, 90);
708 assert!(
709 w.iter().any(|x| x.message.contains("deprecated signature")),
710 "{name} must be flagged: {w:?}"
711 );
712 }
713 }
714
715 #[test]
716 fn derive_cert_warnings_flags_ca_marked_leaf() {
717 let mut leaf = sample_leaf();
718 leaf.is_ca = true;
719 let w = derive_cert_warnings(&leaf, true, true, 90);
720 assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
721 }
722
723 #[test]
727 fn ip_only_san_still_falls_back_to_the_cn() {
728 use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP};
729
730 let presented = PresentedChain {
731 leaf: cert(CN_VICTIM_SAN_IP),
732 intermediates: vec![],
733 protocol: None,
734 };
735 let report =
736 build_report("victim.example".to_string(), presented, CaaPolicy::empty()).unwrap();
737 assert!(report.hostname_verified);
738 assert_eq!(report.san_names, ["203.0.113.7"]);
739 }
740}