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::{self, RequestType};
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
88    // Check that fullcmc is enabled in the configuration.
89    if !state.config.est.fullcmc {
90        return Err(KipukaError::Est("Full CMC is not enabled".into()));
91    }
92
93    // Full CMC requires mTLS authentication.
94    if auth.0.method != AuthMethod::Mtls {
95        return Err(KipukaError::Auth(
96            "Full CMC requires mTLS client certificate authentication".into(),
97        ));
98    }
99
100    // RHELBU-3536 R15: Validate that the signer certificate carries the
101    // id-kp-cmcRA Extended Key Usage.
102    if !auth.0.has_cmc_ra_eku() {
103        tracing::warn!(
104            identity = %identity,
105            "fullcmc rejected: signer lacks id-kp-cmcRA EKU"
106        );
107        return Err(KipukaError::Auth(
108            "CMC signer certificate must have id-kp-cmcRA EKU (1.3.6.1.5.5.7.3.28)".into(),
109        ));
110    }
111
112    tracing::info!(
113        ca_id = %ca_id,
114        label = %label.label,
115        identity = %identity,
116        "fullcmc request"
117    );
118
119    // Decode the base64-encoded CMC request.
120    let cmc_request_der = decode_est_base64(&body)
121        .map_err(|_e| {
122            tracing::debug!(error = %_e, "CMC request base64 decoding failed");
123            KipukaError::BadRequest("malformed CMC request".into())
124        })?;
125
126    if cmc_request_der.is_empty() {
127        return Err(KipukaError::BadRequest("empty CMC request".into()));
128    }
129
130    // ── Dogtag CMC passthrough ──────────────────────────────────────────────
131    //
132    // If a Dogtag backend is configured, forward the raw CMC request to
133    // Dogtag's profileSubmitCMCFull endpoint and relay the response.
134    // This is a pure passthrough: kipuka does not interpret the CMC
135    // message content when Dogtag handles it.
136    if let Some(ref dogtag_pool) = state.dogtag {
137        let client = dogtag_pool.get_client().map_err(|e| {
138            KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}"))
139        })?;
140
141        tracing::info!(
142            ca_id = %ca_id,
143            identity = %identity,
144            cmc_size = cmc_request_der.len(),
145            "forwarding Full CMC request to Dogtag CA"
146        );
147
148        let response_der = client
149            .submit_cmc_request(&cmc_request_der)
150            .await
151            .map_err(|e| KipukaError::Ca(format!("Dogtag CMC passthrough failed: {e}")))?;
152
153        let body = encode_est_base64(&response_der);
154        let mut resp = (StatusCode::OK, body).into_response();
155        resp.headers_mut().insert(
156            header::CONTENT_TYPE,
157            HeaderValue::from_static(content_types::CMC_RESPONSE),
158        );
159        resp.headers_mut().insert(
160            header::HeaderName::from_static("content-transfer-encoding"),
161            HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
162        );
163
164        state
165            .record_audit_event(
166                "fullcmc_success",
167                &format!("ca_id={ca_id}, identity={identity}, backend=dogtag"),
168            )
169            .await;
170
171        return Ok(resp);
172    }
173
174    // ── Direct-signing path (no Dogtag) ─────────────────────────────────────
175
176    // Look up the CA backend.
177    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
178
179    // Step 1: Unwrap the CMS SignedData to extract the PKIData content.
180    let (pki_data_der, signer_certs) =
181        parser::unwrap_signed_cmc(&cmc_request_der).map_err(|e| {
182            tracing::warn!(error = %e, "CMC SignedData unwrap failed");
183            KipukaError::BadRequest("malformed CMC request".into())
184        })?;
185
186    if signer_certs.is_empty() {
187        tracing::warn!("CMC request has no signer certificates — CMS signature cannot be verified");
188    }
189    // TODO(security): Verify CMS SignedData signature using signer_certs.
190    // RFC 5272 §5 requires RA signature verification. Currently relying on
191    // mTLS+EKU authentication at the transport layer only.
192    tracing::warn!("CMC SignedData signature verification not yet implemented");
193
194    if pki_data_der.is_empty() {
195        return Err(KipukaError::BadRequest(
196            "CMC SignedData has empty eContent".into(),
197        ));
198    }
199
200    // Step 2: Parse the PKIData to extract controls and certification requests.
201    let pki_data = parser::parse_pki_data(&pki_data_der).map_err(|e| {
202        tracing::warn!(error = %e, "CMC PKIData parse failed");
203        KipukaError::BadRequest("malformed CMC request".into())
204    })?;
205
206    // Step 3: Extract control attributes for audit and response construction.
207    let transaction_id = extract_transaction_id(&pki_data.controls);
208    let sender_nonce = extract_sender_nonce(&pki_data.controls);
209
210    let control_names: Vec<String> = pki_data
211        .controls
212        .iter()
213        .map(|c| format!("{:?}", c.oid))
214        .collect();
215
216    tracing::info!(
217        ca_id = %ca_id,
218        identity = %identity,
219        transaction_id = ?transaction_id,
220        num_requests = pki_data.certification_requests.len(),
221        num_controls = pki_data.controls.len(),
222        controls = ?control_names,
223        "CMC PKIData parsed"
224    );
225
226    if pki_data.certification_requests.is_empty() {
227        return Err(KipukaError::BadRequest(
228            "CMC request contains no certification requests".into(),
229        ));
230    }
231
232    // Step 4: Process each certification request.
233    //
234    // The direct signing path iterates PKCS#10 CSRs and issues certificates
235    // using the same `issue_certificate()` function as simpleenroll.
236    // CRMF requests are not yet supported for direct signing.
237    let ca_cfg = state
238        .config
239        .cas
240        .iter()
241        .find(|c| c.id == ca_id)
242        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
243
244    // Resolve key material -- variables must outlive signing_key borrows.
245    // Both are initialized unconditionally so the compiler can verify that
246    // only the relevant one is populated before use.
247    let ca_key_pem: Vec<u8>;
248    let key_label_owned: String;
249    let is_hsm = ca_cfg.is_hsm_backed();
250
251    if is_hsm {
252        let _hsm_ctx = state
253            .hsm
254            .as_ref()
255            .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
256        key_label_owned =
257            crate::routes::simpleenroll::parse_pkcs11_object_label(ca_cfg.pkcs11_uri.as_deref().unwrap())
258                .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
259        ca_key_pem = Vec::new(); // unused in HSM path
260    } else {
261        ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
262            KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
263        })?;
264        key_label_owned = String::new(); // unused in PEM path
265    }
266
267    let profile = crate::ca::issue::EnrollmentProfile {
268        max_validity_days: ca.validity_days.min(398),
269        ..crate::ca::issue::EnrollmentProfile::default()
270    };
271
272    let mut issued_certs: Vec<Vec<u8>> = Vec::new();
273    let mut body_part_ids: Vec<u32> = Vec::new();
274    let mut failed_body_part_ids: Vec<u32> = Vec::new();
275
276    for req_entry in &pki_data.certification_requests {
277        match req_entry.request_type {
278            RequestType::Pkcs10 => {
279                // Construct the signing key reference for this iteration.
280                let signing_key = if is_hsm {
281                    crate::ca::issue::CaSigningKey::Hsm {
282                        context: state.hsm.as_ref().unwrap(),
283                        key_label: &key_label_owned,
284                    }
285                } else {
286                    crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
287                };
288
289                match crate::ca::issue::issue_certificate(
290                    &req_entry.der,
291                    &profile,
292                    &ca.cert_der,
293                    signing_key,
294                    &ca.hash_algorithm,
295                ) {
296                    Ok(result) => {
297                        tracing::info!(
298                            body_part_id = req_entry.body_part_id,
299                            serial = %result.serial_number,
300                            subject = %result.subject_dn,
301                            "CMC: certificate issued for PKCS#10 request"
302                        );
303
304                        // Store the issued certificate in the database.
305                        let serial = &result.serial_number;
306                        let subject_dn = &result.subject_dn;
307                        let issuer_dn = match synta_certificate::Certificate::from_der(&ca.cert_der) {
308                            Ok(c) => synta_certificate::format_dn(c.tbs_certificate.subject.0),
309                            Err(e) => {
310                                tracing::warn!(error = %e, "failed to parse CA certificate for issuer DN");
311                                String::from("unknown")
312                            }
313                        };
314                        let not_before_str =
315                            result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
316                        let not_after_str =
317                            result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
318
319                        match sqlx::query(crate::db::pg_sql(
320                            "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
321                             VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
322                        ))
323                        .bind(serial)
324                        .bind(subject_dn)
325                        .bind(&issuer_dn)
326                        .bind(&not_before_str)
327                        .bind(&not_after_str)
328                        .bind(&result.certificate_der)
329                        .bind(ca_id)
330                        .bind(&profile.name)
331                        .execute(&state.db)
332                        .await
333                        {
334                            Ok(_) => {
335                                issued_certs.push(result.certificate_der);
336                                body_part_ids.push(req_entry.body_part_id);
337                            }
338                            Err(e) => {
339                                tracing::error!(error = %e, serial = %serial, "failed to store CMC-issued certificate in DB");
340                                failed_body_part_ids.push(req_entry.body_part_id);
341                                state.record_audit_event(
342                                    "fullcmc_db_error",
343                                    &format!("ca_id={ca_id}, serial={serial}, error={e}"),
344                                ).await;
345                            }
346                        }
347                    }
348                    Err(e) => {
349                        tracing::error!(
350                            body_part_id = req_entry.body_part_id,
351                            error = %e,
352                            "CMC: certificate issuance failed for PKCS#10 request"
353                        );
354                        failed_body_part_ids.push(req_entry.body_part_id);
355
356                        state
357                            .record_audit_event(
358                                "fullcmc_request_failed",
359                                &format!(
360                                    "ca_id={ca_id}, identity={identity}, body_part_id={}, error={e}",
361                                    req_entry.body_part_id
362                                ),
363                            )
364                            .await;
365                    }
366                }
367            }
368            RequestType::Crmf => {
369                tracing::warn!(body_part_id = req_entry.body_part_id, "CMC: CRMF requests not yet supported");
370                failed_body_part_ids.push(req_entry.body_part_id);
371                state.record_audit_event(
372                    "fullcmc_request_failed",
373                    &format!("ca_id={ca_id}, identity={identity}, body_part_id={}, reason=CRMF unsupported", req_entry.body_part_id),
374                ).await;
375            }
376            RequestType::Other(ref oid) => {
377                tracing::warn!(body_part_id = req_entry.body_part_id, oid = ?oid, "CMC: unsupported request type");
378                failed_body_part_ids.push(req_entry.body_part_id);
379                state.record_audit_event(
380                    "fullcmc_request_failed",
381                    &format!("ca_id={ca_id}, identity={identity}, body_part_id={}, reason=unsupported type {:?}", req_entry.body_part_id, oid),
382                ).await;
383            }
384        }
385    }
386
387    // Step 5: Build the PKIResponse.
388    let mut resp_builder = PKIResponseBuilder::new();
389
390    if !body_part_ids.is_empty() {
391        resp_builder = resp_builder.add_status(&body_part_ids).map_err(|e| {
392            KipukaError::Ca(format!("CMC response builder failed (status): {e}"))
393        })?;
394    }
395
396    if !failed_body_part_ids.is_empty() {
397        resp_builder = resp_builder
398            .add_failed(&failed_body_part_ids, CMCFailInfo::InternalCaError)
399            .map_err(|e| {
400                KipukaError::Ca(format!("CMC response builder failed (failed status): {e}"))
401            })?;
402    }
403
404    // Echo the sender nonce as recipient nonce per RFC 5272 §6.6.
405    if let Some(nonce) = &sender_nonce {
406        resp_builder = resp_builder.recipient_nonce(nonce).map_err(|e| {
407            KipukaError::Ca(format!(
408                "CMC response builder failed (recipient nonce): {e}"
409            ))
410        })?;
411    }
412
413    // Generate a fresh sender nonce for the response.
414    let response_nonce: Vec<u8> = {
415        use rand::Rng;
416        let mut rng = rand::thread_rng();
417        let mut n = vec![0u8; 16];
418        rng.fill(&mut n[..]);
419        n
420    };
421    resp_builder = resp_builder.sender_nonce(&response_nonce).map_err(|e| {
422        KipukaError::Ca(format!("CMC response builder failed (sender nonce): {e}"))
423    })?;
424
425    let pki_response_der = resp_builder.build().map_err(|e| {
426        KipukaError::Ca(format!("CMC PKIResponse build failed: {e}"))
427    })?;
428
429    // Step 6: Encode the response as base64.
430    let body = encode_est_base64(&pki_response_der);
431
432    let status_code = if body_part_ids.is_empty() && !failed_body_part_ids.is_empty() {
433        StatusCode::INTERNAL_SERVER_ERROR
434    } else {
435        StatusCode::OK
436    };
437    let mut resp = (status_code, body).into_response();
438    resp.headers_mut().insert(
439        header::CONTENT_TYPE,
440        HeaderValue::from_static(content_types::CMC_RESPONSE),
441    );
442    resp.headers_mut().insert(
443        header::HeaderName::from_static("content-transfer-encoding"),
444        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
445    );
446
447    let audit_event = if failed_body_part_ids.is_empty() {
448        "fullcmc_success"
449    } else if body_part_ids.is_empty() {
450        "fullcmc_failed"
451    } else {
452        "fullcmc_partial"
453    };
454
455    state
456        .record_audit_event(
457            audit_event,
458            &format!(
459                "ca_id={ca_id}, identity={identity}, transaction_id={:?}, requests={}, issued={}, failed={}",
460                transaction_id,
461                pki_data.certification_requests.len(),
462                issued_certs.len(),
463                failed_body_part_ids.len()
464            ),
465        )
466        .await;
467
468    Ok(resp)
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use synta_cmc::status::CMCFailInfo;
475
476    #[test]
477    fn cmc_fail_bad_request_maps_to_400() {
478        let err = cmc_fail_to_error(CMCFailInfo::BadRequest, "test");
479        assert!(
480            matches!(err, KipukaError::BadRequest(_)),
481            "BadRequest should map to KipukaError::BadRequest"
482        );
483    }
484
485    #[test]
486    fn cmc_fail_bad_alg_maps_to_400() {
487        let err = cmc_fail_to_error(CMCFailInfo::BadAlg, "test");
488        assert!(
489            matches!(err, KipukaError::BadRequest(_)),
490            "BadAlg should map to KipukaError::BadRequest"
491        );
492    }
493
494    #[test]
495    fn cmc_fail_bad_message_check_maps_to_400() {
496        let err = cmc_fail_to_error(CMCFailInfo::BadMessageCheck, "test");
497        assert!(
498            matches!(err, KipukaError::BadRequest(_)),
499            "BadMessageCheck should map to KipukaError::BadRequest"
500        );
501    }
502
503    #[test]
504    fn cmc_fail_bad_time_maps_to_400() {
505        let err = cmc_fail_to_error(CMCFailInfo::BadTime, "test");
506        assert!(
507            matches!(err, KipukaError::BadRequest(_)),
508            "BadTime should map to KipukaError::BadRequest"
509        );
510    }
511
512    #[test]
513    fn cmc_fail_bad_identity_maps_to_403() {
514        let err = cmc_fail_to_error(CMCFailInfo::BadIdentity, "test");
515        assert!(
516            matches!(err, KipukaError::Auth(_)),
517            "BadIdentity should map to KipukaError::Auth (403)"
518        );
519    }
520
521    #[test]
522    fn cmc_fail_pop_failed_maps_to_403() {
523        let err = cmc_fail_to_error(CMCFailInfo::PopFailed, "test");
524        assert!(
525            matches!(err, KipukaError::Auth(_)),
526            "PopFailed should map to KipukaError::Auth (403)"
527        );
528    }
529
530    #[test]
531    fn cmc_fail_pop_required_maps_to_403() {
532        let err = cmc_fail_to_error(CMCFailInfo::PopRequired, "test");
533        assert!(
534            matches!(err, KipukaError::Auth(_)),
535            "PopRequired should map to KipukaError::Auth (403)"
536        );
537    }
538
539    #[test]
540    fn cmc_fail_auth_data_fail_maps_to_403() {
541        let err = cmc_fail_to_error(CMCFailInfo::AuthDataFail, "test");
542        assert!(
543            matches!(err, KipukaError::Auth(_)),
544            "AuthDataFail should map to KipukaError::Auth (403)"
545        );
546    }
547
548    #[test]
549    fn cmc_fail_bad_cert_id_maps_to_404() {
550        let err = cmc_fail_to_error(CMCFailInfo::BadCertId, "test");
551        assert!(
552            matches!(err, KipukaError::NotFound),
553            "BadCertId should map to KipukaError::NotFound"
554        );
555    }
556
557    #[test]
558    fn cmc_fail_internal_ca_error_maps_to_500() {
559        let err = cmc_fail_to_error(CMCFailInfo::InternalCaError, "test");
560        assert!(
561            matches!(err, KipukaError::Ca(_)),
562            "InternalCaError should map to KipukaError::Ca (500)"
563        );
564    }
565
566    #[test]
567    fn cmc_fail_try_later_maps_to_503() {
568        let err = cmc_fail_to_error(CMCFailInfo::TryLater, "test");
569        assert!(
570            matches!(err, KipukaError::ServiceUnavailable(_)),
571            "TryLater should map to KipukaError::ServiceUnavailable (503)"
572        );
573    }
574}