use blueprint_core::debug;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa,
Issuer, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::tls_envelope::TlsEnvelope;
use crate::types::ServiceId;
pub struct CertificateAuthority {
ca_key_pair: KeyPair,
ca_cert_pem: String,
tls_envelope: TlsEnvelope,
}
impl CertificateAuthority {
pub fn new(tls_envelope: TlsEnvelope) -> Result<Self, crate::Error> {
let params = ca_certificate_params();
let ca_key_pair = KeyPair::generate()?;
let ca_cert = params.self_signed(&ca_key_pair)?;
let ca_cert_pem = ca_cert.pem();
debug!("Created new certificate authority");
Ok(Self {
ca_key_pair,
ca_cert_pem,
tls_envelope,
})
}
pub fn from_components(
ca_cert_pem: String,
ca_key_pem: String,
tls_envelope: TlsEnvelope,
) -> Result<Self, crate::Error> {
let cert_block = pem::parse(&ca_cert_pem).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Failed to parse CA certificate PEM: {e}"
)))
})?;
if cert_block.tag() != "CERTIFICATE" {
return Err(crate::Error::Io(std::io::Error::other(
"CA bundle is missing a CERTIFICATE block",
)));
}
let ca_key_pair = KeyPair::from_pem(&ca_key_pem).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Failed to parse CA private key PEM: {e}"
)))
})?;
debug!("Loaded existing certificate authority");
Ok(Self {
ca_key_pair,
ca_cert_pem,
tls_envelope,
})
}
pub fn ca_certificate_pem(&self) -> String {
self.ca_cert_pem.clone()
}
pub fn ca_private_key_pem(&self) -> String {
self.ca_key_pair.serialize_pem()
}
fn issuer(&self) -> Result<Issuer<'static, KeyPair>, crate::Error> {
let issuer_key = KeyPair::from_pem(&self.ca_key_pair.serialize_pem()).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Failed to clone CA private key: {e}"
)))
})?;
Issuer::from_ca_cert_pem(&self.ca_cert_pem, issuer_key).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Failed to build issuer from CA cert: {e}"
)))
})
}
pub fn generate_server_certificate(
&self,
service_id: ServiceId,
dns_names: Vec<String>,
) -> Result<(String, String), crate::Error> {
let params = server_certificate_params(service_id, dns_names)?;
let leaf_key = KeyPair::generate()?;
let issuer = self.issuer()?;
let cert = params.signed_by(&leaf_key, &issuer)?;
Ok((cert.pem(), leaf_key.serialize_pem()))
}
pub fn generate_client_certificate(
&self,
common_name: String,
subject_alt_names: Vec<String>,
ttl_hours: u32,
) -> Result<ClientCertificate, crate::Error> {
let (params, expiry) =
client_certificate_params(&common_name, subject_alt_names, ttl_hours)?;
let client_key = KeyPair::generate()?;
let issuer = self.issuer()?;
let cert = params.signed_by(&client_key, &issuer)?;
let serial = params
.serial_number
.as_ref()
.map(|s| hex::encode(s.to_bytes()))
.unwrap_or_else(|| "missing-serial".to_string());
Ok(ClientCertificate {
certificate_pem: cert.pem(),
private_key_pem: client_key.serialize_pem(),
ca_bundle_pem: self.ca_cert_pem.clone(),
serial: serial.clone(),
expires_at: expiry.unix_timestamp().max(0) as u64,
revocation_url: Some(format!("/v1/auth/certificates/{serial}/revoke")),
})
}
pub fn envelope(&self) -> &TlsEnvelope {
&self.tls_envelope
}
}
fn ca_certificate_params() -> CertificateParams {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
params.extended_key_usages = vec![
ExtendedKeyUsagePurpose::ServerAuth,
ExtendedKeyUsagePurpose::ClientAuth,
];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "Tangle Network CA");
dn.push(DnType::OrganizationName, "Tangle Network");
params.distinguished_name = dn;
params
}
fn server_certificate_params(
service_id: ServiceId,
dns_names: Vec<String>,
) -> Result<CertificateParams, crate::Error> {
let mut params = CertificateParams::default();
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, format!("Service {service_id}"));
dn.push(DnType::OrganizationName, "Tangle Network");
params.distinguished_name = dn;
params.subject_alt_names = dns_names
.into_iter()
.map(try_dns_name)
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.map(SanType::DnsName)
.collect();
params.serial_number = Some(random_serial()?);
Ok(params)
}
fn client_certificate_params(
common_name: &str,
subject_alt_names: Vec<String>,
ttl_hours: u32,
) -> Result<(CertificateParams, OffsetDateTime), crate::Error> {
let mut params = CertificateParams::default();
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyAgreement,
];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, common_name);
dn.push(DnType::OrganizationName, "Tangle Network");
params.distinguished_name = dn;
for san in subject_alt_names {
if let Some(rest) = san.strip_prefix("DNS:") {
let dns = try_dns_name(rest.to_string())?;
params.subject_alt_names.push(SanType::DnsName(dns));
continue;
}
if let Some(rest) = san.strip_prefix("URI:") {
let uri = try_uri_name(rest.to_string())?;
params.subject_alt_names.push(SanType::URI(uri));
continue;
}
let dns = try_dns_name(san.clone())?;
params.subject_alt_names.push(SanType::DnsName(dns));
}
let now = OffsetDateTime::now_utc();
params.not_before = now;
let ttl = time::Duration::hours(i64::from(ttl_hours));
let expiry = now + ttl;
params.not_after = expiry;
params.serial_number = Some(random_serial()?);
Ok((params, expiry))
}
fn random_serial() -> Result<SerialNumber, crate::Error> {
use blueprint_std::rand::RngCore;
let mut rng = blueprint_std::BlueprintRng::new();
let mut bytes = [0u8; 16];
loop {
rng.fill_bytes(&mut bytes);
bytes[0] &= 0x7F; if bytes.iter().any(|b| *b != 0) {
break;
}
}
Ok(SerialNumber::from_slice(&bytes))
}
fn try_dns_name(value: String) -> Result<rcgen::string::Ia5String, crate::Error> {
rcgen::string::Ia5String::try_from(value.clone()).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Invalid DNS subjectAltName `{value}`: {e}"
)))
})
}
fn try_uri_name(value: String) -> Result<rcgen::string::Ia5String, crate::Error> {
rcgen::string::Ia5String::try_from(value.clone()).map_err(|e| {
crate::Error::Io(std::io::Error::other(format!(
"Invalid URI subjectAltName `{value}`: {e}"
)))
})
}
mod types {
use super::*;
use crate::models::TlsProfile;
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientCertificate {
pub certificate_pem: String,
pub private_key_pem: String,
pub ca_bundle_pem: String,
pub serial: String,
pub expires_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub revocation_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTlsProfileRequest {
pub require_client_mtls: bool,
pub client_cert_ttl_hours: u32,
pub subject_alt_name_template: Option<String>,
pub allowed_dns_names: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueCertificateRequest {
pub service_id: u64,
pub common_name: String,
pub subject_alt_names: Vec<String>,
pub ttl_hours: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsProfileResponse {
pub tls_enabled: bool,
pub require_client_mtls: bool,
pub client_cert_ttl_hours: u32,
pub mtls_listener: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_listener: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ca_certificate_pem: Option<String>,
pub subject_alt_name_template: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_dns_names: Vec<String>,
}
pub fn validate_certificate_request(
request: &IssueCertificateRequest,
profile: &TlsProfile,
) -> Result<(), crate::Error> {
if !profile.require_client_mtls {
return Err(crate::Error::Io(std::io::Error::other(
"Client mTLS is not enabled for this service",
)));
}
if request.ttl_hours > profile.client_cert_ttl_hours {
return Err(crate::Error::Io(std::io::Error::other(format!(
"Certificate TTL {} hours exceeds maximum allowed {} hours",
request.ttl_hours, profile.client_cert_ttl_hours
))));
}
if !profile.allowed_dns_names.is_empty() {
let allowed: HashSet<&str> = profile
.allowed_dns_names
.iter()
.map(|s| s.as_str())
.collect();
for san in &request.subject_alt_names {
let candidate = if let Some(rest) = san.strip_prefix("DNS:") {
Some(rest)
} else if san.starts_with("URI:") || san.contains("://") {
None
} else {
Some(san.as_str())
};
if let Some(name) = candidate {
if !allowed.contains(name) {
return Err(crate::Error::Io(std::io::Error::other(format!(
"Subject alternative name `{name}` is not allowed by profile",
))));
}
}
}
}
Ok(())
}
}
pub use types::{
ClientCertificate, CreateTlsProfileRequest, IssueCertificateRequest, TlsProfileResponse,
validate_certificate_request,
};
#[cfg(test)]
mod tests {
use super::*;
use crate::tls_envelope::TlsEnvelope;
#[test]
fn ca_material_round_trips() {
let ca = CertificateAuthority::new(TlsEnvelope::new()).expect("fresh ca");
let cert_pem = ca.ca_certificate_pem();
let key_pem = ca.ca_private_key_pem();
let restored = CertificateAuthority::from_components(
cert_pem.clone(),
key_pem.clone(),
TlsEnvelope::new(),
)
.expect("restore ca");
assert_eq!(restored.ca_certificate_pem(), cert_pem);
assert_eq!(restored.ca_private_key_pem(), key_pem);
}
#[test]
fn client_cert_respects_ttl_and_serial() {
let ca = CertificateAuthority::new(TlsEnvelope::new()).expect("ca");
let cert = ca
.generate_client_certificate("tenant-alpha".into(), vec!["localhost".into()], 1)
.expect("client cert");
assert_ne!(cert.serial, "missing-serial");
assert!(cert.ca_bundle_pem.contains("BEGIN CERTIFICATE"));
assert!(
cert.revocation_url
.as_ref()
.expect("revocation url")
.ends_with(&format!("{}/revoke", cert.serial))
);
let now = OffsetDateTime::now_utc().unix_timestamp();
assert!(cert.expires_at >= now as u64);
}
}