use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use aa_core::config::TlsConfig;
use thiserror::Error;
use x509_parser::pem::Pem;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TlsValidation {
Ok,
ExpiringSoon {
days_until_expiry: i64,
},
Expired {
expired_days_ago: i64,
},
}
const EXPIRING_SOON_DAYS: i64 = 30;
const SECONDS_PER_DAY: i64 = 86_400;
pub fn validate(cfg: &TlsConfig) -> Result<TlsValidation, TlsError> {
if !cfg.cert_file.exists() {
return Err(TlsError::CertFileMissing(cfg.cert_file.clone()));
}
if !cfg.key_file.exists() {
return Err(TlsError::KeyFileMissing(cfg.key_file.clone()));
}
let cert_bytes = read_file(&cfg.cert_file)?;
let _key_bytes = read_file(&cfg.key_file)?;
let leaf_pem = Pem::iter_from_buffer(&cert_bytes)
.next()
.ok_or_else(|| TlsError::CertParse("no PEM block in cert file".to_string()))?
.map_err(|e| TlsError::CertParse(format!("pem decode: {e}")))?;
let x509 = leaf_pem
.parse_x509()
.map_err(|e| TlsError::CertParse(format!("x509 decode: {e}")))?;
let not_after_secs = x509.validity().not_after.timestamp();
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days_until = (not_after_secs - now_secs) / SECONDS_PER_DAY;
if days_until < 0 {
Ok(TlsValidation::Expired {
expired_days_ago: -days_until,
})
} else if days_until <= EXPIRING_SOON_DAYS {
Ok(TlsValidation::ExpiringSoon {
days_until_expiry: days_until,
})
} else {
Ok(TlsValidation::Ok)
}
}
fn read_file(path: &Path) -> Result<Vec<u8>, TlsError> {
fs::read(path).map_err(|source| TlsError::Io {
path: path.to_path_buf(),
source,
})
}
#[derive(Debug, Error)]
pub enum TlsError {
#[error("TLS cert_file not found: {0}")]
CertFileMissing(PathBuf),
#[error("TLS key_file not found: {0}")]
KeyFileMissing(PathBuf),
#[error("failed to read TLS file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse TLS cert as PEM x509: {0}")]
CertParse(String),
}
#[cfg(test)]
mod tests {
use rcgen::{CertificateParams, KeyPair};
use tempfile::TempDir;
use time::{Duration as TimeDuration, OffsetDateTime};
use super::*;
fn issue_cert_with_validity(not_before_days: i64, not_after_days: i64) -> (Vec<u8>, Vec<u8>) {
let now = OffsetDateTime::now_utc();
let mut params = CertificateParams::new(vec!["test.example".to_string()]).expect("params");
params.not_before = now + TimeDuration::days(not_before_days);
params.not_after = now + TimeDuration::days(not_after_days);
let key_pair = KeyPair::generate().expect("key_pair");
let cert = params.self_signed(&key_pair).expect("self-signed");
(cert.pem().into_bytes(), key_pair.serialize_pem().into_bytes())
}
fn write_pair(cert_pem: &[u8], key_pem: &[u8]) -> (TempDir, TlsConfig) {
let dir = TempDir::new().expect("tempdir");
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
fs::write(&cert_path, cert_pem).expect("write cert");
fs::write(&key_path, key_pem).expect("write key");
let cfg = TlsConfig {
cert_file: cert_path,
key_file: key_path,
};
(dir, cfg)
}
#[test]
fn returns_ok_for_fresh_year_long_cert() {
let (cert, key) = issue_cert_with_validity(-1, 365);
let (_dir, cfg) = write_pair(&cert, &key);
assert_eq!(validate(&cfg).expect("validate"), TlsValidation::Ok);
}
#[test]
fn flags_expiring_soon_within_30_days() {
let (cert, key) = issue_cert_with_validity(-1, 10);
let (_dir, cfg) = write_pair(&cert, &key);
let result = validate(&cfg).expect("validate");
match result {
TlsValidation::ExpiringSoon { days_until_expiry } => {
assert!(
(9..=10).contains(&days_until_expiry),
"expected days_until_expiry in 9..=10, got {days_until_expiry}"
);
}
other => panic!("expected ExpiringSoon, got {other:?}"),
}
}
#[test]
fn flags_expired_for_past_not_after() {
let (cert, key) = issue_cert_with_validity(-100, -7);
let (_dir, cfg) = write_pair(&cert, &key);
let result = validate(&cfg).expect("validate");
match result {
TlsValidation::Expired { expired_days_ago } => {
assert!(
(6..=7).contains(&expired_days_ago),
"expected expired_days_ago in 6..=7, got {expired_days_ago}"
);
}
other => panic!("expected Expired, got {other:?}"),
}
}
#[test]
fn errors_when_cert_file_missing() {
let (cert, key) = issue_cert_with_validity(-1, 365);
let (dir, mut cfg) = write_pair(&cert, &key);
cfg.cert_file = dir.path().join("does-not-exist.pem");
match validate(&cfg).expect_err("expected error") {
TlsError::CertFileMissing(path) => assert_eq!(path, cfg.cert_file),
other => panic!("expected CertFileMissing, got {other:?}"),
}
}
#[test]
fn errors_when_key_file_missing() {
let (cert, key) = issue_cert_with_validity(-1, 365);
let (dir, mut cfg) = write_pair(&cert, &key);
cfg.key_file = dir.path().join("missing-key.pem");
match validate(&cfg).expect_err("expected error") {
TlsError::KeyFileMissing(path) => assert_eq!(path, cfg.key_file),
other => panic!("expected KeyFileMissing, got {other:?}"),
}
}
#[test]
fn errors_when_cert_is_not_pem() {
let (_real_cert, key) = issue_cert_with_validity(-1, 365);
let junk = b"this is not a PEM-wrapped X.509 cert".to_vec();
let (_dir, cfg) = write_pair(&junk, &key);
match validate(&cfg).expect_err("expected error") {
TlsError::CertParse(_) => {}
other => panic!("expected CertParse, got {other:?}"),
}
}
}