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
53/// Result of a successful certificate issuance.
54#[derive(Debug, Clone)]
55pub struct IssuanceResult {
56    /// DER-encoded issued certificate.
57    pub certificate_der: Vec<u8>,
58    /// Serial number (hex string).
59    pub serial_number: String,
60    /// Subject DN of the issued certificate.
61    pub subject_dn: String,
62    /// Not Before timestamp.
63    pub not_before: DateTime<Utc>,
64    /// Not After timestamp.
65    pub not_after: DateTime<Utc>,
66}
67
68/// CA signing key — either a PEM key from disk or an HSM-backed key.
69///
70/// When `Hsm` is used, the private key never leaves the HSM; signing
71/// is performed via PKCS#11 `C_Sign` operations.
72pub enum CaSigningKey<'a> {
73    /// PEM-encoded private key loaded from disk.
74    Pem(&'a [u8]),
75    /// HSM-backed private key accessed via PKCS#11.
76    Hsm {
77        /// Reference to the HSM context with an active session.
78        context: &'a Arc<kipuka_hsm::HsmContext>,
79        /// Object label of the private key in the PKCS#11 token.
80        key_label: &'a str,
81    },
82}
83
84/// Enrollment profile defining constraints for issued certificates.
85///
86/// Supports classical (RSA, ECDSA), post-quantum (ML-DSA, ML-KEM),
87/// and composite hybrid algorithms for PQC migration scenarios.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EnrollmentProfile {
90    /// Profile name (referenced in OTP records and EST label config).
91    pub name: String,
92    /// Maximum validity period in days.
93    pub max_validity_days: u32,
94    /// Key usage flags to set (e.g., digitalSignature, keyEncipherment).
95    pub key_usage: Vec<String>,
96    /// Extended key usage OIDs (e.g., serverAuth, clientAuth).
97    pub extended_key_usage: Vec<String>,
98    /// Whether to include Subject Key Identifier.
99    pub include_ski: bool,
100    /// Whether to include Authority Key Identifier.
101    pub include_aki: bool,
102    /// Minimum RSA key size in bits.
103    pub min_rsa_bits: u32,
104    /// Minimum ECDSA curve (P-256, P-384).
105    pub min_ecdsa_curve: String,
106    /// Whether to inject Certificate Transparency SCTs.
107    pub ct_enabled: bool,
108
109    // --- Post-Quantum Cryptography (FIPS 203/204) ---
110    /// Allowed ML-DSA levels for signing key CSRs (FIPS 204).
111    /// Empty means ML-DSA is not accepted for this profile.
112    /// Values: "ml-dsa-44", "ml-dsa-65", "ml-dsa-87".
113    #[serde(default)]
114    pub allowed_ml_dsa_levels: Vec<String>,
115
116    /// Allowed ML-KEM levels for KEM key CSRs (FIPS 203).
117    /// Used with /serverkeygen for KRA-based key generation.
118    /// Values: "ml-kem-512", "ml-kem-768", "ml-kem-1024".
119    #[serde(default)]
120    pub allowed_ml_kem_levels: Vec<String>,
121
122    /// Whether to accept composite ML-DSA+classical CSRs.
123    /// Per draft-ietf-lamps-pq-composite-sigs-19.
124    #[serde(default)]
125    pub allow_composite_ml_dsa: bool,
126
127    /// Require dual certificates (paired legacy + PQC) for hybrid
128    /// migration scenarios per IDM-5563 lifecycle requirements.
129    /// When true, a classical enrollment triggers automatic paired
130    /// PQC enrollment (and vice versa) as linked certificates.
131    #[serde(default)]
132    pub require_dual_cert: bool,
133
134    /// Include the TLS Feature Extension (RFC 7633, OID 1.3.6.1.5.5.7.1.24)
135    /// in issued certificates.
136    ///
137    /// RFC 7633 Section 4: when set, the issued certificate declares that
138    /// the TLS server presenting it MUST provide an OCSP stapled response
139    /// (status_request, TLS extension type 5) during the TLS handshake.
140    /// Clients that understand this extension MUST abort the handshake if
141    /// the server fails to staple a valid OCSP response.
142    ///
143    /// This is required for NIAP CA PP compliance and is commonly referred
144    /// to as "must-staple".
145    ///
146    /// When `true`, the certificate will contain:
147    /// ```text
148    /// TLS Feature Extension (id-pe-tlsfeature):
149    ///   OID:   1.3.6.1.5.5.7.1.24
150    ///   Value: SEQUENCE { INTEGER 5 }   -- status_request
151    /// ```
152    ///
153    /// Default: `false`.
154    #[serde(default)]
155    pub must_staple: bool,
156}
157
158impl Default for EnrollmentProfile {
159    fn default() -> Self {
160        Self {
161            name: "default".into(),
162            max_validity_days: 398, // CA/B Forum current maximum
163            key_usage: vec!["digitalSignature".into(), "keyEncipherment".into()],
164            extended_key_usage: vec!["serverAuth".into(), "clientAuth".into()],
165            include_ski: true,
166            include_aki: true,
167            min_rsa_bits: 2048,
168            min_ecdsa_curve: "P-256".into(),
169            ct_enabled: false,
170            // PQC defaults: accept all ML-DSA and ML-KEM levels
171            allowed_ml_dsa_levels: vec!["ml-dsa-44".into(), "ml-dsa-65".into(), "ml-dsa-87".into()],
172            allowed_ml_kem_levels: vec![
173                "ml-kem-512".into(),
174                "ml-kem-768".into(),
175                "ml-kem-1024".into(),
176            ],
177            allow_composite_ml_dsa: true,
178            require_dual_cert: false,
179            must_staple: false,
180        }
181    }
182}
183
184/// DER-encoded TLS Feature Extension value for must-staple certificates.
185///
186/// RFC 7633 Section 4 / RFC 6066 Section 8:
187///   TLSFeature ::= SEQUENCE OF INTEGER
188///   status_request(5)
189///
190/// ASN.1 DER encoding of SEQUENCE { INTEGER 5 }:
191///   30 03        -- SEQUENCE, length 3
192///     02 01 05   -- INTEGER, length 1, value 5
193///
194/// OID: 1.3.6.1.5.5.7.1.24 (id-pe-tlsfeature)
195pub const TLS_FEATURE_MUST_STAPLE_DER: &[u8] = &[0x30, 0x03, 0x02, 0x01, 0x05];
196
197/// OID for the TLS Feature Extension (id-pe-tlsfeature).
198///
199/// RFC 7633 Section 4: 1.3.6.1.5.5.7.1.24
200pub const OID_TLS_FEATURE: &str = "1.3.6.1.5.5.7.1.24";
201
202/// OID components for the TLS Feature Extension.
203const OID_TLS_FEATURE_COMPONENTS: &[u32] = &[1, 3, 6, 1, 5, 5, 7, 1, 24];
204
205/// Issue a certificate from a CSR.
206///
207/// Performs CA/B Forum compliance checks before signing:
208/// - Key size minimums (RSA 2048+, ECDSA P-256+)
209/// - Maximum validity period (398 days for public, 47 days from March 2029)
210/// - Required extensions (AKI, SKI, Key Usage, Basic Constraints)
211/// - Certificate Transparency SCT injection (when configured)
212///
213/// # Arguments
214///
215/// * `csr_der` - DER-encoded PKCS#10 Certificate Signing Request
216/// * `profile` - Enrollment profile with constraints to apply
217/// * `ca_cert_der` - DER-encoded CA certificate (for issuer DN and AKI)
218/// * `signing_key` - CA signing key (PEM from disk or HSM-backed)
219/// * `hash_algorithm` - Hash algorithm name (e.g. "sha256")
220///
221/// # Returns
222///
223/// [`IssuanceResult`] on success with the DER-encoded certificate and metadata.
224pub fn issue_certificate(
225    csr_der: &[u8],
226    profile: &EnrollmentProfile,
227    ca_cert_der: &[u8],
228    signing_key: CaSigningKey<'_>,
229    hash_algorithm: &str,
230) -> Result<IssuanceResult, IssuanceError> {
231    // Step 1: Parse and validate CSR.
232    validate_csr(csr_der)?;
233
234    // Step 2: Check key size against profile minimums.
235    check_key_size(csr_der, profile)?;
236
237    // Step 3: Validate requested validity against CA/B Forum limits.
238    check_validity_period(profile)?;
239
240    // Step 4: Verify required extensions will be present.
241    check_required_extensions(profile)?;
242
243    // Step 5: Parse the CSR to extract subject and public key.
244    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
245        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR parse failed: {e}")))?;
246
247    let csr_subject_der = csr
248        .certification_request_info
249        .subject
250        .to_der()
251        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR subject encode failed: {e}")))?;
252
253    let csr_spki_der = csr
254        .certification_request_info
255        .subject_pkinfo
256        .to_der()
257        .map_err(|e| IssuanceError::InvalidCsr(format!("CSR SPKI encode failed: {e}")))?;
258
259    // Step 6: Parse the CA certificate to extract issuer DN and SPKI (for AKI).
260    let ca_cert = synta_certificate::Certificate::from_der(ca_cert_der)
261        .map_err(|e| IssuanceError::SigningError(format!("CA cert parse failed: {e}")))?;
262
263    let ca_subject_der = ca_cert.tbs_certificate.subject.0;
264    let ca_spki_der = ca_cert
265        .tbs_certificate
266        .subject_public_key_info
267        .to_der()
268        .map_err(|e| IssuanceError::SigningError(format!("CA SPKI encode failed: {e}")))?;
269
270    // Step 7: Prepare the signing backend (PEM or HSM).
271    let pem_key: Option<synta_certificate::BackendPrivateKey>;
272    match &signing_key {
273        CaSigningKey::Pem(pem) => {
274            pem_key = Some(
275                synta_certificate::BackendPrivateKey::from_pem(pem, None).map_err(|e| {
276                    IssuanceError::SigningError(format!("CA key parse failed: {e}"))
277                })?,
278            );
279            debug!("loaded CA private key from PEM");
280        }
281        CaSigningKey::Hsm { key_label, .. } => {
282            pem_key = None;
283            debug!(key_label = %key_label, "using HSM-backed CA signing key");
284        }
285    }
286
287    // Step 8: Generate serial number — 20 bytes of random data (RFC 5280 §4.1.2.2
288    // recommends at least 64 bits of entropy; we use 159 bits with leading 0 for positive).
289    let serial_bytes = generate_serial_bytes();
290    let serial = synta::Integer::from_unsigned_bytes(&serial_bytes);
291    let serial_hex = hex::encode(&serial_bytes);
292
293    // Step 9: Compute validity period.
294    let now = Utc::now();
295    let not_after_chrono = now + chrono::Duration::days(profile.max_validity_days as i64);
296
297    let not_before_time = chrono_to_synta_time(now)
298        .map_err(|e| IssuanceError::SigningError(format!("not_before time conversion: {e}")))?;
299    let not_after_time = chrono_to_synta_time(not_after_chrono)
300        .map_err(|e| IssuanceError::SigningError(format!("not_after time conversion: {e}")))?;
301
302    // Step 10: Build extensions.
303    debug!(
304        profile = %profile.name,
305        max_days = profile.max_validity_days,
306        ct = profile.ct_enabled,
307        must_staple = profile.must_staple,
308        "building certificate from CSR"
309    );
310
311    let mut builder = synta_certificate::CertificateBuilder::new()
312        .issuer_name(ca_subject_der)
313        .subject_name(&csr_subject_der)
314        .public_key_der(&csr_spki_der)
315        .serial_number(serial)
316        .not_valid_before(not_before_time)
317        .not_valid_after(not_after_time);
318
319    // Basic Constraints: CA:FALSE (critical, per CA/B Forum BR §7.1.2.7).
320    if let Some(bc_der) = synta_certificate::encode_basic_constraints(false, None) {
321        builder =
322            builder.add_extension_oid(synta_certificate::oids::BASIC_CONSTRAINTS, true, &bc_der);
323    }
324
325    // Key Usage (critical, per CA/B Forum BR §7.1.2.1).
326    let ku_bits = profile_key_usage_bits(profile);
327    if let Some(ku_der) = synta_certificate::encode_key_usage(ku_bits) {
328        builder = builder.add_extension_oid(synta_certificate::oids::KEY_USAGE, true, &ku_der);
329    }
330
331    // Extended Key Usage (non-critical).
332    let eku_der = profile_extended_key_usage(profile);
333    if let Some(eku) = eku_der {
334        builder =
335            builder.add_extension_oid(synta_certificate::oids::EXTENDED_KEY_USAGE, false, &eku);
336    }
337
338    // Subject Key Identifier (non-critical, per CA/B Forum BR §7.1.2.7.2).
339    if profile.include_ski {
340        let hasher = synta_certificate::OpensslKeyIdHasher;
341        if let Some(ski_der) = synta_certificate::encode_subject_key_identifier(
342            &csr_spki_der,
343            synta_certificate::KeyIdMethod::Rfc5280Sha1,
344            &hasher,
345        ) {
346            builder = builder.add_extension_oid(
347                synta_certificate::oids::SUBJECT_KEY_IDENTIFIER,
348                false,
349                &ski_der,
350            );
351        }
352    }
353
354    // Authority Key Identifier (non-critical, per CA/B Forum BR §7.1.2.7.3).
355    if profile.include_aki {
356        let hasher = synta_certificate::OpensslKeyIdHasher;
357        if let Some(aki_der) = synta_certificate::encode_authority_key_identifier(
358            &ca_spki_der,
359            synta_certificate::KeyIdMethod::Rfc5280Sha1,
360            &hasher,
361        ) {
362            builder = builder.add_extension_oid(
363                synta_certificate::oids::AUTHORITY_KEY_IDENTIFIER,
364                false,
365                &aki_der,
366            );
367        }
368    }
369
370    // TLS Feature Extension (must-staple) per RFC 7633 §4.
371    if profile.must_staple {
372        debug!(
373            oid = OID_TLS_FEATURE,
374            "including TLS Feature Extension (must-staple) per RFC 7633 §4"
375        );
376        builder = builder.add_extension_oid(
377            OID_TLS_FEATURE_COMPONENTS,
378            false,
379            TLS_FEATURE_MUST_STAPLE_DER,
380        );
381    }
382
383    // Step 11: Sign the certificate.
384    let cert_der = match &signing_key {
385        CaSigningKey::Pem(_) => {
386            // PEM path: use the synta-certificate OpenSSL signer.
387            use synta_certificate::PrivateKey as _;
388            let ca_pkey = pem_key.as_ref().expect("PEM key loaded in step 7");
389            let signer = ca_pkey.as_signer(hash_algorithm);
390            builder.sign(&signer).map_err(|e| {
391                IssuanceError::SigningError(format!("certificate signing failed: {e}"))
392            })?
393        }
394        CaSigningKey::Hsm { context, key_label } => {
395            // HSM path: build TBS, sign via PKCS#11, assemble.
396            let hsm_signer = HsmCertificateSigner {
397                context,
398                key_label,
399                hash_algorithm,
400            };
401            builder.sign(&hsm_signer).map_err(|e| {
402                IssuanceError::SigningError(format!("HSM certificate signing failed: {e}"))
403            })?
404        }
405    };
406
407    // Step 12: Format subject DN for logging and DB storage.
408    let subject_dn = synta_certificate::format_dn(&csr_subject_der);
409
410    info!(
411        serial = %serial_hex,
412        profile = %profile.name,
413        subject = %subject_dn,
414        not_after = %not_after_chrono,
415        cert_len = cert_der.len(),
416        "certificate issued"
417    );
418
419    Ok(IssuanceResult {
420        certificate_der: cert_der,
421        serial_number: serial_hex,
422        subject_dn,
423        not_before: now,
424        not_after: not_after_chrono,
425    })
426}
427
428/// Generate a 20-byte random serial number suitable for RFC 5280 §4.1.2.2.
429///
430/// The first byte is masked to 0x7F to guarantee the integer is positive
431/// (no leading 0x00 padding needed).  This gives 159 bits of entropy,
432/// well above the 64-bit minimum recommended by CA/B Forum.
433fn generate_serial_bytes() -> Vec<u8> {
434    use rand::Rng;
435    let mut rng = rand::thread_rng();
436    let mut bytes = vec![0u8; 20];
437    rng.fill(&mut bytes[..]);
438    // Ensure positive by clearing the high bit.
439    bytes[0] &= 0x7F;
440    // Ensure non-zero first byte.
441    if bytes[0] == 0 {
442        bytes[0] = 1;
443    }
444    bytes
445}
446
447/// Convert a chrono DateTime<Utc> to a synta_certificate::Time.
448///
449/// Per RFC 5280 §4.1.2.5:
450/// - Dates before 2050: use UTCTime (YYMMDDHHMMSSZ)
451/// - Dates from 2050 onward: use GeneralizedTime (YYYYMMDDHHMMSSZ)
452fn chrono_to_synta_time(dt: DateTime<Utc>) -> Result<synta_certificate::Time, String> {
453    let year = dt.format("%Y").to_string().parse::<u16>().unwrap_or(2024);
454    let month = dt.format("%m").to_string().parse::<u8>().unwrap_or(1);
455    let day = dt.format("%d").to_string().parse::<u8>().unwrap_or(1);
456    let hour = dt.format("%H").to_string().parse::<u8>().unwrap_or(0);
457    let minute = dt.format("%M").to_string().parse::<u8>().unwrap_or(0);
458    let second = dt.format("%S").to_string().parse::<u8>().unwrap_or(0);
459
460    if year < 2050 {
461        let utc_time = synta::UtcTime::new(year, month, day, hour, minute, second)
462            .map_err(|e| format!("UtcTime creation failed: {e}"))?;
463        Ok(synta_certificate::Time::UtcTime(utc_time))
464    } else {
465        let gen_time = synta::GeneralizedTime::new(year, month, day, hour, minute, second, None)
466            .map_err(|e| format!("GeneralizedTime creation failed: {e}"))?;
467        Ok(synta_certificate::Time::GeneralTime(gen_time))
468    }
469}
470
471/// Convert profile key usage strings to a bitmask for `encode_key_usage`.
472fn profile_key_usage_bits(profile: &EnrollmentProfile) -> u16 {
473    use synta_certificate::{
474        KEY_USAGE_DATA_ENCIPHERMENT, KEY_USAGE_DIGITAL_SIGNATURE, KEY_USAGE_KEY_AGREEMENT,
475        KEY_USAGE_KEY_ENCIPHERMENT, KEY_USAGE_NON_REPUDIATION,
476    };
477
478    let mut bits: u16 = 0;
479    for ku in &profile.key_usage {
480        match ku.as_str() {
481            "digitalSignature" => bits |= 1 << KEY_USAGE_DIGITAL_SIGNATURE,
482            "nonRepudiation" | "contentCommitment" => bits |= 1 << KEY_USAGE_NON_REPUDIATION,
483            "keyEncipherment" => bits |= 1 << KEY_USAGE_KEY_ENCIPHERMENT,
484            "dataEncipherment" => bits |= 1 << KEY_USAGE_DATA_ENCIPHERMENT,
485            "keyAgreement" => bits |= 1 << KEY_USAGE_KEY_AGREEMENT,
486            other => {
487                warn!(key_usage = %other, "unknown key usage flag in profile; skipping");
488            }
489        }
490    }
491    bits
492}
493
494/// Build Extended Key Usage DER from profile strings.
495fn profile_extended_key_usage(profile: &EnrollmentProfile) -> Option<Vec<u8>> {
496    if profile.extended_key_usage.is_empty() {
497        return None;
498    }
499
500    let mut builder = synta_certificate::ExtendedKeyUsageBuilder::new();
501    for eku in &profile.extended_key_usage {
502        builder = match eku.as_str() {
503            "serverAuth" => builder.server_auth(),
504            "clientAuth" => builder.client_auth(),
505            "codeSigning" => builder.code_signing(),
506            "emailProtection" => builder.email_protection(),
507            "timeStamping" => builder.time_stamping(),
508            "OCSPSigning" | "ocspSigning" => builder.ocsp_signing(),
509            other => {
510                warn!(eku = %other, "unknown extended key usage in profile; skipping");
511                builder
512            }
513        };
514    }
515    builder.build().ok()
516}
517
518/// Validate CSR structure.
519fn validate_csr(csr_der: &[u8]) -> Result<(), IssuanceError> {
520    if csr_der.is_empty() {
521        return Err(IssuanceError::InvalidCsr("empty CSR".into()));
522    }
523
524    // Check for ASN.1 SEQUENCE tag (0x30) at start.
525    if csr_der[0] != 0x30 {
526        return Err(IssuanceError::InvalidCsr(
527            "does not start with ASN.1 SEQUENCE".into(),
528        ));
529    }
530
531    // Verify the CSR can be parsed.
532    synta_certificate::csr::CertificationRequest::from_der(csr_der)
533        .map_err(|e| IssuanceError::InvalidCsr(format!("PKCS#10 parse failed: {e}")))?;
534
535    debug!(len = csr_der.len(), "CSR structure validated");
536    Ok(())
537}
538
539/// Check key size from CSR against profile minimums.
540fn check_key_size(csr_der: &[u8], profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
541    // Parse the CSR to extract the public key info.
542    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der).map_err(|e| {
543        IssuanceError::InvalidCsr(format!("CSR parse failed in key size check: {e}"))
544    })?;
545
546    let spki = &csr.certification_request_info.subject_pkinfo;
547    let alg_oid = spki.algorithm.algorithm.components();
548    let key_bits = spki.subject_public_key.bit_len();
549
550    let pk_info = synta_certificate::decode_public_key_info(
551        &spki.algorithm.algorithm,
552        spki.algorithm.parameters.as_ref(),
553        spki.subject_public_key.as_bytes(),
554        key_bits,
555    );
556
557    match &pk_info {
558        synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
559            debug!(
560                algorithm = "RSA",
561                key_bits = bit_count,
562                "CSR public key info"
563            );
564            if (*bit_count as u32) < profile.min_rsa_bits {
565                return Err(IssuanceError::KeyTooSmall {
566                    algorithm: "RSA".into(),
567                    bits: *bit_count as u32,
568                    min_bits: profile.min_rsa_bits,
569                });
570            }
571        }
572        synta_certificate::PublicKeyInfo::Ec {
573            bit_count,
574            curve_nist_name,
575            ..
576        } => {
577            let curve_name = curve_nist_name.unwrap_or("unknown");
578            debug!(
579                algorithm = "EC",
580                curve = curve_name,
581                key_bits = bit_count,
582                "CSR public key info"
583            );
584            let min_bits: usize = match profile.min_ecdsa_curve.as_str() {
585                "P-256" => 256,
586                "P-384" => 384,
587                "P-521" => 521,
588                _ => 256,
589            };
590            if *bit_count < min_bits {
591                return Err(IssuanceError::KeyTooSmall {
592                    algorithm: format!("EC {curve_name}"),
593                    bits: *bit_count as u32,
594                    min_bits: min_bits as u32,
595                });
596            }
597        }
598        synta_certificate::PublicKeyInfo::Unknown {
599            alg_name,
600            bit_count,
601            ..
602        } => {
603            debug!(
604                algorithm = %alg_name,
605                key_bits = bit_count,
606                alg_oid = ?alg_oid,
607                "CSR public key: unknown algorithm (skipping size check)"
608            );
609        }
610    }
611    Ok(())
612}
613
614/// Validate the requested validity period.
615fn check_validity_period(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
616    // CA/B Forum maximum: 398 days (current), 47 days (from March 2029).
617    const CAB_CURRENT_MAX_DAYS: u32 = 398;
618
619    if profile.max_validity_days > CAB_CURRENT_MAX_DAYS {
620        warn!(
621            requested = profile.max_validity_days,
622            max = CAB_CURRENT_MAX_DAYS,
623            "validity period exceeds CA/B Forum maximum"
624        );
625        return Err(IssuanceError::ValidityTooLong {
626            requested_days: profile.max_validity_days,
627            max_days: CAB_CURRENT_MAX_DAYS,
628        });
629    }
630
631    Ok(())
632}
633
634// ── HSM CertificateSigner ────────────────────────────────────────────────────
635
636/// `CertificateSigner` implementation that delegates signing to a PKCS#11 HSM.
637///
638/// Uses `CKM_SHA256_RSA_PKCS` (or SHA-384/SHA-512 variants) which hashes
639/// the TBS data and signs in a single PKCS#11 `C_Sign` operation.
640struct HsmCertificateSigner<'a> {
641    context: &'a Arc<kipuka_hsm::HsmContext>,
642    key_label: &'a str,
643    hash_algorithm: &'a str,
644}
645
646/// Error type for HSM signing operations in the CertificateSigner trait.
647#[derive(Debug)]
648struct HsmSignerError(String);
649
650impl std::fmt::Display for HsmSignerError {
651    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652        write!(f, "{}", self.0)
653    }
654}
655
656impl std::error::Error for HsmSignerError {}
657
658impl<'a> synta_certificate::CertificateSigner for HsmCertificateSigner<'a> {
659    type Error = HsmSignerError;
660
661    fn signature_algorithm_der(&self) -> Result<Vec<u8>, Self::Error> {
662        // Return the DER-encoded AlgorithmIdentifier for SHA-256 with RSA.
663        //
664        // AlgorithmIdentifier ::= SEQUENCE {
665        //   algorithm   OBJECT IDENTIFIER,
666        //   parameters  ANY OPTIONAL
667        // }
668        //
669        // sha256WithRSAEncryption: OID 1.2.840.113549.1.1.11
670        // Parameters: NULL
671        match self.hash_algorithm {
672            "sha256" => {
673                // OID 1.2.840.113549.1.1.11 + NULL params
674                Ok(vec![
675                    0x30, 0x0d, // SEQUENCE, length 13
676                    0x06, 0x09, // OID, length 9
677                    0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
678                    0x0b, // sha256WithRSAEncryption
679                    0x05, 0x00, // NULL
680                ])
681            }
682            "sha384" => {
683                // OID 1.2.840.113549.1.1.12 + NULL params
684                Ok(vec![
685                    0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c,
686                    0x05, 0x00,
687                ])
688            }
689            "sha512" => {
690                // OID 1.2.840.113549.1.1.13 + NULL params
691                Ok(vec![
692                    0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d,
693                    0x05, 0x00,
694                ])
695            }
696            other => Err(HsmSignerError(format!(
697                "unsupported hash algorithm for HSM RSA signing: {other}"
698            ))),
699        }
700    }
701
702    fn sign_tbs(&self, tbs_der: &[u8]) -> Result<Vec<u8>, Self::Error> {
703        // CKM_SHA256_RSA_PKCS (and variants) hash the data internally,
704        // so we pass the raw TBS bytes directly.
705        self.context
706            .sign_data(self.key_label, tbs_der, self.hash_algorithm)
707            .map_err(|e| HsmSignerError(format!("PKCS#11 sign failed: {e}")))
708    }
709}
710
711/// Verify that required extensions are configured.
712fn check_required_extensions(profile: &EnrollmentProfile) -> Result<(), IssuanceError> {
713    if !profile.include_aki {
714        return Err(IssuanceError::MissingExtension(
715            "Authority Key Identifier (required by CA/B Forum)".into(),
716        ));
717    }
718    if !profile.include_ski {
719        return Err(IssuanceError::MissingExtension(
720            "Subject Key Identifier (required by CA/B Forum)".into(),
721        ));
722    }
723    if profile.key_usage.is_empty() {
724        return Err(IssuanceError::MissingExtension(
725            "Key Usage (required by CA/B Forum)".into(),
726        ));
727    }
728
729    Ok(())
730}