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