use chrono::{Duration, Utc};
use rcgen::{CertificateParams, CertificateSigningRequestParams, DnType, KeyPair, SanType};
use crate::ca::CaState;
use crate::error::CertmeshError;
pub const DEFAULT_CSR_VALIDITY_DAYS: u32 = 90;
pub fn generate_keypair_and_csr(
hostname: &str,
sans: &[String],
) -> Result<(String, String), CertmeshError> {
let key = KeyPair::generate().map_err(|e| CertmeshError::Certificate(e.to_string()))?;
let dns_sans: Vec<String> = sans
.iter()
.filter(|s| s.parse::<std::net::IpAddr>().is_err())
.cloned()
.collect();
let mut params =
CertificateParams::new(dns_sans).map_err(|e| CertmeshError::Certificate(e.to_string()))?;
params.distinguished_name.push(DnType::CommonName, hostname);
for san in sans {
if let Ok(ip) = san.parse::<std::net::IpAddr>() {
params.subject_alt_names.push(SanType::IpAddress(ip));
}
}
let csr = params
.serialize_request(&key)
.map_err(|e| CertmeshError::Certificate(e.to_string()))?;
let csr_pem = csr
.pem()
.map_err(|e| CertmeshError::Certificate(e.to_string()))?;
Ok((key.serialize_pem(), csr_pem))
}
pub fn sign_csr(
ca: &CaState,
csr_pem: &str,
sans: &[String],
validity_days: u32,
) -> Result<String, CertmeshError> {
let mut csr_params = CertificateSigningRequestParams::from_pem(csr_pem)
.map_err(|e| CertmeshError::InvalidPayload(format!("invalid CSR: {e}")))?;
csr_params.params.subject_alt_names = build_san_list(sans);
crate::ca::apply_leaf_profile(&mut csr_params.params);
let days = if validity_days == 0 {
DEFAULT_CSR_VALIDITY_DAYS
} else {
validity_days
};
let not_before = Utc::now();
let not_after = not_before + Duration::days(i64::from(days));
csr_params.params.not_before =
time::OffsetDateTime::from_unix_timestamp(not_before.timestamp())
.unwrap_or(time::OffsetDateTime::now_utc());
csr_params.params.not_after = time::OffsetDateTime::from_unix_timestamp(not_after.timestamp())
.unwrap_or(time::OffsetDateTime::now_utc());
let leaf = csr_params
.signed_by(&ca.ca_cert, &ca.rcgen_key)
.map_err(|e| CertmeshError::Certificate(e.to_string()))?;
Ok(leaf.pem())
}
fn build_san_list(sans: &[String]) -> Vec<SanType> {
sans.iter()
.filter_map(|s| {
if let Ok(ip) = s.parse::<std::net::IpAddr>() {
Some(SanType::IpAddress(ip))
} else {
SanType::DnsName(s.clone().try_into().ok()?).into()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ca::create_ca;
use rcgen::{CertificateParams, KeyPair};
use x509_parser::prelude::FromDer;
fn test_entropy() -> Vec<u8> {
let _ = koi_common::test::ensure_data_dir("koi-certmesh-csr-tests");
vec![7u8; 32]
}
fn test_ca() -> CaState {
let paths = crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
"koi-certmesh-csr-tests",
));
let (ca, _master) = create_ca("csr-test-pass", &test_entropy(), &paths).unwrap();
ca
}
fn make_csr(requested_sans: &[&str]) -> (String, KeyPair) {
let key = KeyPair::generate().unwrap();
let dns: Vec<String> = requested_sans.iter().map(|s| s.to_string()).collect();
let mut params = CertificateParams::new(dns).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, requested_sans[0]);
let csr = params.serialize_request(&key).unwrap();
(csr.pem().unwrap(), key)
}
fn leaf_dns_sans(cert_pem: &str) -> Vec<String> {
let der = pem::parse(cert_pem).unwrap();
let (_, cert) =
x509_parser::certificate::X509Certificate::from_der(der.contents()).unwrap();
let mut names = Vec::new();
if let Ok(Some(san)) = cert.subject_alternative_name() {
for gn in &san.value.general_names {
if let x509_parser::extensions::GeneralName::DNSName(dns) = gn {
names.push(dns.to_string());
}
}
}
names.sort();
names
}
#[test]
fn sign_csr_issues_cert_chaining_to_ca() {
let ca = test_ca();
let (csr_pem, _key) = make_csr(&["host-a.lan"]);
let leaf_pem = sign_csr(&ca, &csr_pem, &["host-a.lan".to_string()], 30).unwrap();
assert!(leaf_pem.contains("BEGIN CERTIFICATE"));
let leaf_der = pem::parse(&leaf_pem).unwrap();
let (_, leaf) =
x509_parser::certificate::X509Certificate::from_der(leaf_der.contents()).unwrap();
let ca_der = pem::parse(&ca.cert_pem).unwrap();
let (_, ca_cert) =
x509_parser::certificate::X509Certificate::from_der(ca_der.contents()).unwrap();
assert_eq!(
leaf.issuer().to_string(),
ca_cert.subject().to_string(),
"leaf issuer must equal CA subject"
);
assert!(
leaf.verify_signature(Some(ca_cert.public_key())).is_ok(),
"leaf must be signed by the CA"
);
}
#[test]
fn sign_csr_uses_authorized_sans_not_csr_sans() {
let ca = test_ca();
let (csr_pem, _key) = make_csr(&["host-b.lan", "evil.lan"]);
let authorized = vec!["host-b.lan".to_string()];
let leaf_pem = sign_csr(&ca, &csr_pem, &authorized, 30).unwrap();
let issued_sans = leaf_dns_sans(&leaf_pem);
assert_eq!(
issued_sans,
vec!["host-b.lan".to_string()],
"issued cert must carry ONLY the authorized SANs, not the CSR's snuck-in names"
);
assert!(
!issued_sans.contains(&"evil.lan".to_string()),
"the unauthorized SAN from the CSR must NOT appear in the issued cert"
);
}
#[test]
fn sign_csr_rejects_corrupted_signature() {
let ca = test_ca();
let (csr_pem, _key) = make_csr(&["host-c.lan"]);
let der = pem::parse(&csr_pem).unwrap();
let mut bytes = der.contents().to_vec();
let len = bytes.len();
for b in bytes.iter_mut().skip(len.saturating_sub(8)) {
*b ^= 0xFF;
}
let corrupted_pem = pem::encode(&pem::Pem::new("CERTIFICATE REQUEST", bytes));
let result = sign_csr(&ca, &corrupted_pem, &["host-c.lan".to_string()], 30);
assert!(
result.is_err(),
"a CSR with a corrupted signature must be rejected"
);
assert!(
matches!(result, Err(CertmeshError::InvalidPayload(_))),
"corrupted CSR should map to InvalidPayload, got {result:?}"
);
}
#[test]
fn sign_csr_validity_zero_uses_default() {
let ca = test_ca();
let (csr_pem, _key) = make_csr(&["host-d.lan"]);
let leaf_pem = sign_csr(&ca, &csr_pem, &["host-d.lan".to_string()], 0).unwrap();
assert!(leaf_pem.contains("BEGIN CERTIFICATE"));
}
#[test]
fn sign_csr_applies_least_privilege_leaf_profile() {
let ca = test_ca();
let (csr_pem, _key) = make_csr(&["host-prof.internal"]);
let leaf_pem = sign_csr(&ca, &csr_pem, &["host-prof.internal".to_string()], 30).unwrap();
let der = pem::parse(&leaf_pem).unwrap();
let (_, cert) =
x509_parser::certificate::X509Certificate::from_der(der.contents()).unwrap();
let bc = cert
.basic_constraints()
.unwrap()
.expect("BasicConstraints present");
assert!(!bc.value.ca, "issued leaf must be CA:FALSE");
let eku = cert
.extended_key_usage()
.unwrap()
.expect("ExtendedKeyUsage present");
assert!(
eku.value.server_auth && eku.value.client_auth,
"leaf EKU must include serverAuth + clientAuth"
);
let ku = cert.key_usage().unwrap().expect("KeyUsage present");
assert!(
ku.value.digital_signature(),
"leaf KU must allow digitalSignature"
);
}
#[test]
fn generated_csr_is_signable_and_keeps_key_local() {
let (key_pem, csr_pem) = generate_keypair_and_csr(
"host-e.internal",
&["host-e.internal".to_string(), "10.0.0.9".to_string()],
)
.unwrap();
assert!(
key_pem.contains("PRIVATE KEY"),
"key_pem must be a private key the member retains"
);
assert!(csr_pem.contains("CERTIFICATE REQUEST"));
assert!(
!csr_pem.contains("PRIVATE KEY"),
"the CSR must never carry the private key"
);
let ca = test_ca();
let leaf_pem = sign_csr(&ca, &csr_pem, &["host-e.internal".to_string()], 30).unwrap();
assert_eq!(
leaf_dns_sans(&leaf_pem),
vec!["host-e.internal".to_string()]
);
}
}