Skip to main content

kipuka/routes/
cms_est.rs

1//! CMS-wrapped EST endpoints (RFC 8295).
2//!
3//! These endpoints accept EST requests wrapped in CMS SignedData for
4//! authentication and return responses wrapped in CMS EnvelopedData
5//! for confidentiality.  This enables EST over plain HTTP when a
6//! TLS-terminating proxy strips the TLS layer.
7//!
8//! RFC 8295 §4: All EST operations are supported with CMS wrapping.
9//! The Content-Type for all requests and responses is
10//! `application/pkcs7-mime`.
11//!
12//! # Route structure
13//!
14//! ```text
15//! /.well-known/est/cms/
16//!     simpleenroll     POST (§4.2 + CMS wrapping)
17//!     simplereenroll   POST (§4.2.2 + CMS wrapping)
18//!     serverkeygen     POST (§4.4 + CMS wrapping)
19//!     fullcmc          POST (§4.3 + CMS wrapping)
20//! ```
21
22use std::sync::Arc;
23
24use axum::Router;
25use axum::body::Bytes;
26use axum::extract::State;
27use axum::http::{HeaderValue, StatusCode, header};
28use axum::response::{IntoResponse, Response};
29use axum::routing::post;
30
31use crate::auth::cms_auth;
32use crate::error::KipukaError;
33use crate::routes::LabelExtractor;
34use crate::state::AppState;
35
36/// Content-Type for CMS-wrapped EST payloads (RFC 8295 §4).
37const CONTENT_TYPE_PKCS7: &str = "application/pkcs7-mime";
38
39/// Build the CMS-EST sub-router.
40///
41/// Mounts CMS-wrapped variants of the core EST enrollment endpoints
42/// under `/.well-known/est/cms/`.  Each handler unwraps the CMS
43/// SignedData, delegates to the standard EST logic, and optionally
44/// wraps the response in CMS EnvelopedData.
45pub fn cms_est_router() -> Router<Arc<AppState>> {
46    Router::new()
47        .route("/simpleenroll", post(post_cms_simpleenroll))
48        .route("/simplereenroll", post(post_cms_simplereenroll))
49        .route("/serverkeygen", post(post_cms_serverkeygen))
50        .route("/fullcmc", post(post_cms_fullcmc))
51}
52
53/// Extract and validate the CMS-EST configuration from application state.
54///
55/// Returns the configuration reference if CMS-EST is enabled, or an
56/// `KipukaError::Est` error if it is disabled or absent.
57fn get_cms_est_config(state: &AppState) -> Result<&crate::config::CmsEstConfig, KipukaError> {
58    match state.config.cms_est {
59        Some(ref cfg) if cfg.enabled => Ok(cfg),
60        _ => Err(KipukaError::Est("CMS-EST is not enabled".into())),
61    }
62}
63
64/// Build a truststore (list of DER-encoded trust anchors) from the
65/// CA certificate chains in application state.
66///
67/// RFC 8295 §3.1: the signer certificate must chain to a trust anchor
68/// known to the EST server.  We use the CA certificates as the
69/// truststore for CMS signature verification.
70fn build_truststore(state: &AppState) -> Vec<Vec<u8>> {
71    state
72        .cas
73        .values()
74        .flat_map(|ca| ca.cert_chain.iter().cloned())
75        .collect()
76}
77
78/// `POST /.well-known/est/cms/simpleenroll`
79///
80/// CMS-wrapped simple enrollment (RFC 8295 §4 + RFC 7030 §4.2).
81///
82/// # Request
83///
84/// | Header       | Value                    |
85/// |--------------|--------------------------|
86/// | Content-Type | `application/pkcs7-mime` |
87/// | Body         | DER-encoded CMS SignedData wrapping a PKCS#10 CSR |
88///
89/// # Processing
90///
91/// 1. Verify CMS SignedData signature and signer certificate chain.
92/// 2. Extract the PKCS#10 CSR payload from the signed content.
93/// 3. Extract signer identity for authorization.
94/// 4. Delegate to the standard enrollment logic.
95/// 5. Optionally wrap the response certificate in CMS EnvelopedData.
96///
97/// # Response
98///
99/// | Header       | Value                    |
100/// |--------------|--------------------------|
101/// | Content-Type | `application/pkcs7-mime` |
102/// | Body         | DER-encoded CMS EnvelopedData (or raw cert if encryption disabled) |
103pub async fn post_cms_simpleenroll(
104    label: LabelExtractor,
105    State(state): State<Arc<AppState>>,
106    body: Bytes,
107) -> Result<Response, KipukaError> {
108    let cms_config = get_cms_est_config(&state)?;
109    if label.disconnected.unwrap_or(state.config.est.disconnected) {
110        return Err(KipukaError::ServiceUnavailable(
111            "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
112                .into(),
113        ));
114    }
115    if state
116        .config
117        .est
118        .labels
119        .iter()
120        .find(|c| c.name == label.label)
121        .is_some_and(|c| {
122            !c.auth_methods.is_empty()
123                && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
124        })
125    {
126        return Err(KipukaError::Forbidden(
127            "CMS authentication is not permitted for this label".into(),
128        ));
129    }
130    let ca_id = label.ca_id();
131
132    tracing::info!(
133        ca_id = %ca_id,
134        label = %label.label,
135        "CMS simpleenroll request"
136    );
137
138    // CMS payloads are raw DER — no base64 decoding needed (RFC 8295 §4).
139    if body.is_empty() {
140        return Err(KipukaError::BadRequest(
141            "empty CMS SignedData request body".into(),
142        ));
143    }
144
145    // Verify the CMS SignedData and extract the CSR payload.
146    let truststore = build_truststore(&state);
147    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
148    crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
149        .await
150        .map_err(KipukaError::Auth)?;
151
152    // Extract the signer identity for authorization decisions.
153    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
154    let identity = &auth_result.identity;
155    state.admit_enrollment(identity, "cms_est").await?;
156
157    tracing::info!(
158        ca_id = %ca_id,
159        identity = %identity,
160        signature_algorithm = %cms_result.signature_algorithm,
161        "CMS signature verified for simpleenroll"
162    );
163
164    // The unwrapped payload is the PKCS#10 CSR.
165    let csr_der = &cms_result.payload;
166    if csr_der.len() < 60 {
167        return Err(KipukaError::BadRequest(
168            "extracted CSR is too short to be valid".into(),
169        ));
170    }
171
172    // Enrollment authorization (FDP_ACF.1): bind the CMS signer identity to the
173    // requested names and enforce the label allowlist — the same policy the
174    // direct /simpleenroll path applies, so the CMS transport is not a bypass.
175    if let Err(reason) =
176        crate::auth::enroll_authz::authorize_csr_der(csr_der, identity, &label.enroll_policy())
177    {
178        tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS simpleenroll authorization denied");
179        state
180            .record_audit_event_with_actor(
181                "cms_simpleenroll_denied",
182                identity,
183                &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
184            )
185            .await;
186        return Err(KipukaError::Forbidden(format!(
187            "enrollment not authorized: {reason}"
188        )));
189    }
190
191    // Delegate to the standard direct-signing enrollment pipeline.  A bad CSR
192    // (e.g. failed proof-of-possession) surfaces as BadRequest (400); audit
193    // that client-caused rejection as enroll.reject before returning, so the
194    // CMS-EST transport is audited exactly like the direct HTTP path (NIAP
195    // FAU_GEN.1).
196    let cert_der = match issue_certificate_from_csr(&state, ca_id, csr_der).await {
197        Ok(cert_der) => cert_der,
198        Err(e) => {
199            if matches!(e, KipukaError::BadRequest(_)) {
200                state
201                    .record_audit_event_with_actor(
202                        "cms_simpleenroll_denied",
203                        identity,
204                        &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
205                    )
206                    .await;
207            }
208            return Err(e);
209        }
210    };
211
212    // Wrap in PKCS#7 certs-only, then optionally encrypt with CMS EnvelopedData.
213    let pkcs7_der =
214        crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
215    let response_body = if cms_config.encrypt_responses {
216        let enc_alg = cms_config
217            .allowed_content_encryption
218            .first()
219            .map(|s| s.as_str())
220            .unwrap_or("AES-256-GCM");
221
222        cms_auth::build_cms_enveloped_data(&pkcs7_der, &cms_result.signer_cert_der, enc_alg)?
223    } else {
224        pkcs7_der
225    };
226
227    state
228        .record_audit_event(
229            "cms_simpleenroll_success",
230            &format!("ca_id={ca_id}, identity={identity}"),
231        )
232        .await;
233
234    build_cms_response(StatusCode::OK, &response_body)
235}
236
237/// `POST /.well-known/est/cms/simplereenroll`
238///
239/// CMS-wrapped simple re-enrollment (RFC 8295 §4 + RFC 7030 §4.2.2).
240///
241/// Similar to [`post_cms_simpleenroll`] but for certificate renewal.
242/// The CMS signer certificate serves as proof of the existing identity,
243/// analogous to the mTLS client certificate in standard re-enrollment.
244pub async fn post_cms_simplereenroll(
245    label: LabelExtractor,
246    State(state): State<Arc<AppState>>,
247    body: Bytes,
248) -> Result<Response, KipukaError> {
249    let cms_config = get_cms_est_config(&state)?;
250    if label.disconnected.unwrap_or(state.config.est.disconnected) {
251        return Err(KipukaError::ServiceUnavailable(
252            "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
253                .into(),
254        ));
255    }
256    if state
257        .config
258        .est
259        .labels
260        .iter()
261        .find(|c| c.name == label.label)
262        .is_some_and(|c| {
263            !c.auth_methods.is_empty()
264                && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
265        })
266    {
267        return Err(KipukaError::Forbidden(
268            "CMS authentication is not permitted for this label".into(),
269        ));
270    }
271    let ca_id = label.ca_id();
272
273    tracing::info!(
274        ca_id = %ca_id,
275        label = %label.label,
276        "CMS simplereenroll request"
277    );
278
279    if body.is_empty() {
280        return Err(KipukaError::BadRequest(
281            "empty CMS SignedData request body".into(),
282        ));
283    }
284
285    let truststore = build_truststore(&state);
286    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
287    crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
288        .await
289        .map_err(KipukaError::Auth)?;
290    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
291    let identity = &auth_result.identity;
292    state.admit_enrollment(identity, "cms_est").await?;
293
294    tracing::info!(
295        ca_id = %ca_id,
296        identity = %identity,
297        signature_algorithm = %cms_result.signature_algorithm,
298        "CMS signature verified for simplereenroll"
299    );
300
301    let csr_der = &cms_result.payload;
302    if csr_der.len() < 60 {
303        return Err(KipukaError::BadRequest(
304            "extracted CSR is too short to be valid".into(),
305        ));
306    }
307
308    // For re-enrollment, verify the signer certificate matches the CSR subject.
309    //
310    // RFC 7030 §3.5 POP linking: the CMS signer certificate subject
311    // MUST match the CSR subject.  This is analogous to the mTLS POP
312    // linking check in standard re-enrollment.
313    {
314        let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der).map_err(|e| {
315            KipukaError::BadRequest(format!("CSR parse failed for POP linking: {e}"))
316        })?;
317
318        let csr_subject_der = csr
319            .certification_request_info
320            .subject
321            .to_der()
322            .map_err(|e| KipukaError::BadRequest(format!("CSR subject encode failed: {e}")))?;
323        let csr_subject = synta_certificate::format_dn(&csr_subject_der);
324
325        let signer_norm = cms_result.signer_subject_dn.trim().to_lowercase();
326        let csr_norm = csr_subject.trim().to_lowercase();
327
328        if signer_norm != csr_norm {
329            tracing::warn!(
330                signer_subject = %cms_result.signer_subject_dn,
331                csr_subject = %csr_subject,
332                "CMS simplereenroll POP linking failed: subject mismatch"
333            );
334            return Err(KipukaError::Forbidden(format!(
335                "POP linking failed: CSR subject '{csr_subject}' does not match \
336                 CMS signer subject '{}'",
337                cms_result.signer_subject_dn,
338            )));
339        }
340
341        tracing::info!("CMS simplereenroll POP linking: CSR subject matches signer");
342    }
343
344    // Enrollment authorization (FDP_ACF.1): POP linking above proves the CSR
345    // subject equals the signer, but the per-label SAN binding and name
346    // allowlist are independent controls — apply them here too.
347    if let Err(reason) =
348        crate::auth::enroll_authz::authorize_csr_der(csr_der, identity, &label.enroll_policy())
349    {
350        tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS simplereenroll authorization denied");
351        state
352            .record_audit_event_with_actor(
353                "cms_simplereenroll_denied",
354                identity,
355                &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
356            )
357            .await;
358        return Err(KipukaError::Forbidden(format!(
359            "enrollment not authorized: {reason}"
360        )));
361    }
362
363    // Delegate to the standard direct-signing enrollment pipeline.
364    // Re-enrollment uses the same certificate issuance path as simple enrollment;
365    // the authentication difference is that the CMS signer cert IS the existing
366    // certificate being renewed (verified above via CMS SignedData).
367    let cert_der = match issue_certificate_from_csr(&state, ca_id, csr_der).await {
368        Ok(cert_der) => cert_der,
369        Err(e) => {
370            // Client-caused rejection (bad CSR/PoP) → audit enroll.reject.
371            if matches!(e, KipukaError::BadRequest(_)) {
372                state
373                    .record_audit_event_with_actor(
374                        "cms_simplereenroll_denied",
375                        identity,
376                        &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
377                    )
378                    .await;
379            }
380            return Err(e);
381        }
382    };
383
384    let pkcs7_der =
385        crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
386    let response_body = if cms_config.encrypt_responses {
387        let enc_alg = cms_config
388            .allowed_content_encryption
389            .first()
390            .map(|s| s.as_str())
391            .unwrap_or("AES-256-GCM");
392
393        cms_auth::build_cms_enveloped_data(&pkcs7_der, &cms_result.signer_cert_der, enc_alg)?
394    } else {
395        pkcs7_der
396    };
397
398    state
399        .record_audit_event(
400            "cms_simplereenroll_success",
401            &format!("ca_id={ca_id}, identity={identity}"),
402        )
403        .await;
404
405    build_cms_response(StatusCode::OK, &response_body)
406}
407
408/// `POST /.well-known/est/cms/serverkeygen`
409///
410/// CMS-wrapped server-side key generation (RFC 8295 §4 + RFC 7030 §4.4).
411///
412/// The server generates a key pair, signs a certificate, and returns
413/// both the certificate and private key wrapped in CMS EnvelopedData
414/// for confidentiality.
415pub async fn post_cms_serverkeygen(
416    label: LabelExtractor,
417    State(state): State<Arc<AppState>>,
418    body: Bytes,
419) -> Result<Response, KipukaError> {
420    let cms_config = get_cms_est_config(&state)?;
421    if label.disconnected.unwrap_or(state.config.est.disconnected) {
422        return Err(KipukaError::ServiceUnavailable(
423            "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
424                .into(),
425        ));
426    }
427    if state
428        .config
429        .est
430        .labels
431        .iter()
432        .find(|c| c.name == label.label)
433        .is_some_and(|c| {
434            !c.auth_methods.is_empty()
435                && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
436        })
437    {
438        return Err(KipukaError::Forbidden(
439            "CMS authentication is not permitted for this label".into(),
440        ));
441    }
442    let ca_id = label.ca_id();
443
444    if !state.config.est.serverkeygen {
445        return Err(KipukaError::Est(
446            "server-side key generation is not enabled".into(),
447        ));
448    }
449
450    tracing::info!(
451        ca_id = %ca_id,
452        label = %label.label,
453        "CMS serverkeygen request"
454    );
455
456    if body.is_empty() {
457        return Err(KipukaError::BadRequest(
458            "empty CMS SignedData request body".into(),
459        ));
460    }
461
462    let truststore = build_truststore(&state);
463    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
464    crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
465        .await
466        .map_err(KipukaError::Auth)?;
467    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
468    let identity = &auth_result.identity;
469    state.admit_enrollment(identity, "cms_est").await?;
470
471    tracing::info!(
472        ca_id = %ca_id,
473        identity = %identity,
474        signature_algorithm = %cms_result.signature_algorithm,
475        "CMS signature verified for serverkeygen"
476    );
477
478    // The payload is the CSR template with the desired subject/extensions.
479    let csr_template = &cms_result.payload;
480
481    // Enrollment authorization (FDP_ACF.1): the requested subject/SANs in the
482    // template must be authorized for this signer identity and label, exactly
483    // as for a client-supplied CSR.
484    if let Err(reason) =
485        crate::auth::enroll_authz::authorize_csr_der(csr_template, identity, &label.enroll_policy())
486    {
487        tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS serverkeygen authorization denied");
488        state
489            .record_audit_event_with_actor(
490                "cms_serverkeygen_denied",
491                identity,
492                &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
493            )
494            .await;
495        return Err(KipukaError::Forbidden(format!(
496            "enrollment not authorized: {reason}"
497        )));
498    }
499
500    let key_type = super::serverkeygen::detect_key_type_from_csr(csr_template);
501    let generated = crate::ca::keygen::generate_key_pair(
502        &key_type,
503        &crate::ca::keygen::KeyGenConfig::default(),
504    )
505    .map_err(|e| KipukaError::Ca(format!("CMS key generation failed: {e}")))?;
506    let new_csr = super::serverkeygen::build_keygen_csr(
507        csr_template,
508        &generated.public_key_der,
509        &generated.private_key_der,
510    )?;
511    let cert_der = issue_certificate_from_csr(&state, ca_id, &new_csr).await?;
512    let certificates = super::cacerts::build_certs_only_pkcs7(&[cert_der])?;
513    let key_and_cert =
514        super::serverkeygen::build_multipart_response(&certificates, &generated.private_key_der);
515
516    // Server key generation responses MUST always be encrypted —
517    // the response may contain the private key.
518    let enc_alg = cms_config
519        .allowed_content_encryption
520        .first()
521        .map(|s| s.as_str())
522        .unwrap_or("AES-256-GCM");
523
524    let response_body = cms_auth::build_cms_enveloped_data(
525        key_and_cert.as_bytes(),
526        &cms_result.signer_cert_der,
527        enc_alg,
528    )?;
529
530    state
531        .record_audit_event(
532            "cms_serverkeygen_success",
533            &format!("ca_id={ca_id}, identity={identity}"),
534        )
535        .await;
536
537    build_cms_response(StatusCode::OK, &response_body)
538}
539
540/// `POST /.well-known/est/cms/fullcmc`
541///
542/// CMS-wrapped Full CMC request (RFC 8295 §4 + RFC 7030 §4.3).
543///
544/// The outer CMS SignedData provides message-level authentication; the
545/// inner payload is the CMC request (itself a SignedData containing
546/// PKIData).  The signer MUST hold the id-kp-cmcRA EKU.
547pub async fn post_cms_fullcmc(
548    label: LabelExtractor,
549    State(state): State<Arc<AppState>>,
550    body: Bytes,
551) -> Result<Response, KipukaError> {
552    let cms_config = get_cms_est_config(&state)?;
553    if label.disconnected.unwrap_or(state.config.est.disconnected) {
554        return Err(KipukaError::ServiceUnavailable(
555            "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
556                .into(),
557        ));
558    }
559    if state
560        .config
561        .est
562        .labels
563        .iter()
564        .find(|c| c.name == label.label)
565        .is_some_and(|c| {
566            !c.auth_methods.is_empty()
567                && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
568        })
569    {
570        return Err(KipukaError::Forbidden(
571            "CMS authentication is not permitted for this label".into(),
572        ));
573    }
574    let ca_id = label.ca_id();
575
576    if !state.config.est.fullcmc {
577        return Err(KipukaError::Est("Full CMC is not enabled".into()));
578    }
579
580    tracing::info!(
581        ca_id = %ca_id,
582        label = %label.label,
583        "CMS fullcmc request"
584    );
585
586    if body.is_empty() {
587        return Err(KipukaError::BadRequest(
588            "empty CMS SignedData request body".into(),
589        ));
590    }
591
592    let truststore = build_truststore(&state);
593    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
594    crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
595        .await
596        .map_err(KipukaError::Auth)?;
597    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
598    let identity = &auth_result.identity;
599    state.admit_enrollment(identity, "cms_est").await?;
600
601    // RHELBU-3536 R15: Validate id-kp-cmcRA EKU on the signer certificate.
602    //
603    // The signer MUST hold id-kp-cmcRA (1.3.6.1.5.5.7.3.28) to submit
604    // Full CMC requests.  Extract the EKU extension from the signer
605    // certificate and verify the OID is present.
606    {
607        let signer_cert = synta_certificate::Certificate::from_der(&cms_result.signer_cert_der)
608            .map_err(|e| {
609                KipukaError::BadRequest(format!(
610                    "failed to parse CMS signer certificate for EKU check: {e}"
611                ))
612            })?;
613
614        let has_cmc_ra = signer_cert
615            .tbs_certificate
616            .extensions
617            .as_ref()
618            .and_then(|ext_raw| {
619                synta_certificate::find_extension_value(
620                    ext_raw.as_bytes(),
621                    synta_certificate::oids::EXTENDED_KEY_USAGE,
622                )
623            })
624            .map(|eku_bytes| {
625                let mut decoder = synta::Decoder::new(eku_bytes, synta::Encoding::Der);
626                let oids: Vec<synta::ObjectIdentifier> = decoder.decode().unwrap_or_default();
627                oids.iter()
628                    .any(|oid| oid.to_string() == crate::auth::CMC_RA_EKU_OID)
629            })
630            .unwrap_or(false);
631
632        if !has_cmc_ra {
633            tracing::warn!(
634                identity = %identity,
635                "CMS fullcmc rejected: signer certificate lacks id-kp-cmcRA EKU"
636            );
637            return Err(KipukaError::Forbidden(
638                "CMC signer certificate must have id-kp-cmcRA EKU (1.3.6.1.5.5.7.3.28)".into(),
639            ));
640        }
641
642        tracing::info!("CMS fullcmc: signer has id-kp-cmcRA EKU");
643    }
644
645    tracing::info!(
646        ca_id = %ca_id,
647        identity = %identity,
648        signature_algorithm = %cms_result.signature_algorithm,
649        "CMS signature verified for fullcmc"
650    );
651
652    // The inner payload is the CMC request (itself a CMS SignedData
653    // containing PKIData).  Unwrap the inner SignedData and parse PKIData.
654    let cmc_request_der = &cms_result.payload;
655
656    // Unwrap the inner CMC SignedData to get the PKIData content.
657    let (pki_data_der, _inner_signer_certs) = synta_cmc::parser::unwrap_signed_cmc(cmc_request_der)
658        .map_err(|e| KipukaError::BadRequest(format!("CMC inner SignedData unwrap failed: {e}")))?;
659
660    if pki_data_der.is_empty() {
661        return Err(KipukaError::BadRequest(
662            "CMC inner SignedData has empty eContent".into(),
663        ));
664    }
665
666    // Parse PKIData to extract controls and certification requests.
667    let pki_data = synta_cmc::parser::parse_pki_data(&pki_data_der)
668        .map_err(|e| KipukaError::BadRequest(format!("CMC PKIData parse failed: {e}")))?;
669
670    let transaction_id = synta_cmc::controls::extract_transaction_id(&pki_data.control_sequence);
671    let sender_nonce = synta_cmc::controls::extract_sender_nonce(&pki_data.control_sequence);
672
673    tracing::info!(
674        ca_id = %ca_id,
675        identity = %identity,
676        transaction_id = ?transaction_id,
677        num_requests = pki_data.req_sequence.len(),
678        "CMS fullcmc: PKIData parsed"
679    );
680
681    if pki_data.req_sequence.is_empty() {
682        return Err(KipukaError::BadRequest(
683            "CMC request contains no certification requests".into(),
684        ));
685    }
686
687    // Process each certification request using the shared enrollment helper.
688    let mut issued_certs: Vec<Vec<u8>> = Vec::new();
689    let mut body_part_ids: Vec<u32> = Vec::new();
690    let mut failed_body_part_ids: Vec<u32> = Vec::new();
691
692    let requests = crate::ca::protocol::cmc_requests(&pki_data)?;
693    for entry in &requests {
694        let body_part_id = entry.body_part_id;
695
696        if entry.request_type == 0 {
697            crate::auth::enroll_authz::authorize_csr_der(
698                &entry.der,
699                identity,
700                &label.enroll_policy(),
701            )
702            .map_err(KipukaError::Forbidden)?;
703            match issue_certificate_from_csr(&state, ca_id, &entry.der).await {
704                Ok(cert) => {
705                    issued_certs.push(cert);
706                    body_part_ids.push(body_part_id);
707                }
708                Err(e) => {
709                    tracing::error!(body_part_id, error = %e, "CMS fullcmc: certificate issuance failed");
710                    failed_body_part_ids.push(body_part_id);
711                }
712            }
713        } else {
714            tracing::warn!(body_part_id, request_type = ?entry.request_type, "CMS fullcmc: unsupported request type (only PKCS#10 supported)");
715            failed_body_part_ids.push(body_part_id);
716        }
717    }
718
719    // Build the CMC PKIResponse.
720    let mut resp_builder = synta_cmc::builder::PKIResponseBuilder::new();
721
722    if !body_part_ids.is_empty() {
723        resp_builder = resp_builder
724            .add_status(&body_part_ids)
725            .map_err(|e| KipukaError::Ca(format!("CMC response builder failed (status): {e}")))?;
726    }
727
728    if !failed_body_part_ids.is_empty() {
729        resp_builder = resp_builder
730            .add_failed(
731                &failed_body_part_ids,
732                synta_cmc::status::CMCFailInfo::InternalCaError,
733            )
734            .map_err(|e| {
735                KipukaError::Ca(format!("CMC response builder failed (failed status): {e}"))
736            })?;
737    }
738
739    // Echo sender nonce as recipient nonce per RFC 5272 §6.6.
740    if let Some(nonce) = &sender_nonce {
741        resp_builder = resp_builder.recipient_nonce(nonce).map_err(|e| {
742            KipukaError::Ca(format!(
743                "CMC response builder failed (recipient nonce): {e}"
744            ))
745        })?;
746    }
747
748    let cmc_response_der = resp_builder
749        .build()
750        .map_err(|e| KipukaError::Ca(format!("CMC PKIResponse build failed: {e}")))?;
751
752    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
753    let ca_cfg = state
754        .config
755        .cas
756        .iter()
757        .find(|c| c.id == ca_id)
758        .ok_or(KipukaError::NotFound)?;
759    let key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
760    let cmc_response_der = crate::ca::protocol::cmc_transaction(&cmc_response_der, transaction_id)?;
761    let cmc_response_der = crate::ca::protocol::signed_cmc(
762        &cmc_response_der,
763        &issued_certs,
764        &ca.cert_der,
765        key.as_signing_key(),
766        &ca.hash_algorithm,
767    )?;
768    let response_body = if cms_config.encrypt_responses {
769        let enc_alg = cms_config
770            .allowed_content_encryption
771            .first()
772            .map(|s| s.as_str())
773            .unwrap_or("AES-256-GCM");
774
775        cms_auth::build_cms_enveloped_data(&cmc_response_der, &cms_result.signer_cert_der, enc_alg)?
776    } else {
777        cmc_response_der
778    };
779
780    state
781        .record_audit_event(
782            "cms_fullcmc_success",
783            &format!("ca_id={ca_id}, identity={identity}"),
784        )
785        .await;
786
787    build_cms_response(StatusCode::OK, &response_body)
788}
789
790/// Build an HTTP response for a CMS-wrapped EST operation.
791///
792/// Sets Content-Type to `application/pkcs7-mime` per RFC 8295 §4.
793/// The body is raw DER — no base64 transfer encoding is used for
794/// CMS-wrapped payloads.
795fn build_cms_response(status: StatusCode, body: &[u8]) -> Result<Response, KipukaError> {
796    let mut resp = (status, body.to_vec()).into_response();
797    resp.headers_mut().insert(
798        header::CONTENT_TYPE,
799        HeaderValue::from_static(CONTENT_TYPE_PKCS7),
800    );
801    Ok(resp)
802}
803
804/// Issue a certificate from a DER-encoded CSR using the direct-signing path.
805///
806/// This is the shared enrollment pipeline used by all CMS-EST handlers.
807/// It resolves the CA backend (HSM or PEM key), builds an enrollment profile,
808/// calls `issue_certificate`, and returns the DER-encoded issued certificate.
809///
810/// # Errors
811///
812/// Returns `KipukaError::NotFound` if the CA is unknown, `KipukaError::Ca`
813/// on signing failures, and `KipukaError::ServiceUnavailable` if the HSM
814/// is offline.
815async fn issue_certificate_from_csr(
816    state: &AppState,
817    ca_id: &str,
818    csr_der: &[u8],
819) -> Result<Vec<u8>, KipukaError> {
820    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
821
822    let ca_cfg = state
823        .config
824        .cas
825        .iter()
826        .find(|c| c.id == ca_id)
827        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
828
829    // Resolve key material.
830    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
831
832    let profile = crate::ca::issue::EnrollmentProfile {
833        max_validity_days: ca
834            .validity_days
835            .min(crate::ca::issue::cab_forum_max_validity_days()),
836        ..crate::ca::issue::EnrollmentProfile::default()
837    };
838
839    let result = crate::ca::issue::issue_certificate(
840        csr_der,
841        &profile,
842        &ca.cert_der,
843        resolved_key.as_signing_key(),
844        &ca.hash_algorithm,
845        ca.ocsp_url.as_deref(),
846        ca.crl_url.as_deref(),
847    )
848    .map_err(KipukaError::from)?;
849
850    crate::ca::issue::persist_certificate(state, ca_id, &profile.name, &result).await?;
851    Ok(result.certificate_der)
852}