1use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use tokio::net::TcpStream;
16use tokio_native_tls::TlsConnector;
17use tracing::{debug, instrument};
18use x509_parser::oid_registry::Oid;
19use x509_parser::prelude::*;
20
21use crate::caa::{self, CaaPolicy};
22use crate::dns::DnsResolver;
23use crate::error::{Result, SeerError};
24use crate::net::resolve_public_host;
25use crate::validation::normalize_domain;
26
27const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
29
30const RSA_MIN_KEY_BITS: u32 = 2048;
32const EC_MIN_KEY_BITS: u32 = 256;
34const CERT_EXPIRING_SOON_DAYS: i64 = 30;
36
37fn derive_cert_warnings(
41 leaf: &CertDetail,
42 is_valid: bool,
43 hostname_verified: bool,
44 days_until_expiry: i64,
45) -> Vec<CertWarning> {
46 let mut warnings = Vec::new();
47 let critical = |message: String| CertWarning {
48 severity: CertWarningSeverity::Critical,
49 message,
50 };
51 let warn = |message: String| CertWarning {
52 severity: CertWarningSeverity::Warning,
53 message,
54 };
55
56 if let (Some(kt), Some(bits)) = (leaf.key_type.as_deref(), leaf.key_bits) {
58 match kt {
59 "RSA" if bits < RSA_MIN_KEY_BITS => warnings.push(critical(format!(
60 "RSA key size {bits} is below the {RSA_MIN_KEY_BITS}-bit minimum"
61 ))),
62 "EC" if bits < EC_MIN_KEY_BITS => warnings.push(critical(format!(
63 "EC key size {bits} is below the {EC_MIN_KEY_BITS}-bit minimum"
64 ))),
65 _ => {}
66 }
67 }
68
69 if let Some(sig) = leaf.signature_algorithm.as_deref() {
71 let lower = sig.to_lowercase();
72 if lower.contains("sha-1") || lower.contains("sha1") || lower.contains("md5") {
73 warnings.push(critical(format!(
74 "Certificate uses deprecated signature algorithm: {sig}"
75 )));
76 }
77 }
78
79 if leaf.subject == leaf.issuer {
81 warnings.push(warn(
82 "Certificate is self-signed (issuer equals subject)".to_string(),
83 ));
84 }
85
86 if leaf.is_ca {
88 warnings.push(warn(
89 "Leaf certificate is marked as a CA certificate".to_string(),
90 ));
91 }
92
93 if days_until_expiry < 0 {
95 warnings.push(critical(format!(
96 "Certificate expired {} day(s) ago",
97 -days_until_expiry
98 )));
99 } else if !is_valid {
100 warnings.push(critical(
102 "Certificate is not yet valid (notBefore is in the future)".to_string(),
103 ));
104 } else if days_until_expiry <= CERT_EXPIRING_SOON_DAYS {
105 warnings.push(warn(format!(
106 "Certificate expires in {days_until_expiry} day(s)"
107 )));
108 }
109
110 if !hostname_verified {
112 warnings.push(critical(
113 "Certificate does not match the requested hostname".to_string(),
114 ));
115 }
116
117 warnings
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SslReport {
123 pub domain: String,
125 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_domain(domain)?;
276
277 debug!(domain = %domain, "Checking SSL certificate chain");
278
279 let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
283
284 let resolve_future = resolve_public_host(&domain, 443);
289
290 let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
291 let socket_addrs = socket_addrs.map_err(|e| {
292 SeerError::SslError(format!(
293 "could not resolve {} for SSL inspection: {}",
294 domain, e
295 ))
296 })?;
297
298 let connector = native_tls::TlsConnector::builder()
300 .danger_accept_invalid_certs(true)
301 .build()
302 .map_err(|e| SeerError::SslError(format!("Failed to create TLS connector: {}", e)))?;
303 let connector = TlsConnector::from(connector);
304
305 let stream =
307 tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
308 .await
309 .map_err(|_| SeerError::Timeout("SSL connection timed out".to_string()))?
310 .map_err(|e| {
311 SeerError::SslError(format!("Failed to connect to {}:443: {}", domain, e))
312 })?;
313
314 let tls_stream = tokio::time::timeout(self.timeout, connector.connect(&domain, stream))
316 .await
317 .map_err(|_| SeerError::Timeout("TLS handshake timed out".to_string()))?
318 .map_err(|e| SeerError::SslError(format!("TLS handshake failed: {}", e)))?;
319
320 let cert = tls_stream
322 .get_ref()
323 .peer_certificate()
324 .map_err(|e| SeerError::SslError(format!("Failed to get certificate: {}", e)))?
325 .ok_or_else(|| SeerError::SslError("No certificate presented".to_string()))?;
326
327 let der = cert
328 .to_der()
329 .map_err(|e| SeerError::SslError(format!("Failed to encode certificate: {}", e)))?;
330
331 let (_, x509) = X509Certificate::from_der(&der)
333 .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
334
335 let san_names = extract_sans(&x509);
337
338 let leaf_detail = parse_cert_detail(&x509)?;
341
342 let now = Utc::now();
343 let days_until_expiry = (leaf_detail.valid_until - now).num_days();
344 let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
345
346 let hostname_verified = san_names
355 .iter()
356 .any(|san| hostname_matches_pattern(&domain, san))
357 || (san_names.is_empty() && subject_cn_matches_host(&x509, &domain));
358
359 let mut caa_policy = caa_policy;
362 caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
363
364 let warnings =
366 derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
367
368 Ok(SslReport {
369 domain,
370 chain: vec![leaf_detail],
371 protocol_version: None,
372 san_names,
373 is_valid,
374 hostname_verified,
375 days_until_expiry,
376 caa: Some(caa_policy),
377 warnings,
378 })
379 }
380}
381
382fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
387 let host = host.to_ascii_lowercase();
388 let pattern = pattern.to_ascii_lowercase();
389 if let Some(rest) = pattern.strip_prefix("*.") {
390 let Some(dot) = host.find('.') else {
391 return false;
392 };
393 let host_rest = &host[dot + 1..];
394 host_rest == rest
395 } else {
396 host == pattern
397 }
398}
399
400fn subject_cn_matches_host(cert: &X509Certificate, host: &str) -> bool {
405 for cn in cert.subject().iter_common_name() {
406 if let Ok(s) = cn.as_str() {
407 if hostname_matches_pattern(host, s) {
408 return true;
409 }
410 }
411 }
412 false
413}
414
415fn extract_sans(cert: &X509Certificate) -> Vec<String> {
417 let mut sans = Vec::new();
418 if let Ok(Some(ext)) = cert.subject_alternative_name() {
419 for name in &ext.value.general_names {
420 match name {
421 GeneralName::DNSName(dns) => {
422 sans.push(dns.to_string());
423 }
424 GeneralName::IPAddress(ip_bytes) => {
425 sans.push(format_ip_san(ip_bytes));
426 }
427 _ => {}
428 }
429 }
430 }
431 sans
432}
433
434fn format_ip_san(ip_bytes: &[u8]) -> String {
442 match ip_bytes.len() {
443 4 => {
444 let octets: [u8; 4] = [ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]];
445 std::net::Ipv4Addr::from(octets).to_string()
446 }
447 16 => {
448 let mut octets = [0u8; 16];
449 octets.copy_from_slice(ip_bytes);
450 std::net::Ipv6Addr::from(octets).to_string()
451 }
452 _ => format!("{:?}", ip_bytes),
453 }
454}
455
456fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
458 let subject = cert.subject().to_string();
459 let issuer = cert.issuer().to_string();
460
461 let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
462 let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
463
464 let serial_number = cert.serial.to_str_radix(16);
465
466 let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
467
468 let is_ca = cert.is_ca();
469
470 let spki = cert.public_key();
472 let (key_type, key_bits) = extract_key_info(spki);
473
474 Ok(CertDetail {
475 subject,
476 issuer,
477 valid_from,
478 valid_until,
479 serial_number,
480 signature_algorithm,
481 is_ca,
482 key_type,
483 key_bits,
484 })
485}
486
487fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
489 use x509_parser::public_key::PublicKey;
490 let oid = &spki.algorithm.algorithm;
491 let key_type = oid_to_key_type(oid);
492 let key_bits = match spki.parsed() {
493 Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
494 Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
495 _ => None,
496 };
497 (key_type, key_bits)
498}
499
500fn oid_to_name(oid: &Oid) -> Option<String> {
502 let oid_str = format!("{}", oid);
503 match oid_str.as_str() {
504 "1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
505 "1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
506 "1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
507 "1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
508 "1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
509 "1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
510 "1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
511 "1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
512 "1.3.101.112" => Some("Ed25519".to_string()),
513 "1.3.101.113" => Some("Ed448".to_string()),
514 _ => Some(oid_str),
515 }
516}
517
518fn oid_to_key_type(oid: &Oid) -> Option<String> {
520 let oid_str = format!("{}", oid);
521 match oid_str.as_str() {
522 "1.2.840.113549.1.1.1" => Some("RSA".to_string()),
523 "1.2.840.10045.2.1" => Some("EC".to_string()),
524 "1.3.101.112" => Some("Ed25519".to_string()),
525 "1.3.101.113" => Some("Ed448".to_string()),
526 _ => Some(oid_str),
527 }
528}
529
530fn asn1_time_to_chrono(time: ASN1Time) -> Result<DateTime<Utc>> {
532 let timestamp = time.timestamp();
533 DateTime::from_timestamp(timestamp, 0)
534 .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_ssl_checker_creation() {
543 let _checker = SslChecker::new();
544 let _default_checker = SslChecker::default();
545 }
546
547 #[test]
548 fn from_config_applies_http_timeout() {
549 let mut config = crate::config::SeerConfig::default();
550 config.timeouts.http_secs = 77;
551 let checker = SslChecker::from_config(&config);
552 assert_eq!(checker.timeout, Duration::from_secs(77));
553 }
554
555 #[test]
556 fn test_oid_to_name() {
557 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
558 assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
559 }
560
561 #[test]
562 fn test_oid_to_key_type() {
563 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
564 assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
565 }
566
567 #[tokio::test]
573 #[ignore = "requires network — performs a real TLS handshake"]
574 async fn check_live_example_com_succeeds() {
575 let report = SslChecker::new().check("example.com").await.unwrap();
576 assert_eq!(report.domain, "example.com");
577 assert!(!report.chain.is_empty(), "expected at least a leaf cert");
578 assert!(
579 report.is_valid,
580 "example.com's leaf cert should be currently valid"
581 );
582 }
583
584 #[test]
585 fn test_ssl_report_serialization() {
586 let report = SslReport {
587 domain: "example.com".to_string(),
588 chain: vec![CertDetail {
589 subject: "CN=example.com".to_string(),
590 issuer: "CN=R3, O=Let's Encrypt".to_string(),
591 valid_from: Utc::now(),
592 valid_until: Utc::now(),
593 serial_number: "abc123".to_string(),
594 signature_algorithm: Some("SHA-256 with RSA".to_string()),
595 is_ca: false,
596 key_type: Some("RSA".to_string()),
597 key_bits: Some(2048),
598 }],
599 protocol_version: None,
600 san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
601 is_valid: true,
602 hostname_verified: true,
603 days_until_expiry: 90,
604 caa: None,
605 warnings: vec![],
606 };
607 let json = serde_json::to_string(&report).unwrap();
608 assert!(json.contains("example.com"));
609 assert!(json.contains("SHA-256 with RSA"));
610 assert!(json.contains("\"is_valid\":true"));
611 assert!(json.contains("\"hostname_verified\":true"));
612 }
613
614 #[test]
615 fn format_ip_san_renders_canonical_addresses() {
616 assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
618 let mut loopback = [0u8; 16];
620 loopback[15] = 1;
621 assert_eq!(format_ip_san(&loopback), "::1");
622 let mut addr = [0u8; 16];
625 addr[0] = 0x20;
626 addr[1] = 0x01;
627 addr[2] = 0x0d;
628 addr[3] = 0xb8;
629 addr[15] = 1;
630 assert_eq!(format_ip_san(&addr), "2001:db8::1");
631 }
632
633 fn sample_leaf() -> CertDetail {
634 CertDetail {
635 subject: "CN=example.com".to_string(),
636 issuer: "CN=R3, O=Let's Encrypt".to_string(),
637 valid_from: Utc::now(),
638 valid_until: Utc::now(),
639 serial_number: "abc123".to_string(),
640 signature_algorithm: Some("SHA-256 with RSA".to_string()),
641 is_ca: false,
642 key_type: Some("RSA".to_string()),
643 key_bits: Some(2048),
644 }
645 }
646
647 #[test]
648 fn derive_cert_warnings_clean_cert_has_none() {
649 let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
650 assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
651 }
652
653 #[test]
654 fn derive_cert_warnings_flags_weak_rsa_key() {
655 let mut leaf = sample_leaf();
656 leaf.key_bits = Some(1024);
657 let w = derive_cert_warnings(&leaf, true, true, 90);
658 assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
659 && x.severity == CertWarningSeverity::Critical));
660 }
661
662 #[test]
663 fn derive_cert_warnings_flags_sha1_signature() {
664 let mut leaf = sample_leaf();
665 leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
666 let w = derive_cert_warnings(&leaf, true, true, 90);
667 assert!(w
668 .iter()
669 .any(|x| x.message.to_lowercase().contains("deprecated signature")));
670 }
671
672 #[test]
673 fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
674 let mut leaf = sample_leaf();
675 leaf.issuer = leaf.subject.clone();
676 let w = derive_cert_warnings(&leaf, false, false, -3);
677 assert!(w.iter().any(|x| x.message.contains("self-signed")));
678 assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
679 assert!(w.iter().any(|x| x.message.contains("does not match")));
680 }
681
682 #[test]
683 fn derive_cert_warnings_flags_expiring_soon_as_warning() {
684 let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
685 assert_eq!(w.len(), 1);
686 assert_eq!(w[0].severity, CertWarningSeverity::Warning);
687 assert!(w[0].message.contains("expires in 10 day(s)"));
688 }
689
690 #[test]
691 fn derive_cert_warnings_flags_ca_marked_leaf() {
692 let mut leaf = sample_leaf();
693 leaf.is_ca = true;
694 let w = derive_cert_warnings(&leaf, true, true, 90);
695 assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
696 }
697
698 #[test]
699 fn hostname_matches_pattern_exact_and_wildcard() {
700 assert!(hostname_matches_pattern("example.com", "example.com"));
701 assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
702 assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
704 assert!(!hostname_matches_pattern("example.com", "*.example.com"));
706 assert!(!hostname_matches_pattern(
708 "a.b.example.com",
709 "*.example.com"
710 ));
711 assert!(!hostname_matches_pattern("evil.test", "example.com"));
713 }
714}