1use 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
27const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SslReport {
33 pub domain: String,
35 pub chain: Vec<CertDetail>,
37 pub protocol_version: Option<String>,
39 pub san_names: Vec<String>,
41 pub is_valid: bool,
51 #[serde(default)]
59 pub hostname_verified: bool,
60 pub days_until_expiry: i64,
62 #[serde(skip_serializing_if = "Option::is_none")]
69 pub caa: Option<CaaPolicy>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct CertDetail {
75 pub subject: String,
77 pub issuer: String,
79 pub valid_from: DateTime<Utc>,
81 pub valid_until: DateTime<Utc>,
83 pub serial_number: String,
85 pub signature_algorithm: Option<String>,
87 pub is_ca: bool,
89 pub key_type: Option<String>,
91 pub key_bits: Option<u32>,
93}
94
95#[derive(Debug, Clone)]
97pub struct SslChecker {
98 dns_resolver: DnsResolver,
100}
101
102impl Default for SslChecker {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl SslChecker {
109 pub fn new() -> Self {
111 Self {
112 dns_resolver: DnsResolver::new(),
113 }
114 }
115
116 #[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 let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
137
138 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 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 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 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 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 let (_, x509) = X509Certificate::from_der(&der)
187 .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
188
189 let san_names = extract_sans(&x509);
191
192 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 let hostname_verified = san_names
207 .iter()
208 .any(|san| hostname_matches_pattern(&domain, san))
209 || subject_cn_matches_host(&x509, &domain);
210
211 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
229fn 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
247fn 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
261fn 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 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 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
296fn 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 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
327fn 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
340fn 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
358fn 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
370fn 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 #[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 assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
451 assert!(!hostname_matches_pattern("example.com", "*.example.com"));
453 assert!(!hostname_matches_pattern(
455 "a.b.example.com",
456 "*.example.com"
457 ));
458 assert!(!hostname_matches_pattern("evil.test", "example.com"));
460 }
461}