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    let ca_id = label.ca_id();
110
111    tracing::info!(
112        ca_id = %ca_id,
113        label = %label.label,
114        "CMS simpleenroll request"
115    );
116
117    // CMS payloads are raw DER — no base64 decoding needed (RFC 8295 §4).
118    if body.is_empty() {
119        return Err(KipukaError::BadRequest(
120            "empty CMS SignedData request body".into(),
121        ));
122    }
123
124    // Verify the CMS SignedData and extract the CSR payload.
125    let truststore = build_truststore(&state);
126    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
127
128    // Extract the signer identity for authorization decisions.
129    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
130    let identity = &auth_result.identity;
131
132    tracing::info!(
133        ca_id = %ca_id,
134        identity = %identity,
135        signature_algorithm = %cms_result.signature_algorithm,
136        "CMS signature verified for simpleenroll"
137    );
138
139    // The unwrapped payload is the PKCS#10 CSR.
140    let csr_der = &cms_result.payload;
141    if csr_der.len() < 60 {
142        return Err(KipukaError::BadRequest(
143            "extracted CSR is too short to be valid".into(),
144        ));
145    }
146
147    // Delegate to the standard enrollment logic.
148    //
149    // TODO: Call the same enrollment pipeline as simpleenroll::post_simpleenroll.
150    //
151    // let cert_der = kipuka_est::enroll::process_csr(
152    //     &state, ca_id, csr_der, &auth_result, &label,
153    // ).await?;
154    let cert_der: Vec<u8> = Vec::new(); // Placeholder
155
156    if cert_der.is_empty() {
157        return Err(KipukaError::Ca(
158            "CMS-EST enrollment not yet implemented".into(),
159        ));
160    }
161
162    // Optionally wrap the response in CMS EnvelopedData.
163    let response_body = if cms_config.encrypt_responses {
164        let enc_alg = cms_config
165            .allowed_content_encryption
166            .first()
167            .map(|s| s.as_str())
168            .unwrap_or("AES-256-GCM");
169
170        cms_auth::build_cms_enveloped_data(&cert_der, &cms_result.signer_cert_der, enc_alg)?
171    } else {
172        cert_der
173    };
174
175    state
176        .record_audit_event(
177            "cms_simpleenroll_success",
178            &format!("ca_id={ca_id}, identity={identity}"),
179        )
180        .await;
181
182    build_cms_response(StatusCode::OK, &response_body)
183}
184
185/// `POST /.well-known/est/cms/simplereenroll`
186///
187/// CMS-wrapped simple re-enrollment (RFC 8295 §4 + RFC 7030 §4.2.2).
188///
189/// Similar to [`post_cms_simpleenroll`] but for certificate renewal.
190/// The CMS signer certificate serves as proof of the existing identity,
191/// analogous to the mTLS client certificate in standard re-enrollment.
192pub async fn post_cms_simplereenroll(
193    label: LabelExtractor,
194    State(state): State<Arc<AppState>>,
195    body: Bytes,
196) -> Result<Response, KipukaError> {
197    let cms_config = get_cms_est_config(&state)?;
198    let ca_id = label.ca_id();
199
200    tracing::info!(
201        ca_id = %ca_id,
202        label = %label.label,
203        "CMS simplereenroll request"
204    );
205
206    if body.is_empty() {
207        return Err(KipukaError::BadRequest(
208            "empty CMS SignedData request body".into(),
209        ));
210    }
211
212    let truststore = build_truststore(&state);
213    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
214    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
215    let identity = &auth_result.identity;
216
217    tracing::info!(
218        ca_id = %ca_id,
219        identity = %identity,
220        signature_algorithm = %cms_result.signature_algorithm,
221        "CMS signature verified for simplereenroll"
222    );
223
224    let csr_der = &cms_result.payload;
225    if csr_der.len() < 60 {
226        return Err(KipukaError::BadRequest(
227            "extracted CSR is too short to be valid".into(),
228        ));
229    }
230
231    // For re-enrollment, verify the signer certificate matches the CSR subject.
232    //
233    // RFC 7030 §3.5 POP linking: the CMS signer certificate subject
234    // MUST match the CSR subject.  This is analogous to the mTLS POP
235    // linking check in standard re-enrollment.
236    //
237    // TODO: Parse CSR subject and compare with cms_result.signer_subject_dn.
238    // let csr_subject = synta::pkcs10::CertificationRequest::from_der(csr_der)?
239    //     .subject_dn_string();
240    // if csr_subject != cms_result.signer_subject_dn {
241    //     return Err(KipukaError::BadRequest("POP linking failed: CSR subject does not match signer".into()));
242    // }
243
244    // Delegate to re-enrollment logic.
245    //
246    // TODO: Call the same re-enrollment pipeline as simplereenroll::post_simplereenroll.
247    let cert_der: Vec<u8> = Vec::new(); // Placeholder
248
249    if cert_der.is_empty() {
250        return Err(KipukaError::Ca(
251            "CMS-EST re-enrollment not yet implemented".into(),
252        ));
253    }
254
255    let response_body = if cms_config.encrypt_responses {
256        let enc_alg = cms_config
257            .allowed_content_encryption
258            .first()
259            .map(|s| s.as_str())
260            .unwrap_or("AES-256-GCM");
261
262        cms_auth::build_cms_enveloped_data(&cert_der, &cms_result.signer_cert_der, enc_alg)?
263    } else {
264        cert_der
265    };
266
267    state
268        .record_audit_event(
269            "cms_simplereenroll_success",
270            &format!("ca_id={ca_id}, identity={identity}"),
271        )
272        .await;
273
274    build_cms_response(StatusCode::OK, &response_body)
275}
276
277/// `POST /.well-known/est/cms/serverkeygen`
278///
279/// CMS-wrapped server-side key generation (RFC 8295 §4 + RFC 7030 §4.4).
280///
281/// The server generates a key pair, signs a certificate, and returns
282/// both the certificate and private key wrapped in CMS EnvelopedData
283/// for confidentiality.
284pub async fn post_cms_serverkeygen(
285    label: LabelExtractor,
286    State(state): State<Arc<AppState>>,
287    body: Bytes,
288) -> Result<Response, KipukaError> {
289    let cms_config = get_cms_est_config(&state)?;
290    let ca_id = label.ca_id();
291
292    if !state.config.est.serverkeygen {
293        return Err(KipukaError::Est(
294            "server-side key generation is not enabled".into(),
295        ));
296    }
297
298    tracing::info!(
299        ca_id = %ca_id,
300        label = %label.label,
301        "CMS serverkeygen request"
302    );
303
304    if body.is_empty() {
305        return Err(KipukaError::BadRequest(
306            "empty CMS SignedData request body".into(),
307        ));
308    }
309
310    let truststore = build_truststore(&state);
311    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
312    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
313    let identity = &auth_result.identity;
314
315    tracing::info!(
316        ca_id = %ca_id,
317        identity = %identity,
318        signature_algorithm = %cms_result.signature_algorithm,
319        "CMS signature verified for serverkeygen"
320    );
321
322    // The payload is the CSR template with the desired subject/extensions.
323    let _csr_template = &cms_result.payload;
324
325    // Delegate to server key generation logic.
326    //
327    // TODO: Generate key pair, build certificate, return both wrapped
328    // in CMS EnvelopedData.  The response MUST be encrypted because
329    // it contains the private key.
330    //
331    // let (cert_pkcs7_der, private_key_pkcs8) =
332    //     kipuka_est::keygen::server_keygen(&state, ca_id, csr_template, &label).await?;
333    // let combined = kipuka_est::multipart::build(&cert_pkcs7_der, &private_key_pkcs8);
334    let combined: Vec<u8> = Vec::new(); // Placeholder
335
336    if combined.is_empty() {
337        return Err(KipukaError::Ca(
338            "CMS-EST server key generation not yet implemented".into(),
339        ));
340    }
341
342    // Server key generation responses MUST always be encrypted —
343    // the response contains the private key.
344    let enc_alg = cms_config
345        .allowed_content_encryption
346        .first()
347        .map(|s| s.as_str())
348        .unwrap_or("AES-256-GCM");
349
350    let response_body =
351        cms_auth::build_cms_enveloped_data(&combined, &cms_result.signer_cert_der, enc_alg)?;
352
353    state
354        .record_audit_event(
355            "cms_serverkeygen_success",
356            &format!("ca_id={ca_id}, identity={identity}"),
357        )
358        .await;
359
360    build_cms_response(StatusCode::OK, &response_body)
361}
362
363/// `POST /.well-known/est/cms/fullcmc`
364///
365/// CMS-wrapped Full CMC request (RFC 8295 §4 + RFC 7030 §4.3).
366///
367/// The outer CMS SignedData provides message-level authentication; the
368/// inner payload is the CMC request (itself a SignedData containing
369/// PKIData).  The signer MUST hold the id-kp-cmcRA EKU.
370pub async fn post_cms_fullcmc(
371    label: LabelExtractor,
372    State(state): State<Arc<AppState>>,
373    body: Bytes,
374) -> Result<Response, KipukaError> {
375    let cms_config = get_cms_est_config(&state)?;
376    let ca_id = label.ca_id();
377
378    if !state.config.est.fullcmc {
379        return Err(KipukaError::Est("Full CMC is not enabled".into()));
380    }
381
382    tracing::info!(
383        ca_id = %ca_id,
384        label = %label.label,
385        "CMS fullcmc request"
386    );
387
388    if body.is_empty() {
389        return Err(KipukaError::BadRequest(
390            "empty CMS SignedData request body".into(),
391        ));
392    }
393
394    let truststore = build_truststore(&state);
395    let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
396    let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
397    let identity = &auth_result.identity;
398
399    // RHELBU-3536 R15: Validate id-kp-cmcRA EKU on the signer certificate.
400    //
401    // TODO: Parse the signer certificate to extract EKU OIDs and verify
402    // that id-kp-cmcRA (1.3.6.1.5.5.7.3.28) is present.
403    //
404    // For now, this check is deferred until the CMS crypto layer is
405    // implemented — the signer certificate DER is available in
406    // cms_result.signer_cert_der for EKU extraction.
407
408    tracing::info!(
409        ca_id = %ca_id,
410        identity = %identity,
411        signature_algorithm = %cms_result.signature_algorithm,
412        "CMS signature verified for fullcmc"
413    );
414
415    // The inner payload is the CMC request.
416    let _cmc_request_der = &cms_result.payload;
417
418    // Delegate to CMC processing.
419    //
420    // TODO: Process the CMC request via the same path as fullcmc::post_fullcmc.
421    let cmc_response_der: Vec<u8> = Vec::new(); // Placeholder
422
423    if cmc_response_der.is_empty() {
424        return Err(KipukaError::Ca(
425            "CMS-EST Full CMC not yet implemented".into(),
426        ));
427    }
428
429    let response_body = if cms_config.encrypt_responses {
430        let enc_alg = cms_config
431            .allowed_content_encryption
432            .first()
433            .map(|s| s.as_str())
434            .unwrap_or("AES-256-GCM");
435
436        cms_auth::build_cms_enveloped_data(&cmc_response_der, &cms_result.signer_cert_der, enc_alg)?
437    } else {
438        cmc_response_der
439    };
440
441    state
442        .record_audit_event(
443            "cms_fullcmc_success",
444            &format!("ca_id={ca_id}, identity={identity}"),
445        )
446        .await;
447
448    build_cms_response(StatusCode::OK, &response_body)
449}
450
451/// Build an HTTP response for a CMS-wrapped EST operation.
452///
453/// Sets Content-Type to `application/pkcs7-mime` per RFC 8295 §4.
454/// The body is raw DER — no base64 transfer encoding is used for
455/// CMS-wrapped payloads.
456fn build_cms_response(status: StatusCode, body: &[u8]) -> Result<Response, KipukaError> {
457    let mut resp = (status, body.to_vec()).into_response();
458    resp.headers_mut().insert(
459        header::CONTENT_TYPE,
460        HeaderValue::from_static(CONTENT_TYPE_PKCS7),
461    );
462    Ok(resp)
463}