1use 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#[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
50pub 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 if !state.config.est.fullcmc {
91 return Err(KipukaError::Est("Full CMC is not enabled".into()));
92 }
93
94 if auth.0.method != AuthMethod::Mtls {
96 return Err(KipukaError::Auth(
97 "Full CMC requires mTLS client certificate authentication".into(),
98 ));
99 }
100
101 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 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 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 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
179
180 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 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 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 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 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(¬_before_str)
331 .bind(¬_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 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 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 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 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}