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_host;
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        // The exact host: `www.example.com` can serve a different
276        // certificate than the apex, so `www.` must not be stripped here.
277        let domain = normalize_host(domain)?;
278
279        debug!(domain = %domain, "Checking SSL certificate chain");
280
281        // CAA query runs concurrently with the TLS probe — it is advisory
282        // and never fails the report (a resolver error yields an empty
283        // policy).
284        let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
285
286        // Resolve + SSRF check. `resolve_public_host` already falls back to
287        // hickory (Google DNS) when the OS resolver fails — important for
288        // hosts where Tailscale Split-DNS or a corp resolver pins the
289        // domain to a nameserver that can't answer for it.
290        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        // Build TLS connector - accept invalid certs so we can inspect them
301        let connector = native_tls::TlsConnector::builder()
302            .danger_accept_invalid_certs(true)
303            .build()
304            .map_err(|e| SeerError::SslError(format!("Failed to create TLS connector: {}", e)))?;
305        let connector = TlsConnector::from(connector);
306
307        // TCP connect with timeout — connect to pre-resolved address to prevent DNS rebinding
308        let stream =
309            tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
310                .await
311                .map_err(|_| SeerError::Timeout("SSL connection timed out".to_string()))?
312                .map_err(|e| {
313                    SeerError::SslError(format!("Failed to connect to {}:443: {}", domain, e))
314                })?;
315
316        // TLS handshake with timeout
317        let tls_stream = tokio::time::timeout(self.timeout, connector.connect(&domain, stream))
318            .await
319            .map_err(|_| SeerError::Timeout("TLS handshake timed out".to_string()))?
320            .map_err(|e| SeerError::SslError(format!("TLS handshake failed: {}", e)))?;
321
322        // Get the peer certificate (leaf)
323        let cert = tls_stream
324            .get_ref()
325            .peer_certificate()
326            .map_err(|e| SeerError::SslError(format!("Failed to get certificate: {}", e)))?
327            .ok_or_else(|| SeerError::SslError("No certificate presented".to_string()))?;
328
329        let der = cert
330            .to_der()
331            .map_err(|e| SeerError::SslError(format!("Failed to encode certificate: {}", e)))?;
332
333        // Parse leaf certificate with x509-parser
334        let (_, x509) = X509Certificate::from_der(&der)
335            .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
336
337        // Extract SANs from the leaf certificate
338        let san_names = extract_sans(&x509);
339
340        // Build the certificate chain
341        // native-tls only exposes the leaf cert directly; we parse what we have
342        let leaf_detail = parse_cert_detail(&x509)?;
343
344        let now = Utc::now();
345        let days_until_expiry = crate::dates::days_until(leaf_detail.valid_until, now);
346        let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
347
348        // Hostname verification: does the leaf cert's SAN (or CN fallback)
349        // match the requested domain? This is independent of `is_valid` and of
350        // chain trust (which is not verified here — see the field docs). Lets
351        // consumers tell a date-valid-but-wrong-host cert apart from a real
352        // match. Per RFC 6125 §6.4.4 the CN is consulted ONLY when the cert
353        // presents no identifier SANs at all; if any dNSName/IPAddress SAN is
354        // present the CN must be ignored, otherwise a cert whose SANs cover
355        // other hosts but whose CN happens to match would falsely verify.
356        let hostname_verified = san_names
357            .iter()
358            .any(|san| hostname_matches_pattern(&domain, san))
359            || (san_names.is_empty() && subject_cn_matches_host(&x509, &domain));
360
361        // Annotate the CAA policy with the issuer comparison before
362        // attaching it to the report.
363        let mut caa_policy = caa_policy;
364        caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
365
366        // Derive posture warnings from the parsed leaf (pure post-processing).
367        let warnings =
368            derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
369
370        Ok(SslReport {
371            domain,
372            chain: vec![leaf_detail],
373            protocol_version: None,
374            san_names,
375            is_valid,
376            hostname_verified,
377            days_until_expiry,
378            caa: Some(caa_policy),
379            warnings,
380        })
381    }
382}
383
384/// Returns true if `host` matches the certificate name `pattern`, supporting
385/// exact (case-insensitive) matches and single-label wildcards per RFC 6125
386/// (`*.example.com` matches `a.example.com` but not `example.com` or
387/// `a.b.example.com`).
388fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
389    let host = host.to_ascii_lowercase();
390    let pattern = pattern.to_ascii_lowercase();
391    if let Some(rest) = pattern.strip_prefix("*.") {
392        let Some(dot) = host.find('.') else {
393            return false;
394        };
395        let host_rest = &host[dot + 1..];
396        host_rest == rest
397    } else {
398        host == pattern
399    }
400}
401
402/// Legacy CN fallback for hostname verification: checks the leaf certificate's
403/// subject Common Name(s) against `host`. SAN dNSNames are authoritative per
404/// RFC 6125; CN is only consulted when the certificate presents no identifier
405/// SANs at all (the caller gates this on `san_names.is_empty()`).
406fn subject_cn_matches_host(cert: &X509Certificate, host: &str) -> bool {
407    for cn in cert.subject().iter_common_name() {
408        if let Ok(s) = cn.as_str() {
409            if hostname_matches_pattern(host, s) {
410                return true;
411            }
412        }
413    }
414    false
415}
416
417/// Extracts Subject Alternative Names from a certificate.
418fn extract_sans(cert: &X509Certificate) -> Vec<String> {
419    let mut sans = Vec::new();
420    if let Ok(Some(ext)) = cert.subject_alternative_name() {
421        for name in &ext.value.general_names {
422            match name {
423                GeneralName::DNSName(dns) => {
424                    sans.push(dns.to_string());
425                }
426                GeneralName::IPAddress(ip_bytes) => {
427                    sans.push(format_ip_san(ip_bytes));
428                }
429                _ => {}
430            }
431        }
432    }
433    sans
434}
435
436/// Renders an IPAddress SAN from its raw bytes into canonical text form.
437///
438/// A 4-byte value becomes dotted-quad IPv4; a 16-byte value becomes a
439/// zero-compressed IPv6 address (e.g. `::1`, not `0000:0000:...:0001`) by going
440/// through `std::net::Ipv6Addr`'s `Display`. Any other length is unexpected for
441/// an IPAddress general name, so it falls back to a debug rendering of the bytes
442/// rather than guessing.
443fn format_ip_san(ip_bytes: &[u8]) -> String {
444    match ip_bytes.len() {
445        4 => {
446            let octets: [u8; 4] = [ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]];
447            std::net::Ipv4Addr::from(octets).to_string()
448        }
449        16 => {
450            let mut octets = [0u8; 16];
451            octets.copy_from_slice(ip_bytes);
452            std::net::Ipv6Addr::from(octets).to_string()
453        }
454        _ => format!("{:?}", ip_bytes),
455    }
456}
457
458/// Parses detailed information from an X.509 certificate.
459fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
460    let subject = cert.subject().to_string();
461    let issuer = cert.issuer().to_string();
462
463    let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
464    let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
465
466    let serial_number = cert.serial.to_str_radix(16);
467
468    let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
469
470    let is_ca = cert.is_ca();
471
472    // Extract public key info
473    let spki = cert.public_key();
474    let (key_type, key_bits) = extract_key_info(spki);
475
476    Ok(CertDetail {
477        subject,
478        issuer,
479        valid_from,
480        valid_until,
481        serial_number,
482        signature_algorithm,
483        is_ca,
484        key_type,
485        key_bits,
486    })
487}
488
489/// Extracts key type and size from a SubjectPublicKeyInfo.
490fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
491    use x509_parser::public_key::PublicKey;
492    let oid = &spki.algorithm.algorithm;
493    let key_type = oid_to_key_type(oid);
494    let key_bits = match spki.parsed() {
495        Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
496        Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
497        _ => None,
498    };
499    (key_type, key_bits)
500}
501
502/// Maps common OIDs to human-readable algorithm names.
503fn oid_to_name(oid: &Oid) -> Option<String> {
504    let oid_str = format!("{}", oid);
505    match oid_str.as_str() {
506        "1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
507        "1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
508        "1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
509        "1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
510        // Deprecated algorithms are named (rather than left as raw OIDs) so
511        // the "sha1"/"md5" signature warning can recognize them.
512        "1.2.840.113549.1.1.4" => Some("MD5 with RSA".to_string()),
513        "1.2.840.10045.4.1" => Some("ECDSA with SHA-1".to_string()),
514        "1.2.840.10040.4.3" => Some("DSA with SHA-1".to_string()),
515        "1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
516        "1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
517        "1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
518        "1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
519        "1.3.101.112" => Some("Ed25519".to_string()),
520        "1.3.101.113" => Some("Ed448".to_string()),
521        _ => Some(oid_str),
522    }
523}
524
525/// Maps public key algorithm OIDs to human-readable key type names.
526fn oid_to_key_type(oid: &Oid) -> Option<String> {
527    let oid_str = format!("{}", oid);
528    match oid_str.as_str() {
529        "1.2.840.113549.1.1.1" => Some("RSA".to_string()),
530        "1.2.840.10045.2.1" => Some("EC".to_string()),
531        "1.3.101.112" => Some("Ed25519".to_string()),
532        "1.3.101.113" => Some("Ed448".to_string()),
533        _ => Some(oid_str),
534    }
535}
536
537/// Converts an x509-parser ASN1Time to a chrono DateTime.
538fn asn1_time_to_chrono(time: ASN1Time) -> Result<DateTime<Utc>> {
539    let timestamp = time.timestamp();
540    DateTime::from_timestamp(timestamp, 0)
541        .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn test_ssl_checker_creation() {
550        let _checker = SslChecker::new();
551        let _default_checker = SslChecker::default();
552    }
553
554    #[test]
555    fn from_config_applies_http_timeout() {
556        let mut config = crate::config::SeerConfig::default();
557        config.timeouts.http_secs = 77;
558        let checker = SslChecker::from_config(&config);
559        assert_eq!(checker.timeout, Duration::from_secs(77));
560    }
561
562    #[test]
563    fn test_oid_to_name() {
564        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
565        assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
566    }
567
568    #[test]
569    fn test_oid_to_key_type() {
570        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
571        assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
572    }
573
574    /// Live-network sanity check: a real public site with valid TLS
575    /// completes a full chain inspection. Exercises the
576    /// [`resolve_public_host`] code path in `net.rs` (hickory fallback
577    /// engages if the test environment has a broken OS resolver) and the
578    /// rest of the TLS handshake + cert-parse pipeline.
579    #[tokio::test]
580    #[ignore = "requires network — performs a real TLS handshake"]
581    async fn check_live_example_com_succeeds() {
582        let report = SslChecker::new().check("example.com").await.unwrap();
583        assert_eq!(report.domain, "example.com");
584        assert!(!report.chain.is_empty(), "expected at least a leaf cert");
585        assert!(
586            report.is_valid,
587            "example.com's leaf cert should be currently valid"
588        );
589    }
590
591    #[test]
592    fn test_ssl_report_serialization() {
593        let report = SslReport {
594            domain: "example.com".to_string(),
595            chain: vec![CertDetail {
596                subject: "CN=example.com".to_string(),
597                issuer: "CN=R3, O=Let's Encrypt".to_string(),
598                valid_from: Utc::now(),
599                valid_until: Utc::now(),
600                serial_number: "abc123".to_string(),
601                signature_algorithm: Some("SHA-256 with RSA".to_string()),
602                is_ca: false,
603                key_type: Some("RSA".to_string()),
604                key_bits: Some(2048),
605            }],
606            protocol_version: None,
607            san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
608            is_valid: true,
609            hostname_verified: true,
610            days_until_expiry: 90,
611            caa: None,
612            warnings: vec![],
613        };
614        let json = serde_json::to_string(&report).unwrap();
615        assert!(json.contains("example.com"));
616        assert!(json.contains("SHA-256 with RSA"));
617        assert!(json.contains("\"is_valid\":true"));
618        assert!(json.contains("\"hostname_verified\":true"));
619    }
620
621    #[test]
622    fn format_ip_san_renders_canonical_addresses() {
623        // IPv4: dotted quad.
624        assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
625        // IPv6 ::1 — must be zero-compressed, NOT 0000:0000:...:0001.
626        let mut loopback = [0u8; 16];
627        loopback[15] = 1;
628        assert_eq!(format_ip_san(&loopback), "::1");
629        // A fuller IPv6 address still round-trips through canonical form.
630        // 2001:db8::1
631        let mut addr = [0u8; 16];
632        addr[0] = 0x20;
633        addr[1] = 0x01;
634        addr[2] = 0x0d;
635        addr[3] = 0xb8;
636        addr[15] = 1;
637        assert_eq!(format_ip_san(&addr), "2001:db8::1");
638    }
639
640    fn sample_leaf() -> CertDetail {
641        CertDetail {
642            subject: "CN=example.com".to_string(),
643            issuer: "CN=R3, O=Let's Encrypt".to_string(),
644            valid_from: Utc::now(),
645            valid_until: Utc::now(),
646            serial_number: "abc123".to_string(),
647            signature_algorithm: Some("SHA-256 with RSA".to_string()),
648            is_ca: false,
649            key_type: Some("RSA".to_string()),
650            key_bits: Some(2048),
651        }
652    }
653
654    #[test]
655    fn derive_cert_warnings_clean_cert_has_none() {
656        let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
657        assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
658    }
659
660    #[test]
661    fn derive_cert_warnings_flags_weak_rsa_key() {
662        let mut leaf = sample_leaf();
663        leaf.key_bits = Some(1024);
664        let w = derive_cert_warnings(&leaf, true, true, 90);
665        assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
666            && x.severity == CertWarningSeverity::Critical));
667    }
668
669    #[test]
670    fn derive_cert_warnings_flags_sha1_signature() {
671        let mut leaf = sample_leaf();
672        leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
673        let w = derive_cert_warnings(&leaf, true, true, 90);
674        assert!(w
675            .iter()
676            .any(|x| x.message.to_lowercase().contains("deprecated signature")));
677    }
678
679    #[test]
680    fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
681        let mut leaf = sample_leaf();
682        leaf.issuer = leaf.subject.clone();
683        let w = derive_cert_warnings(&leaf, false, false, -3);
684        assert!(w.iter().any(|x| x.message.contains("self-signed")));
685        assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
686        assert!(w.iter().any(|x| x.message.contains("does not match")));
687    }
688
689    #[test]
690    fn derive_cert_warnings_flags_expiring_soon_as_warning() {
691        let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
692        assert_eq!(w.len(), 1);
693        assert_eq!(w[0].severity, CertWarningSeverity::Warning);
694        assert!(w[0].message.contains("expires in 10 day(s)"));
695    }
696
697    #[test]
698    fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
699        let now = Utc::now();
700        let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
701        let w = derive_cert_warnings(&sample_leaf(), false, true, days);
702        assert!(
703            w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
704            "a just-expired cert must be reported expired: {w:?}"
705        );
706        assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
707    }
708
709    #[test]
710    fn deprecated_signature_oids_are_named_and_flagged() {
711        for (arcs, name) in [
712            (&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
713            (&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
714            (&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
715        ] {
716            let oid = Oid::from(arcs).unwrap();
717            assert_eq!(oid_to_name(&oid).as_deref(), Some(name));
718            let mut leaf = sample_leaf();
719            leaf.signature_algorithm = oid_to_name(&oid);
720            let w = derive_cert_warnings(&leaf, true, true, 90);
721            assert!(
722                w.iter().any(|x| x.message.contains("deprecated signature")),
723                "{name} must be flagged: {w:?}"
724            );
725        }
726    }
727
728    #[test]
729    fn derive_cert_warnings_flags_ca_marked_leaf() {
730        let mut leaf = sample_leaf();
731        leaf.is_ca = true;
732        let w = derive_cert_warnings(&leaf, true, true, 90);
733        assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
734    }
735
736    #[test]
737    fn hostname_matches_pattern_exact_and_wildcard() {
738        assert!(hostname_matches_pattern("example.com", "example.com"));
739        assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
740        // Single-label wildcard.
741        assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
742        // Apex must not match a wildcard (RFC 6125).
743        assert!(!hostname_matches_pattern("example.com", "*.example.com"));
744        // Wildcard matches only one label.
745        assert!(!hostname_matches_pattern(
746            "a.b.example.com",
747            "*.example.com"
748        ));
749        // Mismatched host.
750        assert!(!hostname_matches_pattern("evil.test", "example.com"));
751    }
752}