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