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 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
26/// Default timeout for SSL operations (10 seconds).
27const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
28
29/// Minimum acceptable RSA key size in bits (NIST SP 800-57 / CA/B Forum).
30const RSA_MIN_KEY_BITS: u32 = 2048;
31/// Minimum acceptable elliptic-curve key size in bits.
32const EC_MIN_KEY_BITS: u32 = 256;
33/// Days-until-expiry threshold below which a still-valid cert is flagged.
34const CERT_EXPIRING_SOON_DAYS: i64 = 30;
35
36/// Derives security-posture [`CertWarning`]s from an already-parsed leaf
37/// certificate plus the computed validity/hostname signals. Pure — no network
38/// calls — so it is unit-testable in isolation.
39fn 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    // Weak public key.
56    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    // Deprecated signature algorithm (SHA-1 / MD5).
69    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    // Self-signed (issuer equals subject).
79    if leaf.subject == leaf.issuer {
80        warnings.push(warn(
81            "Certificate is self-signed (issuer equals subject)".to_string(),
82        ));
83    }
84
85    // Leaf certificate marked as a CA (basic-constraints misuse).
86    if leaf.is_ca {
87        warnings.push(warn(
88            "Leaf certificate is marked as a CA certificate".to_string(),
89        ));
90    }
91
92    // Validity window.
93    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        // Date-valid check failed but not past expiry → notBefore is future.
100        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    // Hostname mismatch.
110    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/// Full SSL certificate report for a domain.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct SslReport {
122    /// The domain that was inspected
123    pub domain: String,
124    /// Certificate chain as the server presented it, leaf first (servers
125    /// usually omit the root)
126    pub chain: Vec<CertDetail>,
127    /// Negotiated TLS protocol version (`"TLSv1.3"` or `"TLSv1.2"`)
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 (the inspection handshake accepts any presented chain so
136    /// broken/self-signed certs can be inspected) nor that the certificate
137    /// matches the 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, and for the
256    /// CAA lookup's DNS queries on the resolver already configured.
257    pub fn with_timeout(mut self, timeout: Duration) -> Self {
258        self.timeout = timeout;
259        self.dns_resolver = self.dns_resolver.with_timeout(timeout);
260        self
261    }
262
263    /// Inspects the SSL certificate chain for the given domain.
264    ///
265    /// Connects to port 443, performs a TLS handshake, and extracts detailed
266    /// certificate information including the full chain, SANs, and key details.
267    ///
268    /// # Arguments
269    /// * `domain` - The domain name to inspect (e.g., "example.com")
270    ///
271    /// # Returns
272    /// * `Ok(SslReport)` - Detailed SSL certificate information
273    /// * `Err(SeerError)` - If connection or certificate parsing fails
274    #[instrument(skip(self), fields(domain = %domain))]
275    pub async fn check(&self, domain: &str) -> Result<SslReport> {
276        // The exact host: `www.example.com` can serve a different
277        // certificate than the apex, so `www.` must not be stripped here.
278        let domain = normalize_host(domain)?;
279
280        debug!(domain = %domain, "Checking SSL certificate chain");
281
282        // CAA query runs concurrently with the TLS probe — it is advisory
283        // and never fails the report (a resolver error yields an empty
284        // policy).
285        let caa_future = caa::lookup_caa(&self.dns_resolver, &domain, None);
286
287        // Resolve + SSRF check. `resolve_public_host` already falls back to
288        // hickory (Google DNS) when the OS resolver fails — important for
289        // hosts where Tailscale Split-DNS or a corp resolver pins the
290        // domain to a nameserver that can't answer for it.
291        let resolve_future = resolve_public_host(&domain, 443);
292
293        let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
294        // `domain` is already normalized, so only a resolver miss is left,
295        // and that is an empty policy anyway.
296        let caa_policy = caa_policy.unwrap_or_else(|_| CaaPolicy::empty());
297        let socket_addrs = socket_addrs.map_err(|e| {
298            SeerError::SslError(format!(
299                "could not resolve {} for SSL inspection: {}",
300                domain, e
301            ))
302        })?;
303
304        // Handshake against the pre-resolved (SSRF-vetted) addresses so DNS
305        // cannot rebind between validation and connect. Any presented chain
306        // is accepted so broken certificates can still be inspected.
307        let presented =
308            crate::tls::inspect(&domain, &socket_addrs, self.timeout, SeerError::SslError).await?;
309
310        build_report(domain, presented, caa_policy)
311    }
312}
313
314/// Builds the report from what the server presented. Pure (no network), so
315/// the projection is unit-testable against a loopback handshake.
316fn build_report(
317    domain: String,
318    presented: PresentedChain,
319    mut caa_policy: CaaPolicy,
320) -> Result<SslReport> {
321    // Parse leaf certificate with x509-parser
322    let (_, x509) = X509Certificate::from_der(&presented.leaf)
323        .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
324
325    // Extract SANs from the leaf certificate
326    let san_names = extract_sans(&x509);
327    let leaf_detail = parse_cert_detail(&x509)?;
328
329    let now = Utc::now();
330    let days_until_expiry = crate::dates::days_until(leaf_detail.valid_until, now);
331    let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
332
333    // Hostname verification (RFC 6125, shared with `status`): independent of
334    // `is_valid` and of chain trust (not verified here — see the field docs),
335    // so consumers can tell a date-valid-but-wrong-host cert from a match.
336    let hostname_verified = crate::tls::cert_matches_host(&x509, &domain);
337
338    // Annotate the CAA policy with the issuer comparison before
339    // attaching it to the report.
340    // RFC 8659 §4.3: `issuewild` governs a wildcard certificate.
341    let wildcard = crate::tls::cert_names_wildcard(&x509);
342    caa_policy.issuer_match = Some(caa::classify_issuer(
343        &leaf_detail.issuer,
344        &caa_policy,
345        wildcard,
346    ));
347
348    // Derive posture warnings from the parsed leaf (pure post-processing).
349    let warnings =
350        derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
351
352    // The chain as the server sent it, leaf first. Every verdict above comes
353    // from the leaf, so an extra certificate that fails to parse is skipped
354    // rather than failing the whole report.
355    let mut chain = vec![leaf_detail];
356    chain.extend(presented.intermediates.iter().filter_map(|der| {
357        let detail = X509Certificate::from_der(der)
358            .ok()
359            .and_then(|(_, cert)| parse_cert_detail(&cert).ok());
360        if detail.is_none() {
361            debug!("skipping unparseable certificate in the presented chain");
362        }
363        detail
364    }));
365
366    Ok(SslReport {
367        domain,
368        chain,
369        protocol_version: presented.protocol,
370        san_names,
371        is_valid,
372        hostname_verified,
373        days_until_expiry,
374        caa: Some(caa_policy),
375        warnings,
376    })
377}
378
379/// Extracts Subject Alternative Names from a certificate.
380fn extract_sans(cert: &X509Certificate) -> Vec<String> {
381    let mut sans = Vec::new();
382    if let Ok(Some(ext)) = cert.subject_alternative_name() {
383        for name in &ext.value.general_names {
384            match name {
385                GeneralName::DNSName(dns) => {
386                    sans.push(dns.to_string());
387                }
388                GeneralName::IPAddress(ip_bytes) => {
389                    sans.push(format_ip_san(ip_bytes));
390                }
391                _ => {}
392            }
393        }
394    }
395    sans
396}
397
398/// Renders an IPAddress SAN from its raw bytes into canonical text form:
399/// dotted-quad IPv4, or zero-compressed IPv6 (e.g. `::1`, not
400/// `0000:0000:...:0001`). Any other length is unexpected for an IPAddress
401/// general name, so it falls back to a debug rendering of the bytes rather
402/// than guessing.
403fn format_ip_san(ip_bytes: &[u8]) -> String {
404    crate::tls::san_ip(ip_bytes).map_or_else(|| format!("{:?}", ip_bytes), |ip| ip.to_string())
405}
406
407/// Parses detailed information from an X.509 certificate.
408fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
409    let subject = cert.subject().to_string();
410    let issuer = cert.issuer().to_string();
411
412    let (valid_from, valid_until) = crate::tls::validity_window(cert)
413        .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))?;
414
415    let serial_number = cert.serial.to_str_radix(16);
416
417    let signature_algorithm = Some(oid_to_name(&cert.signature_algorithm.algorithm));
418
419    let is_ca = cert.is_ca();
420
421    // Extract public key info
422    let spki = cert.public_key();
423    let (key_type, key_bits) = extract_key_info(spki);
424
425    Ok(CertDetail {
426        subject,
427        issuer,
428        valid_from,
429        valid_until,
430        serial_number,
431        signature_algorithm,
432        is_ca,
433        key_type,
434        key_bits,
435    })
436}
437
438/// Extracts key type and size from a SubjectPublicKeyInfo.
439fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
440    use x509_parser::public_key::PublicKey;
441    let oid = &spki.algorithm.algorithm;
442    let key_type = Some(oid_to_key_type(oid));
443    let key_bits = match spki.parsed() {
444        Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
445        Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
446        _ => None,
447    };
448    (key_type, key_bits)
449}
450
451/// Maps common OIDs to human-readable algorithm names; any other OID is
452/// shown in dotted form.
453fn oid_to_name(oid: &Oid) -> String {
454    let name = match oid.to_id_string().as_str() {
455        "1.2.840.113549.1.1.11" => "SHA-256 with RSA",
456        "1.2.840.113549.1.1.12" => "SHA-384 with RSA",
457        "1.2.840.113549.1.1.13" => "SHA-512 with RSA",
458        "1.2.840.113549.1.1.5" => "SHA-1 with RSA",
459        // Deprecated algorithms are named (rather than left as raw OIDs) so
460        // the "sha1"/"md5" signature warning can recognize them.
461        "1.2.840.113549.1.1.4" => "MD5 with RSA",
462        "1.2.840.10045.4.1" => "ECDSA with SHA-1",
463        "1.2.840.10040.4.3" => "DSA with SHA-1",
464        "1.2.840.113549.1.1.14" => "SHA-224 with RSA",
465        "1.2.840.10045.4.3.2" => "ECDSA with SHA-256",
466        "1.2.840.10045.4.3.3" => "ECDSA with SHA-384",
467        "1.2.840.10045.4.3.4" => "ECDSA with SHA-512",
468        "1.3.101.112" => "Ed25519",
469        "1.3.101.113" => "Ed448",
470        other => return other.to_string(),
471    };
472    name.to_string()
473}
474
475/// Maps public key algorithm OIDs to human-readable key type names; any
476/// other OID is shown in dotted form.
477fn oid_to_key_type(oid: &Oid) -> String {
478    let name = match oid.to_id_string().as_str() {
479        "1.2.840.113549.1.1.1" => "RSA",
480        "1.2.840.10045.2.1" => "EC",
481        "1.3.101.112" => "Ed25519",
482        "1.3.101.113" => "Ed448",
483        other => return other.to_string(),
484    };
485    name.to_string()
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_ssl_checker_creation() {
494        let _checker = SslChecker::new();
495        let _default_checker = SslChecker::default();
496    }
497
498    /// Regression: `with_timeout` swapped the configured resolver for a
499    /// fresh default one, dropping whatever it had been set up with.
500    #[tokio::test]
501    async fn with_timeout_keeps_the_configured_resolver() {
502        use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
503
504        // Only the loopback zone has a CAA policy at `seer.test`.
505        let port = spawn_mock_dns(MockMode::Zone).await;
506        let checker = SslChecker {
507            dns_resolver: mock_dns_resolver_default(port),
508            ..SslChecker::new()
509        }
510        .with_timeout(Duration::from_secs(3));
511        assert_eq!(checker.dns_resolver.timeout(), Duration::from_secs(3));
512        let policy = caa::lookup_caa(&checker.dns_resolver, "seer.test", None)
513            .await
514            .unwrap();
515        assert!(policy.has_policy, "the configured resolver must be kept");
516    }
517
518    #[test]
519    fn from_config_applies_http_timeout() {
520        let mut config = crate::config::SeerConfig::default();
521        config.timeouts.http_secs = 77;
522        let checker = SslChecker::from_config(&config);
523        assert_eq!(checker.timeout, Duration::from_secs(77));
524    }
525
526    #[test]
527    fn test_oid_to_name() {
528        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
529        assert_eq!(oid_to_name(&oid), "SHA-256 with RSA");
530    }
531
532    #[test]
533    fn test_oid_to_key_type() {
534        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
535        assert_eq!(oid_to_key_type(&oid), "RSA");
536    }
537
538    /// Live-network sanity check: a real public site with valid TLS
539    /// completes a full chain inspection. Exercises the
540    /// [`resolve_public_host`] code path in `net.rs` (hickory fallback
541    /// engages if the test environment has a broken OS resolver) and the
542    /// rest of the TLS handshake + cert-parse pipeline.
543    #[tokio::test]
544    #[ignore = "requires network — performs a real TLS handshake"]
545    async fn check_live_example_com_succeeds() {
546        let report = SslChecker::new().check("example.com").await.unwrap();
547        assert_eq!(report.domain, "example.com");
548        assert!(!report.chain.is_empty(), "expected at least a leaf cert");
549        assert!(
550            report.is_valid,
551            "example.com's leaf cert should be currently valid"
552        );
553    }
554
555    /// Regression: native-tls exposed only the leaf, so `chain` always held
556    /// one entry and `protocol_version` was always `None`.
557    #[tokio::test]
558    async fn report_carries_the_presented_chain_and_protocol() {
559        use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
560
561        let addr = serve_once(&[LEAF_DER, CA_DER], &[&rustls::version::TLS12]).await;
562        let presented = crate::tls::inspect(
563            "chain.test",
564            &[addr],
565            Duration::from_secs(5),
566            SeerError::SslError,
567        )
568        .await
569        .unwrap();
570        let report = build_report("chain.test".to_string(), presented, CaaPolicy::empty()).unwrap();
571
572        let subjects: Vec<&str> = report.chain.iter().map(|c| c.subject.as_str()).collect();
573        assert_eq!(subjects, ["CN=chain.test", "CN=Seer Test CA"]);
574        assert!(report.chain[1].is_ca);
575        assert_eq!(report.protocol_version.as_deref(), Some("TLSv1.2"));
576        assert!(report.hostname_verified);
577    }
578
579    /// Regression: an RSA-1024 leaf failed the inspection handshake, so the
580    /// weak-key warning could never fire for a live certificate.
581    #[tokio::test]
582    async fn an_rsa_1024_leaf_is_inspected_and_flagged() {
583        use crate::tls::test_support::{serve_once, RSA_1024_LEAF};
584
585        let addr = serve_once(&[RSA_1024_LEAF], rustls::DEFAULT_VERSIONS).await;
586        let presented = crate::tls::inspect(
587            "weak-rsa.test",
588            &[addr],
589            Duration::from_secs(5),
590            SeerError::SslError,
591        )
592        .await
593        .unwrap();
594        let report =
595            build_report("weak-rsa.test".to_string(), presented, CaaPolicy::empty()).unwrap();
596
597        assert_eq!(report.chain[0].key_type.as_deref(), Some("RSA"));
598        assert_eq!(report.chain[0].key_bits, Some(1024));
599        assert!(
600            report
601                .warnings
602                .iter()
603                .any(|w| w.severity == CertWarningSeverity::Critical
604                    && w.message == "RSA key size 1024 is below the 2048-bit minimum"),
605            "got {:?}",
606            report.warnings
607        );
608    }
609
610    #[test]
611    fn test_ssl_report_serialization() {
612        let report = SslReport {
613            domain: "example.com".to_string(),
614            chain: vec![CertDetail {
615                subject: "CN=example.com".to_string(),
616                issuer: "CN=R3, O=Let's Encrypt".to_string(),
617                valid_from: Utc::now(),
618                valid_until: Utc::now(),
619                serial_number: "abc123".to_string(),
620                signature_algorithm: Some("SHA-256 with RSA".to_string()),
621                is_ca: false,
622                key_type: Some("RSA".to_string()),
623                key_bits: Some(2048),
624            }],
625            protocol_version: None,
626            san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
627            is_valid: true,
628            hostname_verified: true,
629            days_until_expiry: 90,
630            caa: None,
631            warnings: vec![],
632        };
633        let json = serde_json::to_string(&report).unwrap();
634        assert!(json.contains("example.com"));
635        assert!(json.contains("SHA-256 with RSA"));
636        assert!(json.contains("\"is_valid\":true"));
637        assert!(json.contains("\"hostname_verified\":true"));
638    }
639
640    #[test]
641    fn format_ip_san_renders_canonical_addresses() {
642        // IPv4: dotted quad.
643        assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
644        // IPv6 ::1 — must be zero-compressed, NOT 0000:0000:...:0001.
645        let mut loopback = [0u8; 16];
646        loopback[15] = 1;
647        assert_eq!(format_ip_san(&loopback), "::1");
648        // A fuller IPv6 address still round-trips through canonical form.
649        // 2001:db8::1
650        let mut addr = [0u8; 16];
651        addr[0] = 0x20;
652        addr[1] = 0x01;
653        addr[2] = 0x0d;
654        addr[3] = 0xb8;
655        addr[15] = 1;
656        assert_eq!(format_ip_san(&addr), "2001:db8::1");
657    }
658
659    fn sample_leaf() -> CertDetail {
660        CertDetail {
661            subject: "CN=example.com".to_string(),
662            issuer: "CN=R3, O=Let's Encrypt".to_string(),
663            valid_from: Utc::now(),
664            valid_until: Utc::now(),
665            serial_number: "abc123".to_string(),
666            signature_algorithm: Some("SHA-256 with RSA".to_string()),
667            is_ca: false,
668            key_type: Some("RSA".to_string()),
669            key_bits: Some(2048),
670        }
671    }
672
673    #[test]
674    fn derive_cert_warnings_clean_cert_has_none() {
675        let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
676        assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
677    }
678
679    #[test]
680    fn derive_cert_warnings_flags_weak_rsa_key() {
681        let mut leaf = sample_leaf();
682        leaf.key_bits = Some(1024);
683        let w = derive_cert_warnings(&leaf, true, true, 90);
684        assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
685            && x.severity == CertWarningSeverity::Critical));
686    }
687
688    #[test]
689    fn derive_cert_warnings_flags_sha1_signature() {
690        let mut leaf = sample_leaf();
691        leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
692        let w = derive_cert_warnings(&leaf, true, true, 90);
693        assert!(w
694            .iter()
695            .any(|x| x.message.to_lowercase().contains("deprecated signature")));
696    }
697
698    #[test]
699    fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
700        let mut leaf = sample_leaf();
701        leaf.issuer = leaf.subject.clone();
702        let w = derive_cert_warnings(&leaf, false, false, -3);
703        assert!(w.iter().any(|x| x.message.contains("self-signed")));
704        assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
705        assert!(w.iter().any(|x| x.message.contains("does not match")));
706    }
707
708    #[test]
709    fn derive_cert_warnings_flags_expiring_soon_as_warning() {
710        let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
711        assert_eq!(w.len(), 1);
712        assert_eq!(w[0].severity, CertWarningSeverity::Warning);
713        assert!(w[0].message.contains("expires in 10 day(s)"));
714    }
715
716    #[test]
717    fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
718        let now = Utc::now();
719        let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
720        let w = derive_cert_warnings(&sample_leaf(), false, true, days);
721        assert!(
722            w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
723            "a just-expired cert must be reported expired: {w:?}"
724        );
725        assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
726    }
727
728    #[test]
729    fn deprecated_signature_oids_are_named_and_flagged() {
730        for (arcs, name) in [
731            (&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
732            (&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
733            (&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
734        ] {
735            let oid = Oid::from(arcs).unwrap();
736            assert_eq!(oid_to_name(&oid), name);
737            let mut leaf = sample_leaf();
738            leaf.signature_algorithm = Some(oid_to_name(&oid));
739            let w = derive_cert_warnings(&leaf, true, true, 90);
740            assert!(
741                w.iter().any(|x| x.message.contains("deprecated signature")),
742                "{name} must be flagged: {w:?}"
743            );
744        }
745    }
746
747    #[test]
748    fn derive_cert_warnings_flags_ca_marked_leaf() {
749        let mut leaf = sample_leaf();
750        leaf.is_ca = true;
751        let w = derive_cert_warnings(&leaf, true, true, 90);
752        assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
753    }
754
755    /// Regression: `seer ssl` switched the CN fallback off for any SAN, so an
756    /// IP-SAN-only cert failed here while `seer status` verified it. Both now
757    /// share `tls::cert_matches_host`.
758    #[test]
759    fn ip_only_san_still_falls_back_to_the_cn() {
760        use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP};
761
762        let presented = PresentedChain {
763            leaf: cert(CN_VICTIM_SAN_IP),
764            intermediates: vec![],
765            protocol: None,
766        };
767        let report =
768            build_report("victim.example".to_string(), presented, CaaPolicy::empty()).unwrap();
769        assert!(report.hostname_verified);
770        assert_eq!(report.san_names, ["203.0.113.7"]);
771    }
772}