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