1use blueprint_core::debug;
8use rcgen::{
9 BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa,
10 Issuer, KeyPair, KeyUsagePurpose, SanType, SerialNumber,
11};
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14
15use crate::tls_envelope::TlsEnvelope;
16use crate::types::ServiceId;
17
18pub struct CertificateAuthority {
20 ca_key_pair: KeyPair,
21 ca_cert_pem: String,
22 tls_envelope: TlsEnvelope,
23}
24
25impl CertificateAuthority {
26 pub fn new(tls_envelope: TlsEnvelope) -> Result<Self, crate::Error> {
28 let params = ca_certificate_params();
29
30 let ca_key_pair = KeyPair::generate()?;
31 let ca_cert = params.self_signed(&ca_key_pair)?;
32 let ca_cert_pem = ca_cert.pem();
33
34 debug!("Created new certificate authority");
35 Ok(Self {
36 ca_key_pair,
37 ca_cert_pem,
38 tls_envelope,
39 })
40 }
41
42 pub fn from_components(
44 ca_cert_pem: String,
45 ca_key_pem: String,
46 tls_envelope: TlsEnvelope,
47 ) -> Result<Self, crate::Error> {
48 let cert_block = pem::parse(&ca_cert_pem).map_err(|e| {
50 crate::Error::Io(std::io::Error::other(format!(
51 "Failed to parse CA certificate PEM: {e}"
52 )))
53 })?;
54 if cert_block.tag != "CERTIFICATE" {
55 return Err(crate::Error::Io(std::io::Error::other(
56 "CA bundle is missing a CERTIFICATE block",
57 )));
58 }
59
60 let ca_key_pair = KeyPair::from_pem(&ca_key_pem).map_err(|e| {
61 crate::Error::Io(std::io::Error::other(format!(
62 "Failed to parse CA private key PEM: {e}"
63 )))
64 })?;
65
66 debug!("Loaded existing certificate authority");
67 Ok(Self {
68 ca_key_pair,
69 ca_cert_pem,
70 tls_envelope,
71 })
72 }
73
74 pub fn ca_certificate_pem(&self) -> String {
76 self.ca_cert_pem.clone()
77 }
78
79 pub fn ca_private_key_pem(&self) -> String {
81 self.ca_key_pair.serialize_pem()
82 }
83
84 fn issuer(&self) -> Result<Issuer<'static, KeyPair>, crate::Error> {
86 let issuer_key = KeyPair::from_pem(&self.ca_key_pair.serialize_pem()).map_err(|e| {
87 crate::Error::Io(std::io::Error::other(format!(
88 "Failed to clone CA private key: {e}"
89 )))
90 })?;
91 Issuer::from_ca_cert_pem(&self.ca_cert_pem, issuer_key).map_err(|e| {
92 crate::Error::Io(std::io::Error::other(format!(
93 "Failed to build issuer from CA cert: {e}"
94 )))
95 })
96 }
97
98 pub fn generate_server_certificate(
100 &self,
101 service_id: ServiceId,
102 dns_names: Vec<String>,
103 ) -> Result<(String, String), crate::Error> {
104 let params = server_certificate_params(service_id, dns_names)?;
105
106 let leaf_key = KeyPair::generate()?;
107 let issuer = self.issuer()?;
108 let cert = params.signed_by(&leaf_key, &issuer)?;
109
110 Ok((cert.pem(), leaf_key.serialize_pem()))
111 }
112
113 pub fn generate_client_certificate(
115 &self,
116 common_name: String,
117 subject_alt_names: Vec<String>,
118 ttl_hours: u32,
119 ) -> Result<ClientCertificate, crate::Error> {
120 let (params, expiry) =
121 client_certificate_params(&common_name, subject_alt_names, ttl_hours)?;
122
123 let client_key = KeyPair::generate()?;
124 let issuer = self.issuer()?;
125 let cert = params.signed_by(&client_key, &issuer)?;
126 let serial = params
127 .serial_number
128 .as_ref()
129 .map(|s| hex::encode(s.to_bytes()))
130 .unwrap_or_else(|| "missing-serial".to_string());
131
132 Ok(ClientCertificate {
133 certificate_pem: cert.pem(),
134 private_key_pem: client_key.serialize_pem(),
135 ca_bundle_pem: self.ca_cert_pem.clone(),
136 serial: serial.clone(),
137 expires_at: expiry.unix_timestamp().max(0) as u64,
138 revocation_url: Some(format!("/v1/auth/certificates/{serial}/revoke")),
139 })
140 }
141
142 pub fn envelope(&self) -> &TlsEnvelope {
144 &self.tls_envelope
145 }
146}
147
148fn ca_certificate_params() -> CertificateParams {
149 let mut params = CertificateParams::default();
150 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
151 params.key_usages = vec![
152 KeyUsagePurpose::KeyCertSign,
153 KeyUsagePurpose::CrlSign,
154 KeyUsagePurpose::DigitalSignature,
155 ];
156 params.extended_key_usages = vec![
157 ExtendedKeyUsagePurpose::ServerAuth,
158 ExtendedKeyUsagePurpose::ClientAuth,
159 ];
160
161 let mut dn = DistinguishedName::new();
162 dn.push(DnType::CommonName, "Tangle Network CA");
163 dn.push(DnType::OrganizationName, "Tangle Network");
164 params.distinguished_name = dn;
165
166 params
167}
168
169fn server_certificate_params(
170 service_id: ServiceId,
171 dns_names: Vec<String>,
172) -> Result<CertificateParams, crate::Error> {
173 let mut params = CertificateParams::default();
174 params.key_usages = vec![
175 KeyUsagePurpose::DigitalSignature,
176 KeyUsagePurpose::KeyEncipherment,
177 ];
178 params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
179
180 let mut dn = DistinguishedName::new();
181 dn.push(DnType::CommonName, format!("Service {service_id}"));
182 dn.push(DnType::OrganizationName, "Tangle Network");
183 params.distinguished_name = dn;
184
185 params.subject_alt_names = dns_names
186 .into_iter()
187 .map(try_dns_name)
188 .collect::<Result<Vec<_>, _>>()?
189 .into_iter()
190 .map(SanType::DnsName)
191 .collect();
192
193 params.serial_number = Some(random_serial()?);
194
195 Ok(params)
196}
197
198fn client_certificate_params(
199 common_name: &str,
200 subject_alt_names: Vec<String>,
201 ttl_hours: u32,
202) -> Result<(CertificateParams, OffsetDateTime), crate::Error> {
203 let mut params = CertificateParams::default();
204 params.key_usages = vec![
205 KeyUsagePurpose::DigitalSignature,
206 KeyUsagePurpose::KeyAgreement,
207 ];
208 params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
209
210 let mut dn = DistinguishedName::new();
211 dn.push(DnType::CommonName, common_name);
212 dn.push(DnType::OrganizationName, "Tangle Network");
213 params.distinguished_name = dn;
214
215 for san in subject_alt_names {
216 if let Some(rest) = san.strip_prefix("DNS:") {
217 let dns = try_dns_name(rest.to_string())?;
218 params.subject_alt_names.push(SanType::DnsName(dns));
219 continue;
220 }
221 if let Some(rest) = san.strip_prefix("URI:") {
222 let uri = try_uri_name(rest.to_string())?;
223 params.subject_alt_names.push(SanType::URI(uri));
224 continue;
225 }
226 let dns = try_dns_name(san.clone())?;
227 params.subject_alt_names.push(SanType::DnsName(dns));
228 }
229
230 let now = OffsetDateTime::now_utc();
231 params.not_before = now;
232 let ttl = time::Duration::hours(i64::from(ttl_hours));
233 let expiry = now + ttl;
234 params.not_after = expiry;
235 params.serial_number = Some(random_serial()?);
236
237 Ok((params, expiry))
238}
239
240fn random_serial() -> Result<SerialNumber, crate::Error> {
242 use blueprint_std::rand::RngCore;
243
244 let mut rng = blueprint_std::BlueprintRng::new();
245 let mut bytes = [0u8; 16];
246 loop {
247 rng.fill_bytes(&mut bytes);
248 bytes[0] &= 0x7F; if bytes.iter().any(|b| *b != 0) {
250 break;
251 }
252 }
253 Ok(SerialNumber::from_slice(&bytes))
254}
255
256fn try_dns_name(value: String) -> Result<rcgen::string::Ia5String, crate::Error> {
257 rcgen::string::Ia5String::try_from(value.clone()).map_err(|e| {
258 crate::Error::Io(std::io::Error::other(format!(
259 "Invalid DNS subjectAltName `{value}`: {e}"
260 )))
261 })
262}
263
264fn try_uri_name(value: String) -> Result<rcgen::string::Ia5String, crate::Error> {
265 rcgen::string::Ia5String::try_from(value.clone()).map_err(|e| {
266 crate::Error::Io(std::io::Error::other(format!(
267 "Invalid URI subjectAltName `{value}`: {e}"
268 )))
269 })
270}
271
272mod types {
273 use super::*;
274 use crate::models::TlsProfile;
275 use std::collections::HashSet;
276
277 #[derive(Debug, Clone, Serialize, Deserialize)]
279 pub struct ClientCertificate {
280 pub certificate_pem: String,
281 pub private_key_pem: String,
282 pub ca_bundle_pem: String,
283 pub serial: String,
284 pub expires_at: u64,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub revocation_url: Option<String>,
287 }
288
289 #[derive(Debug, Clone, Serialize, Deserialize)]
291 pub struct CreateTlsProfileRequest {
292 pub require_client_mtls: bool,
293 pub client_cert_ttl_hours: u32,
294 pub subject_alt_name_template: Option<String>,
295 pub allowed_dns_names: Option<Vec<String>>,
296 }
297
298 #[derive(Debug, Clone, Serialize, Deserialize)]
300 pub struct IssueCertificateRequest {
301 pub service_id: u64,
302 pub common_name: String,
303 pub subject_alt_names: Vec<String>,
304 pub ttl_hours: u32,
305 }
306
307 #[derive(Debug, Clone, Serialize, Deserialize)]
309 pub struct TlsProfileResponse {
310 pub tls_enabled: bool,
311 pub require_client_mtls: bool,
312 pub client_cert_ttl_hours: u32,
313 pub mtls_listener: String,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub http_listener: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub ca_certificate_pem: Option<String>,
318 pub subject_alt_name_template: Option<String>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 pub allowed_dns_names: Vec<String>,
321 }
322
323 pub fn validate_certificate_request(
325 request: &IssueCertificateRequest,
326 profile: &TlsProfile,
327 ) -> Result<(), crate::Error> {
328 if !profile.require_client_mtls {
329 return Err(crate::Error::Io(std::io::Error::other(
330 "Client mTLS is not enabled for this service",
331 )));
332 }
333
334 if request.ttl_hours > profile.client_cert_ttl_hours {
335 return Err(crate::Error::Io(std::io::Error::other(format!(
336 "Certificate TTL {} hours exceeds maximum allowed {} hours",
337 request.ttl_hours, profile.client_cert_ttl_hours
338 ))));
339 }
340
341 if !profile.allowed_dns_names.is_empty() {
342 let allowed: HashSet<&str> = profile
343 .allowed_dns_names
344 .iter()
345 .map(|s| s.as_str())
346 .collect();
347
348 for san in &request.subject_alt_names {
349 let candidate = if let Some(rest) = san.strip_prefix("DNS:") {
350 Some(rest)
351 } else if san.starts_with("URI:") || san.contains("://") {
352 None
353 } else {
354 Some(san.as_str())
355 };
356
357 if let Some(name) = candidate {
358 if !allowed.contains(name) {
359 return Err(crate::Error::Io(std::io::Error::other(format!(
360 "Subject alternative name `{name}` is not allowed by profile",
361 ))));
362 }
363 }
364 }
365 }
366
367 Ok(())
368 }
369}
370
371pub use types::{
372 ClientCertificate, CreateTlsProfileRequest, IssueCertificateRequest, TlsProfileResponse,
373 validate_certificate_request,
374};
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::tls_envelope::TlsEnvelope;
380
381 #[test]
382 fn ca_material_round_trips() {
383 let ca = CertificateAuthority::new(TlsEnvelope::new()).expect("fresh ca");
384
385 let cert_pem = ca.ca_certificate_pem();
386 let key_pem = ca.ca_private_key_pem();
387
388 let restored = CertificateAuthority::from_components(
389 cert_pem.clone(),
390 key_pem.clone(),
391 TlsEnvelope::new(),
392 )
393 .expect("restore ca");
394
395 assert_eq!(restored.ca_certificate_pem(), cert_pem);
396 assert_eq!(restored.ca_private_key_pem(), key_pem);
397 }
398
399 #[test]
400 fn client_cert_respects_ttl_and_serial() {
401 let ca = CertificateAuthority::new(TlsEnvelope::new()).expect("ca");
402 let cert = ca
403 .generate_client_certificate("tenant-alpha".into(), vec!["localhost".into()], 1)
404 .expect("client cert");
405
406 assert_ne!(cert.serial, "missing-serial");
407 assert!(cert.ca_bundle_pem.contains("BEGIN CERTIFICATE"));
408 assert!(
409 cert.revocation_url
410 .as_ref()
411 .expect("revocation url")
412 .ends_with(&format!("{}/revoke", cert.serial))
413 );
414
415 let now = OffsetDateTime::now_utc().unix_timestamp();
416 assert!(cert.expires_at >= now as u64);
417 }
418}