blueprint_auth/
certificate_authority.rs

1//! Certificate Authority utilities for mTLS implementation.
2//!
3//! The helper manages a per-service certificate authority capable of issuing
4//! server and client certificates that chain back to the stored root. All
5//! private material is derived from [`TlsEnvelope`] encrypted storage.
6
7use 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
18/// Certificate authority wrapper that signs leaf certificates for a service.
19pub struct CertificateAuthority {
20    ca_key_pair: KeyPair,
21    ca_cert_pem: String,
22    tls_envelope: TlsEnvelope,
23}
24
25impl CertificateAuthority {
26    /// Create a brand new CA certificate and key.
27    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    /// Restore a CA from persisted certificate and key PEM strings.
43    pub fn from_components(
44        ca_cert_pem: String,
45        ca_key_pem: String,
46        tls_envelope: TlsEnvelope,
47    ) -> Result<Self, crate::Error> {
48        // Basic sanity validation – ensure the PEM really contains a certificate.
49        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    /// Return the CA certificate in PEM encoding.
75    pub fn ca_certificate_pem(&self) -> String {
76        self.ca_cert_pem.clone()
77    }
78
79    /// Return the CA private key PEM.
80    pub fn ca_private_key_pem(&self) -> String {
81        self.ca_key_pair.serialize_pem()
82    }
83
84    /// Helper to create an issuer used for signing.
85    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    /// Generate a server certificate for the upstream service.
99    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    /// Generate a client certificate respecting TTL and metadata expectations.
114    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    /// Encrypt helper used by persistence routines.
143    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
240/// Random 128-bit serial compliant with RFC 5280 (avoid negative).
241fn 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; // ensure positive (highest bit zero)
249        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    /// Issued client certificate bundle returned to callers.
278    #[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    /// Request payload for creating or updating a TLS profile.
290    #[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    /// Request payload for client certificate issuance.
299    #[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    /// Response returned when updating a TLS profile.
308    #[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    /// Validate a certificate issuance request against the stored TLS profile.
324    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}