Skip to main content

kipuka/ca/
issue.rs

1//! Certificate issuance from CSR with CA/B Forum compliance.
2//!
3//! Implements RFC 7030 §4.2 enrollment and applies profile-based
4//! constraints. Validates CSR contents against CA/B Forum Baseline
5//! Requirements before signing.
6//!
7//! Certificate signing uses the `synta-certificate` `CertificateBuilder`
8//! with `OpensslCertificateSigner` for the actual cryptographic operations.
9
10use std::sync::Arc;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use tracing::{debug, info, warn};
16
17/// Errors during certificate issuance.
18#[derive(Debug, Error)]
19pub enum IssuanceError {
20    /// The CSR is malformed or cannot be parsed.
21    #[error("invalid CSR: {0}")]
22    InvalidCsr(String),
23
24    /// The CSR key does not meet minimum size requirements.
25    #[error("key too small: {algorithm} {bits}-bit (minimum {min_bits}-bit required)")]
26    KeyTooSmall {
27        algorithm: String,
28        bits: u32,
29        min_bits: u32,
30    },
31
32    /// The requested validity period exceeds the maximum.
33    #[error("validity period {requested_days} days exceeds maximum {max_days} days")]
34    ValidityTooLong { requested_days: u32, max_days: u32 },
35
36    /// A required extension is missing from the profile.
37    #[error("missing required extension: {0}")]
38    MissingExtension(String),
39
40    /// The enrollment profile does not exist.
41    #[error("unknown enrollment profile: {0}")]
42    UnknownProfile(String),
43
44    /// CA signing operation failed.
45    #[error("signing failed: {0}")]
46    SigningError(String),
47
48    /// Database storage error.
49    #[error("storage error: {0}")]
50    StorageError(String),
51
52    /// The CSR algorithm is not permitted by the enrollment profile.
53    #[error("algorithm not allowed: {algorithm} not in profile '{profile}'")]
54    AlgorithmNotAllowed { algorithm: String, profile: String },
55
56    /// The CSR subject contains a prohibited DN attribute.
57    #[error("prohibited DN attribute: {name} ({oid})")]
58    ProhibitedDnAttribute { oid: String, name: String },
59}
60
61impl IssuanceError {
62    /// Whether this failure was caused by the *client's* request content
63    /// (the CSR) rather than a server-side fault.
64    ///
65    /// This is the single source of truth for HTTP/CoAP status classification:
66    /// client-content faults must surface as `4xx` (RFC 7030 §4.2.3 — the
67    /// requester can fix the CSR and retry), while server/profile/signing
68    /// faults must surface as `5xx` (the client cannot fix them, and internal
69    /// detail must not leak).  A misclassification here would either blame the
70    /// client for a server misconfiguration or hide a bad CSR behind a 500.
71    ///
72    /// Note the two profile-driven variants — [`MissingExtension`] and
73    /// [`ValidityTooLong`] — are **server** errors: they originate from the
74    /// server's [`EnrollmentProfile`] (`check_required_extensions`,
75    /// `check_validity_period`), not from anything the client sent.
76    ///
77    /// [`MissingExtension`]: Self::MissingExtension
78    /// [`ValidityTooLong`]: Self::ValidityTooLong
79    pub fn is_client_error(&self) -> bool {
80        match self {
81            // Faults in the client-supplied CSR — the requester can fix and retry.
82            Self::InvalidCsr(_)
83            | Self::KeyTooSmall { .. }
84            | Self::AlgorithmNotAllowed { .. }
85            | Self::ProhibitedDnAttribute { .. } => true,
86            // Server-side faults: signing, storage, and profile configuration.
87            Self::SigningError(_)
88            | Self::StorageError(_)
89            | Self::MissingExtension(_)
90            | Self::ValidityTooLong { .. }
91            | Self::UnknownProfile(_) => false,
92        }
93    }
94}
95
96/// Result of a successful certificate issuance.
97#[derive(Debug, Clone)]
98pub struct IssuanceResult {
99    /// DER-encoded issued certificate.
100    pub certificate_der: Vec<u8>,
101    /// Serial number (hex string).
102    pub serial_number: String,
103    /// Subject DN of the issued certificate.
104    pub subject_dn: String,
105    /// Not Before timestamp.
106    pub not_before: DateTime<Utc>,
107    /// Not After timestamp.
108    pub not_after: DateTime<Utc>,
109}
110
111/// CA signing key — either a PEM key from disk or an HSM-backed key.
112///
113/// When `Hsm` is used, the private key never leaves the HSM; signing
114/// is performed via PKCS#11 `C_Sign` operations.
115pub enum CaSigningKey<'a> {
116    /// PEM-encoded private key loaded from disk.
117    Pem(&'a [u8]),
118    /// HSM-backed private key accessed via PKCS#11.
119    Hsm {
120        /// Reference to the HSM context with an active session.
121        context: &'a Arc<kipuka_hsm::HsmContext>,
122        /// Object label of the private key in the PKCS#11 token.
123        key_label: &'a str,
124    },
125}
126
127/// Owned signing key — either PEM bytes from disk or an HSM context + label.
128///
129/// Unlike [`CaSigningKey`] (which borrows), this type owns all data,
130/// making it easy to return from an async resolver without lifetime issues.
131/// Call [`ResolvedSigningKey::as_signing_key`] to obtain a borrowed
132/// [`CaSigningKey`] for passing to [`issue_certificate`].
133pub enum ResolvedSigningKey {
134    /// PEM-encoded private key loaded from disk.
135    Pem(Vec<u8>),
136    /// HSM-backed private key accessed via PKCS#11.
137    Hsm {
138        /// Shared HSM context with an active session.
139        context: std::sync::Arc<kipuka_hsm::HsmContext>,
140        /// Object label of the private key in the PKCS#11 token.
141        key_label: String,
142    },
143}
144
145impl ResolvedSigningKey {
146    /// Borrow as a [`CaSigningKey`] suitable for [`issue_certificate`].
147    pub fn as_signing_key(&self) -> CaSigningKey<'_> {
148        match self {
149            ResolvedSigningKey::Pem(pem) => CaSigningKey::Pem(pem),
150            ResolvedSigningKey::Hsm { context, key_label } => {
151                CaSigningKey::Hsm { context, key_label }
152            }
153        }
154    }
155}
156
157/// Resolve the CA signing key from configuration (async — uses `tokio::fs::read`).
158///
159/// Returns a [`ResolvedSigningKey`] that owns all key material.  Use
160/// [`ResolvedSigningKey::as_signing_key`] to obtain a borrowed reference
161/// for [`issue_certificate`].
162pub async fn resolve_signing_key(
163    ca_cfg: &crate::config::CaConfig,
164    hsm: Option<&std::sync::Arc<kipuka_hsm::HsmContext>>,
165) -> Result<ResolvedSigningKey, crate::error::KipukaError> {
166    if ca_cfg.is_hsm_backed() {
167        let hsm_ctx = hsm.ok_or_else(|| {
168            crate::error::KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into())
169        })?;
170        let pkcs11_uri = ca_cfg.pkcs11_uri.as_deref().ok_or_else(|| {
171            crate::error::KipukaError::Ca(
172                "CA marked as HSM-backed but pkcs11_uri not configured".into(),
173            )
174        })?;
175        let key_label = parse_pkcs11_object_label(pkcs11_uri)
176            .map_err(|e| crate::error::KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
177        Ok(ResolvedSigningKey::Hsm {
178            context: std::sync::Arc::clone(hsm_ctx),
179            key_label,
180        })
181    } else {
182        let pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
183            crate::error::KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
184        })?;
185        Ok(ResolvedSigningKey::Pem(pem))
186    }
187}
188
189/// Resolve the CA signing key from configuration (sync — uses `std::fs::read`).
190///
191/// Identical to [`resolve_signing_key`] but suitable for non-async contexts
192/// such as the STAR background renewal task.
193pub fn resolve_signing_key_sync(
194    ca_cfg: &crate::config::CaConfig,
195    hsm: Option<&std::sync::Arc<kipuka_hsm::HsmContext>>,
196) -> Result<ResolvedSigningKey, crate::error::KipukaError> {
197    if ca_cfg.is_hsm_backed() {
198        let hsm_ctx = hsm.ok_or_else(|| {
199            crate::error::KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into())
200        })?;
201        let pkcs11_uri = ca_cfg.pkcs11_uri.as_deref().ok_or_else(|| {
202            crate::error::KipukaError::Ca(
203                "CA marked as HSM-backed but pkcs11_uri not configured".into(),
204            )
205        })?;
206        let key_label = parse_pkcs11_object_label(pkcs11_uri)
207            .map_err(|e| crate::error::KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
208        Ok(ResolvedSigningKey::Hsm {
209            context: std::sync::Arc::clone(hsm_ctx),
210            key_label,
211        })
212    } else {
213        let pem = std::fs::read(&ca_cfg.key_file).map_err(|e| {
214            crate::error::KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
215        })?;
216        Ok(ResolvedSigningKey::Pem(pem))
217    }
218}
219
220/// Extract the `object` (key label) from a PKCS#11 URI.
221///
222/// PKCS#11 URI format: `pkcs11:token=TOKEN;object=KEY_LABEL;type=private`
223///
224/// Returns the value of the `object` attribute, which is the CKA_LABEL
225/// used to find the private key in the PKCS#11 token.
226///
227/// Per RFC 7512 §2.3, values may be percent-encoded; this function
228/// decodes `%XX` sequences.
229pub(crate) fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
230    let path = uri
231        .strip_prefix("pkcs11:")
232        .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
233
234    for part in path.split(';') {
235        if let Some((key, value)) = part.split_once('=')
236            && key == "object"
237        {
238            return pkcs11_percent_decode(value);
239        }
240    }
241
242    Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
243}
244
245/// Percent-decode a PKCS#11 URI value per RFC 7512 §2.3.
246fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
247    let mut result = Vec::with_capacity(s.len());
248    let bytes = s.as_bytes();
249    let mut i = 0;
250    while i < bytes.len() {
251        if bytes[i] == b'%' && i + 2 < bytes.len() {
252            let hi = hex_digit(bytes[i + 1])
253                .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
254            let lo = hex_digit(bytes[i + 2])
255                .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
256            result.push((hi << 4) | lo);
257            i += 3;
258        } else {
259            result.push(bytes[i]);
260            i += 1;
261        }
262    }
263    String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
264}
265
266fn hex_digit(b: u8) -> Option<u8> {
267    match b {
268        b'0'..=b'9' => Some(b - b'0'),
269        b'a'..=b'f' => Some(b - b'a' + 10),
270        b'A'..=b'F' => Some(b - b'A' + 10),
271        _ => None,
272    }
273}
274
275/// Enrollment profile defining constraints for issued certificates.
276///
277/// Supports classical (RSA, ECDSA), post-quantum (ML-DSA, ML-KEM),
278/// and composite hybrid algorithms for PQC migration scenarios.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct EnrollmentProfile {
281    /// Profile name (referenced in OTP records and EST label config).
282    pub name: String,
283    /// Maximum validity period in days.
284    pub max_validity_days: u32,
285    /// Optional shorter validity for automatic renewal.
286    #[serde(default)]
287    pub max_validity_seconds: Option<u64>,
288    /// Absolute issuance deadline (for renewal order lifetime).
289    #[serde(default)]
290    pub max_not_after: Option<DateTime<Utc>>,
291    /// Key usage flags to set (e.g., digitalSignature, keyEncipherment).
292    pub key_usage: Vec<String>,
293    /// Extended key usage OIDs (e.g., serverAuth, clientAuth).
294    pub extended_key_usage: Vec<String>,
295    /// Whether to include Subject Key Identifier.
296    pub include_ski: bool,
297    /// Whether to include Authority Key Identifier.
298    pub include_aki: bool,
299    /// Minimum RSA key size in bits.
300    pub min_rsa_bits: u32,
301    /// Minimum ECDSA curve (P-256, P-384).
302    pub min_ecdsa_curve: String,
303    /// Whether to inject Certificate Transparency SCTs.
304    pub ct_enabled: bool,
305
306    /// Certificate Policies OIDs (BR §7.1.2.7.9).
307    /// DV: `["2.23.140.1.2.1"]`, OV: `["2.23.140.1.2.2"]`.
308    #[serde(default)]
309    pub certificate_policies: Vec<String>,
310
311    // --- Post-Quantum Cryptography (FIPS 203/204) ---
312    /// Allowed ML-DSA levels for signing key CSRs (FIPS 204).
313    /// Empty means ML-DSA is not accepted for this profile.
314    /// Values: "ml-dsa-44", "ml-dsa-65", "ml-dsa-87".
315    #[serde(default)]
316    pub allowed_ml_dsa_levels: Vec<String>,
317
318    /// Allowed ML-KEM levels for KEM key CSRs (FIPS 203).
319    /// Used with /serverkeygen for KRA-based key generation.
320    /// Values: "ml-kem-512", "ml-kem-768", "ml-kem-1024".
321    #[serde(default)]
322    pub allowed_ml_kem_levels: Vec<String>,
323
324    /// Whether to accept composite ML-DSA+classical CSRs.
325    /// Per draft-ietf-lamps-pq-composite-sigs-19.
326    #[serde(default)]
327    pub allow_composite_ml_dsa: bool,
328
329    /// Require dual certificates (paired legacy + PQC) for hybrid
330    /// migration scenarios per IDM-5563 lifecycle requirements.
331    /// When true, a classical enrollment triggers automatic paired
332    /// PQC enrollment (and vice versa) as linked certificates.
333    #[serde(default)]
334    pub require_dual_cert: bool,
335
336    /// Include the TLS Feature Extension (RFC 7633, OID 1.3.6.1.5.5.7.1.24)
337    /// in issued certificates.
338    ///
339    /// RFC 7633 Section 4: when set, the issued certificate declares that
340    /// the TLS server presenting it MUST provide an OCSP stapled response
341    /// (status_request, TLS extension type 5) during the TLS handshake.
342    /// Clients that understand this extension MUST abort the handshake if
343    /// the server fails to staple a valid OCSP response.
344    ///
345    /// This is required for NIAP CA PP compliance and is commonly referred
346    /// to as "must-staple".
347    ///
348    /// When `true`, the certificate will contain:
349    /// ```text
350    /// TLS Feature Extension (id-pe-tlsfeature):
351    ///   OID:   1.3.6.1.5.5.7.1.24
352    ///   Value: SEQUENCE { INTEGER 5 }   -- status_request
353    /// ```
354    ///
355    /// Default: `false`.
356    #[serde(default)]
357    pub must_staple: bool,
358}
359
360impl Default for EnrollmentProfile {
361    fn default() -> Self {
362        Self {
363            name: "default".into(),
364            max_validity_days: cab_forum_max_validity_days(),
365            max_validity_seconds: None,
366            max_not_after: None,
367            key_usage: vec!["digitalSignature".into(), "keyEncipherment".into()],
368            extended_key_usage: vec!["serverAuth".into()],
369            include_ski: true,
370            include_aki: true,
371            min_rsa_bits: 2048,
372            min_ecdsa_curve: "P-256".into(),
373            ct_enabled: false,
374            certificate_policies: vec!["2.23.140.1.2.1".into()],
375            // PQC defaults: accept all ML-DSA and ML-KEM levels
376            allowed_ml_dsa_levels: vec!["ml-dsa-44".into(), "ml-dsa-65".into(), "ml-dsa-87".into()],
377            allowed_ml_kem_levels: vec![
378                "ml-kem-512".into(),
379                "ml-kem-768".into(),
380                "ml-kem-1024".into(),
381            ],
382            allow_composite_ml_dsa: true,
383            require_dual_cert: false,
384            must_staple: false,
385        }
386    }
387}
388
389/// DER-encoded TLS Feature Extension value for must-staple certificates.
390///
391/// RFC 7633 Section 4 / RFC 6066 Section 8:
392///   TLSFeature ::= SEQUENCE OF INTEGER
393///   status_request(5)
394///
395/// ASN.1 DER encoding of SEQUENCE { INTEGER 5 }:
396///   30 03        -- SEQUENCE, length 3
397///     02 01 05   -- INTEGER, length 1, value 5
398///
399/// OID: 1.3.6.1.5.5.7.1.24 (id-pe-tlsfeature)
400pub const TLS_FEATURE_MUST_STAPLE_DER: &[u8] = &[0x30, 0x03, 0x02, 0x01, 0x05];
401
402/// OID for the TLS Feature Extension (id-pe-tlsfeature).
403///
404/// RFC 7633 Section 4: 1.3.6.1.5.5.7.1.24
405pub const OID_TLS_FEATURE: &str = "1.3.6.1.5.5.7.1.24";
406
407/// OID components for the TLS Feature Extension.
408const OID_TLS_FEATURE_COMPONENTS: &[u32] = &[1, 3, 6, 1, 5, 5, 7, 1, 24];
409
410/// Issue a certificate from a CSR.
411///
412/// Performs CA/B Forum compliance checks before signing:
413/// - Key size minimums (RSA 2048+, ECDSA P-256+)
414/// - Maximum validity period (398 days for public, 47 days from March 2029)
415/// - Required extensions (AKI, SKI, Key Usage, Basic Constraints)
416/// - Certificate Transparency SCT injection (when configured)
417///
418/// # Arguments
419///
420/// * `csr_der` - DER-encoded PKCS#10 Certificate Signing Request
421/// * `profile` - Enrollment profile with constraints to apply
422/// * `ca_cert_der` - DER-encoded CA certificate (for issuer DN and AKI)
423/// * `signing_key` - CA signing key (PEM from disk or HSM-backed)
424/// * `hash_algorithm` - Hash algorithm name (e.g. "sha256")
425///
426/// # Returns
427///
428/// [`IssuanceResult`] on success with the DER-encoded certificate and metadata.
429pub fn issue_certificate(
430    csr_der: &[u8],
431    profile: &EnrollmentProfile,
432    ca_cert_der: &[u8],
433    signing_key: CaSigningKey<'_>,
434    hash_algorithm: &str,
435    ocsp_url: Option<&str>,
436    crl_url: Option<&str>,
437) -> Result<IssuanceResult, IssuanceError> {
438    // Step 0: Verify CSR proof-of-possession (RFC 7030 §4.2 / RFC 2986 §3).
439    //
440    // This is the central PoP gate for *every* direct-signing enrollment path
441    // (simpleenroll, simplereenroll, CMS-EST, CoAP, STAR, CMP/fullcmc, and the
442    // software serverkeygen path). Enforcing it here — rather than per-route —
443    // guarantees a CA never signs a CSR whose private key the requester has not
444    // proven possession of, regardless of which transport delivered the request.
445    verify_csr_pop(csr_der)?;
446
447    // Step 1: Parse and validate CSR.
448    validate_csr(csr_der)?;
449    issue_verified_request(
450        csr_der,
451        profile,
452        ca_cert_der,
453        signing_key,
454        hash_algorithm,
455        ocsp_url,
456        crl_url,
457    )
458}
459
460// Private: only the PKCS#10 and CRMF verification boundaries may call this.
461fn issue_verified_request(
462    csr_der: &[u8],
463    profile: &EnrollmentProfile,
464    ca_cert_der: &[u8],
465    signing_key: CaSigningKey<'_>,
466    hash_algorithm: &str,
467    ocsp_url: Option<&str>,
468    crl_url: Option<&str>,
469) -> Result<IssuanceResult, IssuanceError> {
470    // Step 1a: Check subject DN for prohibited attributes (BR §7.1.2.10.2).
471    check_subject_dn_compliance(csr_der)?;
472
473    // Step 2: Check key size against profile minimums.
474    check_key_size(csr_der, profile)?;
475
476    // Step 3: Validate requested validity against CA/B Forum limits.
477    check_validity_period(profile)?;
478
479    // Step 4: Verify required extensions will be present.
480    check_required_extensions(profile)?;
481
482    // Step 5: Parse the CSR to extract subject and public key.
483    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
484        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
485
486    let csr_subject_der = csr
487        .certification_request_info
488        .subject
489        .to_der()
490        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR subject encode failed: {e}")))?;
491
492    let csr_spki_der = csr
493        .certification_request_info
494        .subject_pkinfo
495        .to_der()
496        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR SPKI encode failed: {e}")))?;
497
498    // Step 6: Parse the CA certificate to extract issuer DN and SPKI (for AKI).
499    let ca_cert = synta_certificate::Certificate::from_der(ca_cert_der)
500        .map_err(|e| IssuanceError::SigningError(format!("CA cert parse failed: {e}")))?;
501
502    let ca_subject_der = ca_cert.tbs_certificate.subject.0;
503    let ca_spki_der = ca_cert
504        .tbs_certificate
505        .subject_public_key_info
506        .to_der()
507        .map_err(|e| IssuanceError::SigningError(format!("CA SPKI encode failed: {e}")))?;
508
509    // Step 7: Prepare the signing backend (PEM or HSM).
510    let pem_key: Option<synta_certificate::BackendPrivateKey>;
511    match &signing_key {
512        CaSigningKey::Pem(pem) => {
513            pem_key = Some(
514                synta_certificate::BackendPrivateKey::from_pem(pem, None).map_err(|e| {
515                    IssuanceError::SigningError(format!("CA key parse failed: {e}"))
516                })?,
517            );
518            debug!("loaded CA private key from PEM");
519        }
520        CaSigningKey::Hsm { key_label, .. } => {
521            pem_key = None;
522            debug!(key_label = %key_label, "using HSM-backed CA signing key");
523        }
524    }
525
526    // Step 8: Generate serial number — 20 bytes of random data (RFC 5280 §4.1.2.2
527    // recommends at least 64 bits of entropy; we use 159 bits with leading 0 for positive).
528    let serial_bytes = generate_serial_bytes();
529    let serial = synta::Integer::from_unsigned_bytes(&serial_bytes);
530    let serial_hex = hex::encode(&serial_bytes);
531
532    // Step 9: Compute validity period.
533    let now = Utc::now();
534    let mut not_after_chrono = now + chrono::Duration::days(profile.max_validity_days as i64);
535    if let Some(seconds) = profile.max_validity_seconds {
536        let seconds = i64::try_from(seconds)
537            .map_err(|_| IssuanceError::SigningError("validity overflow".into()))?;
538        not_after_chrono = not_after_chrono.min(
539            now.checked_add_signed(chrono::Duration::seconds(seconds))
540                .ok_or_else(|| IssuanceError::SigningError("validity overflow".into()))?,
541        );
542    }
543    if let Some(limit) = profile.max_not_after {
544        not_after_chrono = not_after_chrono.min(limit);
545    }
546    crate::auth::certificate::valid_now(ca_cert_der).map_err(IssuanceError::SigningError)?;
547    not_after_chrono = not_after_chrono.min(
548        crate::auth::certificate::time(&ca_cert.tbs_certificate.validity.not_after)
549            .map_err(IssuanceError::SigningError)?,
550    );
551    if not_after_chrono <= now {
552        return Err(IssuanceError::SigningError(
553            "empty certificate validity period".into(),
554        ));
555    }
556
557    let not_before_time = chrono_to_synta_time(now)
558        .map_err(|e| IssuanceError::SigningError(format!("not_before time conversion: {e}")))?;
559    let not_after_time = chrono_to_synta_time(not_after_chrono)
560        .map_err(|e| IssuanceError::SigningError(format!("not_after time conversion: {e}")))?;
561
562    // Step 10: Build extensions.
563    debug!(
564        profile = %profile.name,
565        max_days = profile.max_validity_days,
566        ct = profile.ct_enabled,
567        must_staple = profile.must_staple,
568        "building certificate from CSR"
569    );
570
571    let mut builder = synta_certificate::CertificateBuilder::new()
572        .issuer_name(ca_subject_der)
573        .subject_name(&csr_subject_der)
574        .public_key_der(&csr_spki_der)
575        .serial_number(serial)
576        .not_valid_before(not_before_time)
577        .not_valid_after(not_after_time);
578
579    // SANs are the requested identities already authorized by the route policy.
580    // All CA/usage/policy extensions remain controlled by the enrollment profile.
581    for (oid, critical, value) in requested_extensions(csr_der)? {
582        if oid.components() == synta_certificate::oids::SUBJECT_ALT_NAME {
583            builder = builder.add_extension(oid, critical, &value);
584        }
585    }
586
587    // Basic Constraints: CA:FALSE (critical, per CA/B Forum BR §7.1.2.7).
588    if let Some(bc_der) = synta_certificate::encode_basic_constraints(false, None) {
589        builder =
590            builder.add_extension_oid(synta_certificate::oids::BASIC_CONSTRAINTS, true, &bc_der);
591    }
592
593    // Key Usage (critical, per CA/B Forum BR §7.1.2.1).
594    let ku_bits = profile_key_usage_bits(profile);
595    if let Some(ku_der) = synta_certificate::encode_key_usage(ku_bits) {
596        builder = builder.add_extension_oid(synta_certificate::oids::KEY_USAGE, true, &ku_der);
597    }
598
599    // Extended Key Usage (non-critical).
600    let eku_der = profile_extended_key_usage(profile);
601    if let Some(eku) = eku_der {
602        builder =
603            builder.add_extension_oid(synta_certificate::oids::EXTENDED_KEY_USAGE, false, &eku);
604    }
605
606    // Certificate Policies (non-critical, per CA/B Forum BR §7.1.2.7.9).
607    if !profile.certificate_policies.is_empty() {
608        let cp_der = encode_certificate_policies(&profile.certificate_policies)?;
609        builder = builder.add_extension_oid(
610            synta_certificate::oids::CERTIFICATE_POLICIES,
611            false,
612            &cp_der,
613        );
614    }
615
616    // Subject Key Identifier (non-critical, per CA/B Forum BR §7.1.2.7.2).
617    if profile.include_ski {
618        let hasher = synta_certificate::OpensslKeyIdHasher;
619        if let Some(ski_der) = synta_certificate::encode_subject_key_identifier(
620            &csr_spki_der,
621            synta_certificate::KeyIdMethod::Rfc5280Sha1,
622            &hasher,
623        ) {
624            builder = builder.add_extension_oid(
625                synta_certificate::oids::SUBJECT_KEY_IDENTIFIER,
626                false,
627                &ski_der,
628            );
629        }
630    }
631
632    // Authority Key Identifier (non-critical, per CA/B Forum BR §7.1.2.7.3).
633    if profile.include_aki {
634        let hasher = synta_certificate::OpensslKeyIdHasher;
635        if let Some(aki_der) = synta_certificate::encode_authority_key_identifier(
636            &ca_spki_der,
637            synta_certificate::KeyIdMethod::Rfc5280Sha1,
638            &hasher,
639        ) {
640            builder = builder.add_extension_oid(
641                synta_certificate::oids::AUTHORITY_KEY_IDENTIFIER,
642                false,
643                &aki_der,
644            );
645        }
646    }
647
648    // Authority Information Access (non-critical, per CA/B Forum BR §7.1.2.7.7).
649    if let Some(url) = ocsp_url {
650        let aia_der = encode_aia_extension(url)?;
651        builder = builder.add_extension_oid(
652            synta_certificate::oids::AUTHORITY_INFO_ACCESS,
653            false,
654            &aia_der,
655        );
656    }
657
658    // CRL Distribution Points (non-critical, per CA/B Forum BR §7.1.2.7.7).
659    if let Some(url) = crl_url {
660        let cdp_der = encode_cdp_extension(url)?;
661        builder = builder.add_extension_oid(
662            synta_certificate::oids::CRL_DISTRIBUTION_POINTS,
663            false,
664            &cdp_der,
665        );
666    }
667
668    // TLS Feature Extension (must-staple) per RFC 7633 §4.
669    if profile.must_staple {
670        debug!(
671            oid = OID_TLS_FEATURE,
672            "including TLS Feature Extension (must-staple) per RFC 7633 §4"
673        );
674        builder = builder.add_extension_oid(
675            OID_TLS_FEATURE_COMPONENTS,
676            false,
677            TLS_FEATURE_MUST_STAPLE_DER,
678        );
679    }
680
681    // Step 11: Sign the certificate.
682    // For ML-DSA CAs, the hash algorithm is built into the signing algorithm
683    // (FIPS 204) — pass "none" so synta-certificate uses the key's native
684    // algorithm without a separate hash. For classical keys (RSA, ECDSA),
685    // the hash_algorithm selects SHA-256/384/512.
686    let cert_der = match &signing_key {
687        CaSigningKey::Pem(_) => {
688            use synta_certificate::PrivateKey as _;
689            let ca_pkey = pem_key.as_ref().expect("PEM key loaded in step 7");
690            let effective_hash = if hash_algorithm == "none" {
691                ""
692            } else {
693                hash_algorithm
694            };
695            let signer = ca_pkey.as_signer(effective_hash);
696            builder.sign(&signer).map_err(|e| {
697                IssuanceError::SigningError(format!("certificate signing failed: {e}"))
698            })?
699        }
700        CaSigningKey::Hsm { context, key_label } => {
701            // HSM path: build TBS, sign via PKCS#11, assemble.
702            let hsm_signer = HsmCertificateSigner {
703                context,
704                key_label,
705                hash_algorithm,
706            };
707            builder.sign(&hsm_signer).map_err(|e| {
708                IssuanceError::SigningError(format!("HSM certificate signing failed: {e}"))
709            })?
710        }
711    };
712
713    // Step 12: Format subject DN for logging and DB storage.
714    let subject_dn = synta_certificate::format_dn(&csr_subject_der);
715
716    info!(
717        serial = %serial_hex,
718        profile = %profile.name,
719        subject = %subject_dn,
720        not_after = %not_after_chrono,
721        cert_len = cert_der.len(),
722        "certificate issued"
723    );
724
725    Ok(IssuanceResult {
726        certificate_der: cert_der,
727        serial_number: serial_hex,
728        subject_dn,
729        not_before: now,
730        not_after: not_after_chrono,
731    })
732}
733
734/// Generate a 20-byte random serial number suitable for RFC 5280 §4.1.2.2.
735///
736/// The first byte is masked to 0x7F to guarantee the integer is positive
737/// (no leading 0x00 padding needed).  This gives 159 bits of entropy,
738/// well above the 64-bit minimum recommended by CA/B Forum.
739fn generate_serial_bytes() -> Vec<u8> {
740    use rand::Rng;
741    let mut bytes = vec![0u8; 20];
742    rand::rng().fill_bytes(&mut bytes);
743    // Ensure positive by clearing the high bit.
744    bytes[0] &= 0x7F;
745    // Ensure non-zero first byte.
746    if bytes[0] == 0 {
747        bytes[0] = 1;
748    }
749    bytes
750}
751
752/// Convert a chrono DateTime<Utc> to a synta_certificate::Time.
753///
754/// Per RFC 5280 §4.1.2.5:
755/// - Dates before 2050: use UTCTime (YYMMDDHHMMSSZ)
756/// - Dates from 2050 onward: use GeneralizedTime (YYYYMMDDHHMMSSZ)
757fn chrono_to_synta_time(dt: DateTime<Utc>) -> Result<synta_certificate::Time, String> {
758    let year = dt.format("%Y").to_string().parse::<u16>().unwrap_or(2024);
759    let month = dt.format("%m").to_string().parse::<u8>().unwrap_or(1);
760    let day = dt.format("%d").to_string().parse::<u8>().unwrap_or(1);
761    let hour = dt.format("%H").to_string().parse::<u8>().unwrap_or(0);
762    let minute = dt.format("%M").to_string().parse::<u8>().unwrap_or(0);
763    let second = dt.format("%S").to_string().parse::<u8>().unwrap_or(0);
764
765    if year < 2050 {
766        let utc_time = synta::UtcTime::new(year, month, day, hour, minute, second)
767            .map_err(|e| format!("UtcTime creation failed: {e}"))?;
768        Ok(synta_certificate::Time::UtcTime(utc_time))
769    } else {
770        let gen_time = synta::GeneralizedTime::new(year, month, day, hour, minute, second, None)
771            .map_err(|e| format!("GeneralizedTime creation failed: {e}"))?;
772        Ok(synta_certificate::Time::GeneralTime(gen_time))
773    }
774}
775
776/// Convert profile key usage strings to a bitmask for `encode_key_usage`.
777fn profile_key_usage_bits(profile: &EnrollmentProfile) -> u16 {
778    use synta_certificate::{
779        KEY_USAGE_DATA_ENCIPHERMENT, KEY_USAGE_DIGITAL_SIGNATURE, KEY_USAGE_KEY_AGREEMENT,
780        KEY_USAGE_KEY_ENCIPHERMENT, KEY_USAGE_NON_REPUDIATION,
781    };
782
783    let mut bits: u16 = 0;
784    for ku in &profile.key_usage {
785        match ku.as_str() {
786            "digitalSignature" => bits |= 1 << KEY_USAGE_DIGITAL_SIGNATURE,
787            "nonRepudiation" | "contentCommitment" => bits |= 1 << KEY_USAGE_NON_REPUDIATION,
788            "keyEncipherment" => bits |= 1 << KEY_USAGE_KEY_ENCIPHERMENT,
789            "dataEncipherment" => bits |= 1 << KEY_USAGE_DATA_ENCIPHERMENT,
790            "keyAgreement" => bits |= 1 << KEY_USAGE_KEY_AGREEMENT,
791            other => {
792                warn!(key_usage = %other, "unknown key usage flag in profile; skipping");
793            }
794        }
795    }
796    bits
797}
798
799/// Build Extended Key Usage DER from profile strings.
800fn profile_extended_key_usage(profile: &EnrollmentProfile) -> Option<Vec<u8>> {
801    if profile.extended_key_usage.is_empty() {
802        return None;
803    }
804
805    let mut builder = synta_certificate::ExtendedKeyUsageBuilder::new();
806    for eku in &profile.extended_key_usage {
807        builder = match eku.as_str() {
808            "serverAuth" => builder.server_auth(),
809            "clientAuth" => builder.client_auth(),
810            "codeSigning" => builder.code_signing(),
811            "emailProtection" => builder.email_protection(),
812            "timeStamping" => builder.time_stamping(),
813            "OCSPSigning" | "ocspSigning" => builder.ocsp_signing(),
814            other => {
815                warn!(eku = %other, "unknown extended key usage in profile; skipping");
816                builder
817            }
818        };
819    }
820    builder.build().ok()
821}
822
823/// Validate CSR structure.
824fn validate_csr(csr_der: &[u8]) -> Result<(), IssuanceError> {
825    if csr_der.is_empty() {
826        return Err(IssuanceError::InvalidCsr("empty CSR".into()));
827    }
828
829    // Check for ASN.1 SEQUENCE tag (0x30) at start.
830    if csr_der[0] != 0x30 {
831        return Err(IssuanceError::InvalidCsr(
832            "does not start with ASN.1 SEQUENCE".into(),
833        ));
834    }
835
836    use synta_certificate::SignatureVerifier;
837    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
838        .map_err(|e| IssuanceError::InvalidCsr(format!("PKCS#10 parse failed: {e}")))?;
839    let encode_error = |e| IssuanceError::InvalidCsr(format!("CSR encoding: {e}"));
840    let cri = csr
841        .certification_request_info
842        .to_der()
843        .map_err(encode_error)?;
844    let algorithm = csr.signature_algorithm.to_der().map_err(encode_error)?;
845    let spki = csr
846        .certification_request_info
847        .subject_pkinfo
848        .to_der()
849        .map_err(encode_error)?;
850    synta_certificate::default_signature_verifier()
851        .verify_certificate_signature(&cri, &algorithm, csr.signature.as_bytes(), &spki)
852        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR proof of possession failed: {e}")))?;
853
854    debug!(len = csr_der.len(), "CSR structure validated");
855    Ok(())
856}
857
858/// Check CSR subject DN for prohibited attributes (BR §7.1.2.10.2).
859fn check_subject_dn_compliance(csr_der: &[u8]) -> Result<(), IssuanceError> {
860    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
861        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
862
863    let subject_der = csr
864        .certification_request_info
865        .subject
866        .to_der()
867        .map_err(|e| IssuanceError::InvalidCsr(format!("subject DN encode failed: {e}")))?;
868
869    let attrs = synta_certificate::parse_name_attrs(&subject_der);
870    for (oid, _value) in &attrs {
871        if oid == "2.5.4.11" {
872            return Err(IssuanceError::ProhibitedDnAttribute {
873                oid: oid.clone(),
874                name: "organizationalUnitName".into(),
875            });
876        }
877    }
878
879    Ok(())
880}
881
882/// Check key size from CSR against profile minimums.
883fn check_key_size(csr_der: &[u8], profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
884    // Parse the CSR to extract the public key info.
885    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der).map_err(|e| {
886        IssuanceError::InvalidCsr(format!("CSR parse failed in key size check: {e}"))
887    })?;
888
889    let spki = &csr.certification_request_info.subject_pkinfo;
890    let alg_oid = spki.algorithm.algorithm.components();
891    let key_bits = spki.subject_public_key.bit_len();
892
893    let pk_info = synta_certificate::decode_public_key_info(
894        &spki.algorithm.algorithm,
895        spki.algorithm.parameters.as_ref(),
896        spki.subject_public_key.as_bytes(),
897        key_bits,
898    );
899
900    match &pk_info {
901        synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
902            debug!(
903                algorithm = "RSA",
904                key_bits = bit_count,
905                "CSR public key info"
906            );
907            if (*bit_count as u32) < profile.min_rsa_bits {
908                return Err(IssuanceError::KeyTooSmall {
909                    algorithm: "RSA".into(),
910                    bits: *bit_count as u32,
911                    min_bits: profile.min_rsa_bits,
912                });
913            }
914        }
915        synta_certificate::PublicKeyInfo::Ec {
916            bit_count,
917            curve_nist_name,
918            ..
919        } => {
920            let curve_name = curve_nist_name.unwrap_or("unknown");
921            debug!(
922                algorithm = "EC",
923                curve = curve_name,
924                key_bits = bit_count,
925                "CSR public key info"
926            );
927            let min_bits: usize = match profile.min_ecdsa_curve.as_str() {
928                "P-256" => 256,
929                "P-384" => 384,
930                "P-521" => 521,
931                _ => 256,
932            };
933            if *bit_count < min_bits {
934                return Err(IssuanceError::KeyTooSmall {
935                    algorithm: format!("EC {curve_name}"),
936                    bits: *bit_count as u32,
937                    min_bits: min_bits as u32,
938                });
939            }
940        }
941        synta_certificate::PublicKeyInfo::Unknown {
942            alg_name,
943            bit_count,
944            ..
945        } => {
946            // FIPS 204 (ML-DSA) and FIPS 203 (ML-KEM) OID-to-level mapping
947            const PQC_ALGORITHMS: &[(&str, &str, &str)] = &[
948                ("2.16.840.1.101.3.4.3.17", "ML-DSA-44", "ml-dsa-44"),
949                ("2.16.840.1.101.3.4.3.18", "ML-DSA-65", "ml-dsa-65"),
950                ("2.16.840.1.101.3.4.3.19", "ML-DSA-87", "ml-dsa-87"),
951                ("2.16.840.1.101.3.4.4.1", "ML-KEM-512", "ml-kem-512"),
952                ("2.16.840.1.101.3.4.4.2", "ML-KEM-768", "ml-kem-768"),
953                ("2.16.840.1.101.3.4.4.3", "ML-KEM-1024", "ml-kem-1024"),
954            ];
955
956            let oid_str = alg_oid
957                .iter()
958                .map(|c| c.to_string())
959                .collect::<Vec<_>>()
960                .join(".");
961
962            if let Some(&(_, display_name, level)) =
963                PQC_ALGORITHMS.iter().find(|(oid, _, _)| *oid == oid_str)
964            {
965                debug!(
966                    algorithm = display_name,
967                    key_bits = bit_count,
968                    "CSR public key"
969                );
970                let allowed = if level.starts_with("ml-dsa") {
971                    &profile.allowed_ml_dsa_levels
972                } else {
973                    &profile.allowed_ml_kem_levels
974                };
975                if !allowed.is_empty() && !allowed.iter().any(|l| l == level) {
976                    return Err(IssuanceError::AlgorithmNotAllowed {
977                        algorithm: display_name.into(),
978                        profile: profile.name.clone(),
979                    });
980                }
981            } else if oid_str.starts_with("2.16.840.1.114027.80.5.2.") {
982                // Composite ML-DSA (draft-ietf-lamps-pq-composite-sigs-19)
983                debug!(algorithm = %alg_name, oid = %oid_str, "CSR public key: composite ML-DSA");
984                if !profile.allow_composite_ml_dsa {
985                    return Err(IssuanceError::AlgorithmNotAllowed {
986                        algorithm: format!("composite ML-DSA ({oid_str})"),
987                        profile: profile.name.clone(),
988                    });
989                }
990            } else {
991                debug!(
992                    algorithm = %alg_name,
993                    key_bits = bit_count,
994                    alg_oid = ?alg_oid,
995                    "CSR public key: unknown algorithm (skipping size check)"
996                );
997            }
998        }
999    }
1000    Ok(())
1001}
1002
1003/// Validate the requested validity period.
1004fn check_validity_period(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
1005    let max_days = cab_forum_max_validity_days();
1006
1007    if profile.max_validity_days > max_days {
1008        warn!(
1009            requested = profile.max_validity_days,
1010            max = max_days,
1011            "validity period exceeds CA/B Forum maximum"
1012        );
1013        return Err(IssuanceError::ValidityTooLong {
1014            requested_days: profile.max_validity_days,
1015            max_days,
1016        });
1017    }
1018
1019    Ok(())
1020}
1021
1022/// Encode Certificate Policies extension (RFC 5280 §4.2.1.4).
1023fn encode_certificate_policies(policy_oids: &[String]) -> Result<Vec<u8>, IssuanceError> {
1024    let mut builder = synta_certificate::CertificatePoliciesBuilder::new();
1025    for oid_str in policy_oids {
1026        let components: Vec<u32> = oid_str
1027            .split('.')
1028            .map(|s| s.parse())
1029            .collect::<Result<_, _>>()
1030            .map_err(|e| {
1031                IssuanceError::InvalidCsr(format!("invalid policy OID '{oid_str}': {e}"))
1032            })?;
1033        builder = builder.add_policy(&components);
1034    }
1035    builder.build().map_err(|e| {
1036        IssuanceError::SigningError(format!("Certificate Policies encoding failed: {e}"))
1037    })
1038}
1039
1040/// Encode Authority Information Access extension (RFC 5280 §4.2.2.1).
1041fn encode_aia_extension(ocsp_url: &str) -> Result<Vec<u8>, IssuanceError> {
1042    synta_certificate::AuthorityInformationAccessBuilder::new()
1043        .ocsp(ocsp_url)
1044        .build()
1045        .map_err(|e| IssuanceError::SigningError(format!("AIA encoding failed: {e}")))
1046}
1047
1048/// Encode CRL Distribution Points extension (RFC 5280 §4.2.1.13).
1049fn encode_cdp_extension(crl_url: &str) -> Result<Vec<u8>, IssuanceError> {
1050    synta_certificate::CRLDistributionPointsBuilder::new()
1051        .full_name_uri(crl_url)
1052        .build()
1053        .map_err(|e| IssuanceError::SigningError(format!("CDP encoding failed: {e}")))
1054}
1055
1056/// CA/B Forum maximum validity period per Ballot SC-081v3.
1057///
1058/// Returns the maximum certificate validity in days based on the current
1059/// date. The schedule reduces validity over time:
1060///
1061/// | Effective Date | Maximum |
1062/// |----------------|---------|
1063/// | Before 2026-03-15 | 398 days |
1064/// | 2026-03-15 | 200 days |
1065/// | 2027-03-15 | 100 days |
1066/// | 2029-03-15 | 47 days |
1067pub fn cab_forum_max_validity_days() -> u32 {
1068    use chrono::NaiveDate;
1069
1070    let today = Utc::now().date_naive();
1071    let p200 = NaiveDate::from_ymd_opt(2026, 3, 15).expect("valid date");
1072    let p100 = NaiveDate::from_ymd_opt(2027, 3, 15).expect("valid date");
1073    let p47 = NaiveDate::from_ymd_opt(2029, 3, 15).expect("valid date");
1074
1075    if today < p200 {
1076        398
1077    } else if today < p100 {
1078        200
1079    } else if today < p47 {
1080        100
1081    } else {
1082        47
1083    }
1084}
1085
1086// ── HSM CertificateSigner ────────────────────────────────────────────────────
1087
1088/// `CertificateSigner` implementation that delegates signing to a PKCS#11 HSM.
1089///
1090/// Uses `CKM_SHA256_RSA_PKCS` (or SHA-384/SHA-512 variants) which hashes
1091/// the TBS data and signs in a single PKCS#11 `C_Sign` operation.
1092struct HsmCertificateSigner<'a> {
1093    context: &'a Arc<kipuka_hsm::HsmContext>,
1094    key_label: &'a str,
1095    hash_algorithm: &'a str,
1096}
1097
1098/// Error type for HSM signing operations in the CertificateSigner trait.
1099#[derive(Debug)]
1100struct HsmSignerError(String);
1101
1102impl std::fmt::Display for HsmSignerError {
1103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104        write!(f, "{}", self.0)
1105    }
1106}
1107
1108impl std::error::Error for HsmSignerError {}
1109
1110impl<'a> synta_certificate::CertificateSigner for HsmCertificateSigner<'a> {
1111    type Error = HsmSignerError;
1112
1113    fn signature_algorithm_der(&self) -> Result<Vec<u8>, Self::Error> {
1114        // Return the DER-encoded AlgorithmIdentifier for SHA-256 with RSA.
1115        //
1116        // AlgorithmIdentifier ::= SEQUENCE {
1117        //   algorithm   OBJECT IDENTIFIER,
1118        //   parameters  ANY OPTIONAL
1119        // }
1120        //
1121        // sha256WithRSAEncryption: OID 1.2.840.113549.1.1.11
1122        // Parameters: NULL
1123        match self.hash_algorithm {
1124            "sha256" => {
1125                // OID 1.2.840.113549.1.1.11 + NULL params
1126                Ok(vec![
1127                    0x30, 0x0d, // SEQUENCE, length 13
1128                    0x06, 0x09, // OID, length 9
1129                    0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
1130                    0x0b, // sha256WithRSAEncryption
1131                    0x05, 0x00, // NULL
1132                ])
1133            }
1134            "sha384" => {
1135                // OID 1.2.840.113549.1.1.12 + NULL params
1136                Ok(vec![
1137                    0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c,
1138                    0x05, 0x00,
1139                ])
1140            }
1141            "sha512" => {
1142                // OID 1.2.840.113549.1.1.13 + NULL params
1143                Ok(vec![
1144                    0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d,
1145                    0x05, 0x00,
1146                ])
1147            }
1148            other => Err(HsmSignerError(format!(
1149                "unsupported hash algorithm for HSM RSA signing: {other}"
1150            ))),
1151        }
1152    }
1153
1154    fn sign_tbs(&self, tbs_der: &[u8]) -> Result<Vec<u8>, Self::Error> {
1155        // CKM_SHA256_RSA_PKCS (and variants) hash the data internally,
1156        // so we pass the raw TBS bytes directly.
1157        self.context
1158            .sign_data(self.key_label, tbs_der, self.hash_algorithm)
1159            .map_err(|e| HsmSignerError(format!("PKCS#11 sign failed: {e}")))
1160    }
1161}
1162
1163/// Verify that required extensions are configured.
1164fn check_required_extensions(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
1165    if !profile.include_aki {
1166        return Err(IssuanceError::MissingExtension(
1167            "Authority Key Identifier (required by CA/B Forum)".into(),
1168        ));
1169    }
1170    if !profile.include_ski {
1171        return Err(IssuanceError::MissingExtension(
1172            "Subject Key Identifier (required by CA/B Forum)".into(),
1173        ));
1174    }
1175    if profile.key_usage.is_empty() {
1176        return Err(IssuanceError::MissingExtension(
1177            "Key Usage (required by CA/B Forum)".into(),
1178        ));
1179    }
1180
1181    Ok(())
1182}
1183
1184/// Decode requested extensions strictly; duplicate OIDs are ambiguous and rejected.
1185pub(crate) fn requested_extensions(
1186    csr_der: &[u8],
1187) -> Result<Vec<(synta::ObjectIdentifier, bool, Vec<u8>)>, IssuanceError> {
1188    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
1189        .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1190    let mut result = Vec::new();
1191    if let Some(attrs) = &csr.certification_request_info.attributes {
1192        for attr in attrs.elements() {
1193            if attr.attr_type.components() != synta_certificate::oids::PKCS9_EXTENSION_REQUEST {
1194                continue;
1195            }
1196            for value in attr.attr_values.elements() {
1197                let extensions: Vec<synta_certificate::Extension<'_>> =
1198                    synta::Decoder::new(value.as_bytes(), synta::Encoding::Der)
1199                        .decode()
1200                        .map_err(|e| {
1201                            IssuanceError::InvalidCsr(format!("requested extensions: {e}"))
1202                        })?;
1203                for ext in extensions {
1204                    if result.iter().any(|(oid, _, _)| oid == &ext.extn_id) {
1205                        return Err(IssuanceError::InvalidCsr(
1206                            "duplicate requested extension".into(),
1207                        ));
1208                    }
1209                    result.push((
1210                        ext.extn_id,
1211                        ext.critical.as_ref().is_some_and(|v| v.0),
1212                        ext.extn_value.as_bytes().to_vec(),
1213                    ));
1214                }
1215            }
1216        }
1217    }
1218    Ok(result)
1219}
1220
1221/// CRMF signing-key proof boundary. RA-verified and encryption PoP need separate
1222/// explicit RA/encipherment policies and are not implicitly trusted here.
1223pub(crate) fn issue_crmf_certificate(
1224    request: &synta_certificate::crmf_types::CertReqMsg<'_>,
1225    profile: &EnrollmentProfile,
1226    ca_cert_der: &[u8],
1227    signing_key: CaSigningKey<'_>,
1228    hash_algorithm: &str,
1229    ocsp_url: Option<&str>,
1230    crl_url: Option<&str>,
1231) -> Result<IssuanceResult, IssuanceError> {
1232    use synta_certificate::{SignatureVerifier, crmf_types::ProofOfPossession};
1233    let invalid = |e| IssuanceError::InvalidCsr(format!("CRMF encoding: {e}"));
1234    let template = &request.cert_req.cert_template;
1235    let subject = template
1236        .subject
1237        .as_ref()
1238        .ok_or_else(|| IssuanceError::InvalidCsr("CRMF subject required".into()))?
1239        .to_der()
1240        .map_err(invalid)?;
1241    let spki = template
1242        .public_key
1243        .as_ref()
1244        .ok_or_else(|| IssuanceError::InvalidCsr("CRMF public key required".into()))?
1245        .to_der()
1246        .map_err(invalid)?;
1247    let Some(ProofOfPossession::Signature(pop)) = &request.popo else {
1248        return Err(IssuanceError::InvalidCsr(
1249            "supported CRMF signature proof of possession required".into(),
1250        ));
1251    };
1252    // When subject and publicKey are in CertTemplate, RFC4211 requires signing
1253    // CertRequest itself, with poposkInput absent.
1254    if pop.poposk_input.is_some() {
1255        return Err(IssuanceError::InvalidCsr(
1256            "unexpected poposkInput for complete CertTemplate".into(),
1257        ));
1258    }
1259    let tbs = request.cert_req.to_der().map_err(invalid)?;
1260    let algorithm = pop.algorithm_identifier.to_der().map_err(invalid)?;
1261    synta_certificate::default_signature_verifier()
1262        .verify_certificate_signature(&tbs, &algorithm, pop.signature.as_bytes(), &spki)
1263        .map_err(|e| IssuanceError::InvalidCsr(format!("CRMF proof of possession failed: {e}")))?;
1264    let mut builder = synta_certificate::CsrBuilder::new()
1265        .subject_name(&subject)
1266        .public_key_der(&spki);
1267    if let Some(extensions) = &template.extensions {
1268        for ext in extensions {
1269            builder = builder.add_extension(
1270                ext.extn_id.clone(),
1271                ext.critical.as_ref().is_some_and(|v| v.0),
1272                ext.extn_value.as_bytes(),
1273            );
1274        }
1275    }
1276    // Internal representation only, after verifying the real CRMF signature.
1277    let cri = builder
1278        .build_cri(&algorithm)
1279        .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1280    let csr = synta_certificate::CsrBuilder::assemble(&cri, &algorithm, pop.signature.as_bytes())
1281        .map_err(|e| IssuanceError::InvalidCsr(e.to_string()))?;
1282    issue_verified_request(
1283        &csr,
1284        profile,
1285        ca_cert_der,
1286        signing_key,
1287        hash_algorithm,
1288        ocsp_url,
1289        crl_url,
1290    )
1291}
1292
1293/// Sign a protocol message with the configured CA key without exporting HSM keys.
1294pub(crate) fn sign_message(
1295    key: CaSigningKey<'_>,
1296    hash: &str,
1297    message: &[u8],
1298) -> Result<(Vec<u8>, Vec<u8>), crate::error::KipukaError> {
1299    use synta_certificate::{CertificateSigner, PrivateKey};
1300    let err = |e: String| crate::error::KipukaError::Ca(format!("message signing failed: {e}"));
1301    match key {
1302        CaSigningKey::Pem(pem) => {
1303            let key = synta_certificate::BackendPrivateKey::from_pem(pem, None)
1304                .map_err(|e| err(e.to_string()))?;
1305            let signer = key.as_signer(hash);
1306            Ok((
1307                signer
1308                    .signature_algorithm_der_erased()
1309                    .map_err(|e| err(e.to_string()))?,
1310                signer
1311                    .sign_tbs_erased(message)
1312                    .map_err(|e| err(e.to_string()))?,
1313            ))
1314        }
1315        CaSigningKey::Hsm { context, key_label } => {
1316            let signer = HsmCertificateSigner {
1317                context,
1318                key_label,
1319                hash_algorithm: hash,
1320            };
1321            Ok((
1322                signer
1323                    .signature_algorithm_der()
1324                    .map_err(|e| err(e.to_string()))?,
1325                signer.sign_tbs(message).map_err(|e| err(e.to_string()))?,
1326            ))
1327        }
1328    }
1329}
1330
1331/// Store a newly issued certificate before reporting successful issuance.
1332pub(crate) async fn persist_certificate(
1333    state: &crate::state::AppState,
1334    ca_id: &str,
1335    profile: &str,
1336    result: &IssuanceResult,
1337) -> Result<(), crate::error::KipukaError> {
1338    persist_certificate_on(&state.db, ca_id, profile, result).await?;
1339    crate::audit::record_checked(
1340        &state.db,
1341        &state.audit,
1342        crate::audit::AuditEvent::new(crate::audit::AuditEventType::CertIssue)
1343            .with_detail(format!("ca_id={ca_id}, serial={}", result.serial_number)),
1344    )
1345    .await?;
1346    Ok(())
1347}
1348
1349/// Persist within the caller's transaction, allowing STAR state and inventory to commit atomically.
1350pub(crate) async fn persist_certificate_on<'e, E: sqlx::Executor<'e, Database = sqlx::Any>>(
1351    executor: E,
1352    ca_id: &str,
1353    profile: &str,
1354    result: &IssuanceResult,
1355) -> Result<(), crate::error::KipukaError> {
1356    let cert = synta_certificate::Certificate::from_der(&result.certificate_der)
1357        .map_err(|e| crate::error::KipukaError::Ca(e.to_string()))?;
1358    let issuer = synta_certificate::format_dn(cert.tbs_certificate.issuer.as_bytes());
1359    sqlx::query(crate::db::pg_sql("INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')"))
1360        .bind(&result.serial_number).bind(&result.subject_dn).bind(issuer)
1361        .bind(result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string())
1362        .bind(result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string())
1363        .bind(&result.certificate_der).bind(ca_id).bind(profile).execute(executor).await
1364        .map_err(|e| crate::error::KipukaError::Db(format!("certificate persistence failed: {e}")))?;
1365    Ok(())
1366}
1367
1368/// Verify CSR proof-of-possession (RFC 7030 §4.2, RFC 2986 §3).
1369///
1370/// A PKCS#10 CSR is self-signed: the `signature` field is produced with the
1371/// private key corresponding to the public key carried in the CSR's own
1372/// `SubjectPublicKeyInfo`. Verifying that signature over the
1373/// `CertificationRequestInfo` (the TBS portion) proves the requester holds the
1374/// private key — proof-of-possession. RFC 7030 §4.2 requires an EST server to
1375/// perform this check before issuing.
1376///
1377/// ML-KEM keys are a special case: ML-KEM is a key-encapsulation mechanism and
1378/// cannot produce a signature, so a FIPS 203 CSR carries proof-of-possession as
1379/// a *separate* ML-DSA signature (see RFC 9688 and issue #9/#17/#25) rather than
1380/// a self-signature. That mechanism is not implemented here, so we **fail
1381/// closed** — rejecting the CSR — rather than skipping PoP and issuing blindly.
1382fn verify_csr_pop(csr_der: &[u8]) -> Result<(), IssuanceError> {
1383    use synta_certificate::SignatureVerifier;
1384
1385    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
1386        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
1387
1388    // Reject key-encapsulation keys up front: they cannot self-sign, so the
1389    // generic verifier below would fail with a cryptic algorithm error. An
1390    // explicit rejection makes the fail-closed behaviour clear in the logs.
1391    let spki_alg_oid = &csr
1392        .certification_request_info
1393        .subject_pkinfo
1394        .algorithm
1395        .algorithm;
1396    if let Some(name) = synta_certificate::identify_public_key_algorithm(spki_alg_oid)
1397        && matches!(
1398            name,
1399            synta_certificate::names::ML_KEM_512
1400                | synta_certificate::names::ML_KEM_768
1401                | synta_certificate::names::ML_KEM_1024
1402        )
1403    {
1404        warn!(
1405            algorithm = %name,
1406            "rejecting ML-KEM CSR: separate ML-DSA proof-of-possession not yet supported"
1407        );
1408        return Err(IssuanceError::InvalidCsr(format!(
1409            "{name} is a key-encapsulation key and cannot self-sign; \
1410             ML-KEM proof-of-possession (a separate ML-DSA signature) is not yet supported"
1411        )));
1412    }
1413
1414    // Encode the CertificationRequestInfo (the signed TBS portion).
1415    let cri_der = csr
1416        .certification_request_info
1417        .to_der()
1418        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR CRI encode failed: {e}")))?;
1419
1420    // Encode the signature algorithm identifier.
1421    let sig_alg_der = csr
1422        .signature_algorithm
1423        .to_der()
1424        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR sig alg encode failed: {e}")))?;
1425
1426    // Raw signature bytes from the BIT STRING.
1427    let signature_bits = csr.signature.as_bytes();
1428
1429    // The CSR is self-signed, so the verifying key is the CSR's own SPKI.
1430    let spki_der = csr
1431        .certification_request_info
1432        .subject_pkinfo
1433        .to_der()
1434        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR SPKI encode failed: {e}")))?;
1435
1436    let verifier = synta_certificate::default_signature_verifier();
1437    verifier
1438        .verify_certificate_signature(&cri_der, &sig_alg_der, signature_bits, &spki_der)
1439        .map_err(|e| {
1440            warn!(error = %e, "CSR proof-of-possession verification failed");
1441            IssuanceError::InvalidCsr(format!(
1442                "CSR self-signature (proof-of-possession) verification failed: {e}"
1443            ))
1444        })?;
1445
1446    debug!("CSR proof-of-possession verified");
1447    Ok(())
1448}
1449
1450#[cfg(test)]
1451mod issuance_error_classification_tests {
1452    //! Pin the client/server split that drives HTTP and CoAP status codes.
1453    //!
1454    //! A regression here silently mislabels a bad CSR as a server fault (500,
1455    //! reason hidden) or blames the client for a server misconfiguration (400).
1456
1457    use super::IssuanceError;
1458    use crate::error::KipukaError;
1459
1460    #[test]
1461    fn csr_content_faults_are_client_errors() {
1462        let client = [
1463            IssuanceError::InvalidCsr("bad self-signature".into()),
1464            IssuanceError::KeyTooSmall {
1465                algorithm: "RSA".into(),
1466                bits: 1024,
1467                min_bits: 2048,
1468            },
1469            IssuanceError::AlgorithmNotAllowed {
1470                algorithm: "MD5".into(),
1471                profile: "default".into(),
1472            },
1473            IssuanceError::ProhibitedDnAttribute {
1474                oid: "2.5.4.5".into(),
1475                name: "serialNumber".into(),
1476            },
1477        ];
1478        for e in client {
1479            assert!(e.is_client_error(), "{e} must classify as a client error");
1480            assert!(
1481                matches!(KipukaError::from(e), KipukaError::BadRequest(_)),
1482                "a client CSR fault must map to BadRequest (400)"
1483            );
1484        }
1485    }
1486
1487    #[test]
1488    fn server_and_profile_faults_are_server_errors() {
1489        // MissingExtension and ValidityTooLong come from the server-side
1490        // EnrollmentProfile, not the client's CSR — they must stay 500.
1491        let server = [
1492            IssuanceError::SigningError("HSM offline".into()),
1493            IssuanceError::StorageError("db write failed".into()),
1494            IssuanceError::MissingExtension("keyUsage".into()),
1495            IssuanceError::ValidityTooLong {
1496                requested_days: 900,
1497                max_days: 825,
1498            },
1499            IssuanceError::UnknownProfile("ghost".into()),
1500        ];
1501        for e in server {
1502            assert!(!e.is_client_error(), "{e} must classify as a server error");
1503            assert!(
1504                matches!(KipukaError::from(e), KipukaError::Ca(_)),
1505                "a server/profile fault must map to Ca (500)"
1506            );
1507        }
1508    }
1509}