use std::fs;
use std::io::BufReader;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use rcgen::{
BasicConstraints, Certificate, CertificateParams, IsCa, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::CertificateDer;
use tracing::info;
use crate::common::tls::{cert_sha256, format_fingerprint};
#[derive(Debug)]
pub struct CertOutput {
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
pub fn generate_ca(out_dir: &Path, common_name: &str) -> Result<CertOutput> {
fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let mut params = CertificateParams::new(vec![common_name.to_string()])
.context("failed to build CA params")?;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
let key_pair = KeyPair::generate().context("failed to generate CA key pair")?;
let cert = params
.self_signed(&key_pair)
.context("failed to self-sign CA cert")?;
let cert_path = out_dir.join("ca.pem");
let key_path = out_dir.join("ca.key");
write_pem(&cert_path, cert.pem().as_bytes())?;
write_secret_pem(&key_path, key_pair.serialize_pem().as_bytes())?;
info!("wrote {} and {}", cert_path.display(), key_path.display());
Ok(CertOutput {
cert_path,
key_path,
})
}
pub fn generate_server_cert(
out_dir: &Path,
ca_cert_path: &Path,
ca_key_path: &Path,
common_name: &str,
dns_sans: &[String],
ip_sans: &[IpAddr],
file_stem: &str,
) -> Result<CertOutput> {
if dns_sans.is_empty() && ip_sans.is_empty() {
return Err(anyhow!(
"server certs require at least one --name or --ip SAN; \
without it clients won't be able to verify the connection"
));
}
fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let mut params = CertificateParams::new(vec![common_name.to_string()])
.context("failed to build server cert params")?;
for dns in dns_sans {
params.subject_alt_names.push(SanType::DnsName(
dns.clone()
.try_into()
.with_context(|| format!("invalid DNS SAN `{dns}`"))?,
));
}
for ip in ip_sans {
params.subject_alt_names.push(SanType::IpAddress(*ip));
}
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
let (ca_cert, ca_key) = load_signing_ca(ca_cert_path, ca_key_path)?;
let key_pair = KeyPair::generate().context("failed to generate server key pair")?;
let cert = params
.signed_by(&key_pair, &ca_cert, &ca_key)
.context("failed to sign server cert")?;
let cert_path = out_dir.join(format!("{file_stem}.pem"));
let key_path = out_dir.join(format!("{file_stem}.key"));
write_pem(&cert_path, cert.pem().as_bytes())?;
write_secret_pem(&key_path, key_pair.serialize_pem().as_bytes())?;
info!("wrote {} and {}", cert_path.display(), key_path.display());
Ok(CertOutput {
cert_path,
key_path,
})
}
pub fn generate_client_cert(
out_dir: &Path,
ca_cert_path: &Path,
ca_key_path: &Path,
common_name: &str,
file_stem: &str,
) -> Result<CertOutput> {
fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let mut params = CertificateParams::new(vec![common_name.to_string()])
.context("failed to build client cert params")?;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
let (ca_cert, ca_key) = load_signing_ca(ca_cert_path, ca_key_path)?;
let key_pair = KeyPair::generate().context("failed to generate client key pair")?;
let cert = params
.signed_by(&key_pair, &ca_cert, &ca_key)
.context("failed to sign client cert")?;
let cert_path = out_dir.join(format!("{file_stem}.pem"));
let key_path = out_dir.join(format!("{file_stem}.key"));
write_pem(&cert_path, cert.pem().as_bytes())?;
write_secret_pem(&key_path, key_pair.serialize_pem().as_bytes())?;
info!("wrote {} and {}", cert_path.display(), key_path.display());
Ok(CertOutput {
cert_path,
key_path,
})
}
pub fn print_fingerprint(path: &Path) -> Result<String> {
let pem =
fs::read(path).with_context(|| format!("failed to read cert file {}", path.display()))?;
let mut reader = BufReader::new(pem.as_slice());
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut reader)
.collect::<std::result::Result<_, _>>()
.with_context(|| format!("failed to parse PEM certs in {}", path.display()))?;
let leaf = certs
.first()
.ok_or_else(|| anyhow!("no certificates found in {}", path.display()))?;
Ok(format_fingerprint(&cert_sha256(leaf)))
}
fn load_signing_ca(cert_path: &Path, key_path: &Path) -> Result<(Certificate, KeyPair)> {
let cert_pem = fs::read_to_string(cert_path)
.with_context(|| format!("failed to read CA cert {}", cert_path.display()))?;
let key_pem = fs::read_to_string(key_path)
.with_context(|| format!("failed to read CA key {}", key_path.display()))?;
let key_pair = KeyPair::from_pem(&key_pem).context("failed to parse CA key as PEM")?;
let params =
CertificateParams::from_ca_cert_pem(&cert_pem).context("failed to parse CA cert as PEM")?;
let cert = params
.self_signed(&key_pair)
.context("failed to rebuild CA cert from params + key")?;
Ok((cert, key_pair))
}
fn write_pem(path: &Path, contents: &[u8]) -> Result<()> {
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))
}
fn write_secret_pem(path: &Path, contents: &[u8]) -> Result<()> {
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn scratch() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"rusnel-cert-test-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn ca_then_server_and_client_roundtrip() {
let dir = scratch();
let ca = generate_ca(&dir, "test-ca").unwrap();
let srv = generate_server_cert(
&dir,
&ca.cert_path,
&ca.key_path,
"localhost",
&["localhost".into()],
&[IpAddr::V4(Ipv4Addr::LOCALHOST)],
"server",
)
.unwrap();
assert!(srv.cert_path.exists());
assert!(srv.key_path.exists());
let cli =
generate_client_cert(&dir, &ca.cert_path, &ca.key_path, "alice", "alice").unwrap();
assert!(cli.cert_path.exists());
assert!(cli.key_path.exists());
let fp = print_fingerprint(&srv.cert_path).unwrap();
assert!(fp.starts_with("sha256:"));
assert_eq!(fp.len(), 7 + 64);
}
#[test]
fn server_cert_requires_at_least_one_san() {
let dir = scratch();
let ca = generate_ca(&dir, "test-ca").unwrap();
let err = generate_server_cert(
&dir,
&ca.cert_path,
&ca.key_path,
"no-sans",
&[],
&[],
"server",
)
.unwrap_err();
assert!(err.to_string().contains("--name"));
}
}