use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
use openssl::asn1::Asn1Time;
use openssl::x509::{X509Builder, X509Ref};
use std::cmp::Ordering;
use std::fs;
use std::fs::{OpenOptions, create_dir_all};
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
pub trait X509Parts {
fn get_pem(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>>;
fn get_private_key(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>>;
fn pem_extension(&self) -> &'static str;
}
pub trait X509Common {
fn save<P: AsRef<Path>, F: AsRef<Path>>(
&self,
path: P,
filename: F,
) -> Result<(), Box<dyn std::error::Error>>;
}
impl<T: X509Parts> X509Common for T {
fn save<P: AsRef<Path>, F: AsRef<Path>>(
&self,
path: P,
filename: F,
) -> Result<(), Box<dyn std::error::Error>> {
create_dir_all(&path)?;
let os_file = filename
.as_ref()
.file_name()
.ok_or("Failed to extract file name")?;
let write_file =
|suffix: &str, content: &[u8], mode: u32| -> Result<(), Box<dyn std::error::Error>> {
let mut new_name = os_file.to_os_string();
new_name.push(suffix);
let full_path = path.as_ref().join(new_name);
let mut opts = OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(mode);
#[cfg(not(unix))]
let _ = mode;
if full_path.exists() {
fs::remove_file(&full_path)?;
}
let mut file = opts.open(full_path)?;
file.write_all(content)?;
Ok(())
};
if let Ok(ref key) = self.get_private_key() {
write_file("_pkey.pem", key, 0o600)?;
}
write_file(self.pem_extension(), &self.get_pem()?, 0o644)?;
Ok(())
}
}
pub(crate) fn create_asn1_time_from_date(
date_str: &str,
) -> Result<Asn1Time, Box<dyn std::error::Error>> {
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| format!("invalid date {date_str:?}: expected yyyy-mm-dd ({e})"))?;
let datetime = NaiveDateTime::new(date, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap());
Ok(Asn1Time::from_unix(
Utc.from_utc_datetime(&datetime).timestamp(),
)?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ValidityBound {
DaysFromNow(u32),
Date(String),
}
impl ValidityBound {
pub(crate) fn resolve(&self) -> Result<Asn1Time, Box<dyn std::error::Error>> {
match self {
Self::DaysFromNow(days) => Ok(Asn1Time::days_from_now(*days)?),
Self::Date(date) => create_asn1_time_from_date(date),
}
}
}
pub(crate) fn set_validity(
builder: &mut X509Builder,
valid_from: &ValidityBound,
valid_to: &ValidityBound,
signer: Option<&X509Ref>,
) -> Result<(), Box<dyn std::error::Error>> {
let not_before = valid_from.resolve()?;
let not_after = valid_to.resolve()?;
builder.set_not_before(¬_before)?;
if let Some(signer) = signer {
let signer_not_after = signer.not_after();
if not_after.compare(signer_not_after)? == Ordering::Greater {
match valid_to {
ValidityBound::Date(date) => {
return Err(format!(
"certificate notAfter {date} is later than the signer's notAfter \
{signer_not_after} (signer {:?}); a certificate must not outlive \
its issuer",
signer.subject_name()
)
.into());
}
ValidityBound::DaysFromNow(_) => {
builder.set_not_after(signer_not_after)?;
return Ok(());
}
}
}
}
builder.set_not_after(¬_after)?;
Ok(())
}
#[cfg(test)]
mod asn1_time_encoding {
use crate::certificate::{CertBuilder, CsrBuilder, CsrOptions, UseesBuilderFields};
use crate::test_der::{TAG_GENERALIZEDTIME, TAG_UTCTIME, certificate_validity};
fn cert_der(valid_from: &str, valid_to: &str) -> Vec<u8> {
CertBuilder::new()
.common_name("asn1 time test")
.valid_from(valid_from)
.valid_to(valid_to)
.build_and_self_sign()
.unwrap()
.x509
.to_der()
.unwrap()
}
#[test]
fn explicit_date_before_2050_is_utctime() {
let (not_before, not_after) = certificate_validity(&cert_der("2026-08-07", "2026-11-05"));
assert_eq!(not_before.tag, TAG_UTCTIME, "notBefore: {not_before:?}");
assert_eq!(not_after.tag, TAG_UTCTIME, "notAfter: {not_after:?}");
assert_eq!(not_before.value, "260807000000Z");
assert_eq!(not_after.value, "261105000000Z");
}
#[test]
fn last_utctime_year_2049_is_utctime() {
let (not_before, not_after) = certificate_validity(&cert_der("2049-12-30", "2049-12-31"));
assert_eq!(not_before.tag, TAG_UTCTIME, "notBefore: {not_before:?}");
assert_eq!(not_after.tag, TAG_UTCTIME, "notAfter: {not_after:?}");
assert_eq!(not_after.value, "491231000000Z");
}
#[test]
fn first_generalizedtime_year_2050_is_generalizedtime() {
let (not_before, not_after) = certificate_validity(&cert_der("2050-01-01", "2050-01-02"));
assert_eq!(
not_before.tag, TAG_GENERALIZEDTIME,
"notBefore: {not_before:?}"
);
assert_eq!(
not_after.tag, TAG_GENERALIZEDTIME,
"notAfter: {not_after:?}"
);
assert_eq!(not_before.value, "20500101000000Z");
}
#[test]
fn the_two_encodings_may_be_mixed_across_the_boundary() {
let (not_before, not_after) = certificate_validity(&cert_der("2049-12-31", "2050-01-01"));
assert_eq!(not_before.tag, TAG_UTCTIME, "notBefore: {not_before:?}");
assert_eq!(
not_after.tag, TAG_GENERALIZEDTIME,
"notAfter: {not_after:?}"
);
}
#[test]
fn a_far_future_date_keeps_its_century() {
let (_, not_after) = certificate_validity(&cert_der("2026-01-01", "2075-06-01"));
assert_eq!(
not_after.tag, TAG_GENERALIZEDTIME,
"notAfter: {not_after:?}"
);
assert_eq!(not_after.value, "20750601000000Z");
}
#[test]
fn builder_default_dates_are_utctime() {
let der = CertBuilder::new()
.common_name("asn1 default test")
.build_and_self_sign()
.unwrap()
.x509
.to_der()
.unwrap();
let (not_before, not_after) = certificate_validity(&der);
assert_eq!(not_before.tag, TAG_UTCTIME, "notBefore: {not_before:?}");
assert_eq!(not_after.tag, TAG_UTCTIME, "notAfter: {not_after:?}");
}
#[test]
fn a_certificate_signed_from_a_csr_is_utctime() {
let ca = CertBuilder::new()
.common_name("csr test ca")
.is_ca(true)
.build_and_self_sign()
.unwrap();
let csr = CsrBuilder::new()
.common_name("csr subject")
.certificate_signing_request()
.unwrap();
let signed = csr
.build_signed_certificate(
&ca,
CsrOptions::new()
.valid_from("2026-08-07")
.valid_to("2026-11-05"),
)
.unwrap();
let (not_before, not_after) = certificate_validity(&signed.x509.to_der().unwrap());
assert_eq!(not_before.tag, TAG_UTCTIME, "notBefore: {not_before:?}");
assert_eq!(not_after.tag, TAG_UTCTIME, "notAfter: {not_after:?}");
assert_eq!(not_before.value, "260807000000Z");
}
}
#[cfg(test)]
mod validity_bounds {
use crate::certificate::{
CertBuilder, Certificate, CsrBuilder, CsrOptions, UseesBuilderFields,
};
use std::cmp::Ordering;
fn long_lived_ca() -> Certificate {
CertBuilder::new()
.common_name("validity test ca")
.is_ca(true)
.valid_to("2049-01-01")
.build_and_self_sign()
.unwrap()
}
fn ca_expiring_in_days(days: i64) -> Certificate {
let valid_to = (chrono::Utc::now() + chrono::Duration::days(days))
.format("%Y-%m-%d")
.to_string();
CertBuilder::new()
.common_name("short lived ca")
.is_ca(true)
.valid_to(&valid_to)
.build_and_self_sign()
.unwrap()
}
#[test]
fn explicit_not_after_later_than_signer_is_rejected() {
let ca = long_lived_ca();
let err = CertBuilder::new()
.common_name("leaf")
.valid_to("2049-06-01")
.build_and_sign(&ca)
.expect_err("a leaf outliving its issuer must be rejected")
.to_string();
assert!(
err.contains("2049-06-01"),
"names the offending date: {err}"
);
assert!(err.contains("notAfter"), "names the field: {err}");
}
#[test]
fn explicit_not_after_equal_to_signer_is_accepted() {
let ca = long_lived_ca();
let leaf = CertBuilder::new()
.common_name("leaf")
.valid_to("2049-01-01")
.build_and_sign(&ca)
.unwrap();
assert_eq!(
leaf.x509.not_after().compare(ca.x509.not_after()).unwrap(),
Ordering::Equal
);
}
#[test]
fn default_not_after_is_clamped_to_signer() {
let ca = ca_expiring_in_days(30);
let leaf = CertBuilder::new()
.common_name("leaf")
.build_and_sign(&ca)
.unwrap();
assert_eq!(
leaf.x509.not_after().compare(ca.x509.not_after()).unwrap(),
Ordering::Equal,
"the default one-year window must be clamped to the CA's notAfter"
);
}
#[test]
fn default_not_after_is_kept_when_signer_outlives_it() {
let ca = long_lived_ca();
let leaf = CertBuilder::new()
.common_name("leaf")
.build_and_sign(&ca)
.unwrap();
assert_eq!(
leaf.x509.not_after().compare(ca.x509.not_after()).unwrap(),
Ordering::Less
);
}
#[test]
fn with_chain_path_rejects_a_leaf_outliving_its_issuer() {
let ca = long_lived_ca();
let result = CertBuilder::new()
.common_name("intermediate")
.is_ca(true)
.pathlen(0)
.valid_to("2049-06-01")
.build_and_sign_with_chain(&ca, &[]);
assert!(
result.is_err(),
"build_and_sign_with_chain shares the check"
);
}
#[test]
fn self_signed_certificate_is_not_constrained() {
let cert = CertBuilder::new()
.common_name("root")
.valid_to("2075-06-01")
.build_and_self_sign();
assert!(cert.is_ok());
}
fn csr() -> crate::certificate::Csr {
CsrBuilder::new()
.common_name("csr subject")
.certificate_signing_request()
.unwrap()
}
#[test]
fn csr_signed_with_explicit_not_after_later_than_signer_is_rejected() {
let ca = long_lived_ca();
let err = csr()
.build_signed_certificate(&ca, CsrOptions::new().valid_to("2049-06-01"))
.expect_err("a CSR-issued certificate outliving its issuer must be rejected")
.to_string();
assert!(err.contains("2049-06-01"), "{err}");
}
#[test]
fn csr_signed_with_default_not_after_is_clamped_to_signer() {
let ca = ca_expiring_in_days(30);
let signed = csr()
.build_signed_certificate(&ca, CsrOptions::new())
.unwrap();
assert_eq!(
signed
.x509
.not_after()
.compare(ca.x509.not_after())
.unwrap(),
Ordering::Equal
);
}
#[test]
fn malformed_valid_from_is_an_error_not_a_panic() {
let err = CertBuilder::new()
.common_name("bad date")
.valid_from("2026-13-01")
.build_and_self_sign()
.expect_err("a malformed date must be an Err")
.to_string();
assert!(
err.contains("2026-13-01"),
"names the offending input: {err}"
);
assert!(
err.contains("yyyy-mm-dd"),
"names the expected format: {err}"
);
}
#[test]
fn malformed_valid_to_is_an_error_not_a_panic() {
let err = CertBuilder::new()
.common_name("bad date")
.valid_to("01-01-2026")
.build_and_self_sign()
.expect_err("a malformed date must be an Err")
.to_string();
assert!(err.contains("01-01-2026"), "{err}");
}
#[test]
fn malformed_csr_option_dates_are_errors_not_panics() {
let ca = long_lived_ca();
let from = csr().build_signed_certificate(&ca, CsrOptions::new().valid_from("2026/01/01"));
assert!(from.is_err(), "CsrOptions::valid_from must not panic");
let to = csr().build_signed_certificate(&ca, CsrOptions::new().valid_to("not-a-date"));
assert!(to.is_err(), "CsrOptions::valid_to must not panic");
}
#[test]
fn well_formed_dates_still_land_on_the_certificate() {
let cert = CertBuilder::new()
.common_name("dates")
.valid_from("2026-08-07")
.valid_to("2026-11-05")
.build_and_self_sign()
.unwrap();
assert_eq!(
cert.x509.not_before().to_string(),
"Aug 7 00:00:00 2026 GMT"
);
assert_eq!(
cert.x509.not_after().to_string(),
"Nov 5 00:00:00 2026 GMT"
);
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::certificate::{CertBuilder, UseesBuilderFields};
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
fn mode_of(path: &Path) -> u32 {
fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn a_certificate() -> impl X509Common {
CertBuilder::new()
.common_name("perm test")
.build_and_self_sign()
.unwrap()
}
#[test]
fn private_key_is_not_readable_by_group_or_other() {
let dir = tempdir().unwrap();
a_certificate().save(dir.path(), "mytest").unwrap();
let key = dir.path().join("mytest_pkey.pem");
let mode = mode_of(&key);
assert_eq!(
mode & 0o077,
0,
"private key must not be group/world accessible, got {mode:o}"
);
assert_eq!(
mode, 0o600,
"private key should be exactly 0600, got {mode:o}"
);
}
#[test]
fn certificate_itself_is_left_readable() {
let dir = tempdir().unwrap();
a_certificate().save(dir.path(), "mytest").unwrap();
let cert = dir.path().join("mytest_cert.pem");
let mode = mode_of(&cert);
assert_ne!(mode & 0o400, 0, "certificate should be owner-readable");
}
#[test]
fn resaving_tightens_permissions_on_a_pre_existing_key_file() {
let dir = tempdir().unwrap();
let key = dir.path().join("mytest_pkey.pem");
fs::write(&key, b"stale").unwrap();
fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(mode_of(&key), 0o644);
a_certificate().save(dir.path(), "mytest").unwrap();
assert_eq!(
mode_of(&key),
0o600,
"re-saving must not inherit the old world-readable mode"
);
}
}