Skip to main content

kipuka/routes/
fullcmc.rs

1//! `POST /.well-known/est/fullcmc` — Full CMC Request.
2//!
3//! RFC 7030 §4.3: EST clients submit a Full CMC request (PKCS#7 SignedData
4//! containing a CMC PKIData) for complex enrollment scenarios that require
5//! RA intermediation.
6//!
7//! The signer of the CMC request MUST hold the id-kp-cmcRA EKU
8//! (OID 1.3.6.1.5.5.7.3.28) per RHELBU-3536 R15.
9//!
10//! The server parses the CMC PKIData, extracts certification requests,
11//! issues certificates for each one (or proxies to Dogtag if configured),
12//! and returns a CMC PKIResponse.
13
14use std::sync::Arc;
15
16use axum::body::Bytes;
17use axum::extract::State;
18use axum::http::{HeaderValue, StatusCode, header};
19use axum::response::{IntoResponse, Response};
20
21use synta_cmc::builder::PKIResponseBuilder;
22use synta_cmc::controls::{extract_sender_nonce, extract_transaction_id};
23use synta_cmc::parser;
24use synta_cmc::status::CMCFailInfo;
25
26use crate::auth::{AuthMethod, EstAuth};
27use crate::error::KipukaError;
28use crate::routes::LabelExtractor;
29use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
30use crate::state::AppState;
31
32/// Map a `CMCFailInfo` to a `KipukaError`.
33///
34/// Uses `CMCFailInfo::http_status()` to determine the HTTP status code,
35/// then selects the appropriate `KipukaError` variant.
36///
37/// Currently used by tests; will be used in future CMC validation paths.
38#[allow(dead_code)]
39fn cmc_fail_to_error(fail: CMCFailInfo, detail: &str) -> KipukaError {
40    let http_status = fail.http_status();
41    match http_status {
42        400 => KipukaError::BadRequest(format!("CMC error ({fail:?}): {detail}")),
43        403 => KipukaError::Auth(format!("CMC error ({fail:?}): {detail}")),
44        404 => KipukaError::NotFound,
45        503 => KipukaError::ServiceUnavailable(format!("CMC error ({fail:?}): {detail}")),
46        _ => KipukaError::Ca(format!("CMC error ({fail:?}): {detail}")),
47    }
48}
49
50/// `POST /.well-known/est/fullcmc`
51///
52/// Accepts a CMC request (PKCS#7 SignedData) and returns a CMC response.
53///
54/// # Authentication
55///
56/// Requires mTLS with a certificate carrying the id-kp-cmcRA EKU
57/// (OID 1.3.6.1.5.5.7.3.28, RHELBU-3536 R15).
58///
59/// # Request
60///
61/// | Header         | Value                                        |
62/// |----------------|----------------------------------------------|
63/// | Content-Type   | `application/pkcs7-mime; smime-type=CMC-request` |
64/// | Body           | Base64-encoded DER PKCS#7 SignedData (CMC PKIData) |
65///
66/// # Response
67///
68/// | Header         | Value                                        |
69/// |----------------|----------------------------------------------|
70/// | Status         | `200 OK`                                     |
71/// | Content-Type   | `application/pkcs7-mime; smime-type=CMC-response` |
72///
73/// # Errors
74///
75/// - `400 Bad Request` — malformed CMC request
76/// - `401 Unauthorized` — authentication failed
77/// - `403 Forbidden` — signer lacks id-kp-cmcRA EKU
78/// - `500 Internal Server Error` — CA backend error
79pub async fn post_fullcmc(
80    auth: EstAuth,
81    label: LabelExtractor,
82    State(state): State<Arc<AppState>>,
83    body: Bytes,
84) -> Result<Response, KipukaError> {
85    let ca_id = label.ca_id();
86    let identity = &auth.0.identity;
87    state.admit_enrollment(identity, "fullcmc").await?;
88
89    // Check that fullcmc is enabled in the configuration.
90    if !state.config.est.fullcmc {
91        return Err(KipukaError::Est("Full CMC is not enabled".into()));
92    }
93
94    // Full CMC requires mTLS authentication.
95    if auth.0.method != AuthMethod::Mtls {
96        return Err(KipukaError::Auth(
97            "Full CMC requires mTLS client certificate authentication".into(),
98        ));
99    }
100
101    // RHELBU-3536 R15: Validate that the signer certificate carries the
102    // id-kp-cmcRA Extended Key Usage.
103    if !auth.0.has_cmc_ra_eku() {
104        tracing::warn!(
105            identity = %identity,
106            "fullcmc rejected: signer lacks id-kp-cmcRA EKU"
107        );
108        return Err(KipukaError::Auth(format!(
109            "CMC signer certificate must have id-kp-cmcRA EKU ({})",
110            crate::auth::CMC_RA_EKU_OID,
111        )));
112    }
113
114    tracing::info!(
115        ca_id = %ca_id,
116        label = %label.label,
117        identity = %identity,
118        "fullcmc request"
119    );
120
121    // Decode the base64-encoded CMC request.
122    let cmc_request_der = decode_est_base64(&body).map_err(|_e| {
123        tracing::debug!(error = %_e, "CMC request base64 decoding failed");
124        KipukaError::BadRequest("malformed CMC request".into())
125    })?;
126
127    if cmc_request_der.is_empty() {
128        return Err(KipukaError::BadRequest("empty CMC request".into()));
129    }
130
131    // ── Dogtag CMC passthrough ──────────────────────────────────────────────
132    //
133    // If a Dogtag backend is configured, forward the raw CMC request to
134    // Dogtag's profileSubmitCMCFull endpoint and relay the response.
135    // This is a pure passthrough: kipuka does not interpret the CMC
136    // message content when Dogtag handles it.
137    if let Some(ref dogtag_pool) = state.dogtag {
138        let client = dogtag_pool
139            .get_client()
140            .map_err(|e| KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}")))?;
141
142        tracing::info!(
143            ca_id = %ca_id,
144            identity = %identity,
145            cmc_size = cmc_request_der.len(),
146            "forwarding Full CMC request to Dogtag CA"
147        );
148
149        let response_der = client
150            .submit_cmc_request(&cmc_request_der)
151            .await
152            .map_err(|e| KipukaError::Ca(format!("Dogtag CMC passthrough failed: {e}")))?;
153
154        let body = encode_est_base64(&response_der);
155        let mut resp = (StatusCode::OK, body).into_response();
156        resp.headers_mut().insert(
157            header::CONTENT_TYPE,
158            HeaderValue::from_static(content_types::CMC_RESPONSE),
159        );
160        resp.headers_mut().insert(
161            header::HeaderName::from_static("content-transfer-encoding"),
162            HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
163        );
164
165        state
166            .record_audit_event(
167                "fullcmc_success",
168                &format!("ca_id={ca_id}, identity={identity}, backend=dogtag"),
169            )
170            .await;
171
172        return Ok(resp);
173    }
174
175    // ── Direct-signing path (no Dogtag) ─────────────────────────────────────
176
177    // Look up the CA backend.
178    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
179
180    // Step 1: Verify the CMS SignedData signature and extract the PKIData content.
181    //
182    // RFC 5272 §5 requires RA signature verification against the CA's
183    // truststore.  When `cmc_truststore_file` is configured, RA certs
184    // issued by a different CA or intermediate are accepted. Otherwise
185    // the target CA cert is the sole trust anchor.
186    let truststore: Vec<Vec<u8>> = if let Some(ref ts_file) = state.config.est.cmc_truststore_file {
187        let pem_data = tokio::fs::read(ts_file).await.map_err(|e| {
188            KipukaError::Ca(format!("failed to read CMC truststore {ts_file}: {e}"))
189        })?;
190        let certs: Vec<Vec<u8>> =
191            rustls_pemfile::certs(&mut std::io::BufReader::new(&pem_data[..]))
192                .collect::<Result<Vec<_>, _>>()
193                .map_err(|e| {
194                    KipukaError::Ca(format!(
195                        "malformed PEM certificate in CMC truststore {ts_file}: {e}"
196                    ))
197                })?
198                .into_iter()
199                .map(|c| c.to_vec())
200                .collect();
201        if certs.is_empty() {
202            return Err(KipukaError::Ca(format!(
203                "CMC truststore {ts_file} contains no certificates"
204            )));
205        }
206        certs
207    } else {
208        vec![ca.cert_der.clone()]
209    };
210    let cms_result = crate::auth::cms_auth::verify_cms_signed_data(&cmc_request_der, &truststore)?;
211    let pki_data_der = cms_result.payload;
212
213    tracing::info!(
214        signer_dn = %cms_result.signer_subject_dn,
215        sig_alg = %cms_result.signature_algorithm,
216        "CMC SignedData signature verified"
217    );
218
219    if pki_data_der.is_empty() {
220        return Err(KipukaError::BadRequest(
221            "CMC SignedData has empty eContent".into(),
222        ));
223    }
224
225    // Step 2: Parse the PKIData to extract controls and certification requests.
226    let pki_data = parser::parse_pki_data(&pki_data_der).map_err(|e| {
227        tracing::warn!(error = %e, "CMC PKIData parse failed");
228        KipukaError::BadRequest("malformed CMC request".into())
229    })?;
230
231    // Step 3: Extract control attributes for audit and response construction.
232    let transaction_id = extract_transaction_id(&pki_data.control_sequence);
233    let sender_nonce = extract_sender_nonce(&pki_data.control_sequence);
234
235    let control_names: Vec<String> = pki_data
236        .control_sequence
237        .iter()
238        .map(|c| format!("{:?}", c.attr_type))
239        .collect();
240
241    tracing::info!(
242        ca_id = %ca_id,
243        identity = %identity,
244        transaction_id = ?transaction_id,
245        num_requests = pki_data.req_sequence.len(),
246        num_controls = pki_data.control_sequence.len(),
247        controls = ?control_names,
248        "CMC PKIData parsed"
249    );
250
251    if pki_data.req_sequence.is_empty() {
252        return Err(KipukaError::BadRequest(
253            "CMC request contains no certification requests".into(),
254        ));
255    }
256
257    // Step 4: Process each certification request.
258    //
259    // The direct signing path iterates PKCS#10 CSRs and issues certificates
260    // using the same `issue_certificate()` function as simpleenroll.
261    // CRMF requests are not yet supported for direct signing.
262    let ca_cfg = state
263        .config
264        .cas
265        .iter()
266        .find(|c| c.id == ca_id)
267        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
268
269    // Resolve key material.
270    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
271
272    let profile = crate::ca::issue::EnrollmentProfile {
273        max_validity_days: ca
274            .validity_days
275            .min(crate::ca::issue::cab_forum_max_validity_days()),
276        ..crate::ca::issue::EnrollmentProfile::default()
277    };
278
279    let mut issued_certs: Vec<Vec<u8>> = Vec::new();
280    let mut body_part_ids: Vec<u32> = Vec::new();
281    let mut failed_body_part_ids: Vec<u32> = Vec::new();
282
283    let requests = crate::ca::protocol::cmc_requests(&pki_data)?;
284    for entry in &requests {
285        let body_part_id = entry.body_part_id;
286
287        if entry.request_type == 0 {
288            crate::auth::enroll_authz::authorize_csr_der(
289                &entry.der,
290                identity,
291                &label.enroll_policy(),
292            )
293            .map_err(KipukaError::Forbidden)?;
294            match crate::ca::issue::issue_certificate(
295                &entry.der,
296                &profile,
297                &ca.cert_der,
298                resolved_key.as_signing_key(),
299                &ca.hash_algorithm,
300                ca.ocsp_url.as_deref(),
301                ca.crl_url.as_deref(),
302            ) {
303                Ok(result) => {
304                    tracing::info!(
305                        body_part_id,
306                        serial = %result.serial_number,
307                        subject = %result.subject_dn,
308                        "CMC: certificate issued for PKCS#10 request"
309                    );
310
311                    let serial = &result.serial_number;
312                    let subject_dn = &result.subject_dn;
313                    let issuer_dn = match synta_certificate::Certificate::from_der(&ca.cert_der) {
314                        Ok(c) => synta_certificate::format_dn(c.tbs_certificate.subject.0),
315                        Err(e) => {
316                            tracing::warn!(error = %e, "failed to parse CA certificate for issuer DN");
317                            String::from("unknown")
318                        }
319                    };
320                    let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
321                    let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
322
323                    match sqlx::query(crate::db::pg_sql(
324                        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
325                         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
326                    ))
327                    .bind(serial)
328                    .bind(subject_dn)
329                    .bind(&issuer_dn)
330                    .bind(&not_before_str)
331                    .bind(&not_after_str)
332                    .bind(&result.certificate_der)
333                    .bind(ca_id)
334                    .bind(&profile.name)
335                    .execute(&state.db)
336                    .await
337                    {
338                        Ok(_) => {
339                            issued_certs.push(result.certificate_der);
340                            body_part_ids.push(body_part_id);
341                        }
342                        Err(e) => {
343                            tracing::error!(error = %e, serial = %serial, "failed to store CMC-issued certificate in DB");
344                            failed_body_part_ids.push(body_part_id);
345                            state.record_audit_event(
346                                "fullcmc_db_error",
347                                &format!("ca_id={ca_id}, serial={serial}, error={e}"),
348                            ).await;
349                        }
350                    }
351                }
352                Err(e) => {
353                    tracing::error!(body_part_id, error = %e, "CMC: certificate issuance failed");
354                    failed_body_part_ids.push(body_part_id);
355                    state.record_audit_event(
356                        "fullcmc_request_failed",
357                        &format!("ca_id={ca_id}, identity={identity}, body_part_id={body_part_id}, error={e}"),
358                    ).await;
359                }
360            }
361        } else {
362            tracing::warn!(body_part_id, request_type = ?entry.request_type, "CMC: non-PKCS#10 requests not yet supported");
363            failed_body_part_ids.push(body_part_id);
364            state.record_audit_event(
365                "fullcmc_request_failed",
366                &format!("ca_id={ca_id}, identity={identity}, body_part_id={body_part_id}, reason=unsupported request type {:?}", entry.request_type),
367            ).await;
368        }
369    }
370
371    // Step 5: Build the PKIResponse.
372    let mut resp_builder = PKIResponseBuilder::new();
373
374    if !body_part_ids.is_empty() {
375        resp_builder = resp_builder
376            .add_status(&body_part_ids)
377            .map_err(|e| KipukaError::Ca(format!("CMC response builder failed (status): {e}")))?;
378    }
379
380    if !failed_body_part_ids.is_empty() {
381        resp_builder = resp_builder
382            .add_failed(&failed_body_part_ids, CMCFailInfo::InternalCaError)
383            .map_err(|e| {
384                KipukaError::Ca(format!("CMC response builder failed (failed status): {e}"))
385            })?;
386    }
387
388    // Echo the sender nonce as recipient nonce per RFC 5272 §6.6.
389    if let Some(nonce) = &sender_nonce {
390        resp_builder = resp_builder.recipient_nonce(nonce).map_err(|e| {
391            KipukaError::Ca(format!(
392                "CMC response builder failed (recipient nonce): {e}"
393            ))
394        })?;
395    }
396
397    // Generate a fresh sender nonce for the response.
398    let response_nonce: Vec<u8> = {
399        use rand::Rng;
400        let mut n = vec![0u8; 16];
401        rand::rng().fill_bytes(&mut n);
402        n
403    };
404    resp_builder = resp_builder
405        .sender_nonce(&response_nonce)
406        .map_err(|e| KipukaError::Ca(format!("CMC response builder failed (sender nonce): {e}")))?;
407
408    let pki_response_der = resp_builder
409        .build()
410        .map_err(|e| KipukaError::Ca(format!("CMC PKIResponse build failed: {e}")))?;
411
412    let pki_response_der = crate::ca::protocol::cmc_transaction(&pki_response_der, transaction_id)?;
413    let pki_response_der = crate::ca::protocol::signed_cmc(
414        &pki_response_der,
415        &issued_certs,
416        &ca.cert_der,
417        resolved_key.as_signing_key(),
418        &ca.hash_algorithm,
419    )?;
420
421    // Step 6: Encode the response as base64.
422    let body = encode_est_base64(&pki_response_der);
423
424    let status_code = if body_part_ids.is_empty() && !failed_body_part_ids.is_empty() {
425        StatusCode::INTERNAL_SERVER_ERROR
426    } else {
427        StatusCode::OK
428    };
429    let mut resp = (status_code, body).into_response();
430    resp.headers_mut().insert(
431        header::CONTENT_TYPE,
432        HeaderValue::from_static(content_types::CMC_RESPONSE),
433    );
434    resp.headers_mut().insert(
435        header::HeaderName::from_static("content-transfer-encoding"),
436        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
437    );
438
439    let audit_event = if failed_body_part_ids.is_empty() {
440        "fullcmc_success"
441    } else if body_part_ids.is_empty() {
442        "fullcmc_failed"
443    } else {
444        "fullcmc_partial"
445    };
446
447    state
448        .record_audit_event(
449            audit_event,
450            &format!(
451                "ca_id={ca_id}, identity={identity}, transaction_id={:?}, requests={}, issued={}, failed={}",
452                transaction_id,
453                pki_data.req_sequence.len(),
454                issued_certs.len(),
455                failed_body_part_ids.len()
456            ),
457        )
458        .await;
459
460    Ok(resp)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use synta_cmc::status::CMCFailInfo;
467
468    #[test]
469    fn cmc_fail_bad_request_maps_to_400() {
470        let err = cmc_fail_to_error(CMCFailInfo::BadRequest, "test");
471        assert!(
472            matches!(err, KipukaError::BadRequest(_)),
473            "BadRequest should map to KipukaError::BadRequest"
474        );
475    }
476
477    #[test]
478    fn cmc_fail_bad_alg_maps_to_400() {
479        let err = cmc_fail_to_error(CMCFailInfo::BadAlg, "test");
480        assert!(
481            matches!(err, KipukaError::BadRequest(_)),
482            "BadAlg should map to KipukaError::BadRequest"
483        );
484    }
485
486    #[test]
487    fn cmc_fail_bad_message_check_maps_to_400() {
488        let err = cmc_fail_to_error(CMCFailInfo::BadMessageCheck, "test");
489        assert!(
490            matches!(err, KipukaError::BadRequest(_)),
491            "BadMessageCheck should map to KipukaError::BadRequest"
492        );
493    }
494
495    #[test]
496    fn cmc_fail_bad_time_maps_to_400() {
497        let err = cmc_fail_to_error(CMCFailInfo::BadTime, "test");
498        assert!(
499            matches!(err, KipukaError::BadRequest(_)),
500            "BadTime should map to KipukaError::BadRequest"
501        );
502    }
503
504    #[test]
505    fn cmc_fail_bad_identity_maps_to_403() {
506        let err = cmc_fail_to_error(CMCFailInfo::BadIdentity, "test");
507        assert!(
508            matches!(err, KipukaError::Auth(_)),
509            "BadIdentity should map to KipukaError::Auth (403)"
510        );
511    }
512
513    #[test]
514    fn cmc_fail_pop_failed_maps_to_403() {
515        let err = cmc_fail_to_error(CMCFailInfo::PopFailed, "test");
516        assert!(
517            matches!(err, KipukaError::Auth(_)),
518            "PopFailed should map to KipukaError::Auth (403)"
519        );
520    }
521
522    #[test]
523    fn cmc_fail_pop_required_maps_to_403() {
524        let err = cmc_fail_to_error(CMCFailInfo::PopRequired, "test");
525        assert!(
526            matches!(err, KipukaError::Auth(_)),
527            "PopRequired should map to KipukaError::Auth (403)"
528        );
529    }
530
531    #[test]
532    fn cmc_fail_auth_data_fail_maps_to_403() {
533        let err = cmc_fail_to_error(CMCFailInfo::AuthDataFail, "test");
534        assert!(
535            matches!(err, KipukaError::Auth(_)),
536            "AuthDataFail should map to KipukaError::Auth (403)"
537        );
538    }
539
540    #[test]
541    fn cmc_fail_bad_cert_id_maps_to_404() {
542        let err = cmc_fail_to_error(CMCFailInfo::BadCertId, "test");
543        assert!(
544            matches!(err, KipukaError::NotFound),
545            "BadCertId should map to KipukaError::NotFound"
546        );
547    }
548
549    #[test]
550    fn cmc_fail_internal_ca_error_maps_to_500() {
551        let err = cmc_fail_to_error(CMCFailInfo::InternalCaError, "test");
552        assert!(
553            matches!(err, KipukaError::Ca(_)),
554            "InternalCaError should map to KipukaError::Ca (500)"
555        );
556    }
557
558    #[test]
559    fn cmc_fail_try_later_maps_to_503() {
560        let err = cmc_fail_to_error(CMCFailInfo::TryLater, "test");
561        assert!(
562            matches!(err, KipukaError::ServiceUnavailable(_)),
563            "TryLater should map to KipukaError::ServiceUnavailable (503)"
564        );
565    }
566}