use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_native_tls::TlsConnector;
use tracing::{debug, instrument};
use x509_parser::oid_registry::Oid;
use x509_parser::prelude::*;
use crate::caa::{self, CaaPolicy};
use crate::dns::DnsResolver;
use crate::error::{Result, SeerError};
use crate::net::resolve_public_host;
use crate::validation::normalize_domain;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SslReport {
pub domain: String,
pub chain: Vec<CertDetail>,
pub protocol_version: Option<String>,
pub san_names: Vec<String>,
pub is_valid: bool,
pub days_until_expiry: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub caa: Option<CaaPolicy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CertDetail {
pub subject: String,
pub issuer: String,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub serial_number: String,
pub signature_algorithm: Option<String>,
pub is_ca: bool,
pub key_type: Option<String>,
pub key_bits: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct SslChecker {
dns_resolver: DnsResolver,
}
impl Default for SslChecker {
fn default() -> Self {
Self::new()
}
}
impl SslChecker {
pub fn new() -> Self {
Self {
dns_resolver: DnsResolver::new(),
}
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<SslReport> {
let domain = normalize_domain(domain)?;
debug!(domain = %domain, "Checking SSL certificate chain");
let caa_future = caa::lookup_caa(&self.dns_resolver, &domain);
let resolve_future = resolve_public_host(&domain, 443);
let (caa_policy, socket_addrs) = tokio::join!(caa_future, resolve_future);
let socket_addrs = socket_addrs.map_err(|e| {
SeerError::SslError(format!(
"could not resolve {} for SSL inspection: {}",
domain, e
))
})?;
let connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.build()
.map_err(|e| SeerError::SslError(format!("Failed to create TLS connector: {}", e)))?;
let connector = TlsConnector::from(connector);
let stream =
tokio::time::timeout(DEFAULT_TIMEOUT, TcpStream::connect(socket_addrs.as_slice()))
.await
.map_err(|_| SeerError::Timeout("SSL connection timed out".to_string()))?
.map_err(|e| {
SeerError::SslError(format!("Failed to connect to {}:443: {}", domain, e))
})?;
let tls_stream = tokio::time::timeout(DEFAULT_TIMEOUT, connector.connect(&domain, stream))
.await
.map_err(|_| SeerError::Timeout("TLS handshake timed out".to_string()))?
.map_err(|e| SeerError::SslError(format!("TLS handshake failed: {}", e)))?;
let cert = tls_stream
.get_ref()
.peer_certificate()
.map_err(|e| SeerError::SslError(format!("Failed to get certificate: {}", e)))?
.ok_or_else(|| SeerError::SslError("No certificate presented".to_string()))?;
let der = cert
.to_der()
.map_err(|e| SeerError::SslError(format!("Failed to encode certificate: {}", e)))?;
let (_, x509) = X509Certificate::from_der(&der)
.map_err(|e| SeerError::SslError(format!("Failed to parse certificate: {}", e)))?;
let san_names = extract_sans(&x509);
let leaf_detail = parse_cert_detail(&x509)?;
let now = Utc::now();
let days_until_expiry = (leaf_detail.valid_until - now).num_days();
let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
let mut caa_policy = caa_policy;
caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
Ok(SslReport {
domain,
chain: vec![leaf_detail],
protocol_version: None,
san_names,
is_valid,
days_until_expiry,
caa: Some(caa_policy),
})
}
}
fn extract_sans(cert: &X509Certificate) -> Vec<String> {
let mut sans = Vec::new();
if let Ok(Some(ext)) = cert.subject_alternative_name() {
for name in &ext.value.general_names {
match name {
GeneralName::DNSName(dns) => {
sans.push(dns.to_string());
}
GeneralName::IPAddress(ip_bytes) => {
let ip_str = match ip_bytes.len() {
4 => format!(
"{}.{}.{}.{}",
ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]
),
16 => {
let mut parts = Vec::new();
for chunk in ip_bytes.chunks(2) {
parts.push(format!("{:02x}{:02x}", chunk[0], chunk[1]));
}
parts.join(":")
}
_ => format!("{:?}", ip_bytes),
};
sans.push(ip_str);
}
_ => {}
}
}
}
sans
}
fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
let subject = cert.subject().to_string();
let issuer = cert.issuer().to_string();
let valid_from = asn1_time_to_chrono(cert.validity().not_before)?;
let valid_until = asn1_time_to_chrono(cert.validity().not_after)?;
let serial_number = cert.serial.to_str_radix(16);
let signature_algorithm = oid_to_name(&cert.signature_algorithm.algorithm);
let is_ca = cert.is_ca();
let spki = cert.public_key();
let (key_type, key_bits) = extract_key_info(spki);
Ok(CertDetail {
subject,
issuer,
valid_from,
valid_until,
serial_number,
signature_algorithm,
is_ca,
key_type,
key_bits,
})
}
fn extract_key_info(spki: &SubjectPublicKeyInfo) -> (Option<String>, Option<u32>) {
use x509_parser::public_key::PublicKey;
let oid = &spki.algorithm.algorithm;
let key_type = oid_to_key_type(oid);
let key_bits = match spki.parsed() {
Ok(PublicKey::RSA(rsa)) => Some(rsa.key_size() as u32),
Ok(PublicKey::EC(ec)) => Some(ec.key_size() as u32),
_ => None,
};
(key_type, key_bits)
}
fn oid_to_name(oid: &Oid) -> Option<String> {
let oid_str = format!("{}", oid);
match oid_str.as_str() {
"1.2.840.113549.1.1.11" => Some("SHA-256 with RSA".to_string()),
"1.2.840.113549.1.1.12" => Some("SHA-384 with RSA".to_string()),
"1.2.840.113549.1.1.13" => Some("SHA-512 with RSA".to_string()),
"1.2.840.113549.1.1.5" => Some("SHA-1 with RSA".to_string()),
"1.2.840.113549.1.1.14" => Some("SHA-224 with RSA".to_string()),
"1.2.840.10045.4.3.2" => Some("ECDSA with SHA-256".to_string()),
"1.2.840.10045.4.3.3" => Some("ECDSA with SHA-384".to_string()),
"1.2.840.10045.4.3.4" => Some("ECDSA with SHA-512".to_string()),
"1.3.101.112" => Some("Ed25519".to_string()),
"1.3.101.113" => Some("Ed448".to_string()),
_ => Some(oid_str),
}
}
fn oid_to_key_type(oid: &Oid) -> Option<String> {
let oid_str = format!("{}", oid);
match oid_str.as_str() {
"1.2.840.113549.1.1.1" => Some("RSA".to_string()),
"1.2.840.10045.2.1" => Some("EC".to_string()),
"1.3.101.112" => Some("Ed25519".to_string()),
"1.3.101.113" => Some("Ed448".to_string()),
_ => Some(oid_str),
}
}
fn asn1_time_to_chrono(time: ASN1Time) -> Result<DateTime<Utc>> {
let timestamp = time.timestamp();
DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssl_checker_creation() {
let _checker = SslChecker::new();
let _default_checker = SslChecker::default();
}
#[test]
fn test_oid_to_name() {
let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 11][..]).unwrap();
assert_eq!(oid_to_name(&oid), Some("SHA-256 with RSA".to_string()));
}
#[test]
fn test_oid_to_key_type() {
let oid = Oid::from(&[1, 2, 840, 113549, 1, 1, 1][..]).unwrap();
assert_eq!(oid_to_key_type(&oid), Some("RSA".to_string()));
}
#[tokio::test]
#[ignore = "requires network — performs a real TLS handshake"]
async fn check_live_example_com_succeeds() {
let report = SslChecker::new().check("example.com").await.unwrap();
assert_eq!(report.domain, "example.com");
assert!(!report.chain.is_empty(), "expected at least a leaf cert");
assert!(
report.is_valid,
"example.com's leaf cert should be currently valid"
);
}
#[test]
fn test_ssl_report_serialization() {
let report = SslReport {
domain: "example.com".to_string(),
chain: vec![CertDetail {
subject: "CN=example.com".to_string(),
issuer: "CN=R3, O=Let's Encrypt".to_string(),
valid_from: Utc::now(),
valid_until: Utc::now(),
serial_number: "abc123".to_string(),
signature_algorithm: Some("SHA-256 with RSA".to_string()),
is_ca: false,
key_type: Some("RSA".to_string()),
key_bits: Some(2048),
}],
protocol_version: None,
san_names: vec!["example.com".to_string(), "*.example.com".to_string()],
is_valid: true,
days_until_expiry: 90,
caa: None,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("example.com"));
assert!(json.contains("SHA-256 with RSA"));
assert!(json.contains("\"is_valid\":true"));
}
}