Skip to main content

seer_core/
ssl.rs

1//! SSL certificate chain inspection.
2//!
3//! Provides detailed SSL/TLS certificate information including the certificate
4//! chain, Subject Alternative Names (SANs), key details, and validity status.
5//!
6//! Retry boundary (deliberate): single-attempt, like [`crate::status`] — a
7//! certificate inspection is a point-in-time observation, and retrying would
8//! hide intermittent TLS failures from the user. Transient-failure tolerance
9//! belongs to callers (e.g. watch mode).
10
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use tokio::net::TcpStream;
16use tokio_native_tls::TlsConnector;
17use tracing::{debug, instrument};
18use x509_parser::oid_registry::Oid;
19use x509_parser::prelude::*;
20
21use crate::caa::{self, CaaPolicy};
22use crate::dns::DnsResolver;
23use crate::error::{Result, SeerError};
24use crate::net::resolve_public_host;
25use crate::validation::normalize_domain;
26
27/// Default timeout for SSL operations (10 seconds).
28const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
29
30/// Full SSL certificate report for a domain.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SslReport {
33    /// The domain that was inspected
34    pub domain: String,
35    /// Certificate chain from leaf to root (as many as the server provides)
36    pub chain: Vec<CertDetail>,
37    /// TLS protocol version (best-effort detection)
38    pub protocol_version: Option<String>,
39    /// Subject Alternative Names from the leaf certificate
40    pub san_names: Vec<String>,
41    /// Whether the leaf certificate is within its validity period.
42    ///
43    /// This reflects ONLY the date-range check (`notBefore <= now <=
44    /// notAfter`) of the leaf certificate. It does NOT verify the certificate
45    /// chain's trust (this checker uses `danger_accept_invalid_certs(true)` to
46    /// inspect broken/self-signed certs) nor that the certificate matches the
47    /// requested hostname — see [`SslReport::hostname_verified`]. A
48    /// date-valid cert may still be self-signed, issued by an untrusted CA, or
49    /// presented for the wrong host.
50    pub is_valid: bool,
51    /// Whether the leaf certificate's SAN dNSNames (or CN as a legacy
52    /// fallback) match the requested domain, per RFC 6125 (exact and
53    /// single-label wildcard matches).
54    ///
55    /// This is an additive signal independent of `is_valid`. Chain trust is
56    /// NOT verified here: a `true` value means the cert was presented for the
57    /// right host, not that it was issued by a trusted CA.
58    #[serde(default)]
59    pub hostname_verified: bool,
60    /// Days until the leaf certificate expires
61    pub days_until_expiry: i64,
62    /// CAA (Certification Authority Authorization) policy for the domain
63    /// plus a comparison against the presented certificate's issuer.
64    ///
65    /// CAA is consulted by CAs at *issuance time*, not by clients at
66    /// *validation time*, so a mismatch here is informational — see the
67    /// `note` field on [`CaaPolicy`].
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub caa: Option<CaaPolicy>,
70}
71
72/// Detailed information about a single certificate in the chain.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct CertDetail {
75    /// Certificate subject (e.g., "CN=example.com")
76    pub subject: String,
77    /// Certificate issuer (e.g., "CN=R3, O=Let's Encrypt")
78    pub issuer: String,
79    /// Certificate validity start date
80    pub valid_from: DateTime<Utc>,
81    /// Certificate expiration date
82    pub valid_until: DateTime<Utc>,
83    /// Serial number in hexadecimal
84    pub serial_number: String,
85    /// Signature algorithm (e.g., "sha256WithRSAEncryption")
86    pub signature_algorithm: Option<String>,
87    /// Whether this is a Certificate Authority certificate
88    pub is_ca: bool,
89    /// Public key type (e.g., "RSA", "EC")
90    pub key_type: Option<String>,
91    /// Public key size in bits
92    pub key_bits: Option<u32>,
93}
94
95/// Client for performing SSL certificate chain inspection.
96#[derive(Debug, Clone)]
97pub struct SslChecker {
98    /// Cached DNS resolver used for CAA lookups alongside the TLS probe.
99    dns_resolver: DnsResolver,
100}
101
102impl Default for SslChecker {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl SslChecker {
109    /// Creates a new SslChecker instance.
110    pub fn new() -> Self {
111        Self {
112            dns_resolver: DnsResolver::new(),
113        }
114    }
115
116    /// Inspects the SSL certificate chain for the given domain.
117    ///
118    /// Connects to port 443, performs a TLS handshake, and extracts detailed
119    /// certificate information including the full chain, SANs, and key details.
120    ///
121    /// # Arguments
122    /// * `domain` - The domain name to inspect (e.g., "example.com")
123    ///
124    /// # Returns
125    /// * `Ok(SslReport)` - Detailed SSL certificate information
126    /// * `Err(SeerError)` - If connection or certificate parsing fails
127    #[instrument(skip(self), fields(domain = %domain))]
128    pub async fn check(&self, domain: &str) -> Result<SslReport> {
129        let domain = normalize_domain(domain)?;
130
131        debug!(domain = %domain, "Checking SSL certificate chain");
132
133        // CAA query runs concurrently with the TLS probe — it is advisory
134        // and never fails the report (a resolver error yields an empty
135        // policy).
136        let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
137
138        // Resolve + SSRF check. `resolve_public_host` already falls back to
139        // hickory (Google DNS) when the OS resolver fails — important for
140        // hosts where Tailscale Split-DNS or a corp resolver pins the
141        // domain to a nameserver that can't answer for it.
142        let resolve_future = resolve_public_host(&domain, 443);
143
144        let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
145        let socket_addrs = socket_addrs.map_err(|e| {
146            SeerError::SslError(format!(
147                "could not resolve {} for SSL inspection: {}",
148                domain, e
149            ))
150        })?;
151
152        // Build TLS connector - accept invalid certs so we can inspect them
153        let connector = native_tls::TlsConnector::builder()
154            .danger_accept_invalid_certs(true)
155            .build()
156            .map_err(|e| SeerError::SslError(format!("Failed to create TLS connector: {}", e)))?;
157        let connector = TlsConnector::from(connector);
158
159        // TCP connect with timeout — connect to pre-resolved address to prevent DNS rebinding
160        let stream =
161            tokio::time::timeout(DEFAULT_TIMEOUT, TcpStream::connect(socket_addrs.as_slice()))
162                .await
163                .map_err(|_| SeerError::Timeout("SSL connection timed out".to_string()))?
164                .map_err(|e| {
165                    SeerError::SslError(format!("Failed to connect to {}:443: {}", domain, e))
166                })?;
167
168        // TLS handshake with timeout
169        let tls_stream = tokio::time::timeout(DEFAULT_TIMEOUT, connector.connect(&domain, stream))
170            .await
171            .map_err(|_| SeerError::Timeout("TLS handshake timed out".to_string()))?
172            .map_err(|e| SeerError::SslError(format!("TLS handshake failed: {}", e)))?;
173
174        // Get the peer certificate (leaf)
175        let cert = tls_stream
176            .get_ref()
177            .peer_certificate()
178            .map_err(|e| SeerError::SslError(format!("Failed to get certificate: {}", e)))?
179            .ok_or_else(|| SeerError::SslError("No certificate presented".to_string()))?;
180
181        let der = cert
182            .to_der()
183            .map_err(|e| SeerError::SslError(format!("Failed to encode certificate: {}", e)))?;
184
185        // Parse leaf certificate with x509-parser
186        let (_, x509) = X509Certificate::from_der(&der)
187            .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
188
189        // Extract SANs from the leaf certificate
190        let san_names = extract_sans(&x509);
191
192        // Build the certificate chain
193        // native-tls only exposes the leaf cert directly; we parse what we have
194        let leaf_detail = parse_cert_detail(&x509)?;
195
196        let now = Utc::now();
197        let days_until_expiry = (leaf_detail.valid_until - now).num_days();
198        let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
199
200        // Hostname verification: does the leaf cert's SAN (or CN fallback)
201        // match the requested domain? This is independent of `is_valid` and of
202        // chain trust (which is not verified here — see the field docs). Lets
203        // consumers tell a date-valid-but-wrong-host cert apart from a real
204        // match. The CN fallback is read from the leaf subject string since the
205        // SANs are already extracted.
206        let hostname_verified = san_names
207            .iter()
208            .any(|san| hostname_matches_pattern(&domain, san))
209            || subject_cn_matches_host(&x509, &domain);
210
211        // Annotate the CAA policy with the issuer comparison before
212        // attaching it to the report.
213        let mut caa_policy = caa_policy;
214        caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
215
216        Ok(SslReport {
217            domain,
218            chain: vec![leaf_detail],
219            protocol_version: None,
220            san_names,
221            is_valid,
222            hostname_verified,
223            days_until_expiry,
224            caa: Some(caa_policy),
225        })
226    }
227}
228
229/// Returns true if `host` matches the certificate name `pattern`, supporting
230/// exact (case-insensitive) matches and single-label wildcards per RFC 6125
231/// (`*.example.com` matches `a.example.com` but not `example.com` or
232/// `a.b.example.com`).
233fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
234    let host = host.to_ascii_lowercase();
235    let pattern = pattern.to_ascii_lowercase();
236    if let Some(rest) = pattern.strip_prefix("*.") {
237        let Some(dot) = host.find('.') else {
238            return false;
239        };
240        let host_rest = &host[dot + 1..];
241        host_rest == rest
242    } else {
243        host == pattern
244    }
245}
246
247/// Legacy CN fallback for hostname verification: checks the leaf certificate's
248/// subject Common Name(s) against `host`. SAN dNSNames are authoritative per
249/// RFC 6125; CN is only consulted when no SAN matched.
250fn subject_cn_matches_host(cert: &X509Certificate, host: &str) -> bool {
251    for cn in cert.subject().iter_common_name() {
252        if let Ok(s) = cn.as_str() {
253            if hostname_matches_pattern(host, s) {
254                return true;
255            }
256        }
257    }
258    false
259}
260
261/// Extracts Subject Alternative Names from a certificate.
262fn extract_sans(cert: &X509Certificate) -> Vec<String> {
263    let mut sans = Vec::new();
264    if let Ok(Some(ext)) = cert.subject_alternative_name() {
265        for name in &ext.value.general_names {
266            match name {
267                GeneralName::DNSName(dns) => {
268                    sans.push(dns.to_string());
269                }
270                GeneralName::IPAddress(ip_bytes) => {
271                    // IP addresses are encoded as bytes
272                    let ip_str = match ip_bytes.len() {
273                        4 => format!(
274                            "{}.{}.{}.{}",
275                            ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]
276                        ),
277                        16 => {
278                            // IPv6
279                            let mut parts = Vec::new();
280                            for chunk in ip_bytes.chunks(2) {
281                                parts.push(format!("{:02x}{:02x}", chunk[0], chunk[1]));
282                            }
283                            parts.join(":")
284                        }
285                        _ => format!("{:?}", ip_bytes),
286                    };
287                    sans.push(ip_str);
288                }
289                _ => {}
290            }
291        }
292    }
293    sans
294}
295
296/// Parses detailed information from an X.509 certificate.
297fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
298    let subject = cert.subject().to_string();
299    let issuer = cert.issuer().to_string();
300
301    let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
302    let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
303
304    let serial_number = cert.serial.to_str_radix(16);
305
306    let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
307
308    let is_ca = cert.is_ca();
309
310    // Extract public key info
311    let spki = cert.public_key();
312    let (key_type, key_bits) = extract_key_info(spki);
313
314    Ok(CertDetail {
315        subject,
316        issuer,
317        valid_from,
318        valid_until,
319        serial_number,
320        signature_algorithm,
321        is_ca,
322        key_type,
323        key_bits,
324    })
325}
326
327/// Extracts key type and size from a SubjectPublicKeyInfo.
328fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
329    use x509_parser::public_key::PublicKey;
330    let oid = &spki.algorithm.algorithm;
331    let key_type = oid_to_key_type(oid);
332    let key_bits = match spki.parsed() {
333        Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
334        Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
335        _ => None,
336    };
337    (key_type, key_bits)
338}
339
340/// Maps common OIDs to human-readable algorithm names.
341fn oid_to_name(oid: &Oid) -> Option<String> {
342    let oid_str = format!("{}", oid);
343    match oid_str.as_str() {
344        "1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
345        "1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
346        "1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
347        "1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
348        "1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
349        "1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
350        "1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
351        "1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
352        "1.3.101.112" => Some("Ed25519".to_string()),
353        "1.3.101.113" => Some("Ed448".to_string()),
354        _ => Some(oid_str),
355    }
356}
357
358/// Maps public key algorithm OIDs to human-readable key type names.
359fn oid_to_key_type(oid: &Oid) -> Option<String> {
360    let oid_str = format!("{}", oid);
361    match oid_str.as_str() {
362        "1.2.840.113549.1.1.1" => Some("RSA".to_string()),
363        "1.2.840.10045.2.1" => Some("EC".to_string()),
364        "1.3.101.112" => Some("Ed25519".to_string()),
365        "1.3.101.113" => Some("Ed448".to_string()),
366        _ => Some(oid_str),
367    }
368}
369
370/// Converts an x509-parser ASN1Time to a chrono DateTime.
371fn asn1_time_to_chrono(time: ASN1Time) -> Result<DateTime<Utc>> {
372    let timestamp = time.timestamp();
373    DateTime::from_timestamp(timestamp, 0)
374        .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_ssl_checker_creation() {
383        let _checker = SslChecker::new();
384        let _default_checker = SslChecker::default();
385    }
386
387    #[test]
388    fn test_oid_to_name() {
389        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
390        assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
391    }
392
393    #[test]
394    fn test_oid_to_key_type() {
395        let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
396        assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
397    }
398
399    /// Live-network sanity check: a real public site with valid TLS
400    /// completes a full chain inspection. Exercises the
401    /// [`resolve_public_host`] code path in `net.rs` (hickory fallback
402    /// engages if the test environment has a broken OS resolver) and the
403    /// rest of the TLS handshake + cert-parse pipeline.
404    #[tokio::test]
405    #[ignore = "requires network — performs a real TLS handshake"]
406    async fn check_live_example_com_succeeds() {
407        let report = SslChecker::new().check("example.com").await.unwrap();
408        assert_eq!(report.domain, "example.com");
409        assert!(!report.chain.is_empty(), "expected at least a leaf cert");
410        assert!(
411            report.is_valid,
412            "example.com's leaf cert should be currently valid"
413        );
414    }
415
416    #[test]
417    fn test_ssl_report_serialization() {
418        let report = SslReport {
419            domain: "example.com".to_string(),
420            chain: vec![CertDetail {
421                subject: "CN=example.com".to_string(),
422                issuer: "CN=R3, O=Let's Encrypt".to_string(),
423                valid_from: Utc::now(),
424                valid_until: Utc::now(),
425                serial_number: "abc123".to_string(),
426                signature_algorithm: Some("SHA-256 with RSA".to_string()),
427                is_ca: false,
428                key_type: Some("RSA".to_string()),
429                key_bits: Some(2048),
430            }],
431            protocol_version: None,
432            san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
433            is_valid: true,
434            hostname_verified: true,
435            days_until_expiry: 90,
436            caa: None,
437        };
438        let json = serde_json::to_string(&report).unwrap();
439        assert!(json.contains("example.com"));
440        assert!(json.contains("SHA-256 with RSA"));
441        assert!(json.contains("\"is_valid\":true"));
442        assert!(json.contains("\"hostname_verified\":true"));
443    }
444
445    #[test]
446    fn hostname_matches_pattern_exact_and_wildcard() {
447        assert!(hostname_matches_pattern("example.com", "example.com"));
448        assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
449        // Single-label wildcard.
450        assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
451        // Apex must not match a wildcard (RFC 6125).
452        assert!(!hostname_matches_pattern("example.com", "*.example.com"));
453        // Wildcard matches only one label.
454        assert!(!hostname_matches_pattern(
455            "a.b.example.com",
456            "*.example.com"
457        ));
458        // Mismatched host.
459        assert!(!hostname_matches_pattern("evil.test", "example.com"));
460    }
461}