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 = |kind: CertWarningKind, message: String| CertWarning {
47        severity: CertWarningSeverity::Critical,
48        kind,
49        message,
50    };
51    let warn = |kind: CertWarningKind, message: String| CertWarning {
52        severity: CertWarningSeverity::Warning,
53        kind,
54        message,
55    };
56
57    // Weak public key.
58    if let (Some(kt), Some(bits)) = (leaf.key_type.as_deref(), leaf.key_bits) {
59        match kt {
60            "RSA" if bits < RSA_MIN_KEY_BITS => warnings.push(critical(
61                CertWarningKind::WeakKey,
62                format!("RSA key size {bits} is below the {RSA_MIN_KEY_BITS}-bit minimum"),
63            )),
64            "EC" if bits < EC_MIN_KEY_BITS => warnings.push(critical(
65                CertWarningKind::WeakKey,
66                format!("EC key size {bits} is below the {EC_MIN_KEY_BITS}-bit minimum"),
67            )),
68            _ => {}
69        }
70    }
71
72    // Deprecated signature algorithm (SHA-1 / MD5).
73    if let Some(sig) = leaf.signature_algorithm.as_deref() {
74        let lower = sig.to_lowercase();
75        if lower.contains("sha-1") || lower.contains("sha1") || lower.contains("md5") {
76            warnings.push(critical(
77                CertWarningKind::DeprecatedSignature,
78                format!("Certificate uses deprecated signature algorithm: {sig}"),
79            ));
80        }
81    }
82
83    // Self-signed (issuer equals subject).
84    if leaf.subject == leaf.issuer {
85        warnings.push(warn(
86            CertWarningKind::SelfSigned,
87            "Certificate is self-signed (issuer equals subject)".to_string(),
88        ));
89    }
90
91    // Leaf certificate marked as a CA (basic-constraints misuse).
92    if leaf.is_ca {
93        warnings.push(warn(
94            CertWarningKind::CaLeaf,
95            "Leaf certificate is marked as a CA certificate".to_string(),
96        ));
97    }
98
99    // Validity window.
100    if days_until_expiry < 0 {
101        warnings.push(critical(
102            CertWarningKind::Expired,
103            format!("Certificate expired {} day(s) ago", -days_until_expiry),
104        ));
105    } else if !is_valid {
106        // Date-valid check failed but not past expiry → notBefore is future.
107        warnings.push(critical(
108            CertWarningKind::NotYetValid,
109            "Certificate is not yet valid (notBefore is in the future)".to_string(),
110        ));
111    } else if days_until_expiry <= CERT_EXPIRING_SOON_DAYS {
112        warnings.push(warn(
113            CertWarningKind::ExpiringSoon,
114            format!("Certificate expires in {days_until_expiry} day(s)"),
115        ));
116    }
117
118    // Hostname mismatch.
119    if !hostname_verified {
120        warnings.push(critical(
121            CertWarningKind::HostnameMismatch,
122            "Certificate does not match the requested hostname".to_string(),
123        ));
124    }
125
126    warnings
127}
128
129/// Full SSL certificate report for a domain.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct SslReport {
132    /// The domain that was inspected
133    pub domain: String,
134    /// Certificate chain as the server presented it, leaf first (servers
135    /// usually omit the root)
136    pub chain: Vec<CertDetail>,
137    /// Negotiated TLS protocol version (`"TLSv1.3"` or `"TLSv1.2"`)
138    pub protocol_version: Option<String>,
139    /// Subject Alternative Names from the leaf certificate
140    pub san_names: Vec<String>,
141    /// Whether the leaf certificate is within its validity period.
142    ///
143    /// This reflects ONLY the date-range check (`notBefore <= now <=
144    /// notAfter`) of the leaf certificate. It does NOT verify the certificate
145    /// chain's trust (the inspection handshake accepts any presented chain so
146    /// broken/self-signed certs can be inspected) nor that the certificate
147    /// matches the requested hostname — see [`SslReport::hostname_verified`]. A
148    /// date-valid cert may still be self-signed, issued by an untrusted CA, or
149    /// presented for the wrong host.
150    pub is_valid: bool,
151    /// Whether the leaf certificate's SAN dNSNames (or CN as a legacy
152    /// fallback) match the requested domain, per RFC 6125 (exact and
153    /// single-label wildcard matches).
154    ///
155    /// This is an additive signal independent of `is_valid`. Chain trust is
156    /// NOT verified here: a `true` value means the cert was presented for the
157    /// right host, not that it was issued by a trusted CA.
158    #[serde(default)]
159    pub hostname_verified: bool,
160    /// Days until the leaf certificate expires
161    pub days_until_expiry: i64,
162    /// CAA (Certification Authority Authorization) policy for the domain
163    /// plus a comparison against the presented certificate's issuer.
164    ///
165    /// CAA is consulted by CAs at *issuance time*, not by clients at
166    /// *validation time*, so a mismatch here is informational — see the
167    /// `note` field on [`CaaPolicy`].
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub caa: Option<CaaPolicy>,
170    /// Security-posture warnings derived from the leaf certificate: weak keys,
171    /// deprecated signatures, self-signed / CA-marked leaves, expiry, and
172    /// hostname mismatch. Empty when the leaf presents no notable issues.
173    ///
174    /// These are pure post-processing of already-parsed fields (no extra
175    /// network calls) — a summary so consumers don't have to eyeball key sizes
176    /// and algorithms.
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub warnings: Vec<CertWarning>,
179}
180
181/// Severity of a [`CertWarning`].
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "kebab-case")]
184pub enum CertWarningSeverity {
185    /// A trust-affecting problem (weak key, deprecated signature, expired,
186    /// hostname mismatch).
187    Critical,
188    /// A notable-but-not-fatal signal (self-signed, CA-marked leaf, expiring
189    /// soon).
190    Warning,
191}
192
193/// What a [`CertWarning`] is about, so consumers (the security audit) can act
194/// on a warning without parsing its `message`.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum CertWarningKind {
198    /// RSA below 2048 bits or EC below 256 bits.
199    WeakKey,
200    /// SHA-1 or MD5 signature.
201    DeprecatedSignature,
202    /// Issuer equals subject.
203    SelfSigned,
204    /// Leaf marked as a CA certificate.
205    CaLeaf,
206    /// Past `notAfter`.
207    Expired,
208    /// Before `notBefore`.
209    NotYetValid,
210    /// Within the expiring-soon window.
211    ExpiringSoon,
212    /// SANs/CN do not match the requested host.
213    HostnameMismatch,
214}
215
216/// A single security-posture warning about a certificate.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct CertWarning {
219    /// How serious the finding is.
220    pub severity: CertWarningSeverity,
221    /// What the warning is about.
222    pub kind: CertWarningKind,
223    /// Human-readable description of the finding.
224    pub message: String,
225}
226
227/// Detailed information about a single certificate in the chain.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct CertDetail {
230    /// Certificate subject (e.g., "CN=example.com")
231    pub subject: String,
232    /// Certificate issuer (e.g., "CN=R3, O=Let's Encrypt")
233    pub issuer: String,
234    /// Certificate validity start date
235    pub valid_from: DateTime<Utc>,
236    /// Certificate expiration date
237    pub valid_until: DateTime<Utc>,
238    /// Serial number in hexadecimal
239    pub serial_number: String,
240    /// Signature algorithm (e.g., "sha256WithRSAEncryption")
241    pub signature_algorithm: Option<String>,
242    /// Whether this is a Certificate Authority certificate
243    pub is_ca: bool,
244    /// Public key type (e.g., "RSA", "EC")
245    pub key_type: Option<String>,
246    /// Public key size in bits
247    pub key_bits: Option<u32>,
248}
249
250/// Client for performing SSL certificate chain inspection.
251#[derive(Debug, Clone)]
252pub struct SslChecker {
253    /// Cached DNS resolver used for CAA lookups alongside the TLS probe.
254    dns_resolver: DnsResolver,
255    /// Timeout for the TCP connect and TLS handshake.
256    timeout: Duration,
257}
258
259impl Default for SslChecker {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl SslChecker {
266    /// Creates a new SslChecker instance.
267    pub fn new() -> Self {
268        Self {
269            dns_resolver: DnsResolver::new(),
270            timeout: DEFAULT_TIMEOUT,
271        }
272    }
273
274    /// Builds a checker honoring `~/.seer/config.toml` settings.
275    ///
276    /// Reads `timeouts.http_secs` (clamped to 1–120s by
277    /// [`crate::config::SeerConfig::load`]) for the TCP connect + TLS
278    /// handshake, and `timeouts.dns_secs` (1–60s) for the internal resolver
279    /// used by the parallel CAA lookup. Unlike the coarse
280    /// [`SslChecker::with_timeout`] (which reuses the TLS timeout for DNS),
281    /// this applies each protocol's own configured timeout — matching the
282    /// [`crate::availability::AvailabilityChecker::from_config`] precedent.
283    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
284        Self {
285            dns_resolver: DnsResolver::from_config(config),
286            timeout: config.http_timeout(),
287        }
288    }
289
290    /// Sets the timeout for the TCP connect and TLS handshake, and for the
291    /// CAA lookup's DNS queries on the resolver already configured.
292    pub fn with_timeout(mut self, timeout: Duration) -> Self {
293        self.timeout = timeout;
294        self.dns_resolver = self.dns_resolver.with_timeout(timeout);
295        self
296    }
297
298    /// Inspects the SSL certificate chain for the given domain.
299    ///
300    /// Connects to port 443, performs a TLS handshake, and extracts detailed
301    /// certificate information including the full chain, SANs, and key details.
302    ///
303    /// # Arguments
304    /// * `domain` - The domain name to inspect (e.g., "example.com")
305    ///
306    /// # Returns
307    /// * `Ok(SslReport)` - Detailed SSL certificate information
308    /// * `Err(SeerError)` - If connection or certificate parsing fails
309    #[instrument(skip(self), fields(domain = %domain))]
310    pub async fn check(&self, domain: &str) -> Result<SslReport> {
311        // The exact host: `www.example.com` can serve a different
312        // certificate than the apex, so `www.` must not be stripped here.
313        let domain = normalize_host(domain)?;
314
315        debug!(domain = %domain, "Checking SSL certificate chain");
316
317        // CAA query runs concurrently with the TLS probe — it is advisory
318        // and never fails the report (a resolver error yields an empty
319        // policy).
320        let caa_future = caa::lookup_caa(&self.dns_resolver, &domain, None);
321
322        // Resolve + SSRF check. `resolve_public_host` already falls back to
323        // hickory (Google DNS) when the OS resolver fails — important for
324        // hosts where Tailscale Split-DNS or a corp resolver pins the
325        // domain to a nameserver that can't answer for it.
326        let resolve_future = resolve_public_host(&domain, 443);
327
328        let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
329        // `domain` is already normalized, so only a resolver miss is left,
330        // and that is an empty policy anyway.
331        let caa_policy = caa_policy.unwrap_or_else(|_| CaaPolicy::empty());
332        let socket_addrs = socket_addrs.map_err(|e| {
333            SeerError::SslError(format!(
334                "could not resolve {} for SSL inspection: {}",
335                domain, e
336            ))
337        })?;
338
339        // Handshake against the pre-resolved (SSRF-vetted) addresses so DNS
340        // cannot rebind between validation and connect. Any presented chain
341        // is accepted so broken certificates can still be inspected.
342        let presented =
343            crate::tls::inspect(&domain, &socket_addrs, self.timeout, SeerError::SslError).await?;
344
345        build_report(domain, presented, caa_policy)
346    }
347}
348
349/// Builds the report from what the server presented. Pure (no network), so
350/// the projection is unit-testable against a loopback handshake.
351fn build_report(
352    domain: String,
353    presented: PresentedChain,
354    mut caa_policy: CaaPolicy,
355) -> Result<SslReport> {
356    // Parse leaf certificate with x509-parser
357    let (_, x509) = X509Certificate::from_der(&presented.leaf)
358        .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
359
360    // Extract SANs from the leaf certificate
361    let san_names = extract_sans(&x509);
362    let leaf_detail = parse_cert_detail(&x509)?;
363
364    let now = Utc::now();
365    let days_until_expiry = crate::dates::days_until(leaf_detail.valid_until, now);
366    let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
367
368    // Hostname verification (RFC 6125, shared with `status`): independent of
369    // `is_valid` and of chain trust (not verified here — see the field docs),
370    // so consumers can tell a date-valid-but-wrong-host cert from a match.
371    let hostname_verified = crate::tls::cert_matches_host(&x509, &domain);
372
373    // Annotate the CAA policy with the issuer comparison before
374    // attaching it to the report.
375    // RFC 8659 §4.3: `issuewild` governs a wildcard certificate.
376    let wildcard = crate::tls::cert_names_wildcard(&x509);
377    caa_policy.issuer_match = Some(caa::classify_issuer(
378        &leaf_detail.issuer,
379        &caa_policy,
380        wildcard,
381    ));
382
383    // Derive posture warnings from the parsed leaf (pure post-processing).
384    let warnings =
385        derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
386
387    // The chain as the server sent it, leaf first. Every verdict above comes
388    // from the leaf, so an extra certificate that fails to parse is skipped
389    // rather than failing the whole report.
390    let mut chain = vec![leaf_detail];
391    chain.extend(presented.intermediates.iter().filter_map(|der| {
392        let detail = X509Certificate::from_der(der)
393            .ok()
394            .and_then(|(_, cert)| parse_cert_detail(&cert).ok());
395        if detail.is_none() {
396            debug!("skipping unparseable certificate in the presented chain");
397        }
398        detail
399    }));
400
401    Ok(SslReport {
402        domain,
403        chain,
404        protocol_version: presented.protocol,
405        san_names,
406        is_valid,
407        hostname_verified,
408        days_until_expiry,
409        caa: Some(caa_policy),
410        warnings,
411    })
412}
413
414/// Extracts Subject Alternative Names from a certificate.
415fn extract_sans(cert: &X509Certificate) -> Vec<String> {
416    let mut sans = Vec::new();
417    if let Ok(Some(ext)) = cert.subject_alternative_name() {
418        for name in &ext.value.general_names {
419            match name {
420                GeneralName::DNSName(dns) => {
421                    sans.push(dns.to_string());
422                }
423                GeneralName::IPAddress(ip_bytes) => {
424                    sans.push(format_ip_san(ip_bytes));
425                }
426                _ => {}
427            }
428        }
429    }
430    sans
431}
432
433/// Renders an IPAddress SAN from its raw bytes into canonical text form:
434/// dotted-quad IPv4, or zero-compressed IPv6 (e.g. `::1`, not
435/// `0000:0000:...:0001`). Any other length is unexpected for an IPAddress
436/// general name, so it falls back to a debug rendering of the bytes rather
437/// than guessing.
438fn format_ip_san(ip_bytes: &[u8]) -> String {
439    crate::tls::san_ip(ip_bytes).map_or_else(|| format!("{:?}", ip_bytes), |ip| ip.to_string())
440}
441
442/// Parses detailed information from an X.509 certificate.
443fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
444    let subject = cert.subject().to_string();
445    let issuer = cert.issuer().to_string();
446
447    let (valid_from, valid_until) = crate::tls::validity_window(cert)
448        .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))?;
449
450    let serial_number = cert.serial.to_str_radix(16);
451
452    let signature_algorithm = Some(oid_to_name(&cert.signature_algorithm.algorithm));
453
454    let is_ca = cert.is_ca();
455
456    // Extract public key info
457    let spki = cert.public_key();
458    let (key_type, key_bits) = extract_key_info(spki);
459
460    Ok(CertDetail {
461        subject,
462        issuer,
463        valid_from,
464        valid_until,
465        serial_number,
466        signature_algorithm,
467        is_ca,
468        key_type,
469        key_bits,
470    })
471}
472
473/// Extracts key type and size from a SubjectPublicKeyInfo.
474pub(crate) fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
475    use x509_parser::public_key::PublicKey;
476    let oid = &spki.algorithm.algorithm;
477    let key_type = Some(oid_to_key_type(oid));
478    let key_bits = match spki.parsed() {
479        Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
480        Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
481        _ => None,
482    };
483    (key_type, key_bits)
484}
485
486/// Maps common OIDs to human-readable algorithm names; any other OID is
487/// shown in dotted form.
488fn oid_to_name(oid: &Oid) -> String {
489    let name = match oid.to_id_string().as_str() {
490        "1.2.840.113549.1.1.11" => "SHA-256 with RSA",
491        "1.2.840.113549.1.1.12" => "SHA-384 with RSA",
492        "1.2.840.113549.1.1.13" => "SHA-512 with RSA",
493        "1.2.840.113549.1.1.5" => "SHA-1 with RSA",
494        // Deprecated algorithms are named (rather than left as raw OIDs) so
495        // the "sha1"/"md5" signature warning can recognize them.
496        "1.2.840.113549.1.1.4" => "MD5 with RSA",
497        "1.2.840.10045.4.1" => "ECDSA with SHA-1",
498        "1.2.840.10040.4.3" => "DSA with SHA-1",
499        "1.2.840.113549.1.1.14" => "SHA-224 with RSA",
500        "1.2.840.10045.4.3.2" => "ECDSA with SHA-256",
501        "1.2.840.10045.4.3.3" => "ECDSA with SHA-384",
502        "1.2.840.10045.4.3.4" => "ECDSA with SHA-512",
503        "1.3.101.112" => "Ed25519",
504        "1.3.101.113" => "Ed448",
505        other => return other.to_string(),
506    };
507    name.to_string()
508}
509
510/// Maps public key algorithm OIDs to human-readable key type names; any
511/// other OID is shown in dotted form.
512fn oid_to_key_type(oid: &Oid) -> String {
513    let name = match oid.to_id_string().as_str() {
514        "1.2.840.113549.1.1.1" => "RSA",
515        "1.2.840.10045.2.1" => "EC",
516        "1.3.101.112" => "Ed25519",
517        "1.3.101.113" => "Ed448",
518        other => return other.to_string(),
519    };
520    name.to_string()
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_ssl_checker_creation() {
529        let _checker = SslChecker::new();
530        let _default_checker = SslChecker::default();
531    }
532
533    /// Regression: `with_timeout` swapped the configured resolver for a
534    /// fresh default one, dropping whatever it had been set up with.
535    #[tokio::test]
536    async fn with_timeout_keeps_the_configured_resolver() {
537        use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns, MockMode};
538
539        // Only the loopback zone has a CAA policy at `seer.test`.
540        let port = spawn_mock_dns(MockMode::Zone).await;
541        let checker = SslChecker {
542            dns_resolver: mock_dns_resolver_default(port),
543            ..SslChecker::new()
544        }
545        .with_timeout(Duration::from_secs(3));
546        assert_eq!(checker.dns_resolver.timeout(), Duration::from_secs(3));
547        let policy = caa::lookup_caa(&checker.dns_resolver, "seer.test", None)
548            .await
549            .unwrap();
550        assert!(policy.has_policy, "the configured resolver must be kept");
551    }
552
553    #[test]
554    fn from_config_applies_http_timeout() {
555        let mut config = crate::config::SeerConfig::default();
556        config.timeouts.http_secs = 77;
557        let checker = SslChecker::from_config(&config);
558        assert_eq!(checker.timeout, Duration::from_secs(77));
559    }
560
561    #[test]
562    fn test_oid_to_name() {
563        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
564        assert_eq!(oid_to_name(&oid), "SHA-256 with RSA");
565    }
566
567    #[test]
568    fn test_oid_to_key_type() {
569        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
570        assert_eq!(oid_to_key_type(&oid), "RSA");
571    }
572
573    /// Live-network sanity check: a real public site with valid TLS
574    /// completes a full chain inspection. Exercises the
575    /// [`resolve_public_host`] code path in `net.rs` (hickory fallback
576    /// engages if the test environment has a broken OS resolver) and the
577    /// rest of the TLS handshake + cert-parse pipeline.
578    #[tokio::test]
579    #[ignore = "requires network — performs a real TLS handshake"]
580    async fn check_live_example_com_succeeds() {
581        let report = SslChecker::new().check("example.com").await.unwrap();
582        assert_eq!(report.domain, "example.com");
583        assert!(!report.chain.is_empty(), "expected at least a leaf cert");
584        assert!(
585            report.is_valid,
586            "example.com's leaf cert should be currently valid"
587        );
588    }
589
590    /// Regression: native-tls exposed only the leaf, so `chain` always held
591    /// one entry and `protocol_version` was always `None`.
592    #[tokio::test]
593    async fn report_carries_the_presented_chain_and_protocol() {
594        use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
595
596        let addr = serve_once(&[LEAF_DER, CA_DER], &[&rustls::version::TLS12]).await;
597        let presented = crate::tls::inspect(
598            "chain.test",
599            &[addr],
600            Duration::from_secs(5),
601            SeerError::SslError,
602        )
603        .await
604        .unwrap();
605        let report = build_report("chain.test".to_string(), presented, CaaPolicy::empty()).unwrap();
606
607        let subjects: Vec<&str> = report.chain.iter().map(|c| c.subject.as_str()).collect();
608        assert_eq!(subjects, ["CN=chain.test", "CN=Seer Test CA"]);
609        assert!(report.chain[1].is_ca);
610        assert_eq!(report.protocol_version.as_deref(), Some("TLSv1.2"));
611        assert!(report.hostname_verified);
612    }
613
614    /// Regression: an RSA-1024 leaf failed the inspection handshake, so the
615    /// weak-key warning could never fire for a live certificate.
616    #[tokio::test]
617    async fn an_rsa_1024_leaf_is_inspected_and_flagged() {
618        use crate::tls::test_support::{serve_once, RSA_1024_LEAF};
619
620        let addr = serve_once(&[RSA_1024_LEAF], rustls::DEFAULT_VERSIONS).await;
621        let presented = crate::tls::inspect(
622            "weak-rsa.test",
623            &[addr],
624            Duration::from_secs(5),
625            SeerError::SslError,
626        )
627        .await
628        .unwrap();
629        let report =
630            build_report("weak-rsa.test".to_string(), presented, CaaPolicy::empty()).unwrap();
631
632        assert_eq!(report.chain[0].key_type.as_deref(), Some("RSA"));
633        assert_eq!(report.chain[0].key_bits, Some(1024));
634        assert!(
635            report
636                .warnings
637                .iter()
638                .any(|w| w.severity == CertWarningSeverity::Critical
639                    && w.message == "RSA key size 1024 is below the 2048-bit minimum"),
640            "got {:?}",
641            report.warnings
642        );
643    }
644
645    #[test]
646    fn test_ssl_report_serialization() {
647        let report = SslReport {
648            domain: "example.com".to_string(),
649            chain: vec![CertDetail {
650                subject: "CN=example.com".to_string(),
651                issuer: "CN=R3, O=Let's Encrypt".to_string(),
652                valid_from: Utc::now(),
653                valid_until: Utc::now(),
654                serial_number: "abc123".to_string(),
655                signature_algorithm: Some("SHA-256 with RSA".to_string()),
656                is_ca: false,
657                key_type: Some("RSA".to_string()),
658                key_bits: Some(2048),
659            }],
660            protocol_version: None,
661            san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
662            is_valid: true,
663            hostname_verified: true,
664            days_until_expiry: 90,
665            caa: None,
666            warnings: vec![],
667        };
668        let json = serde_json::to_string(&report).unwrap();
669        assert!(json.contains("example.com"));
670        assert!(json.contains("SHA-256 with RSA"));
671        assert!(json.contains("\"is_valid\":true"));
672        assert!(json.contains("\"hostname_verified\":true"));
673    }
674
675    #[test]
676    fn format_ip_san_renders_canonical_addresses() {
677        // IPv4: dotted quad.
678        assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
679        // IPv6 ::1 — must be zero-compressed, NOT 0000:0000:...:0001.
680        let mut loopback = [0u8; 16];
681        loopback[15] = 1;
682        assert_eq!(format_ip_san(&loopback), "::1");
683        // A fuller IPv6 address still round-trips through canonical form.
684        // 2001:db8::1
685        let mut addr = [0u8; 16];
686        addr[0] = 0x20;
687        addr[1] = 0x01;
688        addr[2] = 0x0d;
689        addr[3] = 0xb8;
690        addr[15] = 1;
691        assert_eq!(format_ip_san(&addr), "2001:db8::1");
692    }
693
694    fn sample_leaf() -> CertDetail {
695        CertDetail {
696            subject: "CN=example.com".to_string(),
697            issuer: "CN=R3, O=Let's Encrypt".to_string(),
698            valid_from: Utc::now(),
699            valid_until: Utc::now(),
700            serial_number: "abc123".to_string(),
701            signature_algorithm: Some("SHA-256 with RSA".to_string()),
702            is_ca: false,
703            key_type: Some("RSA".to_string()),
704            key_bits: Some(2048),
705        }
706    }
707
708    #[test]
709    fn derive_cert_warnings_clean_cert_has_none() {
710        let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
711        assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
712    }
713
714    #[test]
715    fn derive_cert_warnings_flags_weak_rsa_key() {
716        let mut leaf = sample_leaf();
717        leaf.key_bits = Some(1024);
718        let w = derive_cert_warnings(&leaf, true, true, 90);
719        assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
720            && x.severity == CertWarningSeverity::Critical));
721    }
722
723    #[test]
724    fn derive_cert_warnings_flags_sha1_signature() {
725        let mut leaf = sample_leaf();
726        leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
727        let w = derive_cert_warnings(&leaf, true, true, 90);
728        assert!(w
729            .iter()
730            .any(|x| x.message.to_lowercase().contains("deprecated signature")));
731    }
732
733    #[test]
734    fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
735        let mut leaf = sample_leaf();
736        leaf.issuer = leaf.subject.clone();
737        let w = derive_cert_warnings(&leaf, false, false, -3);
738        assert!(w.iter().any(|x| x.message.contains("self-signed")));
739        assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
740        assert!(w.iter().any(|x| x.message.contains("does not match")));
741    }
742
743    #[test]
744    fn derive_cert_warnings_flags_expiring_soon_as_warning() {
745        let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
746        assert_eq!(w.len(), 1);
747        assert_eq!(w[0].severity, CertWarningSeverity::Warning);
748        assert!(w[0].message.contains("expires in 10 day(s)"));
749    }
750
751    #[test]
752    fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
753        let now = Utc::now();
754        let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
755        let w = derive_cert_warnings(&sample_leaf(), false, true, days);
756        assert!(
757            w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
758            "a just-expired cert must be reported expired: {w:?}"
759        );
760        assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
761    }
762
763    #[test]
764    fn deprecated_signature_oids_are_named_and_flagged() {
765        for (arcs, name) in [
766            (&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
767            (&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
768            (&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
769        ] {
770            let oid = Oid::from(arcs).unwrap();
771            assert_eq!(oid_to_name(&oid), name);
772            let mut leaf = sample_leaf();
773            leaf.signature_algorithm = Some(oid_to_name(&oid));
774            let w = derive_cert_warnings(&leaf, true, true, 90);
775            assert!(
776                w.iter().any(|x| x.message.contains("deprecated signature")),
777                "{name} must be flagged: {w:?}"
778            );
779        }
780    }
781
782    #[test]
783    fn derive_cert_warnings_flags_ca_marked_leaf() {
784        let mut leaf = sample_leaf();
785        leaf.is_ca = true;
786        let w = derive_cert_warnings(&leaf, true, true, 90);
787        assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
788    }
789
790    /// Regression: `seer ssl` switched the CN fallback off for any SAN, so an
791    /// IP-SAN-only cert failed here while `seer status` verified it. Both now
792    /// share `tls::cert_matches_host`.
793    #[test]
794    fn ip_only_san_still_falls_back_to_the_cn() {
795        use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP};
796
797        let presented = PresentedChain {
798            leaf: cert(CN_VICTIM_SAN_IP),
799            intermediates: vec![],
800            protocol: None,
801        };
802        let report =
803            build_report("victim.example".to_string(), presented, CaaPolicy::empty()).unwrap();
804        assert!(report.hostname_verified);
805        assert_eq!(report.san_names, ["203.0.113.7"]);
806    }
807
808    #[test]
809    fn warnings_carry_a_structured_kind() {
810        let leaf = CertDetail {
811            subject: "CN=self.test".into(),
812            issuer: "CN=self.test".into(),
813            valid_from: Utc::now() - chrono::Duration::days(400),
814            valid_until: Utc::now() - chrono::Duration::days(1),
815            serial_number: "01".into(),
816            signature_algorithm: Some("SHA-1 with RSA".into()),
817            is_ca: true,
818            key_type: Some("RSA".into()),
819            key_bits: Some(1024),
820        };
821        let kinds: Vec<CertWarningKind> = derive_cert_warnings(&leaf, false, false, -1)
822            .into_iter()
823            .map(|w| w.kind)
824            .collect();
825        assert_eq!(
826            kinds,
827            vec![
828                CertWarningKind::WeakKey,
829                CertWarningKind::DeprecatedSignature,
830                CertWarningKind::SelfSigned,
831                CertWarningKind::CaLeaf,
832                CertWarningKind::Expired,
833                CertWarningKind::HostnameMismatch,
834            ]
835        );
836    }
837
838    #[test]
839    fn not_yet_valid_and_expiring_soon_have_their_own_kinds() {
840        let leaf = CertDetail {
841            subject: "CN=a.test".into(),
842            issuer: "CN=CA".into(),
843            valid_from: Utc::now(),
844            valid_until: Utc::now(),
845            serial_number: "01".into(),
846            signature_algorithm: Some("SHA-256 with RSA".into()),
847            is_ca: false,
848            key_type: Some("RSA".into()),
849            key_bits: Some(2048),
850        };
851        let kind = |valid, days| derive_cert_warnings(&leaf, valid, true, days)[0].kind;
852        assert_eq!(kind(false, 10), CertWarningKind::NotYetValid);
853        assert_eq!(kind(true, 10), CertWarningKind::ExpiringSoon);
854    }
855}