use crate::Result;
use crate::utils::mtls::MtlsConfig;
use crate::utils::network::Target;
use base64::Engine;
use chrono::{DateTime, Utc};
use openssl::hash::MessageDigest;
use openssl::x509::X509 as OpensslX509;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::timeout;
use x509_parser::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CertificateInfo {
pub subject: String,
pub issuer: String,
pub serial_number: String,
pub not_before: String,
pub not_after: String,
pub expiry_countdown: Option<String>, pub signature_algorithm: String,
pub public_key_algorithm: String,
pub public_key_size: Option<usize>,
pub rsa_exponent: Option<String>, pub san: Vec<String>, pub is_ca: bool,
pub key_usage: Vec<String>,
pub extended_key_usage: Vec<String>,
pub extended_validation: bool,
pub ev_oids: Vec<String>,
pub pin_sha256: Option<String>, pub fingerprint_sha256: Option<String>, pub debian_weak_key: Option<bool>, pub aia_url: Option<String>, pub certificate_transparency: Option<String>, pub der_bytes: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CertificateChain {
pub certificates: Vec<CertificateInfo>,
pub chain_length: usize,
pub chain_size_bytes: usize,
}
pub struct CertificateParser {
target: Target,
connect_timeout: Duration,
read_timeout: Duration,
mtls_config: Option<MtlsConfig>,
}
const EV_POLICY_OIDS: &[&str] = &[
"2.16.840.1.114412.2.1", "2.16.840.1.114412.1.3.0.2", "2.16.840.1.114028.10.1.2", "1.3.6.1.4.1.6449.1.2.1.5.1", "1.3.6.1.4.1.4146.1.1", "2.16.756.1.89.1.2.1.1", "1.3.6.1.4.1.782.1.2.1.8.1", "1.3.6.1.4.1.8024.0.2.100.1.2", "2.16.840.1.114413.1.7.23.3", "2.16.840.1.113733.1.7.48.1", "2.16.840.1.113733.1.7.23.6", "1.2.616.1.113527.2.5.1.1", "2.23.140.1.1", "1.3.6.1.4.1.4146.1.10", "2.16.578.1.26.1.3.3", "1.2.392.200091.100.721.1", "2.16.792.3.0.4.1.1.4", "2.16.792.1.2.1.1.5.7.1.9", "1.2.250.1.177.1.18.2.2", "1.3.6.1.4.1.17326.10.14.2.1.2", "1.3.159.1.17.1", "1.2.156.112570.1.1.3", ];
fn check_extended_validation(cert: &X509Certificate) -> Result<(bool, Vec<String>)> {
let mut policy_oids = Vec::new();
let mut is_ev = false;
if let Ok(Some(ext)) =
cert.get_extension_unique(&oid_registry::OID_X509_EXT_CERTIFICATE_POLICIES)
{
if let ParsedExtension::CertificatePolicies(policies) = ext.parsed_extension() {
for policy in policies.iter() {
let oid_str = policy.policy_id.to_id_string();
policy_oids.push(oid_str.clone());
if EV_POLICY_OIDS.contains(&oid_str.as_str()) {
is_ev = true;
}
}
}
}
Ok((is_ev, policy_oids))
}
fn calculate_pin_sha256(der_bytes: &[u8]) -> Result<Option<String>> {
let cert = OpensslX509::from_der(der_bytes)
.map_err(|e| anyhow::anyhow!("Failed to parse certificate with OpenSSL: {}", e))?;
let public_key = cert
.public_key()
.map_err(|e| anyhow::anyhow!("Failed to extract public key: {}", e))?;
let spki_der = public_key
.public_key_to_der()
.map_err(|e| anyhow::anyhow!("Failed to encode public key to DER: {}", e))?;
let digest = openssl::hash::hash(MessageDigest::sha256(), &spki_der)
.map_err(|e| anyhow::anyhow!("Failed to compute SHA256 hash: {}", e))?;
let pin = base64::engine::general_purpose::STANDARD.encode(digest);
Ok(Some(pin))
}
fn extract_rsa_exponent(der_bytes: &[u8]) -> Result<Option<String>> {
let cert = OpensslX509::from_der(der_bytes)
.map_err(|e| anyhow::anyhow!("Failed to parse certificate with OpenSSL: {}", e))?;
let public_key = cert
.public_key()
.map_err(|e| anyhow::anyhow!("Failed to extract public key: {}", e))?;
match public_key.rsa() {
Ok(rsa) => {
let exponent = rsa.e();
let exponent_str = exponent
.to_dec_str()
.map_err(|e| anyhow::anyhow!("Failed to convert exponent to string: {}", e))?;
Ok(Some(format!("e {}", exponent_str)))
}
Err(_) => {
Ok(None)
}
}
}
fn calculate_fingerprint_sha256(der_bytes: &[u8]) -> Result<Option<String>> {
let digest = openssl::hash::hash(MessageDigest::sha256(), der_bytes)
.map_err(|e| anyhow::anyhow!("Failed to compute SHA256 hash: {}", e))?;
let fingerprint = digest
.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(":");
Ok(Some(fingerprint))
}
fn extract_aia_url(cert: &X509Certificate) -> Result<Option<String>> {
if let Ok(Some(ext)) = cert.get_extension_unique(&oid_registry::OID_PKIX_AUTHORITY_INFO_ACCESS)
&& let ParsedExtension::AuthorityInfoAccess(aia) = ext.parsed_extension()
{
for access_desc in &aia.accessdescs {
if access_desc.access_method.to_string() == "1.3.6.1.5.5.7.48.2"
&& let GeneralName::URI(uri) = &access_desc.access_location
{
return Ok(Some(uri.to_string()));
}
}
}
Ok(None)
}
fn calculate_duration_parts(days: i64) -> (i64, i64, i64) {
let years = days / 365;
let remaining_days = days % 365;
let months = remaining_days / 30;
let final_days = remaining_days % 30;
(years, months, final_days)
}
fn format_time_phrase(years: i64, months: i64, days: i64, is_expired: bool) -> String {
let (prefix, suffix) = if is_expired {
("expired ", " ago")
} else {
("expires in ", "")
};
fn pluralize(n: i64, singular: &str) -> String {
if n == 1 {
format!("1 {}", singular)
} else {
format!("{} {}s", n, singular)
}
}
let phrase = if years > 0 {
if months > 0 {
format!(
"{} and {}",
pluralize(years, "year"),
pluralize(months, "month")
)
} else {
pluralize(years, "year")
}
} else if months > 0 {
if days > 0 {
format!(
"{} and {}",
pluralize(months, "month"),
pluralize(days, "day")
)
} else {
pluralize(months, "month")
}
} else {
pluralize(days, "day")
};
format!("{}{}{}", prefix, phrase, suffix)
}
fn format_expiry_countdown(not_after_str: &str) -> Option<String> {
use chrono::NaiveDateTime;
let not_after = chrono::DateTime::parse_from_rfc3339(not_after_str)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
NaiveDateTime::parse_from_str(not_after_str, "%Y-%m-%d %H:%M:%S UTC")
.map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc))
})
.or_else(|_| {
let cleaned = not_after_str.replace(" UTC", "").replace(" GMT", "");
NaiveDateTime::parse_from_str(&cleaned, "%Y-%m-%d %H:%M:%S")
.map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc))
})
.ok()?;
let duration = not_after.signed_duration_since(Utc::now());
let is_expired = duration.num_seconds() < 0;
let total_days = duration.num_days().abs();
if total_days == 0 {
return Some(
if is_expired {
"expired today"
} else {
"expires today"
}
.to_string(),
);
}
let (years, months, days) = calculate_duration_parts(total_days);
Some(format_time_phrase(years, months, days, is_expired))
}
fn check_debian_weak_key(der_bytes: &[u8]) -> Option<bool> {
match OpensslX509::from_der(der_bytes) {
Ok(cert) => crate::vulnerabilities::debian_keys::is_debian_weak_key(&cert).ok(),
Err(_) => None,
}
}
fn check_certificate_transparency(cert: &X509Certificate) -> Option<String> {
const SCT_EXTENSION_OID: &str = "1.3.6.1.4.1.11129.2.4.2";
for ext in cert.extensions() {
let oid_str = ext.oid.to_id_string();
if oid_str == SCT_EXTENSION_OID {
return Some("Yes (certificate)".to_string());
}
}
Some("No".to_string())
}
impl CertificateParser {
pub fn new(target: Target) -> Self {
Self {
target,
connect_timeout: Duration::from_secs(10),
read_timeout: Duration::from_secs(5),
mtls_config: None,
}
}
pub fn with_mtls(target: Target, mtls_config: MtlsConfig) -> Self {
Self {
target,
connect_timeout: Duration::from_secs(10),
read_timeout: Duration::from_secs(5),
mtls_config: Some(mtls_config),
}
}
pub async fn get_certificate_chain(&self) -> Result<CertificateChain> {
use rustls::{ClientConfig, RootCertStore};
use std::sync::Arc;
use tokio_rustls::TlsConnector;
let addr = self.target.socket_addrs()[0];
let stream = timeout(self.connect_timeout, TcpStream::connect(addr)).await??;
let connector = if let Some(ref mtls_config) = self.mtls_config {
mtls_config.build_tls_connector()?
} else {
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
TlsConnector::from(Arc::new(config))
};
let hostname = self.target.hostname.clone();
let domain = rustls_pki_types::ServerName::try_from(hostname.as_str())
.map_err(|_| anyhow::anyhow!("Invalid DNS name"))?
.to_owned();
let tls_stream = timeout(self.read_timeout, connector.connect(domain, stream)).await??;
let (_io, connection) = tls_stream.into_inner();
let peer_certificates = connection.peer_certificates();
if peer_certificates.is_none() {
return Err(anyhow::anyhow!("No certificates received from server").into());
}
let certs = peer_certificates
.ok_or_else(|| anyhow::anyhow!("No certificates received from server"))?;
let mut parsed_certs = Vec::new();
for cert_der in certs {
let cert_info = Self::parse_certificate(cert_der)?;
parsed_certs.push(cert_info);
}
let chain_size_bytes: usize = parsed_certs.iter().map(|c| c.der_bytes.len()).sum();
Ok(CertificateChain {
chain_length: parsed_certs.len(),
chain_size_bytes,
certificates: parsed_certs,
})
}
pub fn parse_certificate(der_bytes: &[u8]) -> Result<CertificateInfo> {
let (_, cert) = X509Certificate::from_der(der_bytes)
.map_err(|e| anyhow::anyhow!("Failed to parse certificate: {:?}", e))?;
let mut san = Vec::new();
if let Ok(Some(ext)) =
cert.get_extension_unique(&oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME)
&& let ParsedExtension::SubjectAlternativeName(san_gen) = ext.parsed_extension()
{
for name in &san_gen.general_names {
match name {
GeneralName::DNSName(dns) => {
san.push(dns.to_string());
}
GeneralName::IPAddress(ip) => {
san.push(format!("IP:{}", hex::encode(ip)));
}
_ => {}
}
}
}
let mut key_usage = Vec::new();
if let Ok(Some(ext)) = cert.get_extension_unique(&oid_registry::OID_X509_EXT_KEY_USAGE)
&& let ParsedExtension::KeyUsage(ku) = ext.parsed_extension()
{
if ku.digital_signature() {
key_usage.push("Digital Signature".to_string());
}
if ku.key_encipherment() {
key_usage.push("Key Encipherment".to_string());
}
if ku.key_cert_sign() {
key_usage.push("Certificate Sign".to_string());
}
}
let mut extended_key_usage = Vec::new();
if let Ok(Some(ext)) =
cert.get_extension_unique(&oid_registry::OID_X509_EXT_EXTENDED_KEY_USAGE)
&& let ParsedExtension::ExtendedKeyUsage(eku) = ext.parsed_extension()
{
for oid in eku.other.iter() {
let usage = match oid.to_string().as_str() {
"1.3.6.1.5.5.7.3.1" => "Server Authentication",
"1.3.6.1.5.5.7.3.2" => "Client Authentication",
"1.3.6.1.5.5.7.3.3" => "Code Signing",
"1.3.6.1.5.5.7.3.4" => "Email Protection",
_ => "Unknown",
};
extended_key_usage.push(usage.to_string());
}
if eku.server_auth && !extended_key_usage.contains(&"Server Authentication".to_string())
{
extended_key_usage.push("Server Authentication".to_string());
}
if eku.client_auth && !extended_key_usage.contains(&"Client Authentication".to_string())
{
extended_key_usage.push("Client Authentication".to_string());
}
}
let is_ca = cert
.basic_constraints()
.ok()
.flatten()
.map(|ext| ext.value.ca)
.unwrap_or(false);
let public_key_size = match cert.public_key().parsed() {
Ok(x509_parser::public_key::PublicKey::RSA(rsa)) => Some(rsa.key_size()),
Ok(x509_parser::public_key::PublicKey::EC(ec)) => Some(ec.key_size()),
_ => None,
};
let (extended_validation, ev_oids) = check_extended_validation(&cert)?;
let rsa_exponent = extract_rsa_exponent(der_bytes).ok().flatten();
let fingerprint_sha256 = calculate_fingerprint_sha256(der_bytes).ok().flatten();
let pin_sha256 = calculate_pin_sha256(der_bytes).ok().flatten();
let not_after_str = cert.validity().not_after.to_string();
let expiry_countdown = format_expiry_countdown(¬_after_str);
let debian_weak_key = check_debian_weak_key(der_bytes);
let aia_url = extract_aia_url(&cert).ok().flatten();
let certificate_transparency = check_certificate_transparency(&cert);
Ok(CertificateInfo {
subject: cert.subject().to_string(),
issuer: cert.issuer().to_string(),
serial_number: format!("{:x}", cert.serial),
not_before: cert.validity().not_before.to_string(),
not_after: not_after_str,
expiry_countdown,
signature_algorithm: cert.signature_algorithm.algorithm.to_string(),
public_key_algorithm: cert.public_key().algorithm.algorithm.to_string(),
public_key_size,
rsa_exponent,
san,
is_ca,
key_usage,
extended_key_usage,
extended_validation,
ev_oids,
pin_sha256,
fingerprint_sha256,
debian_weak_key,
aia_url,
certificate_transparency,
der_bytes: der_bytes.to_vec(),
})
}
#[allow(dead_code)]
fn check_debian_weak_key_cert(&self, cert_info: &CertificateInfo) -> Result<Option<bool>> {
if cert_info.der_bytes.is_empty() {
return Ok(None);
}
match OpensslX509::from_der(&cert_info.der_bytes) {
Ok(cert) => match crate::vulnerabilities::debian_keys::is_debian_weak_key(&cert) {
Ok(is_weak) => Ok(Some(is_weak)),
Err(_) => Ok(None),
},
Err(_) => Ok(None),
}
}
pub async fn get_leaf_certificate(&self) -> Result<CertificateInfo> {
let chain = self.get_certificate_chain().await?;
Ok(chain
.certificates
.first()
.cloned()
.ok_or_else(|| anyhow::anyhow!("No certificates in chain"))?)
}
}
impl CertificateChain {
pub fn leaf(&self) -> Option<&CertificateInfo> {
self.certificates.first()
}
pub fn intermediates(&self) -> &[CertificateInfo] {
if self.certificates.len() > 1 {
&self.certificates[1..]
} else {
&[]
}
}
pub fn is_complete(&self) -> bool {
self.certificates.last().is_some_and(|c| c.is_ca)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore] async fn test_get_certificate_chain() {
let target = Target::parse("www.google.com:443")
.await
.expect("Failed to parse target");
let parser = CertificateParser::new(target);
let chain = parser
.get_certificate_chain()
.await
.expect("Failed to get certificate chain");
assert!(chain.chain_length > 0);
assert!(!chain.certificates.is_empty());
let expected_size: usize = chain.certificates.iter().map(|c| c.der_bytes.len()).sum();
assert_eq!(
chain.chain_size_bytes, expected_size,
"Chain size should match sum of DER bytes"
);
assert!(
chain.chain_size_bytes > 0,
"Chain size should be greater than 0"
);
let leaf = chain.leaf().expect("Chain should have a leaf certificate");
assert!(!leaf.subject.is_empty());
assert!(!leaf.san.is_empty());
}
#[tokio::test]
#[ignore] async fn test_get_leaf_certificate() {
let target = Target::parse("www.google.com:443")
.await
.expect("Failed to parse target");
let parser = CertificateParser::new(target);
let cert = parser
.get_leaf_certificate()
.await
.expect("Failed to get leaf certificate");
assert!(!cert.subject.is_empty());
assert!(!cert.issuer.is_empty());
assert!(cert.public_key_size.is_some());
}
#[test]
fn test_ev_oids_list() {
assert!(EV_POLICY_OIDS.contains(&"2.16.840.1.114412.2.1")); assert!(EV_POLICY_OIDS.contains(&"1.3.6.1.4.1.6449.1.2.1.5.1")); assert!(EV_POLICY_OIDS.contains(&"1.3.6.1.4.1.4146.1.1")); assert!(EV_POLICY_OIDS.contains(&"2.23.140.1.1")); }
#[test]
fn test_check_extended_validation_detection() {
#[allow(clippy::absurd_extreme_comparisons)]
{
assert!(!EV_POLICY_OIDS.is_empty());
}
assert!(EV_POLICY_OIDS.len() > 20); }
#[tokio::test]
#[ignore] async fn test_pin_sha256_calculation() {
let target = Target::parse("www.google.com:443")
.await
.expect("Failed to parse target");
let parser = CertificateParser::new(target);
let cert = parser
.get_leaf_certificate()
.await
.expect("Failed to get leaf certificate");
assert!(cert.pin_sha256.is_some(), "Pin SHA256 should be calculated");
let pin = cert.pin_sha256.expect("Pin SHA256 should be present");
assert_eq!(
pin.len(),
44,
"Pin SHA256 should be 44 characters (Base64-encoded SHA256)"
);
assert!(
pin.ends_with('=')
|| pin
.chars()
.all(|c| c.is_alphanumeric() || c == '+' || c == '/'),
"Pin should be valid Base64"
);
println!("Calculated Pin SHA256 for google.com: {}", pin);
}
#[test]
fn test_calculate_pin_sha256_function() {
use openssl::asn1::Asn1Time;
use openssl::bn::{BigNum, MsbOption};
use openssl::hash::MessageDigest as OpensslMessageDigest;
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::x509::{X509Builder, X509NameBuilder};
let rsa = Rsa::generate(2048).expect("Failed to generate RSA key");
let pkey = PKey::from_rsa(rsa).expect("Failed to create PKey from RSA");
let mut builder = X509Builder::new().expect("Failed to create X509Builder");
builder.set_version(2).expect("Failed to set version");
let mut serial = BigNum::new().expect("Failed to create BigNum");
serial
.rand(128, MsbOption::MAYBE_ZERO, false)
.expect("Failed to generate random serial");
let serial = serial
.to_asn1_integer()
.expect("Failed to convert to ASN1 integer");
builder
.set_serial_number(&serial)
.expect("Failed to set serial number");
let mut name_builder = X509NameBuilder::new().expect("Failed to create X509NameBuilder");
name_builder
.append_entry_by_text("C", "US")
.expect("Failed to set country");
name_builder
.append_entry_by_text("O", "Test")
.expect("Failed to set organization");
name_builder
.append_entry_by_text("CN", "test.example.com")
.expect("Failed to set common name");
let name = name_builder.build();
builder
.set_subject_name(&name)
.expect("Failed to set subject name");
builder
.set_issuer_name(&name)
.expect("Failed to set issuer name");
let not_before = Asn1Time::days_from_now(0).expect("Failed to create not_before time");
let not_after = Asn1Time::days_from_now(365).expect("Failed to create not_after time");
builder
.set_not_before(¬_before)
.expect("Failed to set not_before");
builder
.set_not_after(¬_after)
.expect("Failed to set not_after");
builder.set_pubkey(&pkey).expect("Failed to set public key");
builder
.sign(&pkey, OpensslMessageDigest::sha256())
.expect("Failed to sign certificate");
let cert = builder.build();
let der_bytes = cert.to_der().expect("Failed to convert certificate to DER");
let pin = calculate_pin_sha256(&der_bytes).expect("Failed to calculate pin SHA256");
assert!(
pin.is_some(),
"Pin should be calculated for self-signed cert"
);
assert_eq!(
pin.as_ref().expect("Pin should be Some").len(),
44,
"Pin should be 44 characters"
);
println!(
"Test certificate Pin SHA256: {}",
pin.expect("Pin should be present")
);
}
}