Files
certforge/src/certs.rs
T
Roman KivalinandClaude Opus 4.6 cf7ccb45db Fix renew_before_days not triggering on exact day boundary
expires_within_days() compared the exact expiry timestamp against
now + N days, so a cert with 3 days 23 hours remaining would report
"days_remaining=3" but not renew with renew_before_days=3.

Fix by rounding days_until_expiry() to the nearest day (using seconds
for precision) instead of truncating. This matches user expectations
and provides a half-day buffer that absorbs systemd timer
randomization delays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 21:43:59 +02:00

140 lines
4.6 KiB
Rust

use std::path::Path;
use x509_parser::prelude::*;
use crate::error::{Error, Result};
/// Parsed certificate information.
pub struct CertInfo {
/// DER-encoded leaf certificate
pub leaf_der: Vec<u8>,
/// Not-after (expiry) timestamp
pub not_after: chrono::DateTime<chrono::Utc>,
/// Subject Alternative Names
pub san: Vec<String>,
/// SubjectPublicKeyInfo DER bytes
pub spki_der: Vec<u8>,
}
impl CertInfo {
/// Load and parse a certificate from a PEM file.
pub fn load(path: &Path) -> Result<Self> {
let pem_chain = std::fs::read_to_string(path).map_err(|e| {
Error::Certificate(format!("failed to read cert {}: {e}", path.display()))
})?;
Self::from_pem(&pem_chain)
}
/// Parse a certificate from PEM data.
pub fn from_pem(pem_data: &str) -> Result<Self> {
let pem_blocks: Vec<::pem::Pem> = ::pem::parse_many(pem_data.as_bytes())
.map_err(|e| Error::Certificate(format!("failed to parse PEM: {e}")))?;
if pem_blocks.is_empty() {
return Err(Error::Certificate("no certificates found in PEM data".into()));
}
let leaf_der = pem_blocks[0].contents().to_vec();
let (_, cert) = X509Certificate::from_der(&leaf_der)
.map_err(|e| Error::Certificate(format!("failed to parse X.509: {e}")))?;
let not_after = asn1_time_to_chrono(cert.validity().not_after)?;
let san = extract_san(&cert);
let spki_der = cert.public_key().raw.to_vec();
Ok(Self {
leaf_der,
not_after,
san,
spki_der,
})
}
/// Check if the certificate expires within the given number of days.
/// Uses rounded day count so that renew_before_days = 3 triggers
/// renewal when days_until_expiry() reports 3 or less.
pub fn expires_within_days(&self, days: u32) -> bool {
self.days_until_expiry() <= days as i64
}
/// Days until expiry, rounded to nearest day (negative if already expired).
/// Rounding provides a half-day buffer that absorbs systemd timer
/// randomization without missing the renewal window.
pub fn days_until_expiry(&self) -> i64 {
let now = chrono::Utc::now();
let secs = (self.not_after - now).num_seconds();
let half_day = 43200; // 12 * 60 * 60
if secs >= 0 {
(secs + half_day) / 86400
} else {
(secs - half_day) / 86400
}
}
}
fn extract_san(cert: &X509Certificate) -> Vec<String> {
let mut names = Vec::new();
if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
for name in &san_ext.value.general_names {
match name {
GeneralName::DNSName(dns) => names.push(dns.to_string()),
GeneralName::IPAddress(ip) => {
// IP addresses come as byte slices
if ip.len() == 4 {
names.push(format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]));
} else if ip.len() == 16 {
let parts: Vec<String> = ip
.chunks(2)
.map(|c| format!("{:02x}{:02x}", c[0], c[1]))
.collect();
names.push(parts.join(":"));
}
}
_ => {}
}
}
}
names
}
fn asn1_time_to_chrono(time: ASN1Time) -> Result<chrono::DateTime<chrono::Utc>> {
let ts = time.timestamp();
chrono::DateTime::from_timestamp(ts, 0)
.ok_or_else(|| Error::Certificate(format!("invalid timestamp: {ts}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_test_cert() -> String {
use rcgen::{CertificateParams, KeyPair};
let mut params = CertificateParams::new(vec!["test.example.com".to_string()]).unwrap();
params.not_after = rcgen::date_time_ymd(2030, 1, 1);
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let cert = params.self_signed(&key_pair).unwrap();
cert.pem()
}
#[test]
fn parse_cert() {
let pem_data = generate_test_cert();
let info = CertInfo::from_pem(&pem_data).unwrap();
assert!(info.san.contains(&"test.example.com".to_string()));
assert!(!info.spki_der.is_empty());
assert!(info.days_until_expiry() > 0);
}
#[test]
fn expiry_check() {
let pem_data = generate_test_cert();
let info = CertInfo::from_pem(&pem_data).unwrap();
// Cert expires in 2030, so it should not expire within 30 days
assert!(!info.expires_within_days(30));
}
}