use crate::{Error, Result};
use colored::*;
use rcgen::string::Ia5String;
use rcgen::{
CertificateParams, ExtendedKeyUsagePurpose, Issuer, KeyPair, KeyUsagePurpose,
PKCS_ECDSA_P256_SHA256, PKCS_RSA_SHA256, RsaKeySize, SanType,
};
use regex::Regex;
use std::fs;
use std::net::IpAddr;
use std::path::PathBuf;
use time::{Duration, OffsetDateTime};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
pub struct CertificateConfig {
pub hosts: Vec<String>,
pub use_ecdsa: bool,
pub client_cert: bool,
pub pkcs12: bool,
pub cert_file: Option<PathBuf>,
pub key_file: Option<PathBuf>,
pub p12_file: Option<PathBuf>,
}
impl CertificateConfig {
pub fn new(hosts: Vec<String>) -> Self {
Self {
hosts,
use_ecdsa: false,
client_cert: false,
pkcs12: false,
cert_file: None,
key_file: None,
p12_file: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum HostType {
DnsName(String),
IpAddress(IpAddr),
Email(String),
Uri(String),
}
impl HostType {
pub fn parse(host: &str) -> Result<Self> {
if let Ok(ip) = host.parse::<IpAddr>() {
validate_ip_address(&ip)?;
return Ok(HostType::IpAddress(ip));
}
if host.contains('@') {
validate_email_address(host)?;
return Ok(HostType::Email(host.to_string()));
}
if host.contains("://") {
validate_uri(host)?;
return Ok(HostType::Uri(host.to_string()));
}
Ok(HostType::DnsName(host.to_string()))
}
}
pub fn validate_ip_address(ip: &IpAddr) -> Result<()> {
match ip {
IpAddr::V4(ipv4) => {
if ipv4.is_unspecified() {
return Err(Error::InvalidHostname(format!(
"Unspecified IP address not allowed: {}",
ip
)));
}
Ok(())
}
IpAddr::V6(ipv6) => {
if ipv6.is_unspecified() {
return Err(Error::InvalidHostname(format!(
"Unspecified IP address not allowed: {}",
ip
)));
}
Ok(())
}
}
}
pub fn validate_email_address(email: &str) -> Result<()> {
let email_regex = Regex::new(
r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
).unwrap();
if !email_regex.is_match(email) {
return Err(Error::InvalidHostname(format!(
"Invalid email address: {}",
email
)));
}
Ok(())
}
pub fn validate_uri(uri: &str) -> Result<()> {
let uri_regex = Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^\s]+$").unwrap();
if !uri_regex.is_match(uri) {
return Err(Error::InvalidHostname(format!(
"Invalid URI format: {}",
uri
)));
}
if let Some(scheme_end) = uri.find("://") {
let scheme = &uri[..scheme_end];
if scheme.is_empty() {
return Err(Error::InvalidHostname(format!(
"URI must have a scheme: {}",
uri
)));
}
}
Ok(())
}
pub fn validate_hostname(hostname: &str) -> Result<()> {
let hostname_regex = Regex::new(r"(?i)^(\*\.)?[0-9a-z_-]([0-9a-z._-]*[0-9a-z_-])?$").unwrap();
if !hostname_regex.is_match(hostname) {
return Err(Error::InvalidHostname(hostname.to_string()));
}
Ok(())
}
pub fn domain_to_ascii(domain: &str) -> Result<String> {
match idna::domain_to_ascii(domain) {
Ok(ascii) => Ok(ascii),
Err(_) => Err(Error::InvalidHostname(format!(
"Invalid international domain name: {}",
domain
))),
}
}
pub fn domain_to_unicode(domain: &str) -> String {
idna::domain_to_unicode(domain).0
}
pub fn generate_serial_number() -> [u8; 16] {
use ring::rand::{SecureRandom, SystemRandom};
let rng = SystemRandom::new();
let mut serial = [0u8; 16];
rng.fill(&mut serial)
.expect("Failed to generate random serial number");
serial[0] &= 0x7F;
serial
}
pub fn format_expiration_date(expiration: OffsetDateTime) -> String {
expiration
.format(&time::format_description::well_known::Rfc2822)
.unwrap_or_else(|_| format!("{}", expiration))
}
pub fn calculate_cert_expiration() -> OffsetDateTime {
OffsetDateTime::now_utc() + Duration::days(730 + 90)
}
pub fn is_cert_expiring_soon(expiration: OffsetDateTime) -> bool {
let now = OffsetDateTime::now_utc();
let days_until_expiry = (expiration - now).whole_days();
(0..=30).contains(&days_until_expiry)
}
pub fn validate_cert_chain(cert_der: &[u8], ca_cert_der: &[u8]) -> Result<()> {
use x509_parser::prelude::*;
let (_, cert) = X509Certificate::from_der(cert_der)
.map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?;
let (_, ca_cert) = X509Certificate::from_der(ca_cert_der)
.map_err(|e| Error::Certificate(format!("Failed to parse CA certificate: {}", e)))?;
if cert.issuer() != ca_cert.subject() {
return Err(Error::Certificate(
"Certificate was not issued by the provided CA".to_string(),
));
}
Ok(())
}
pub fn check_cert_expiry_warning(expiration: OffsetDateTime) {
if is_cert_expiring_soon(expiration) {
let days = (expiration - OffsetDateTime::now_utc()).whole_days();
eprintln!(
"{} Certificate expires in {} days!",
"Warning:".yellow().bold(),
days
);
}
}
fn process_host_to_san(host: &str) -> Result<SanType> {
let host_type = HostType::parse(host)?;
match host_type {
HostType::DnsName(name) => {
validate_hostname(&name)?;
validate_wildcard_depth(&name)?;
check_wildcard_warning(&name);
let ia5 = Ia5String::try_from(name)
.map_err(|e| Error::Certificate(format!("Invalid DNS name: {}", e)))?;
Ok(SanType::DnsName(ia5))
}
HostType::IpAddress(ip) => Ok(SanType::IpAddress(ip)),
HostType::Email(email) => {
let ia5 = Ia5String::try_from(email)
.map_err(|e| Error::Certificate(format!("Invalid email: {}", e)))?;
Ok(SanType::Rfc822Name(ia5))
}
HostType::Uri(uri) => {
let ia5 = Ia5String::try_from(uri)
.map_err(|e| Error::Certificate(format!("Invalid URI: {}", e)))?;
Ok(SanType::URI(ia5))
}
}
}
pub fn build_san_list(hosts: &[String]) -> Result<Vec<SanType>> {
hosts.iter().map(|host| process_host_to_san(host)).collect()
}
pub fn validate_wildcard_depth(name: &str) -> Result<()> {
if let Some(stripped) = name.strip_prefix("*.") {
let wildcard_count = name.matches("*").count();
if wildcard_count > 1 {
return Err(Error::InvalidHostname(format!(
"Multiple wildcards not allowed: {}",
name
)));
}
if stripped.contains('*') {
return Err(Error::InvalidHostname(format!(
"Wildcard must be at the beginning: {}",
name
)));
}
} else if name.contains('*') {
return Err(Error::InvalidHostname(format!(
"Wildcard must be at the beginning: {}",
name
)));
}
Ok(())
}
fn check_wildcard_warning(name: &str) {
let second_level_wildcard_regex = Regex::new(r"(?i)^\*\.[0-9a-z_-]+$").unwrap();
if second_level_wildcard_regex.is_match(name) {
eprintln!(
"{} many browsers don't support second-level wildcards like \"{}\"",
"Warning:".yellow().bold(),
name
);
}
if let Some(stripped) = name.strip_prefix("*.") {
eprintln!(
"{} X.509 wildcards only go one level deep, so this won't match a.b.{}",
"Reminder:".cyan(),
stripped
);
}
}
pub fn create_cert_params(hosts: &[String]) -> Result<CertificateParams> {
let mut params = CertificateParams::default();
let now = OffsetDateTime::now_utc();
params.not_before = now;
params.not_after = now + Duration::days(730 + 90);
let san_list = build_san_list(hosts)?;
params.subject_alt_names = san_list;
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
Ok(params)
}
pub fn add_server_auth(params: &mut CertificateParams) {
if !params
.extended_key_usages
.contains(&ExtendedKeyUsagePurpose::ServerAuth)
{
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ServerAuth);
}
}
pub fn add_client_auth(params: &mut CertificateParams) {
if !params
.extended_key_usages
.contains(&ExtendedKeyUsagePurpose::ClientAuth)
{
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ClientAuth);
}
}
pub fn add_email_protection(params: &mut CertificateParams) {
if !params
.extended_key_usages
.contains(&ExtendedKeyUsagePurpose::EmailProtection)
{
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::EmailProtection);
}
}
pub fn cert_to_pem(cert_der: &[u8]) -> String {
pem::encode(&pem::Pem::new("CERTIFICATE", cert_der))
}
pub fn key_to_pem(key: &KeyPair) -> Result<String> {
let key_der = key.serialize_der();
Ok(pem::encode(&pem::Pem::new("PRIVATE KEY", key_der)))
}
pub fn generate_file_names(config: &CertificateConfig) -> (PathBuf, PathBuf, PathBuf) {
if let (Some(cert), Some(key), Some(p12)) =
(&config.cert_file, &config.key_file, &config.p12_file)
{
return (cert.clone(), key.clone(), p12.clone());
}
let default_name = if config.hosts.is_empty() {
"cert".to_string()
} else {
let mut name = config.hosts[0].replace(':', "_").replace('*', "_wildcard");
if config.hosts.len() > 1 {
name.push_str(&format!("+{}", config.hosts.len() - 1));
}
if config.client_cert {
name.push_str("-client");
}
name
};
let cert_file = config
.cert_file
.clone()
.unwrap_or_else(|| PathBuf::from(format!("./{}.pem", default_name)));
let key_file = config
.key_file
.clone()
.unwrap_or_else(|| PathBuf::from(format!("./{}-key.pem", default_name)));
let p12_file = config
.p12_file
.clone()
.unwrap_or_else(|| PathBuf::from(format!("./{}.p12", default_name)));
(cert_file, key_file, p12_file)
}
pub fn write_pem_files(
cert_path: &PathBuf,
key_path: &PathBuf,
cert_pem: &str,
key_pem: &str,
) -> Result<()> {
use std::io::BufWriter;
if cert_path == key_path {
let file = std::fs::File::create(cert_path).map_err(Error::Io)?;
let mut writer = BufWriter::new(file);
use std::io::Write;
writer.write_all(cert_pem.as_bytes()).map_err(Error::Io)?;
writer.write_all(key_pem.as_bytes()).map_err(Error::Io)?;
writer.flush().map_err(Error::Io)?;
set_file_permissions(cert_path, 0o600)?;
} else {
let cert_file = std::fs::File::create(cert_path).map_err(Error::Io)?;
let mut cert_writer = BufWriter::new(cert_file);
use std::io::Write;
cert_writer
.write_all(cert_pem.as_bytes())
.map_err(Error::Io)?;
cert_writer.flush().map_err(Error::Io)?;
set_file_permissions(cert_path, 0o644)?;
let key_file = std::fs::File::create(key_path).map_err(Error::Io)?;
let mut key_writer = BufWriter::new(key_file);
key_writer
.write_all(key_pem.as_bytes())
.map_err(Error::Io)?;
key_writer.flush().map_err(Error::Io)?;
set_file_permissions(key_path, 0o600)?;
}
Ok(())
}
#[cfg(unix)]
pub(crate) fn set_file_permissions(path: &PathBuf, mode: u32) -> Result<()> {
let permissions = fs::Permissions::from_mode(mode);
fs::set_permissions(path, permissions).map_err(Error::Io)
}
#[cfg(not(unix))]
pub(crate) fn set_file_permissions(_path: &PathBuf, _mode: u32) -> Result<()> {
Ok(())
}
#[cfg(unix)]
pub fn verify_file_permissions(path: &PathBuf, expected_mode: u32) -> Result<bool> {
let metadata = fs::metadata(path).map_err(Error::Io)?;
let permissions = metadata.permissions();
let actual_mode = permissions.mode() & 0o777;
Ok(actual_mode == expected_mode)
}
#[cfg(not(unix))]
pub fn verify_file_permissions(_path: &PathBuf, _expected_mode: u32) -> Result<bool> {
Ok(true)
}
pub fn write_pkcs12_file(
p12_path: &PathBuf,
cert_der: &[u8],
key: &KeyPair,
ca_cert_der: &[u8],
) -> Result<()> {
use p12::PFX;
let key_der = key.serialize_der();
let pfx = PFX::new(cert_der, &key_der, Some(ca_cert_der), "changeit", "")
.ok_or_else(|| Error::Certificate("Failed to create PKCS#12".to_string()))?;
let pfx_data = pfx.to_der();
fs::write(p12_path, &pfx_data).map_err(Error::Io)?;
set_file_permissions(p12_path, 0o644)?;
Ok(())
}
pub fn print_hosts(hosts: &[String]) {
let second_level_wildcard_regex = Regex::new(r"(?i)^\*\.[0-9a-z_-]+$").unwrap();
println!(
"\n{}",
"Created a new certificate valid for the following names"
.green()
.bold()
);
for host in hosts {
println!(" - {}", host.bright_white());
if second_level_wildcard_regex.is_match(host) {
println!(
" {} many browsers don't support second-level wildcards like {}",
"Warning:".yellow().bold(),
host
);
}
}
for host in hosts {
if let Some(stripped) = host.strip_prefix("*.") {
println!(
"\n{} X.509 wildcards only go one level deep, so this won't match a.b.{}",
"Reminder:".cyan(),
stripped
);
break;
}
}
}
pub fn generate_certificate(
domains: &[String],
cert_file: Option<&str>,
key_file: Option<&str>,
p12_file: Option<&str>,
client: bool,
ecdsa: bool,
pkcs12: bool,
) -> Result<()> {
let caroot = crate::ca::get_caroot()?;
let mut ca = crate::ca::CertificateAuthority::new(PathBuf::from(caroot));
ca.load_or_create()?;
let ca_cert_pem = std::fs::read_to_string(ca.cert_path())?;
let ca_key_pem = std::fs::read_to_string(ca.key_path())?;
let mut config = CertificateConfig::new(domains.to_vec());
config.client_cert = client;
config.use_ecdsa = ecdsa;
config.pkcs12 = pkcs12;
config.cert_file = cert_file.map(PathBuf::from);
config.key_file = key_file.map(PathBuf::from);
config.p12_file = p12_file.map(PathBuf::from);
generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem)
}
pub fn read_csr_file(csr_path: &str) -> Result<Vec<u8>> {
fs::read(csr_path).map_err(|e| Error::Certificate(format!("Failed to read CSR file: {}", e)))
}
pub fn parse_csr_pem(csr_bytes: &[u8]) -> Result<Vec<u8>> {
let pem_str = std::str::from_utf8(csr_bytes)
.map_err(|e| Error::Certificate(format!("Invalid UTF-8 in CSR file: {}", e)))?;
let begin_marker = "-----BEGIN";
let end_marker = "-----END";
let begin_pos = pem_str
.find(begin_marker)
.ok_or_else(|| Error::Certificate("No PEM data found in CSR file".to_string()))?;
let end_pos = pem_str
.find(end_marker)
.ok_or_else(|| Error::Certificate("Invalid PEM format in CSR file".to_string()))?;
let mut final_pos = end_pos + end_marker.len();
while final_pos < pem_str.len() {
let ch = pem_str.as_bytes()[final_pos];
if ch == b'\n' || ch == b'\r' {
final_pos += 1;
break;
}
final_pos += 1;
}
let pem_block = &pem_str[begin_pos..final_pos];
let pem_data = ::pem::parse(pem_block.as_bytes())
.map_err(|e| Error::Certificate(format!("Failed to parse CSR PEM: {}", e)))?;
if pem_data.tag() != "CERTIFICATE REQUEST" && pem_data.tag() != "NEW CERTIFICATE REQUEST" {
return Err(Error::Certificate(format!(
"Expected CERTIFICATE REQUEST, got {}",
pem_data.tag()
)));
}
Ok(pem_data.into_contents())
}
pub fn validate_csr_signature(
csr: &x509_parser::certification_request::X509CertificationRequest,
) -> Result<()> {
if csr.certification_request_info.subject_pki.parsed().is_err() {
return Err(Error::Certificate("Invalid public key in CSR".to_string()));
}
Ok(())
}
pub fn extract_san_from_csr(
csr: &x509_parser::certification_request::X509CertificationRequest,
) -> Result<Vec<String>> {
let mut hosts = Vec::new();
let req_info = &csr.certification_request_info;
if let Some(cn) = req_info.subject.iter_common_name().next()
&& let Ok(cn_str) = cn.as_str()
{
hosts.push(cn_str.to_string());
}
if hosts.is_empty() {
return Err(Error::Certificate(
"No Common Name found in CSR subject".to_string(),
));
}
Ok(hosts)
}
pub fn generate_from_csr(csr_path: &str, cert_file: Option<&str>) -> Result<()> {
use x509_parser::prelude::*;
let caroot = crate::ca::get_caroot()?;
let mut ca = crate::ca::CertificateAuthority::new(PathBuf::from(caroot));
ca.load_or_create()?;
if !ca.key_exists() {
return Err(Error::CAKeyMissing);
}
let csr_bytes = read_csr_file(csr_path)?;
let csr_der = parse_csr_pem(&csr_bytes)?;
let (_, csr) = X509CertificationRequest::from_der(&csr_der)
.map_err(|e| Error::Certificate(format!("Failed to parse CSR: {}", e)))?;
validate_csr_signature(&csr)?;
let hosts = extract_san_from_csr(&csr)?;
if hosts.is_empty() {
return Err(Error::Certificate(
"No subject names found in CSR".to_string(),
));
}
let ca_cert_pem = std::fs::read_to_string(ca.cert_path())?;
let ca_key_pem = std::fs::read_to_string(ca.key_path())?;
let cert_key_pair = KeyPair::generate_rsa_for(&PKCS_RSA_SHA256, RsaKeySize::_2048)
.map_err(|e| Error::Certificate(format!("Failed to generate key pair: {}", e)))?;
let ca_key_pair = KeyPair::from_pem(&ca_key_pem)
.map_err(|e| Error::Certificate(format!("Failed to parse CA key: {}", e)))?;
let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key_pair)
.map_err(|e| Error::Certificate(format!("Failed to create issuer: {}", e)))?;
let mut params = create_cert_params(&hosts)?;
add_server_auth(&mut params);
let has_email = hosts.iter().any(|h| h.contains('@'));
if has_email {
add_email_protection(&mut params);
}
let subject = &csr.certification_request_info.subject;
copy_subject_to_params(&mut params, subject)?;
let cert = params
.signed_by(&cert_key_pair, &issuer)
.map_err(|e| Error::Certificate(format!("Failed to create signed certificate: {}", e)))?;
let cert_der = cert.der().to_vec();
let output_file = if let Some(file) = cert_file {
PathBuf::from(file)
} else {
let mut config = CertificateConfig::new(hosts.clone());
config.cert_file = None;
config.key_file = None;
let (cert_path, _, _) = generate_file_names(&config);
cert_path
};
let cert_pem = cert_to_pem(&cert_der);
fs::write(&output_file, cert_pem.as_bytes()).map_err(Error::Io)?;
set_file_permissions(&output_file, 0o644)?;
print_hosts(&hosts);
println!("\nThe certificate is at {:?}\n", output_file);
let expiration = calculate_cert_expiration();
check_cert_expiry_warning(expiration);
println!("It will expire on {}\n", format_expiration_date(expiration));
Ok(())
}
fn copy_subject_to_params(
params: &mut CertificateParams,
subject: &x509_parser::x509::X509Name,
) -> Result<()> {
use rcgen::{DistinguishedName, DnType};
let mut dn = DistinguishedName::new();
if let Some(cn) = subject.iter_common_name().next()
&& let Ok(cn_str) = cn.as_str()
{
dn.push(DnType::CommonName, cn_str);
}
if let Some(o) = subject.iter_organization().next()
&& let Ok(o_str) = o.as_str()
{
dn.push(DnType::OrganizationName, o_str);
}
if let Some(ou) = subject.iter_organizational_unit().next()
&& let Ok(ou_str) = ou.as_str()
{
dn.push(DnType::OrganizationalUnitName, ou_str);
}
params.distinguished_name = dn;
Ok(())
}
fn generate_certificate_internal(
config: &CertificateConfig,
ca_cert_pem: &str,
ca_key_pem: &str,
) -> Result<()> {
if config.hosts.is_empty() {
return Err(Error::Certificate("No hosts specified".to_string()));
}
let cert_key_pair = if config.use_ecdsa {
KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.map_err(|e| Error::Certificate(format!("Failed to generate ECDSA key pair: {}", e)))?
} else {
KeyPair::generate_rsa_for(&PKCS_RSA_SHA256, RsaKeySize::_2048)
.map_err(|e| Error::Certificate(format!("Failed to generate RSA key pair: {}", e)))?
};
let ca_key_pair = KeyPair::from_pem(ca_key_pem)
.map_err(|e| Error::Certificate(format!("Failed to parse CA key: {}", e)))?;
let issuer = Issuer::from_ca_cert_pem(ca_cert_pem, ca_key_pair)
.map_err(|e| Error::Certificate(format!("Failed to create issuer from CA cert: {}", e)))?;
let mut params = create_cert_params(&config.hosts)?;
if config.client_cert {
add_client_auth(&mut params);
}
let has_server_names = config.hosts.iter().any(|h| {
let host_type = HostType::parse(h).ok();
matches!(
host_type,
Some(HostType::DnsName(_)) | Some(HostType::IpAddress(_)) | Some(HostType::Uri(_))
)
});
if has_server_names {
add_server_auth(&mut params);
}
let has_email = config
.hosts
.iter()
.any(|h| matches!(HostType::parse(h).ok(), Some(HostType::Email(_))));
if has_email {
add_email_protection(&mut params);
}
if config.pkcs12 {
params
.distinguished_name
.push(rcgen::DnType::CommonName, config.hosts[0].clone());
}
let cert = params
.signed_by(&cert_key_pair, &issuer)
.map_err(|e| Error::Certificate(format!("Failed to create signed certificate: {}", e)))?;
let cert_der = cert.der().to_vec();
let ca_cert_pem_parsed = pem::parse(ca_cert_pem)
.map_err(|e| Error::Certificate(format!("Failed to parse CA cert PEM: {}", e)))?;
let ca_cert_der = ca_cert_pem_parsed.contents().to_vec();
let (cert_file, key_file, p12_file) = generate_file_names(config);
if !config.pkcs12 {
let cert_pem = cert_to_pem(&cert_der);
let key_pem = key_to_pem(&cert_key_pair)?;
write_pem_files(&cert_file, &key_file, &cert_pem, &key_pem)?;
} else {
write_pkcs12_file(&p12_file, &cert_der, &cert_key_pair, &ca_cert_der)?;
}
print_hosts(&config.hosts);
if !config.pkcs12 {
if cert_file == key_file {
println!(
"\n{} {:?}\n",
"The certificate and key are at".green(),
cert_file
);
} else {
println!(
"\n{} {:?} {} {:?}\n",
"The certificate is at".green(),
cert_file,
"and the key at".green(),
key_file
);
}
} else {
println!("\n{} {:?}", "The PKCS#12 bundle is at".green(), p12_file);
println!(
"\n{} The legacy PKCS#12 encryption password is the often hardcoded default \"changeit\"\n",
"Info:".cyan()
);
}
let expiration = calculate_cert_expiration();
check_cert_expiry_warning(expiration);
println!(
"{} {}\n",
"It will expire on".bright_white(),
format_expiration_date(expiration)
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_ca() -> (String, String) {
let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let mut params = CertificateParams::default();
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params
.distinguished_name
.push(rcgen::DnType::CommonName, "Test CA");
let cert = params.self_signed(&key_pair).unwrap();
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
(cert_pem, key_pem)
}
#[test]
fn test_parse_dns_name() {
let ht = HostType::parse("example.com").unwrap();
assert_eq!(ht, HostType::DnsName("example.com".to_string()));
}
#[test]
fn test_parse_ip() {
let ht = HostType::parse("127.0.0.1").unwrap();
match ht {
HostType::IpAddress(_) => {}
_ => panic!("Expected IP address"),
}
}
#[test]
fn test_parse_email() {
let ht = HostType::parse("test@example.com").unwrap();
assert_eq!(ht, HostType::Email("test@example.com".to_string()));
}
#[test]
fn test_validate_hostname() {
assert!(validate_hostname("example.com").is_ok());
assert!(validate_hostname("sub.example.com").is_ok());
assert!(validate_hostname("*.example.com").is_ok());
assert!(validate_hostname("localhost").is_ok());
}
#[test]
fn test_invalid_hostname() {
assert!(validate_hostname("").is_err());
assert!(validate_hostname("..").is_err());
}
#[test]
fn test_file_naming_single_host() {
let config = CertificateConfig::new(vec!["example.com".to_string()]);
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("./example.com.pem"));
assert_eq!(key, PathBuf::from("./example.com-key.pem"));
assert_eq!(p12, PathBuf::from("./example.com.p12"));
}
#[test]
fn test_file_naming_multiple_hosts() {
let config = CertificateConfig::new(vec![
"example.com".to_string(),
"www.example.com".to_string(),
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
]);
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("./example.com+4.pem"));
assert_eq!(key, PathBuf::from("./example.com+4-key.pem"));
assert_eq!(p12, PathBuf::from("./example.com+4.p12"));
}
#[test]
fn test_file_naming_wildcard() {
let config = CertificateConfig::new(vec!["*.example.com".to_string()]);
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("./_wildcard.example.com.pem"));
assert_eq!(key, PathBuf::from("./_wildcard.example.com-key.pem"));
assert_eq!(p12, PathBuf::from("./_wildcard.example.com.p12"));
}
#[test]
fn test_file_naming_with_port() {
let config = CertificateConfig::new(vec!["localhost:8080".to_string()]);
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("./localhost_8080.pem"));
assert_eq!(key, PathBuf::from("./localhost_8080-key.pem"));
assert_eq!(p12, PathBuf::from("./localhost_8080.p12"));
}
#[test]
fn test_file_naming_client_cert() {
let mut config = CertificateConfig::new(vec!["example.com".to_string()]);
config.client_cert = true;
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("./example.com-client.pem"));
assert_eq!(key, PathBuf::from("./example.com-client-key.pem"));
assert_eq!(p12, PathBuf::from("./example.com-client.p12"));
}
#[test]
fn test_file_naming_custom_paths() {
let mut config = CertificateConfig::new(vec!["example.com".to_string()]);
config.cert_file = Some(PathBuf::from("/tmp/custom.crt"));
config.key_file = Some(PathBuf::from("/tmp/custom.key"));
config.p12_file = Some(PathBuf::from("/tmp/custom.p12"));
let (cert, key, p12) = generate_file_names(&config);
assert_eq!(cert, PathBuf::from("/tmp/custom.crt"));
assert_eq!(key, PathBuf::from("/tmp/custom.key"));
assert_eq!(p12, PathBuf::from("/tmp/custom.p12"));
}
#[test]
fn test_certificate_generation_integration() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let mut config = CertificateConfig::new(vec![
"example.com".to_string(),
"www.example.com".to_string(),
"127.0.0.1".to_string(),
]);
config.use_ecdsa = true;
let cert_path = temp_path.join("example.com+2.pem");
let key_path = temp_path.join("example.com+2-key.pem");
config.cert_file = Some(cert_path.clone());
config.key_file = Some(key_path.clone());
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(
result.is_ok(),
"Certificate generation failed: {:?}",
result.err()
);
assert!(cert_path.exists(), "Certificate file was not created");
assert!(key_path.exists(), "Key file was not created");
let cert_pem = fs::read_to_string(&cert_path).unwrap();
let key_pem = fs::read_to_string(&key_path).unwrap();
assert!(
cert_pem.contains("BEGIN CERTIFICATE"),
"Certificate PEM is invalid"
);
assert!(
key_pem.contains("BEGIN PRIVATE KEY"),
"Private key PEM is invalid"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let cert_perms = fs::metadata(&cert_path).unwrap().permissions();
let key_perms = fs::metadata(&key_path).unwrap().permissions();
assert_eq!(
cert_perms.mode() & 0o777,
0o644,
"Certificate permissions incorrect"
);
assert_eq!(key_perms.mode() & 0o777, 0o600, "Key permissions incorrect");
}
}
#[test]
fn test_certificate_generation_combined_file() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let mut config = CertificateConfig::new(vec!["localhost".to_string()]);
config.use_ecdsa = true;
let combined_path = temp_path.join("localhost-combined.pem");
config.cert_file = Some(combined_path.clone());
config.key_file = Some(combined_path.clone());
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(
result.is_ok(),
"Certificate generation failed: {:?}",
result.err()
);
assert!(combined_path.exists(), "Combined file was not created");
let combined_pem = fs::read_to_string(&combined_path).unwrap();
assert!(
combined_pem.contains("BEGIN CERTIFICATE"),
"Combined file missing certificate"
);
assert!(
combined_pem.contains("BEGIN PRIVATE KEY"),
"Combined file missing key"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::metadata(&combined_path).unwrap().permissions();
assert_eq!(
perms.mode() & 0o777,
0o600,
"Combined file permissions should be 0600"
);
}
}
#[test]
fn test_csr_file_reading() {
use std::io::Write;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let csr_path = temp_dir.path().join("test.csr");
let mut file = std::fs::File::create(&csr_path).unwrap();
file.write_all(b"test content").unwrap();
let result = read_csr_file(csr_path.to_str().unwrap());
assert!(result.is_ok());
assert_eq!(result.unwrap(), b"test content");
}
#[test]
#[ignore] fn test_csr_pem_parsing() {
}
#[test]
#[ignore] fn test_extract_san_from_csr() {
}
#[test]
fn test_end_to_end_certificate_generation() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec!["example.com".to_string(), "localhost".to_string()];
let mut config = CertificateConfig::new(hosts.clone());
config.use_ecdsa = true;
let cert_path = temp_path.join("test.pem");
let key_path = temp_path.join("test-key.pem");
config.cert_file = Some(cert_path.clone());
config.key_file = Some(key_path.clone());
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(
result.is_ok(),
"End-to-end certificate generation failed: {:?}",
result.err()
);
assert!(cert_path.exists(), "Certificate file not created");
assert!(key_path.exists(), "Key file not created");
let cert_pem = fs::read_to_string(&cert_path).unwrap();
let key_pem = fs::read_to_string(&key_path).unwrap();
assert!(cert_pem.contains("BEGIN CERTIFICATE"));
assert!(key_pem.contains("BEGIN PRIVATE KEY"));
}
#[test]
fn test_idna_domain_to_ascii() {
let ascii = domain_to_ascii("例え.jp").unwrap();
assert!(ascii.starts_with("xn--"));
assert_eq!(ascii, "xn--r8jz45g.jp");
}
#[test]
fn test_idna_domain_to_unicode() {
let unicode = domain_to_unicode("xn--r8jz45g.jp");
assert_eq!(unicode, "例え.jp");
}
#[test]
fn test_idna_ascii_passthrough() {
let ascii = domain_to_ascii("example.com").unwrap();
assert_eq!(ascii, "example.com");
}
#[test]
fn test_generate_serial_number() {
let serial1 = generate_serial_number();
let serial2 = generate_serial_number();
assert_eq!(serial1.len(), 16);
assert_eq!(serial2.len(), 16);
assert_ne!(serial1, serial2, "Serial numbers should be unique");
assert_eq!(
serial1[0] & 0x80,
0,
"Serial number high bit should be clear"
);
}
#[test]
fn test_calculate_cert_expiration() {
let expiration = calculate_cert_expiration();
let now = OffsetDateTime::now_utc();
let diff = expiration - now;
assert!(diff.whole_days() >= 819 && diff.whole_days() <= 821);
}
#[test]
fn test_format_expiration_date() {
let now = OffsetDateTime::now_utc();
let formatted = format_expiration_date(now);
assert!(!formatted.is_empty());
assert!(formatted.len() > 10);
}
#[test]
fn test_wildcard_depth_validation() {
assert!(validate_wildcard_depth("*.example.com").is_ok());
assert!(validate_wildcard_depth("example.com").is_ok());
assert!(validate_wildcard_depth("*.*.example.com").is_err());
assert!(validate_wildcard_depth("*example.com").is_err());
assert!(validate_wildcard_depth("example.*.com").is_err());
}
#[test]
fn test_ip_address_validation() {
use std::net::{Ipv4Addr, Ipv6Addr};
assert!(validate_ip_address(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))).is_ok());
assert!(validate_ip_address(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))).is_ok());
assert!(validate_ip_address(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))).is_ok());
assert!(validate_ip_address(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))).is_err());
assert!(validate_ip_address(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1))).is_ok());
assert!(
validate_ip_address(&IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))).is_ok()
);
assert!(validate_ip_address(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0))).is_err());
}
#[test]
fn test_email_address_validation() {
assert!(validate_email_address("test@example.com").is_ok());
assert!(validate_email_address("user.name@example.co.uk").is_ok());
assert!(validate_email_address("user+tag@example.com").is_ok());
assert!(validate_email_address("notanemail").is_err());
assert!(validate_email_address("@example.com").is_err());
assert!(validate_email_address("test@").is_err());
assert!(validate_email_address("test @example.com").is_err());
}
#[test]
fn test_uri_validation() {
assert!(validate_uri("https://example.com").is_ok());
assert!(validate_uri("http://localhost:8080/path").is_ok());
assert!(validate_uri("ftp://files.example.com").is_ok());
assert!(validate_uri("custom-scheme://resource").is_ok());
assert!(validate_uri("not-a-uri").is_err());
assert!(validate_uri("://missing-scheme").is_err());
assert!(validate_uri("http://").is_err());
assert!(validate_uri("http:// space.com").is_err());
}
#[test]
fn test_host_type_parsing_dns() {
let ht = HostType::parse("example.com").unwrap();
assert!(matches!(ht, HostType::DnsName(_)));
let ht = HostType::parse("*.example.com").unwrap();
assert!(matches!(ht, HostType::DnsName(_)));
let ht = HostType::parse("sub.example.com").unwrap();
assert!(matches!(ht, HostType::DnsName(_)));
}
#[test]
fn test_host_type_parsing_ip() {
let ht = HostType::parse("127.0.0.1").unwrap();
assert!(matches!(ht, HostType::IpAddress(_)));
let ht = HostType::parse("::1").unwrap();
assert!(matches!(ht, HostType::IpAddress(_)));
let ht = HostType::parse("192.168.1.1").unwrap();
assert!(matches!(ht, HostType::IpAddress(_)));
}
#[test]
fn test_host_type_parsing_email() {
let ht = HostType::parse("user@example.com").unwrap();
assert!(matches!(ht, HostType::Email(_)));
let ht = HostType::parse("test.user@example.co.uk").unwrap();
assert!(matches!(ht, HostType::Email(_)));
}
#[test]
fn test_host_type_parsing_uri() {
let ht = HostType::parse("https://example.com").unwrap();
assert!(matches!(ht, HostType::Uri(_)));
let ht = HostType::parse("http://localhost:8080").unwrap();
assert!(matches!(ht, HostType::Uri(_)));
}
#[test]
fn test_host_type_validation_errors() {
assert!(HostType::parse("0.0.0.0").is_err());
assert!(HostType::parse("invalid@").is_err());
assert!(HostType::parse("://no-scheme").is_err());
assert!(validate_wildcard_depth("*.*.example.com").is_err());
}
#[test]
fn test_cert_expiry_check() {
let now = OffsetDateTime::now_utc();
let far_future = now + Duration::days(60);
assert!(!is_cert_expiring_soon(far_future));
let near_future = now + Duration::days(15);
assert!(is_cert_expiring_soon(near_future));
let very_soon = now + Duration::days(1);
assert!(is_cert_expiring_soon(very_soon));
let past = now - Duration::days(1);
assert!(!is_cert_expiring_soon(past));
}
#[test]
#[cfg(unix)]
fn test_file_permission_verification() {
use std::fs::File;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
File::create(&file_path).unwrap();
set_file_permissions(&file_path, 0o644).unwrap();
assert!(verify_file_permissions(&file_path, 0o644).unwrap());
assert!(!verify_file_permissions(&file_path, 0o600).unwrap());
set_file_permissions(&file_path, 0o600).unwrap();
assert!(verify_file_permissions(&file_path, 0o600).unwrap());
assert!(!verify_file_permissions(&file_path, 0o644).unwrap());
}
#[test]
fn test_concurrent_certificate_generation() {
use std::sync::Arc;
use std::thread;
use tempfile::TempDir;
let temp_dir = Arc::new(TempDir::new().unwrap());
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let mut handles = vec![];
for i in 0..3 {
let temp_dir = Arc::clone(&temp_dir);
let ca_cert_pem = ca_cert_pem.clone();
let ca_key_pem = ca_key_pem.clone();
let handle = thread::spawn(move || {
let hosts = vec![format!("test{}.example.com", i)];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
let cert_path = temp_dir.path().join(format!("cert{}.pem", i));
let key_path = temp_dir.path().join(format!("key{}.pem", i));
config.cert_file = Some(cert_path.clone());
config.key_file = Some(key_path.clone());
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "Concurrent certificate generation failed");
assert!(cert_path.exists(), "Certificate file not created");
assert!(key_path.exists(), "Key file not created");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_certificate_chain_validation() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let ca_cert_pem_parsed = pem::parse(&ca_cert_pem).unwrap();
let ca_cert_der = ca_cert_pem_parsed.contents().to_vec();
let hosts = vec!["example.com".to_string()];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
let cert_path = temp_dir.path().join("cert.pem");
let key_path = temp_dir.path().join("key.pem");
config.cert_file = Some(cert_path.clone());
config.key_file = Some(key_path.clone());
generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem).unwrap();
let cert_pem = fs::read_to_string(&cert_path).unwrap();
let cert_der_data = pem::parse(&cert_pem).unwrap();
let cert_der = cert_der_data.contents();
let result = validate_cert_chain(cert_der, &ca_cert_der);
assert!(result.is_ok(), "Certificate chain validation failed");
}
#[test]
fn test_multi_domain_certificate() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec![
"example.com".to_string(),
"www.example.com".to_string(),
"api.example.com".to_string(),
"localhost".to_string(),
"127.0.0.1".to_string(),
];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
config.cert_file = Some(temp_dir.path().join("multi.pem"));
config.key_file = Some(temp_dir.path().join("multi-key.pem"));
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "Multi-domain certificate generation failed");
}
#[test]
fn test_ipv6_certificate() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec![
"::1".to_string(),
"fe80::1".to_string(),
"2001:db8::1".to_string(),
];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
config.cert_file = Some(temp_dir.path().join("ipv6.pem"));
config.key_file = Some(temp_dir.path().join("ipv6-key.pem"));
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "IPv6 certificate generation failed");
}
#[test]
fn test_wildcard_certificate() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec!["*.example.com".to_string()];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
config.cert_file = Some(temp_dir.path().join("wildcard.pem"));
config.key_file = Some(temp_dir.path().join("wildcard-key.pem"));
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "Wildcard certificate generation failed");
}
#[test]
fn test_client_certificate() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec!["client@example.com".to_string()];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
config.client_cert = true;
config.cert_file = Some(temp_dir.path().join("client.pem"));
config.key_file = Some(temp_dir.path().join("client-key.pem"));
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "Client certificate generation failed");
}
#[test]
fn test_pkcs12_export() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let (ca_cert_pem, ca_key_pem) = create_test_ca();
let hosts = vec!["example.com".to_string()];
let mut config = CertificateConfig::new(hosts);
config.use_ecdsa = true;
config.pkcs12 = true;
config.p12_file = Some(temp_dir.path().join("example.p12"));
let result = generate_certificate_internal(&config, &ca_cert_pem, &ca_key_pem);
assert!(result.is_ok(), "PKCS#12 export failed");
let p12_path = temp_dir.path().join("example.p12");
assert!(p12_path.exists(), "PKCS#12 file was not created");
}
}