use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair};
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
fn ca_dir() -> PathBuf {
crate::paths::ca_dir()
}
const SYSTEM_ROOTS: &str = "/etc/ssl/certs/ca-certificates.crt";
pub fn ensure_bundle() -> Result<PathBuf, Box<dyn Error>> {
let dir = ca_dir();
let ca_cert = dir.join("ca.crt");
if !ca_cert.exists() {
return Err(format!(
"no CA at {} — start the gateway first (impyard server start creates it)",
ca_cert.display()
)
.into());
}
let bundle = dir.join("bundle.crt");
write_bundle(Path::new(SYSTEM_ROOTS), &ca_cert, &bundle)?;
Ok(bundle)
}
fn write_bundle(system_roots: &Path, ca_cert: &Path, bundle: &Path) -> Result<(), Box<dyn Error>> {
let mtime = |p: &Path| p.metadata().and_then(|m| m.modified()).ok();
if let Some(built) = mtime(bundle) {
let stale = [system_roots, ca_cert]
.iter()
.filter_map(|p| mtime(p))
.any(|input| input > built);
if !stale {
return Ok(());
}
}
let roots = match fs::read_to_string(system_roots) {
Ok(r) => r,
Err(e) => {
eprintln!(
"ca: no system roots at {} ({e}) — bundle will carry the impyard CA only",
system_roots.display()
);
String::new()
}
};
let ca = fs::read_to_string(ca_cert)?;
let newline = if roots.is_empty() || roots.ends_with('\n') {
""
} else {
"\n"
};
fs::write(bundle, format!("{roots}{newline}{ca}"))?;
Ok(())
}
pub struct Ca {
key_pem: String,
cert_pem: String,
}
impl Ca {
pub fn ensure() -> Result<Ca, Box<dyn Error>> {
let dir = ca_dir();
fs::create_dir_all(&dir)?;
let key_path = dir.join("ca.key");
let cert_path = dir.join("ca.crt");
if !key_path.exists() || !cert_path.exists() {
let key = KeyPair::generate()?;
let mut params = CertificateParams::new(Vec::<String>::new())?;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params
.distinguished_name
.push(DnType::CommonName, "Impyard Box CA");
let cert = params.self_signed(&key)?;
fs::write(&key_path, key.serialize_pem())?;
fs::write(&cert_path, cert.pem())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
}
ensure_bundle()?;
Ok(Ca {
key_pem: fs::read_to_string(&key_path)?,
cert_pem: fs::read_to_string(&cert_path)?,
})
}
#[allow(dead_code)] pub fn cert_pem(&self) -> &str {
&self.cert_pem
}
fn issuer(&self) -> Result<Issuer<'static, KeyPair>, Box<dyn Error>> {
let key = KeyPair::from_pem(&self.key_pem)?;
Ok(Issuer::from_ca_cert_pem(&self.cert_pem, key)?)
}
#[allow(dead_code)] pub fn mint_leaf(&self, host: &str) -> Result<(String, String), Box<dyn Error>> {
let issuer = self.issuer()?;
let leaf_key = KeyPair::generate()?;
let params = CertificateParams::new(vec![host.to_string()])?;
let leaf = params.signed_by(&leaf_key, &issuer)?;
let chain = format!("{}{}", leaf.pem(), self.cert_pem);
Ok((leaf_key.serialize_pem(), chain))
}
pub fn mint_leaf_der(&self, host: &str) -> Result<(Vec<u8>, Vec<u8>), Box<dyn Error>> {
let issuer = self.issuer()?;
let leaf_key = KeyPair::generate()?;
let params = CertificateParams::new(vec![host.to_string()])?;
let leaf = params.signed_by(&leaf_key, &issuer)?;
Ok((leaf.der().as_ref().to_vec(), leaf_key.serialize_der()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use x509_parser::prelude::*;
fn temp_ca_dir() -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!("impyard-ca-test-{}", std::process::id()));
dir
}
#[test]
fn bundle_concatenates_roots_and_ca_and_rebuilds_when_stale() {
let dir = std::env::temp_dir().join(format!("impyard-bundle-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let roots = dir.join("roots.crt");
let ca = dir.join("ca.crt");
let bundle = dir.join("bundle.crt");
fs::write(&roots, "ROOTS\n").unwrap();
fs::write(&ca, "CA\n").unwrap();
write_bundle(&roots, &ca, &bundle).unwrap();
assert_eq!(fs::read_to_string(&bundle).unwrap(), "ROOTS\nCA\n");
let before = fs::metadata(&bundle).unwrap().modified().unwrap();
write_bundle(&roots, &ca, &bundle).unwrap();
assert_eq!(fs::metadata(&bundle).unwrap().modified().unwrap(), before);
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&ca, "CA2\n").unwrap();
write_bundle(&roots, &ca, &bundle).unwrap();
assert_eq!(fs::read_to_string(&bundle).unwrap(), "ROOTS\nCA2\n");
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&ca, "CA3\n").unwrap();
write_bundle(Path::new("/nonexistent/roots.crt"), &ca, &bundle).unwrap();
assert_eq!(fs::read_to_string(&bundle).unwrap(), "CA3\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn mints_a_leaf_with_correct_san_signed_by_the_ca() {
let dir = temp_ca_dir();
std::env::set_var("IMPYARD_CA_DIR", &dir);
let ca = Ca::ensure().expect("ensure CA");
let (key_pem, chain_pem) = ca.mint_leaf("chatgpt.com").expect("mint leaf");
assert!(key_pem.contains("PRIVATE KEY"));
assert_eq!(chain_pem.matches("BEGIN CERTIFICATE").count(), 2);
let leaf_der = pem::parse_x509_pem(chain_pem.as_bytes()).unwrap().1;
let (_, leaf) = parse_x509_certificate(&leaf_der.contents).unwrap();
assert!(leaf.issuer().to_string().contains("Impyard Box CA"));
let san = leaf
.get_extension_unique(&oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME)
.unwrap()
.expect("SAN present");
if let ParsedExtension::SubjectAlternativeName(names) = san.parsed_extension() {
let dns: Vec<_> = names
.general_names
.iter()
.filter_map(|n| match n {
GeneralName::DNSName(d) => Some(*d),
_ => None,
})
.collect();
assert_eq!(dns, vec!["chatgpt.com"]);
} else {
panic!("SAN extension did not parse");
}
let ca2 = Ca::ensure().expect("re-ensure CA");
assert_eq!(ca.cert_pem(), ca2.cert_pem());
let _ = fs::remove_dir_all(&dir);
}
}