use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
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::tls::PresentedChain;
use crate::validation::normalize_host;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const RSA_MIN_KEY_BITS: u32 = 2048;
const EC_MIN_KEY_BITS: u32 = 256;
const CERT_EXPIRING_SOON_DAYS: i64 = 30;
fn derive_cert_warnings(
leaf: &CertDetail,
is_valid: bool,
hostname_verified: bool,
days_until_expiry: i64,
) -> Vec<CertWarning> {
let mut warnings = Vec::new();
let critical = |message: String| CertWarning {
severity: CertWarningSeverity::Critical,
message,
};
let warn = |message: String| CertWarning {
severity: CertWarningSeverity::Warning,
message,
};
if let (Some(kt), Some(bits)) = (leaf.key_type.as_deref(), leaf.key_bits) {
match kt {
"RSA" if bits < RSA_MIN_KEY_BITS => warnings.push(critical(format!(
"RSA key size {bits} is below the {RSA_MIN_KEY_BITS}-bit minimum"
))),
"EC" if bits < EC_MIN_KEY_BITS => warnings.push(critical(format!(
"EC key size {bits} is below the {EC_MIN_KEY_BITS}-bit minimum"
))),
_ => {}
}
}
if let Some(sig) = leaf.signature_algorithm.as_deref() {
let lower = sig.to_lowercase();
if lower.contains("sha-1") || lower.contains("sha1") || lower.contains("md5") {
warnings.push(critical(format!(
"Certificate uses deprecated signature algorithm: {sig}"
)));
}
}
if leaf.subject == leaf.issuer {
warnings.push(warn(
"Certificate is self-signed (issuer equals subject)".to_string(),
));
}
if leaf.is_ca {
warnings.push(warn(
"Leaf certificate is marked as a CA certificate".to_string(),
));
}
if days_until_expiry < 0 {
warnings.push(critical(format!(
"Certificate expired {} day(s) ago",
-days_until_expiry
)));
} else if !is_valid {
warnings.push(critical(
"Certificate is not yet valid (notBefore is in the future)".to_string(),
));
} else if days_until_expiry <= CERT_EXPIRING_SOON_DAYS {
warnings.push(warn(format!(
"Certificate expires in {days_until_expiry} day(s)"
)));
}
if !hostname_verified {
warnings.push(critical(
"Certificate does not match the requested hostname".to_string(),
));
}
warnings
}
#[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,
#[serde(default)]
pub hostname_verified: bool,
pub days_until_expiry: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub caa: Option<CaaPolicy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<CertWarning>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CertWarningSeverity {
Critical,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CertWarning {
pub severity: CertWarningSeverity,
pub message: String,
}
#[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,
timeout: Duration,
}
impl Default for SslChecker {
fn default() -> Self {
Self::new()
}
}
impl SslChecker {
pub fn new() -> Self {
Self {
dns_resolver: DnsResolver::new(),
timeout: DEFAULT_TIMEOUT,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
dns_resolver: DnsResolver::from_config(config),
timeout: config.http_timeout(),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.dns_resolver = DnsResolver::new().with_timeout(timeout);
self
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn check(&self, domain: &str) -> Result<SslReport> {
let domain = normalize_host(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 presented =
crate::tls::inspect(&domain, &socket_addrs, self.timeout, SeerError::SslError).await?;
build_report(domain, presented, caa_policy)
}
}
fn build_report(
domain: String,
presented: PresentedChain,
mut caa_policy: CaaPolicy,
) -> Result<SslReport> {
let (_, x509) = X509Certificate::from_der(&presented.leaf)
.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 = crate::dates::days_until(leaf_detail.valid_until, now);
let is_valid = now >= leaf_detail.valid_from && now <= leaf_detail.valid_until;
let hostname_verified = crate::tls::cert_matches_host(&x509, &domain);
caa_policy.issuer_match = Some(caa::classify_issuer(&leaf_detail.issuer, &caa_policy));
let warnings =
derive_cert_warnings(&leaf_detail, is_valid, hostname_verified, days_until_expiry);
let mut chain = vec![leaf_detail];
chain.extend(presented.intermediates.iter().filter_map(|der| {
let detail = X509Certificate::from_der(der)
.ok()
.and_then(|(_, cert)| parse_cert_detail(&cert).ok());
if detail.is_none() {
debug!("skipping unparseable certificate in the presented chain");
}
detail
}));
Ok(SslReport {
domain,
chain,
protocol_version: presented.protocol,
san_names,
is_valid,
hostname_verified,
days_until_expiry,
caa: Some(caa_policy),
warnings,
})
}
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) => {
sans.push(format_ip_san(ip_bytes));
}
_ => {}
}
}
}
sans
}
fn format_ip_san(ip_bytes: &[u8]) -> String {
crate::tls::san_ip(ip_bytes).map_or_else(|| format!("{:?}", ip_bytes), |ip| ip.to_string())
}
fn parse_cert_detail(cert: &X509Certificate) -> Result<CertDetail> {
let subject = cert.subject().to_string();
let issuer = cert.issuer().to_string();
let (valid_from, valid_until) = crate::tls::validity_window(cert)
.ok_or_else(|| SeerError::SslError("invalid certificate timestamp".to_string()))?;
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.4" => Some("MD5 with RSA".to_string()),
"1.2.840.10045.4.1" => Some("ECDSA with SHA-1".to_string()),
"1.2.840.10040.4.3" => Some("DSA with SHA-1".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),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssl_checker_creation() {
let _checker = SslChecker::new();
let _default_checker = SslChecker::default();
}
#[test]
fn from_config_applies_http_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.http_secs = 77;
let checker = SslChecker::from_config(&config);
assert_eq!(checker.timeout, Duration::from_secs(77));
}
#[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"
);
}
#[tokio::test]
async fn report_carries_the_presented_chain_and_protocol() {
use crate::tls::test_support::{serve_once, CA_DER, LEAF_DER};
let addr = serve_once(&[LEAF_DER, CA_DER], &[&rustls::version::TLS12]).await;
let presented = crate::tls::inspect(
"chain.test",
&[addr],
Duration::from_secs(5),
SeerError::SslError,
)
.await
.unwrap();
let report = build_report("chain.test".to_string(), presented, CaaPolicy::empty()).unwrap();
let subjects: Vec<&str> = report.chain.iter().map(|c| c.subject.as_str()).collect();
assert_eq!(subjects, ["CN=chain.test", "CN=Seer Test CA"]);
assert!(report.chain[1].is_ca);
assert_eq!(report.protocol_version.as_deref(), Some("TLSv1.2"));
assert!(report.hostname_verified);
}
#[tokio::test]
async fn an_rsa_1024_leaf_is_inspected_and_flagged() {
use crate::tls::test_support::{serve_once, RSA_1024_LEAF};
let addr = serve_once(&[RSA_1024_LEAF], rustls::DEFAULT_VERSIONS).await;
let presented = crate::tls::inspect(
"weak-rsa.test",
&[addr],
Duration::from_secs(5),
SeerError::SslError,
)
.await
.unwrap();
let report =
build_report("weak-rsa.test".to_string(), presented, CaaPolicy::empty()).unwrap();
assert_eq!(report.chain[0].key_type.as_deref(), Some("RSA"));
assert_eq!(report.chain[0].key_bits, Some(1024));
assert!(
report
.warnings
.iter()
.any(|w| w.severity == CertWarningSeverity::Critical
&& w.message == "RSA key size 1024 is below the 2048-bit minimum"),
"got {:?}",
report.warnings
);
}
#[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,
hostname_verified: true,
days_until_expiry: 90,
caa: None,
warnings: vec![],
};
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"));
assert!(json.contains("\"hostname_verified\":true"));
}
#[test]
fn format_ip_san_renders_canonical_addresses() {
assert_eq!(format_ip_san(&[192, 168, 0, 1]), "192.168.0.1");
let mut loopback = [0u8; 16];
loopback[15] = 1;
assert_eq!(format_ip_san(&loopback), "::1");
let mut addr = [0u8; 16];
addr[0] = 0x20;
addr[1] = 0x01;
addr[2] = 0x0d;
addr[3] = 0xb8;
addr[15] = 1;
assert_eq!(format_ip_san(&addr), "2001:db8::1");
}
fn sample_leaf() -> CertDetail {
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),
}
}
#[test]
fn derive_cert_warnings_clean_cert_has_none() {
let w = derive_cert_warnings(&sample_leaf(), true, true, 90);
assert!(w.is_empty(), "clean cert should have no warnings: {:?}", w);
}
#[test]
fn derive_cert_warnings_flags_weak_rsa_key() {
let mut leaf = sample_leaf();
leaf.key_bits = Some(1024);
let w = derive_cert_warnings(&leaf, true, true, 90);
assert!(w.iter().any(|x| x.message.contains("RSA key size 1024")
&& x.severity == CertWarningSeverity::Critical));
}
#[test]
fn derive_cert_warnings_flags_sha1_signature() {
let mut leaf = sample_leaf();
leaf.signature_algorithm = Some("SHA-1 with RSA".to_string());
let w = derive_cert_warnings(&leaf, true, true, 90);
assert!(w
.iter()
.any(|x| x.message.to_lowercase().contains("deprecated signature")));
}
#[test]
fn derive_cert_warnings_flags_self_signed_expired_and_mismatch() {
let mut leaf = sample_leaf();
leaf.issuer = leaf.subject.clone();
let w = derive_cert_warnings(&leaf, false, false, -3);
assert!(w.iter().any(|x| x.message.contains("self-signed")));
assert!(w.iter().any(|x| x.message.contains("expired 3 day(s) ago")));
assert!(w.iter().any(|x| x.message.contains("does not match")));
}
#[test]
fn derive_cert_warnings_flags_expiring_soon_as_warning() {
let w = derive_cert_warnings(&sample_leaf(), true, true, 10);
assert_eq!(w.len(), 1);
assert_eq!(w[0].severity, CertWarningSeverity::Warning);
assert!(w[0].message.contains("expires in 10 day(s)"));
}
#[test]
fn a_just_expired_cert_is_reported_expired_not_not_yet_valid() {
let now = Utc::now();
let days = crate::dates::days_until(now - chrono::Duration::hours(5), now);
let w = derive_cert_warnings(&sample_leaf(), false, true, days);
assert!(
w.iter().any(|x| x.message.contains("expired 1 day(s) ago")),
"a just-expired cert must be reported expired: {w:?}"
);
assert!(!w.iter().any(|x| x.message.contains("not yet valid")));
}
#[test]
fn deprecated_signature_oids_are_named_and_flagged() {
for (arcs, name) in [
(&[1u64, 2, 840, 113549, 1, 1, 4][..], "MD5 with RSA"),
(&[1u64, 2, 840, 10045, 4, 1][..], "ECDSA with SHA-1"),
(&[1u64, 2, 840, 10040, 4, 3][..], "DSA with SHA-1"),
] {
let oid = Oid::from(arcs).unwrap();
assert_eq!(oid_to_name(&oid).as_deref(), Some(name));
let mut leaf = sample_leaf();
leaf.signature_algorithm = oid_to_name(&oid);
let w = derive_cert_warnings(&leaf, true, true, 90);
assert!(
w.iter().any(|x| x.message.contains("deprecated signature")),
"{name} must be flagged: {w:?}"
);
}
}
#[test]
fn derive_cert_warnings_flags_ca_marked_leaf() {
let mut leaf = sample_leaf();
leaf.is_ca = true;
let w = derive_cert_warnings(&leaf, true, true, 90);
assert!(w.iter().any(|x| x.message.contains("marked as a CA")));
}
#[test]
fn ip_only_san_still_falls_back_to_the_cn() {
use crate::tls::test_support::{cert, CN_VICTIM_SAN_IP};
let presented = PresentedChain {
leaf: cert(CN_VICTIM_SAN_IP),
intermediates: vec![],
protocol: None,
};
let report =
build_report("victim.example".to_string(), presented, CaaPolicy::empty()).unwrap();
assert!(report.hostname_verified);
assert_eq!(report.san_names, ["203.0.113.7"]);
}
}