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 timeout: Duration,
102}
103
104impl Default for SslChecker {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl SslChecker {
111 pub fn new() -> Self {
113 Self {
114 dns_resolver: DnsResolver::new(),
115 timeout: DEFAULT_TIMEOUT,
116 }
117 }
118
119 pub fn with_timeout(mut self, timeout: Duration) -> Self {
121 self.timeout = timeout;
122 self.dns_resolver = DnsResolver::new().with_timeout(timeout);
123 self
124 }
125
126 #[instrument(skip(self), fields(domain = %domain))]
138 pub async fn check(&self, domain: &str) -> Result<SslReport> {
139 let domain = normalize_domain(domain)?;
140
141 debug!(domain = %domain, "Checking SSL certificate chain");
142
143 let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
147
148 let resolve_future = resolve_public_host(&domain, 443);
153
154 let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
155 let socket_addrs = socket_addrs.map_err(|e| {
156 SeerError::SslError(format!(
157 "could not resolve {} for SSL inspection: {}",
158 domain, e
159 ))
160 })?;
161
162 let connector = native_tls::TlsConnector::builder()
164 .danger_accept_invalid_certs(true)
165 .build()
166 .map_err(|e| SeerError::SslError(format!("Failed to create TLS connector: {}", e)))?;
167 let connector = TlsConnector::from(connector);
168
169 let stream =
171 tokio::time::timeout(self.timeout, TcpStream::connect(socket_addrs.as_slice()))
172 .await
173 .map_err(|_| SeerError::Timeout("SSL connection timed out".to_string()))?
174 .map_err(|e| {
175 SeerError::SslError(format!("Failed to connect to {}:443: {}", domain, e))
176 })?;
177
178 let tls_stream = tokio::time::timeout(self.timeout, connector.connect(&domain, stream))
180 .await
181 .map_err(|_| SeerError::Timeout("TLS handshake timed out".to_string()))?
182 .map_err(|e| SeerError::SslError(format!("TLS handshake failed: {}", e)))?;
183
184 let cert = tls_stream
186 .get_ref()
187 .peer_certificate()
188 .map_err(|e| SeerError::SslError(format!("Failed to get certificate: {}", e)))?
189 .ok_or_else(|| SeerError::SslError("No certificate presented".to_string()))?;
190
191 let der = cert
192 .to_der()
193 .map_err(|e| SeerError::SslError(format!("Failed to encode certificate: {}", e)))?;
194
195 let (_, x509) = X509Certificate::from_der(&der)
197 .map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
198
199 let san_names = extract_sans(&x509);
201
202 let leaf_detail = parse_cert_detail(&x509)?;
205
206 let now = Utc::now();
207 let days_until_expiry = (leaf_detail.valid_until - now).num_days();
208 let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
209
210 let hostname_verified = san_names
219 .iter()
220 .any(|san| hostname_matches_pattern(&domain, san))
221 || (san_names.is_empty() && subject_cn_matches_host(&x509, &domain));
222
223 let mut caa_policy = caa_policy;
226 caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
227
228 Ok(SslReport {
229 domain,
230 chain: vec![leaf_detail],
231 protocol_version: None,
232 san_names,
233 is_valid,
234 hostname_verified,
235 days_until_expiry,
236 caa: Some(caa_policy),
237 })
238 }
239}
240
241fn hostname_matches_pattern(host: &str, pattern: &str) -> bool {
246 let host = host.to_ascii_lowercase();
247 let pattern = pattern.to_ascii_lowercase();
248 if let Some(rest) = pattern.strip_prefix("*.") {
249 let Some(dot) = host.find('.') else {
250 return false;
251 };
252 let host_rest = &host[dot + 1..];
253 host_rest == rest
254 } else {
255 host == pattern
256 }
257}
258
259fn subject_cn_matches_host(cert: &X509Certificate, host: &str) -> bool {
264 for cn in cert.subject().iter_common_name() {
265 if let Ok(s) = cn.as_str() {
266 if hostname_matches_pattern(host, s) {
267 return true;
268 }
269 }
270 }
271 false
272}
273
274fn extract_sans(cert: &X509Certificate) -> Vec<String> {
276 let mut sans = Vec::new();
277 if let Ok(Some(ext)) = cert.subject_alternative_name() {
278 for name in &ext.value.general_names {
279 match name {
280 GeneralName::DNSName(dns) => {
281 sans.push(dns.to_string());
282 }
283 GeneralName::IPAddress(ip_bytes) => {
284 sans.push(format_ip_san(ip_bytes));
285 }
286 _ => {}
287 }
288 }
289 }
290 sans
291}
292
293fn format_ip_san(ip_bytes: &[u8]) -> String {
301 match ip_bytes.len() {
302 4 => {
303 let octets: [u8; 4] = [ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]];
304 std::net::Ipv4Addr::from(octets).to_string()
305 }
306 16 => {
307 let mut octets = [0u8; 16];
308 octets.copy_from_slice(ip_bytes);
309 std::net::Ipv6Addr::from(octets).to_string()
310 }
311 _ => format!("{:?}", ip_bytes),
312 }
313}
314
315fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
317 let subject = cert.subject().to_string();
318 let issuer = cert.issuer().to_string();
319
320 let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
321 let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
322
323 let serial_number = cert.serial.to_str_radix(16);
324
325 let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
326
327 let is_ca = cert.is_ca();
328
329 let spki = cert.public_key();
331 let (key_type, key_bits) = extract_key_info(spki);
332
333 Ok(CertDetail {
334 subject,
335 issuer,
336 valid_from,
337 valid_until,
338 serial_number,
339 signature_algorithm,
340 is_ca,
341 key_type,
342 key_bits,
343 })
344}
345
346fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
348 use x509_parser::public_key::PublicKey;
349 let oid = &spki.algorithm.algorithm;
350 let key_type = oid_to_key_type(oid);
351 let key_bits = match spki.parsed() {
352 Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
353 Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
354 _ => None,
355 };
356 (key_type, key_bits)
357}
358
359fn oid_to_name(oid: &Oid) -> Option<String> {
361 let oid_str = format!("{}", oid);
362 match oid_str.as_str() {
363 "1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
364 "1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
365 "1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
366 "1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
367 "1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
368 "1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
369 "1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
370 "1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
371 "1.3.101.112" => Some("Ed25519".to_string()),
372 "1.3.101.113" => Some("Ed448".to_string()),
373 _ => Some(oid_str),
374 }
375}
376
377fn oid_to_key_type(oid: &Oid) -> Option<String> {
379 let oid_str = format!("{}", oid);
380 match oid_str.as_str() {
381 "1.2.840.113549.1.1.1" => Some("RSA".to_string()),
382 "1.2.840.10045.2.1" => Some("EC".to_string()),
383 "1.3.101.112" => Some("Ed25519".to_string()),
384 "1.3.101.113" => Some("Ed448".to_string()),
385 _ => Some(oid_str),
386 }
387}
388
389fn asn1_time_to_chrono(time: ASN1Time) -> Result<DateTime<Utc>> {
391 let timestamp = time.timestamp();
392 DateTime::from_timestamp(timestamp, 0)
393 .ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_ssl_checker_creation() {
402 let _checker = SslChecker::new();
403 let _default_checker = SslChecker::default();
404 }
405
406 #[test]
407 fn test_oid_to_name() {
408 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
409 assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
410 }
411
412 #[test]
413 fn test_oid_to_key_type() {
414 let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
415 assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
416 }
417
418 #[tokio::test]
424 #[ignore = "requires network — performs a real TLS handshake"]
425 async fn check_live_example_com_succeeds() {
426 let report = SslChecker::new().check("example.com").await.unwrap();
427 assert_eq!(report.domain, "example.com");
428 assert!(!report.chain.is_empty(), "expected at least a leaf cert");
429 assert!(
430 report.is_valid,
431 "example.com's leaf cert should be currently valid"
432 );
433 }
434
435 #[test]
436 fn test_ssl_report_serialization() {
437 let report = SslReport {
438 domain: "example.com".to_string(),
439 chain: vec![CertDetail {
440 subject: "CN=example.com".to_string(),
441 issuer: "CN=R3, O=Let's Encrypt".to_string(),
442 valid_from: Utc::now(),
443 valid_until: Utc::now(),
444 serial_number: "abc123".to_string(),
445 signature_algorithm: Some("SHA-256 with RSA".to_string()),
446 is_ca: false,
447 key_type: Some("RSA".to_string()),
448 key_bits: Some(2048),
449 }],
450 protocol_version: None,
451 san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
452 is_valid: true,
453 hostname_verified: true,
454 days_until_expiry: 90,
455 caa: None,
456 };
457 let json = serde_json::to_string(&report).unwrap();
458 assert!(json.contains("example.com"));
459 assert!(json.contains("SHA-256 with RSA"));
460 assert!(json.contains("\"is_valid\":true"));
461 assert!(json.contains("\"hostname_verified\":true"));
462 }
463
464 #[test]
465 fn format_ip_san_renders_canonical_addresses() {
466 assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
468 let mut loopback = [0u8; 16];
470 loopback[15] = 1;
471 assert_eq!(format_ip_san(&loopback), "::1");
472 let mut addr = [0u8; 16];
475 addr[0] = 0x20;
476 addr[1] = 0x01;
477 addr[2] = 0x0d;
478 addr[3] = 0xb8;
479 addr[15] = 1;
480 assert_eq!(format_ip_san(&addr), "2001:db8::1");
481 }
482
483 #[test]
484 fn hostname_matches_pattern_exact_and_wildcard() {
485 assert!(hostname_matches_pattern("example.com", "example.com"));
486 assert!(hostname_matches_pattern("EXAMPLE.COM", "example.com"));
487 assert!(hostname_matches_pattern("a.example.com", "*.example.com"));
489 assert!(!hostname_matches_pattern("example.com", "*.example.com"));
491 assert!(!hostname_matches_pattern(
493 "a.b.example.com",
494 "*.example.com"
495 ));
496 assert!(!hostname_matches_pattern("evil.test", "example.com"));
498 }
499}