Skip to main content

seer_core/
ssl.rs

1//! SSL certificate chain inspection.
2//!
3//! Provides detailed SSL/TLS certificate information including the certificate
4//! chain, Subject Alternative Names (SANs), key details, and validity status.
5//!
6//! Retry boundary (deliberate): single-attempt, like [`crate::status`] — a
7//! certificate inspection is a point-in-time observation, and retrying would
8//! hide intermittent TLS failures from the user. Transient-failure tolerance
9//! belongs to callers (e.g. watch mode).
10
11use 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
27/// Default timeout for SSL operations (10 seconds).
28const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
29
30/// Minimum acceptable RSA key size in bits (NIST SP 800-57 / CA/B Forum).
31const RSA_MIN_KEY_BITS: u32 = 2048;
32/// Minimum acceptable elliptic-curve key size in bits.
33const EC_MIN_KEY_BITS: u32 = 256;
34/// Days-until-expiry threshold below which a still-valid cert is flagged.
35const CERT_EXPIRING_SOON_DAYS: i64 = 30;
36
37/// Derives security-posture [`CertWarning`]s from an already-parsed leaf
38/// certificate plus the computed validity/hostname signals. Pure — no network
39/// calls — so it is unit-testable in isolation.
40fn 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    // Weak public key.
57    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    // Deprecated signature algorithm (SHA-1 / MD5).
70    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    // Self-signed (issuer equals subject).
80    if leaf.subject == leaf.issuer {
81        warnings.push(warn(
82            "Certificate is self-signed (issuer equals subject)".to_string(),
83        ));
84    }
85
86    // Leaf certificate marked as a CA (basic-constraints misuse).
87    if leaf.is_ca {
88        warnings.push(warn(
89            "Leaf certificate is marked as a CA certificate".to_string(),
90        ));
91    }
92
93    // Validity window.
94    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        // Date-valid check failed but not past expiry → notBefore is future.
101        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    // Hostname mismatch.
111    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/// Full SSL certificate report for a domain.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SslReport {
123    /// The domain that was inspected
124    pub domain: String,
125    /// Certificate chain from leaf to root (as many as the server provides)
126    pub chain: Vec<CertDetail>,
127    /// TLS protocol version (best-effort detection)
128    pub protocol_version: Option<String>,
129    /// Subject Alternative Names from the leaf certificate
130    pub san_names: Vec<String>,
131    /// Whether the leaf certificate is within its validity period.
132    ///
133    /// This reflects ONLY the date-range check (`notBefore <= now <=
134    /// notAfter`) of the leaf certificate. It does NOT verify the certificate
135    /// chain's trust (this checker uses `danger_accept_invalid_certs(true)` to
136    /// inspect broken/self-signed certs) nor that the certificate matches the
137    /// requested hostname — see [`SslReport::hostname_verified`]. A
138    /// date-valid cert may still be self-signed, issued by an untrusted CA, or
139    /// presented for the wrong host.
140    pub is_valid: bool,
141    /// Whether the leaf certificate's SAN dNSNames (or CN as a legacy
142    /// fallback) match the requested domain, per RFC 6125 (exact and
143    /// single-label wildcard matches).
144    ///
145    /// This is an additive signal independent of `is_valid`. Chain trust is
146    /// NOT verified here: a `true` value means the cert was presented for the
147    /// right host, not that it was issued by a trusted CA.
148    #[serde(default)]
149    pub hostname_verified: bool,
150    /// Days until the leaf certificate expires
151    pub days_until_expiry: i64,
152    /// CAA (Certification Authority Authorization) policy for the domain
153    /// plus a comparison against the presented certificate's issuer.
154    ///
155    /// CAA is consulted by CAs at *issuance time*, not by clients at
156    /// *validation time*, so a mismatch here is informational — see the
157    /// `note` field on [`CaaPolicy`].
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub caa: Option<CaaPolicy>,
160    /// Security-posture warnings derived from the leaf certificate: weak keys,
161    /// deprecated signatures, self-signed / CA-marked leaves, expiry, and
162    /// hostname mismatch. Empty when the leaf presents no notable issues.
163    ///
164    /// These are pure post-processing of already-parsed fields (no extra
165    /// network calls) — a summary so consumers don't have to eyeball key sizes
166    /// and algorithms.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub warnings: Vec<CertWarning>,
169}
170
171/// Severity of a [`CertWarning`].
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum CertWarningSeverity {
175    /// A trust-affecting problem (weak key, deprecated signature, expired,
176    /// hostname mismatch).
177    Critical,
178    /// A notable-but-not-fatal signal (self-signed, CA-marked leaf, expiring
179    /// soon).
180    Warning,
181}
182
183/// A single security-posture warning about a certificate.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct CertWarning {
186    /// How serious the finding is.
187    pub severity: CertWarningSeverity,
188    /// Human-readable description of the finding.
189    pub message: String,
190}
191
192/// Detailed information about a single certificate in the chain.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CertDetail {
195    /// Certificate subject (e.g., "CN=example.com")
196    pub subject: String,
197    /// Certificate issuer (e.g., "CN=R3, O=Let's Encrypt")
198    pub issuer: String,
199    /// Certificate validity start date
200    pub valid_from: DateTime<Utc>,
201    /// Certificate expiration date
202    pub valid_until: DateTime<Utc>,
203    /// Serial number in hexadecimal
204    pub serial_number: String,
205    /// Signature algorithm (e.g., "sha256WithRSAEncryption")
206    pub signature_algorithm: Option<String>,
207    /// Whether this is a Certificate Authority certificate
208    pub is_ca: bool,
209    /// Public key type (e.g., "RSA", "EC")
210    pub key_type: Option<String>,
211    /// Public key size in bits
212    pub key_bits: Option<u32>,
213}
214
215/// Client for performing SSL certificate chain inspection.
216#[derive(Debug, Clone)]
217pub struct SslChecker {
218    /// Cached DNS resolver used for CAA lookups alongside the TLS probe.
219    dns_resolver: DnsResolver,
220    /// Timeout for the TCP connect and TLS handshake.
221    timeout: Duration,
222}
223
224impl Default for SslChecker {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230impl SslChecker {
231    /// Creates a new SslChecker instance.
232    pub fn new() -> Self {
233        Self {
234            dns_resolver: DnsResolver::new(),
235            timeout: DEFAULT_TIMEOUT,
236        }
237    }
238
239    /// Builds a checker honoring `~/.seer/config.toml` settings.
240    ///
241    /// Reads `timeouts.http_secs` (clamped to 1–120s by
242    /// [`crate::config::SeerConfig::load`]) for the TCP connect + TLS
243    /// handshake, and `timeouts.dns_secs` (1–60s) for the internal resolver
244    /// used by the parallel CAA lookup. Unlike the coarse
245    /// [`SslChecker::with_timeout`] (which reuses the TLS timeout for DNS),
246    /// this applies each protocol's own configured timeout — matching the
247    /// [`crate::availability::AvailabilityChecker::from_config`] precedent.
248    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    /// Sets the timeout for the TCP connect and TLS handshake.
256    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    /// Inspects the SSL certificate chain for the given domain.
263    ///
264    /// Connects to port 443, performs a TLS handshake, and extracts detailed
265    /// certificate information including the full chain, SANs, and key details.
266    ///
267    /// # Arguments
268    /// * `domain` - The domain name to inspect (e.g., "example.com")
269    ///
270    /// # Returns
271    /// * `Ok(SslReport)` - Detailed SSL certificate information
272    /// * `Err(SeerError)` - If connection or certificate parsing fails
273    #[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        // CAA query runs concurrently with the TLS probe — it is advisory
280        // and never fails the report (a resolver error yields an empty
281        // policy).
282        let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
283
284        // Resolve + SSRF check. `resolve_public_host` already falls back to
285        // hickory (Google DNS) when the OS resolver fails — important for
286        // hosts where Tailscale Split-DNS or a corp resolver pins the
287        // domain to a nameserver that can't answer for it.
288        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        // Build TLS connector - accept invalid certs so we can inspect them
299        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        // TCP connect with timeout — connect to pre-resolved address to prevent DNS rebinding
306        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        // TLS handshake with timeout
315        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        // Get the peer certificate (leaf)
321        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        // Parse leaf certificate with x509-parser
332        let (_, x509) = X509Certificate::from_der(&der)
333            .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
334
335        // Extract SANs from the leaf certificate
336        let san_names = extract_sans(&x509);
337
338        // Build the certificate chain
339        // native-tls only exposes the leaf cert directly; we parse what we have
340        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        // Hostname verification: does the leaf cert's SAN (or CN fallback)
347        // match the requested domain? This is independent of `is_valid` and of
348        // chain trust (which is not verified here — see the field docs). Lets
349        // consumers tell a date-valid-but-wrong-host cert apart from a real
350        // match. Per RFC 6125 §6.4.4 the CN is consulted ONLY when the cert
351        // presents no identifier SANs at all; if any dNSName/IPAddress SAN is
352        // present the CN must be ignored, otherwise a cert whose SANs cover
353        // other hosts but whose CN happens to match would falsely verify.
354        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        // Annotate the CAA policy with the issuer comparison before
360        // attaching it to the report.
361        let mut caa_policy = caa_policy;
362        caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
363
364        // Derive posture warnings from the parsed leaf (pure post-processing).
365        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
382/// Returns true if `host` matches the certificate name `pattern`, supporting
383/// exact (case-insensitive) matches and single-label wildcards per RFC 6125
384/// (`*.example.com` matches `a.example.com` but not `example.com` or
385/// `a.b.example.com`).
386fn 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
400/// Legacy CN fallback for hostname verification: checks the leaf certificate's
401/// subject Common Name(s) against `host`. SAN dNSNames are authoritative per
402/// RFC 6125; CN is only consulted when the certificate presents no identifier
403/// SANs at all (the caller gates this on `san_names.is_empty()`).
404fn 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
415/// Extracts Subject Alternative Names from a certificate.
416fn 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
434/// Renders an IPAddress SAN from its raw bytes into canonical text form.
435///
436/// A 4-byte value becomes dotted-quad IPv4; a 16-byte value becomes a
437/// zero-compressed IPv6 address (e.g. `::1`, not `0000:0000:...:0001`) by going
438/// through `std::net::Ipv6Addr`'s `Display`. Any other length is unexpected for
439/// an IPAddress general name, so it falls back to a debug rendering of the bytes
440/// rather than guessing.
441fn 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
456/// Parses detailed information from an X.509 certificate.
457fn 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    // Extract public key info
471    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
487/// Extracts key type and size from a SubjectPublicKeyInfo.
488fn 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
500/// Maps common OIDs to human-readable algorithm names.
501fn 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
518/// Maps public key algorithm OIDs to human-readable key type names.
519fn 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
530/// Converts an x509-parser ASN1Time to a chrono DateTime.
531fn 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    /// Live-network sanity check: a real public site with valid TLS
568    /// completes a full chain inspection. Exercises the
569    /// [`resolve_public_host`] code path in `net.rs` (hickory fallback
570    /// engages if the test environment has a broken OS resolver) and the
571    /// rest of the TLS handshake + cert-parse pipeline.
572    #[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        // IPv4: dotted quad.
617        assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
618        // IPv6 ::1 — must be zero-compressed, NOT 0000:0000:...:0001.
619        let mut loopback = [0u8; 16];
620        loopback[15] = 1;
621        assert_eq!(format_ip_san(&loopback), "::1");
622        // A fuller IPv6 address still round-trips through canonical form.
623        // 2001:db8::1
624        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        // Single-label wildcard.
703        assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
704        // Apex must not match a wildcard (RFC 6125).
705        assert!(!hostname_matches_pattern("example.com", "*.example.com"));
706        // Wildcard matches only one label.
707        assert!(!hostname_matches_pattern(
708            "a.b.example.com",
709            "*.example.com"
710        ));
711        // Mismatched host.
712        assert!(!hostname_matches_pattern("evil.test", "example.com"));
713    }
714}