use std::net::IpAddr;
use std::path::Path;
use chrono::{DateTime, Duration as ChronoDuration, Timelike, Utc};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use sha2::{Digest, Sha256};
use x509_parser::prelude::*;
use crate::acme_client::RenewalInfo;
use crate::error::{CertError, CryptoError, Result};
use crate::ocsp::OcspStatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrivateKeyKind {
Pkcs8,
Pkcs1,
Sec1,
None,
}
pub const DEFAULT_RENEWAL_WINDOW_RATIO: f64 = 1.0 / 3.0;
const EMERGENCY_RENEWAL_HOURS: i64 = 24;
#[derive(Debug, Clone)]
pub struct Certificate {
pub cert_chain: Vec<CertificateDer<'static>>,
pub private_key_der: Option<Vec<u8>>,
pub private_key_kind: PrivateKeyKind,
pub names: Vec<String>,
pub tags: Vec<String>,
pub managed: bool,
pub issuer_key: String,
pub hash: String,
pub ocsp_response: Option<Vec<u8>>,
pub ocsp_status: Option<OcspStatus>,
pub not_after: DateTime<Utc>,
pub not_before: DateTime<Utc>,
pub ari: Option<RenewalInfo>,
}
impl Certificate {
pub fn is_empty(&self) -> bool {
self.cert_chain.is_empty()
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn expired(&self) -> bool {
Utc::now() > expires_at(self.not_after)
}
pub fn lifetime(&self) -> ChronoDuration {
self.not_after - self.not_before
}
pub fn needs_renewal(&self, renewal_window_ratio: f64) -> bool {
if self.expired() {
return true;
}
if let Some(ref ari) = self.ari {
if let Some(selected_time) = ari.selected_time {
if Utc::now() >= selected_time {
return true;
}
} else if let Some(ref window) = ari.suggested_window {
if let (Ok(start), Ok(end)) = (
DateTime::parse_from_rfc3339(&window.start),
DateTime::parse_from_rfc3339(&window.end),
) {
let start_utc = start.with_timezone(&Utc);
let end_utc = end.with_timezone(&Utc);
if start_utc < end_utc {
use rand::RngExt;
let range_secs = (end_utc - start_utc).num_seconds().max(1);
let offset = rand::rng().random_range(0..range_secs);
let random_time = start_utc + ChronoDuration::seconds(offset);
if Utc::now() >= random_time {
return true;
}
}
}
}
}
if currently_in_renewal_window(self.not_before, self.not_after, renewal_window_ratio) {
return true;
}
let remaining = expires_at(self.not_after) - Utc::now();
if remaining < ChronoDuration::hours(EMERGENCY_RENEWAL_HOURS) {
return true;
}
if currently_in_renewal_window(self.not_before, self.not_after, 1.0 / 50.0) {
return true;
}
false
}
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t == tag)
}
pub fn from_pem(cert_pem: &[u8], key_pem: &[u8]) -> Result<Self> {
let cert_pem_str = std::str::from_utf8(cert_pem).map_err(|e| {
CryptoError::InvalidCertificate(format!("cert PEM is not valid UTF-8: {e}"))
})?;
let key_pem_str = std::str::from_utf8(key_pem)
.map_err(|e| CryptoError::InvalidKey(format!("key PEM is not valid UTF-8: {e}")))?;
let cert_ders = parse_cert_chain_from_pem(cert_pem_str)?;
if cert_ders.is_empty() {
return Err(CryptoError::InvalidCertificate(
"no certificates found in PEM data".into(),
)
.into());
}
let private_key = parse_private_key_from_pem(key_pem_str)?;
let leaf_der = cert_ders[0].as_ref();
let (_, leaf) = X509Certificate::from_der(leaf_der).map_err(|e| {
CryptoError::InvalidCertificate(format!("failed to parse leaf certificate: {e}"))
})?;
let names = extract_names(&leaf)?;
let not_before = asn1_time_to_chrono(leaf.validity().not_before)?;
let not_after = asn1_time_to_chrono(leaf.validity().not_after)?;
let hash = hash_certificate_chain(&cert_ders);
let (pk_der, pk_kind) = private_key_to_raw(private_key);
Ok(Certificate {
cert_chain: cert_ders,
private_key_der: Some(pk_der),
private_key_kind: pk_kind,
names,
tags: Vec::new(),
managed: false,
issuer_key: String::new(),
hash,
ocsp_response: None,
ocsp_status: None,
not_after,
not_before,
ari: None,
})
}
pub fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result<Self> {
let cert_pem = std::fs::read(cert_path).map_err(|e| {
CryptoError::InvalidCertificate(format!(
"failed to read certificate file {}: {e}",
cert_path.display()
))
})?;
let key_pem = std::fs::read(key_path).map_err(|e| {
CryptoError::InvalidKey(format!(
"failed to read key file {}: {e}",
key_path.display()
))
})?;
Self::from_pem(&cert_pem, &key_pem)
}
pub fn with_ocsp(mut self, ocsp_response: Vec<u8>) -> Self {
self.ocsp_response = Some(ocsp_response);
self
}
pub fn from_der(
cert_chain: Vec<CertificateDer<'static>>,
private_key: Option<PrivateKeyDer<'static>>,
) -> Result<Self> {
if cert_chain.is_empty() {
return Err(
CryptoError::InvalidCertificate("certificate chain is empty".into()).into(),
);
}
let leaf_der = cert_chain[0].as_ref();
let (_, leaf) = X509Certificate::from_der(leaf_der).map_err(|e| {
CryptoError::InvalidCertificate(format!("failed to parse leaf certificate: {e}"))
})?;
let names = extract_names(&leaf)?;
let not_before = asn1_time_to_chrono(leaf.validity().not_before)?;
let not_after = asn1_time_to_chrono(leaf.validity().not_after)?;
let hash = hash_certificate_chain(&cert_chain);
let (pk_der, pk_kind) = match private_key {
Some(pk) => {
let (der, kind) = private_key_to_raw(pk);
(Some(der), kind)
}
None => (None, PrivateKeyKind::None),
};
Ok(Certificate {
cert_chain,
private_key_der: pk_der,
private_key_kind: pk_kind,
names,
tags: Vec::new(),
managed: false,
issuer_key: String::new(),
hash,
ocsp_response: None,
ocsp_status: None,
not_after,
not_before,
ari: None,
})
}
}
pub async fn reload_managed_certificate(
cache: &crate::cache::CertCache,
storage: &dyn crate::storage::Storage,
domain: &str,
issuer_key: &str,
) -> Result<()> {
use crate::storage::{site_cert_key, site_private_key};
let cert_key = site_cert_key(issuer_key, domain);
let key_key = site_private_key(issuer_key, domain);
let cert_pem = storage.load(&cert_key).await?;
let key_pem = storage.load(&key_key).await?;
let mut cert = Certificate::from_pem(&cert_pem, &key_pem)?;
cert.managed = true;
cert.issuer_key = issuer_key.to_owned();
let existing = cache.get_by_name(domain).await;
match existing {
Some(old_cert) => {
cache.replace(&old_cert.hash, cert).await;
}
None => {
cache.add(cert).await;
}
}
Ok(())
}
pub async fn managed_cert_in_storage_needs_renewal(
storage: &dyn crate::storage::Storage,
domain: &str,
issuer_key: &str,
renewal_window_ratio: f64,
) -> Result<bool> {
use crate::storage::{site_cert_key, site_private_key};
let cert_key = site_cert_key(issuer_key, domain);
let key_key = site_private_key(issuer_key, domain);
let cert_pem = match storage.load(&cert_key).await {
Ok(data) => data,
Err(_) => return Ok(true), };
let key_pem = match storage.load(&key_key).await {
Ok(data) => data,
Err(_) => return Ok(true),
};
let cert = match Certificate::from_pem(&cert_pem, &key_pem) {
Ok(c) => c,
Err(_) => return Ok(true), };
Ok(cert.needs_renewal(renewal_window_ratio))
}
pub fn currently_in_renewal_window(
not_before: DateTime<Utc>,
not_after: DateTime<Utc>,
renewal_window_ratio: f64,
) -> bool {
let lifetime = not_after - not_before;
if lifetime.num_seconds() <= 0 {
return false;
}
let ratio = if renewal_window_ratio <= 0.0 {
DEFAULT_RENEWAL_WINDOW_RATIO
} else {
renewal_window_ratio
};
let renewal_window_secs = (lifetime.num_seconds() as f64 * ratio) as i64;
let renewal_window = ChronoDuration::seconds(renewal_window_secs);
let renewal_start = not_after - renewal_window;
Utc::now() > renewal_start
}
fn expires_at(not_after: DateTime<Utc>) -> DateTime<Utc> {
let truncated = not_after.with_nanosecond(0).unwrap_or(not_after);
truncated + ChronoDuration::seconds(1)
}
pub fn subject_qualifies_for_cert(subject: &str) -> bool {
let trimmed = subject.trim();
if trimmed.is_empty() {
return false;
}
if subject.starts_with('.') || subject.ends_with('.') {
return false;
}
if subject.contains('*') && !subject.starts_with("*.") && subject != "*" {
return false;
}
const BAD_CHARS: &str = "()[]{}<> \t\n\"\\!@#$%^&|;'+=";
if subject.chars().any(|c| BAD_CHARS.contains(c)) {
return false;
}
true
}
pub fn subject_qualifies_for_public_cert(subject: &str) -> bool {
if !subject_qualifies_for_cert(subject) {
return false;
}
if subject_is_internal(subject) {
return false;
}
if subject.contains('*') {
let star_count = subject.matches('*').count();
let dot_count = subject.matches('.').count();
if star_count != 1 || dot_count <= 1 || subject.len() <= 2 || !subject.starts_with("*.") {
return false;
}
}
true
}
pub fn subject_is_ip(subject: &str) -> bool {
subject.parse::<IpAddr>().is_ok()
}
pub fn subject_is_internal(subject: &str) -> bool {
let subj = host_only(subject).to_lowercase();
let subj = subj.trim_end_matches('.');
subj == "localhost"
|| subj.ends_with(".localhost")
|| subj.ends_with(".local")
|| subj.ends_with(".internal")
|| subj.ends_with(".home.arpa")
|| is_internal_ip(subj)
}
fn is_internal_ip(addr: &str) -> bool {
let host = host_only(addr);
let ip: IpAddr = match host.parse() {
Ok(ip) => ip,
Err(_) => return false,
};
match ip {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_unspecified() || v4.is_private() || v4.is_link_local() }
IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
}
}
}
fn host_only(hostport: &str) -> &str {
if hostport.starts_with('[')
&& let Some(end) = hostport.find(']')
{
return &hostport[1..end];
}
if hostport.matches(':').count() > 1 {
return hostport;
}
if let Some(colon_pos) = hostport.rfind(':') {
let after = &hostport[colon_pos + 1..];
if !after.is_empty() && after.chars().all(|c| c.is_ascii_digit()) {
return &hostport[..colon_pos];
}
}
hostport
}
pub fn match_wildcard(subject: &str, wildcard: &str) -> bool {
let subject = subject.to_lowercase();
let wildcard = wildcard.to_lowercase();
if subject == wildcard {
return true;
}
if !wildcard.contains('*') {
return false;
}
let labels: Vec<&str> = subject.split('.').collect();
for i in 0..labels.len() {
if labels[i].is_empty() {
continue;
}
let mut candidate: Vec<&str> = labels.clone();
candidate[i] = "*";
let joined = candidate.join(".");
if joined == wildcard {
return true;
}
}
false
}
fn private_key_to_raw(pk: PrivateKeyDer<'static>) -> (Vec<u8>, PrivateKeyKind) {
match pk {
PrivateKeyDer::Pkcs8(der) => (der.secret_pkcs8_der().to_vec(), PrivateKeyKind::Pkcs8),
PrivateKeyDer::Pkcs1(der) => (der.secret_pkcs1_der().to_vec(), PrivateKeyKind::Pkcs1),
PrivateKeyDer::Sec1(der) => (der.secret_sec1_der().to_vec(), PrivateKeyKind::Sec1),
_ => (Vec::new(), PrivateKeyKind::None),
}
}
fn parse_cert_chain_from_pem(pem_str: &str) -> Result<Vec<CertificateDer<'static>>> {
let pems: Vec<::pem::Pem> = ::pem::parse_many(pem_str)
.map_err(|e| CryptoError::InvalidCertificate(format!("failed to parse PEM bundle: {e}")))?;
let certs: Vec<CertificateDer<'static>> = pems
.into_iter()
.filter(|p: &::pem::Pem| p.tag() == "CERTIFICATE")
.map(|p: ::pem::Pem| CertificateDer::from(p.into_contents()))
.collect();
if certs.is_empty() {
return Err(
CryptoError::InvalidCertificate("no certificates found in PEM data".into()).into(),
);
}
Ok(certs)
}
fn parse_private_key_from_pem(pem_str: &str) -> Result<PrivateKeyDer<'static>> {
let parsed = ::pem::parse(pem_str)
.map_err(|e| CryptoError::InvalidKey(format!("failed to parse key PEM: {e}")))?;
let tag = parsed.tag().to_owned();
let der = parsed.into_contents();
match tag.as_str() {
"PRIVATE KEY" | "ED25519 PRIVATE KEY" => {
Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der)))
}
"RSA PRIVATE KEY" => Ok(PrivateKeyDer::Pkcs1(
rustls::pki_types::PrivatePkcs1KeyDer::from(der),
)),
"EC PRIVATE KEY" => Ok(PrivateKeyDer::Sec1(
rustls::pki_types::PrivateSec1KeyDer::from(der),
)),
other if other.ends_with("PRIVATE KEY") => {
Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(der)))
}
_ => Err(
CryptoError::InvalidKey(format!("unsupported PEM tag for private key: {tag}")).into(),
),
}
}
fn extract_names(cert: &X509Certificate<'_>) -> Result<Vec<String>> {
let mut names = Vec::new();
let cn = cert
.subject()
.iter_common_name()
.next()
.and_then(|attr| attr.as_str().ok())
.map(|s| s.to_lowercase());
if let Some(ref cn) = cn
&& !cn.is_empty()
{
names.push(cn.clone());
}
if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
for name in &san_ext.value.general_names {
let san_str = match name {
GeneralName::DNSName(dns) => Some(dns.to_lowercase()),
GeneralName::IPAddress(ip_bytes) => {
parse_ip_from_bytes(ip_bytes).map(|ip| ip.to_string().to_lowercase())
}
GeneralName::RFC822Name(email) => Some(email.to_lowercase()),
GeneralName::URI(uri) => Some(uri.to_string()),
_ => None,
};
if let Some(san) = san_str {
let dominated_by_cn = cn.as_ref() == Some(&san);
if !dominated_by_cn && !san.is_empty() {
names.push(san);
}
}
}
}
if names.is_empty() {
return Err(
CertError::InvalidDomain("certificate has no names (no CN or SANs)".into()).into(),
);
}
Ok(names)
}
pub fn extract_names_from_der(cert_der: &[u8]) -> Result<Vec<String>> {
let (_, cert) = X509Certificate::from_der(cert_der).map_err(|e| {
CryptoError::InvalidCertificate(format!("failed to parse certificate: {e}"))
})?;
extract_names(&cert)
}
fn parse_ip_from_bytes(bytes: &[u8]) -> Option<IpAddr> {
match bytes.len() {
4 => {
let octets: [u8; 4] = bytes.try_into().ok()?;
Some(IpAddr::V4(std::net::Ipv4Addr::from(octets)))
}
16 => {
let octets: [u8; 16] = bytes.try_into().ok()?;
Some(IpAddr::V6(std::net::Ipv6Addr::from(octets)))
}
_ => None,
}
}
fn asn1_time_to_chrono(t: x509_parser::time::ASN1Time) -> Result<DateTime<Utc>> {
let epoch_secs = t.timestamp();
DateTime::from_timestamp(epoch_secs, 0).ok_or_else(|| {
CryptoError::InvalidCertificate(format!(
"failed to convert ASN.1 time (epoch {epoch_secs}) to DateTime"
))
.into()
})
}
pub fn hash_certificate_chain(chain: &[CertificateDer<'_>]) -> String {
let mut hasher = Sha256::new();
for cert_der in chain {
hasher.update(cert_der.as_ref());
}
let digest = hasher.finalize();
hex_encode(&digest)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qualifies_normal_domain() {
assert!(subject_qualifies_for_cert("example.com"));
}
#[test]
fn qualifies_wildcard_domain() {
assert!(subject_qualifies_for_cert("*.example.com"));
}
#[test]
fn qualifies_bare_wildcard() {
assert!(subject_qualifies_for_cert("*"));
}
#[test]
fn does_not_qualify_empty() {
assert!(!subject_qualifies_for_cert(""));
assert!(!subject_qualifies_for_cert(" "));
}
#[test]
fn does_not_qualify_leading_dot() {
assert!(!subject_qualifies_for_cert(".example.com"));
}
#[test]
fn does_not_qualify_trailing_dot() {
assert!(!subject_qualifies_for_cert("example.com."));
}
#[test]
fn does_not_qualify_middle_wildcard() {
assert!(!subject_qualifies_for_cert("ex*ample.com"));
assert!(!subject_qualifies_for_cert("example.*.com"));
}
#[test]
fn does_not_qualify_special_chars() {
assert!(!subject_qualifies_for_cert("exam ple.com"));
assert!(!subject_qualifies_for_cert("exam[ple].com"));
}
#[test]
fn public_cert_normal_domain() {
assert!(subject_qualifies_for_public_cert("example.com"));
}
#[test]
fn public_cert_wildcard_valid() {
assert!(subject_qualifies_for_public_cert("*.example.com"));
}
#[test]
fn public_cert_rejects_localhost() {
assert!(!subject_qualifies_for_public_cert("localhost"));
}
#[test]
fn public_cert_rejects_internal_domain() {
assert!(!subject_qualifies_for_public_cert("myapp.local"));
}
#[test]
fn public_cert_rejects_loopback() {
assert!(!subject_qualifies_for_public_cert("127.0.0.1"));
}
#[test]
fn public_cert_rejects_private_ip() {
assert!(!subject_qualifies_for_public_cert("192.168.1.1"));
assert!(!subject_qualifies_for_public_cert("10.0.0.1"));
}
#[test]
fn public_cert_rejects_wildcard_too_few_labels() {
assert!(!subject_qualifies_for_public_cert("*.com"));
}
#[test]
fn is_ip_v4() {
assert!(subject_is_ip("192.168.1.1"));
}
#[test]
fn is_ip_v6() {
assert!(subject_is_ip("::1"));
}
#[test]
fn is_not_ip() {
assert!(!subject_is_ip("example.com"));
}
#[test]
fn internal_localhost() {
assert!(subject_is_internal("localhost"));
assert!(subject_is_internal("LOCALHOST"));
}
#[test]
fn internal_localhost_subdomain() {
assert!(subject_is_internal("foo.localhost"));
}
#[test]
fn internal_dot_local() {
assert!(subject_is_internal("myhost.local"));
}
#[test]
fn internal_dot_internal() {
assert!(subject_is_internal("myhost.internal"));
}
#[test]
fn internal_home_arpa() {
assert!(subject_is_internal("myhost.home.arpa"));
}
#[test]
fn internal_loopback_ip() {
assert!(subject_is_internal("127.0.0.1"));
}
#[test]
fn internal_private_ip() {
assert!(subject_is_internal("10.0.0.1"));
assert!(subject_is_internal("172.16.0.1"));
assert!(subject_is_internal("192.168.0.1"));
}
#[test]
fn not_internal_public_domain() {
assert!(!subject_is_internal("example.com"));
}
#[test]
fn not_internal_public_ip() {
assert!(!subject_is_internal("8.8.8.8"));
}
#[test]
fn wildcard_match_basic() {
assert!(match_wildcard("foo.example.com", "*.example.com"));
}
#[test]
fn wildcard_exact_match() {
assert!(match_wildcard("example.com", "example.com"));
}
#[test]
fn wildcard_no_match_different_domain() {
assert!(!match_wildcard("foo.other.com", "*.example.com"));
}
#[test]
fn wildcard_no_match_sub_sub() {
assert!(!match_wildcard("sub.sub.example.com", "*.example.com"));
}
#[test]
fn wildcard_case_insensitive() {
assert!(match_wildcard("FOO.Example.COM", "*.example.com"));
}
#[test]
fn wildcard_no_star_no_match() {
assert!(!match_wildcard("foo.example.com", "example.com"));
}
#[test]
fn renewal_window_expired_cert() {
let not_before = Utc::now() - ChronoDuration::days(100);
let not_after = Utc::now() - ChronoDuration::days(1);
assert!(currently_in_renewal_window(
not_before,
not_after,
DEFAULT_RENEWAL_WINDOW_RATIO
));
}
#[test]
fn renewal_window_fresh_cert() {
let not_before = Utc::now() - ChronoDuration::days(1);
let not_after = Utc::now() + ChronoDuration::days(89);
assert!(!currently_in_renewal_window(
not_before,
not_after,
DEFAULT_RENEWAL_WINDOW_RATIO
));
}
#[test]
fn renewal_window_due_cert() {
let not_before = Utc::now() - ChronoDuration::days(70);
let not_after = Utc::now() + ChronoDuration::days(20);
assert!(currently_in_renewal_window(
not_before,
not_after,
DEFAULT_RENEWAL_WINDOW_RATIO
));
}
#[test]
fn renewal_window_zero_ratio_uses_default() {
let not_before = Utc::now() - ChronoDuration::days(70);
let not_after = Utc::now() + ChronoDuration::days(20);
assert!(currently_in_renewal_window(not_before, not_after, 0.0));
}
#[test]
fn hash_chain_deterministic() {
let certs = vec![
CertificateDer::from(vec![1u8, 2, 3]),
CertificateDer::from(vec![4u8, 5, 6]),
];
let h1 = hash_certificate_chain(&certs);
let h2 = hash_certificate_chain(&certs);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); }
#[test]
fn hash_chain_different_data() {
let c1 = vec![CertificateDer::from(vec![1u8, 2, 3])];
let c2 = vec![CertificateDer::from(vec![4u8, 5, 6])];
assert_ne!(hash_certificate_chain(&c1), hash_certificate_chain(&c2));
}
#[test]
fn host_only_with_port() {
assert_eq!(host_only("example.com:443"), "example.com");
}
#[test]
fn host_only_without_port() {
assert_eq!(host_only("example.com"), "example.com");
}
#[test]
fn host_only_ipv6_bracket() {
assert_eq!(host_only("[::1]:8080"), "::1");
}
#[test]
fn host_only_bare_ipv6() {
assert_eq!(host_only("::1"), "::1");
}
#[test]
fn expires_at_adds_one_second() {
let t = Utc::now().with_nanosecond(0).unwrap();
let exp = expires_at(t);
assert_eq!(exp - t, ChronoDuration::seconds(1));
}
}