1use 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
36const CONTENT_TYPE_PKCS7: &str = "application/pkcs7-mime";
38
39pub 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
53fn 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
64fn 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
78pub 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 if label.disconnected.unwrap_or(state.config.est.disconnected) {
110 return Err(KipukaError::ServiceUnavailable(
111 "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
112 .into(),
113 ));
114 }
115 if state
116 .config
117 .est
118 .labels
119 .iter()
120 .find(|c| c.name == label.label)
121 .is_some_and(|c| {
122 !c.auth_methods.is_empty()
123 && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
124 })
125 {
126 return Err(KipukaError::Forbidden(
127 "CMS authentication is not permitted for this label".into(),
128 ));
129 }
130 let ca_id = label.ca_id();
131
132 tracing::info!(
133 ca_id = %ca_id,
134 label = %label.label,
135 "CMS simpleenroll request"
136 );
137
138 if body.is_empty() {
140 return Err(KipukaError::BadRequest(
141 "empty CMS SignedData request body".into(),
142 ));
143 }
144
145 let truststore = build_truststore(&state);
147 let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
148 crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
149 .await
150 .map_err(KipukaError::Auth)?;
151
152 let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
154 let identity = &auth_result.identity;
155 state.admit_enrollment(identity, "cms_est").await?;
156
157 tracing::info!(
158 ca_id = %ca_id,
159 identity = %identity,
160 signature_algorithm = %cms_result.signature_algorithm,
161 "CMS signature verified for simpleenroll"
162 );
163
164 let csr_der = &cms_result.payload;
166 if csr_der.len() < 60 {
167 return Err(KipukaError::BadRequest(
168 "extracted CSR is too short to be valid".into(),
169 ));
170 }
171
172 if let Err(reason) =
176 crate::auth::enroll_authz::authorize_csr_der(csr_der, identity, &label.enroll_policy())
177 {
178 tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS simpleenroll authorization denied");
179 state
180 .record_audit_event_with_actor(
181 "cms_simpleenroll_denied",
182 identity,
183 &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
184 )
185 .await;
186 return Err(KipukaError::Forbidden(format!(
187 "enrollment not authorized: {reason}"
188 )));
189 }
190
191 let cert_der = match issue_certificate_from_csr(&state, ca_id, csr_der).await {
197 Ok(cert_der) => cert_der,
198 Err(e) => {
199 if matches!(e, KipukaError::BadRequest(_)) {
200 state
201 .record_audit_event_with_actor(
202 "cms_simpleenroll_denied",
203 identity,
204 &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
205 )
206 .await;
207 }
208 return Err(e);
209 }
210 };
211
212 let pkcs7_der =
214 crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
215 let response_body = if cms_config.encrypt_responses {
216 let enc_alg = cms_config
217 .allowed_content_encryption
218 .first()
219 .map(|s| s.as_str())
220 .unwrap_or("AES-256-GCM");
221
222 cms_auth::build_cms_enveloped_data(&pkcs7_der, &cms_result.signer_cert_der, enc_alg)?
223 } else {
224 pkcs7_der
225 };
226
227 state
228 .record_audit_event(
229 "cms_simpleenroll_success",
230 &format!("ca_id={ca_id}, identity={identity}"),
231 )
232 .await;
233
234 build_cms_response(StatusCode::OK, &response_body)
235}
236
237pub async fn post_cms_simplereenroll(
245 label: LabelExtractor,
246 State(state): State<Arc<AppState>>,
247 body: Bytes,
248) -> Result<Response, KipukaError> {
249 let cms_config = get_cms_est_config(&state)?;
250 if label.disconnected.unwrap_or(state.config.est.disconnected) {
251 return Err(KipukaError::ServiceUnavailable(
252 "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
253 .into(),
254 ));
255 }
256 if state
257 .config
258 .est
259 .labels
260 .iter()
261 .find(|c| c.name == label.label)
262 .is_some_and(|c| {
263 !c.auth_methods.is_empty()
264 && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
265 })
266 {
267 return Err(KipukaError::Forbidden(
268 "CMS authentication is not permitted for this label".into(),
269 ));
270 }
271 let ca_id = label.ca_id();
272
273 tracing::info!(
274 ca_id = %ca_id,
275 label = %label.label,
276 "CMS simplereenroll request"
277 );
278
279 if body.is_empty() {
280 return Err(KipukaError::BadRequest(
281 "empty CMS SignedData request body".into(),
282 ));
283 }
284
285 let truststore = build_truststore(&state);
286 let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
287 crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
288 .await
289 .map_err(KipukaError::Auth)?;
290 let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
291 let identity = &auth_result.identity;
292 state.admit_enrollment(identity, "cms_est").await?;
293
294 tracing::info!(
295 ca_id = %ca_id,
296 identity = %identity,
297 signature_algorithm = %cms_result.signature_algorithm,
298 "CMS signature verified for simplereenroll"
299 );
300
301 let csr_der = &cms_result.payload;
302 if csr_der.len() < 60 {
303 return Err(KipukaError::BadRequest(
304 "extracted CSR is too short to be valid".into(),
305 ));
306 }
307
308 {
314 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der).map_err(|e| {
315 KipukaError::BadRequest(format!("CSR parse failed for POP linking: {e}"))
316 })?;
317
318 let csr_subject_der = csr
319 .certification_request_info
320 .subject
321 .to_der()
322 .map_err(|e| KipukaError::BadRequest(format!("CSR subject encode failed: {e}")))?;
323 let csr_subject = synta_certificate::format_dn(&csr_subject_der);
324
325 let signer_norm = cms_result.signer_subject_dn.trim().to_lowercase();
326 let csr_norm = csr_subject.trim().to_lowercase();
327
328 if signer_norm != csr_norm {
329 tracing::warn!(
330 signer_subject = %cms_result.signer_subject_dn,
331 csr_subject = %csr_subject,
332 "CMS simplereenroll POP linking failed: subject mismatch"
333 );
334 return Err(KipukaError::Forbidden(format!(
335 "POP linking failed: CSR subject '{csr_subject}' does not match \
336 CMS signer subject '{}'",
337 cms_result.signer_subject_dn,
338 )));
339 }
340
341 tracing::info!("CMS simplereenroll POP linking: CSR subject matches signer");
342 }
343
344 if let Err(reason) =
348 crate::auth::enroll_authz::authorize_csr_der(csr_der, identity, &label.enroll_policy())
349 {
350 tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS simplereenroll authorization denied");
351 state
352 .record_audit_event_with_actor(
353 "cms_simplereenroll_denied",
354 identity,
355 &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
356 )
357 .await;
358 return Err(KipukaError::Forbidden(format!(
359 "enrollment not authorized: {reason}"
360 )));
361 }
362
363 let cert_der = match issue_certificate_from_csr(&state, ca_id, csr_der).await {
368 Ok(cert_der) => cert_der,
369 Err(e) => {
370 if matches!(e, KipukaError::BadRequest(_)) {
372 state
373 .record_audit_event_with_actor(
374 "cms_simplereenroll_denied",
375 identity,
376 &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
377 )
378 .await;
379 }
380 return Err(e);
381 }
382 };
383
384 let pkcs7_der =
385 crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
386 let response_body = if cms_config.encrypt_responses {
387 let enc_alg = cms_config
388 .allowed_content_encryption
389 .first()
390 .map(|s| s.as_str())
391 .unwrap_or("AES-256-GCM");
392
393 cms_auth::build_cms_enveloped_data(&pkcs7_der, &cms_result.signer_cert_der, enc_alg)?
394 } else {
395 pkcs7_der
396 };
397
398 state
399 .record_audit_event(
400 "cms_simplereenroll_success",
401 &format!("ca_id={ca_id}, identity={identity}"),
402 )
403 .await;
404
405 build_cms_response(StatusCode::OK, &response_body)
406}
407
408pub async fn post_cms_serverkeygen(
416 label: LabelExtractor,
417 State(state): State<Arc<AppState>>,
418 body: Bytes,
419) -> Result<Response, KipukaError> {
420 let cms_config = get_cms_est_config(&state)?;
421 if label.disconnected.unwrap_or(state.config.est.disconnected) {
422 return Err(KipukaError::ServiceUnavailable(
423 "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
424 .into(),
425 ));
426 }
427 if state
428 .config
429 .est
430 .labels
431 .iter()
432 .find(|c| c.name == label.label)
433 .is_some_and(|c| {
434 !c.auth_methods.is_empty()
435 && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
436 })
437 {
438 return Err(KipukaError::Forbidden(
439 "CMS authentication is not permitted for this label".into(),
440 ));
441 }
442 let ca_id = label.ca_id();
443
444 if !state.config.est.serverkeygen {
445 return Err(KipukaError::Est(
446 "server-side key generation is not enabled".into(),
447 ));
448 }
449
450 tracing::info!(
451 ca_id = %ca_id,
452 label = %label.label,
453 "CMS serverkeygen request"
454 );
455
456 if body.is_empty() {
457 return Err(KipukaError::BadRequest(
458 "empty CMS SignedData request body".into(),
459 ));
460 }
461
462 let truststore = build_truststore(&state);
463 let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
464 crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
465 .await
466 .map_err(KipukaError::Auth)?;
467 let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
468 let identity = &auth_result.identity;
469 state.admit_enrollment(identity, "cms_est").await?;
470
471 tracing::info!(
472 ca_id = %ca_id,
473 identity = %identity,
474 signature_algorithm = %cms_result.signature_algorithm,
475 "CMS signature verified for serverkeygen"
476 );
477
478 let csr_template = &cms_result.payload;
480
481 if let Err(reason) =
485 crate::auth::enroll_authz::authorize_csr_der(csr_template, identity, &label.enroll_policy())
486 {
487 tracing::warn!(ca_id = %ca_id, identity = %identity, %reason, "CMS serverkeygen authorization denied");
488 state
489 .record_audit_event_with_actor(
490 "cms_serverkeygen_denied",
491 identity,
492 &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
493 )
494 .await;
495 return Err(KipukaError::Forbidden(format!(
496 "enrollment not authorized: {reason}"
497 )));
498 }
499
500 let key_type = super::serverkeygen::detect_key_type_from_csr(csr_template);
501 let generated = crate::ca::keygen::generate_key_pair(
502 &key_type,
503 &crate::ca::keygen::KeyGenConfig::default(),
504 )
505 .map_err(|e| KipukaError::Ca(format!("CMS key generation failed: {e}")))?;
506 let new_csr = super::serverkeygen::build_keygen_csr(
507 csr_template,
508 &generated.public_key_der,
509 &generated.private_key_der,
510 )?;
511 let cert_der = issue_certificate_from_csr(&state, ca_id, &new_csr).await?;
512 let certificates = super::cacerts::build_certs_only_pkcs7(&[cert_der])?;
513 let key_and_cert =
514 super::serverkeygen::build_multipart_response(&certificates, &generated.private_key_der);
515
516 let enc_alg = cms_config
519 .allowed_content_encryption
520 .first()
521 .map(|s| s.as_str())
522 .unwrap_or("AES-256-GCM");
523
524 let response_body = cms_auth::build_cms_enveloped_data(
525 key_and_cert.as_bytes(),
526 &cms_result.signer_cert_der,
527 enc_alg,
528 )?;
529
530 state
531 .record_audit_event(
532 "cms_serverkeygen_success",
533 &format!("ca_id={ca_id}, identity={identity}"),
534 )
535 .await;
536
537 build_cms_response(StatusCode::OK, &response_body)
538}
539
540pub async fn post_cms_fullcmc(
548 label: LabelExtractor,
549 State(state): State<Arc<AppState>>,
550 body: Bytes,
551) -> Result<Response, KipukaError> {
552 let cms_config = get_cms_est_config(&state)?;
553 if label.disconnected.unwrap_or(state.config.est.disconnected) {
554 return Err(KipukaError::ServiceUnavailable(
555 "CMS enrollment is unavailable in disconnected mode; use queued EST simpleenroll"
556 .into(),
557 ));
558 }
559 if state
560 .config
561 .est
562 .labels
563 .iter()
564 .find(|c| c.name == label.label)
565 .is_some_and(|c| {
566 !c.auth_methods.is_empty()
567 && !c.auth_methods.contains(&crate::config::EstAuthMethod::Cms)
568 })
569 {
570 return Err(KipukaError::Forbidden(
571 "CMS authentication is not permitted for this label".into(),
572 ));
573 }
574 let ca_id = label.ca_id();
575
576 if !state.config.est.fullcmc {
577 return Err(KipukaError::Est("Full CMC is not enabled".into()));
578 }
579
580 tracing::info!(
581 ca_id = %ca_id,
582 label = %label.label,
583 "CMS fullcmc request"
584 );
585
586 if body.is_empty() {
587 return Err(KipukaError::BadRequest(
588 "empty CMS SignedData request body".into(),
589 ));
590 }
591
592 let truststore = build_truststore(&state);
593 let cms_result = cms_auth::verify_cms_signed_data(&body, &truststore)?;
594 crate::auth::mtls::check_revocation(&cms_result.signer_cert_der, &state)
595 .await
596 .map_err(KipukaError::Auth)?;
597 let auth_result = cms_auth::extract_signer_identity(&cms_result)?;
598 let identity = &auth_result.identity;
599 state.admit_enrollment(identity, "cms_est").await?;
600
601 {
607 let signer_cert = synta_certificate::Certificate::from_der(&cms_result.signer_cert_der)
608 .map_err(|e| {
609 KipukaError::BadRequest(format!(
610 "failed to parse CMS signer certificate for EKU check: {e}"
611 ))
612 })?;
613
614 let has_cmc_ra = signer_cert
615 .tbs_certificate
616 .extensions
617 .as_ref()
618 .and_then(|ext_raw| {
619 synta_certificate::find_extension_value(
620 ext_raw.as_bytes(),
621 synta_certificate::oids::EXTENDED_KEY_USAGE,
622 )
623 })
624 .map(|eku_bytes| {
625 let mut decoder = synta::Decoder::new(eku_bytes, synta::Encoding::Der);
626 let oids: Vec<synta::ObjectIdentifier> = decoder.decode().unwrap_or_default();
627 oids.iter()
628 .any(|oid| oid.to_string() == crate::auth::CMC_RA_EKU_OID)
629 })
630 .unwrap_or(false);
631
632 if !has_cmc_ra {
633 tracing::warn!(
634 identity = %identity,
635 "CMS fullcmc rejected: signer certificate lacks id-kp-cmcRA EKU"
636 );
637 return Err(KipukaError::Forbidden(
638 "CMC signer certificate must have id-kp-cmcRA EKU (1.3.6.1.5.5.7.3.28)".into(),
639 ));
640 }
641
642 tracing::info!("CMS fullcmc: signer has id-kp-cmcRA EKU");
643 }
644
645 tracing::info!(
646 ca_id = %ca_id,
647 identity = %identity,
648 signature_algorithm = %cms_result.signature_algorithm,
649 "CMS signature verified for fullcmc"
650 );
651
652 let cmc_request_der = &cms_result.payload;
655
656 let (pki_data_der, _inner_signer_certs) = synta_cmc::parser::unwrap_signed_cmc(cmc_request_der)
658 .map_err(|e| KipukaError::BadRequest(format!("CMC inner SignedData unwrap failed: {e}")))?;
659
660 if pki_data_der.is_empty() {
661 return Err(KipukaError::BadRequest(
662 "CMC inner SignedData has empty eContent".into(),
663 ));
664 }
665
666 let pki_data = synta_cmc::parser::parse_pki_data(&pki_data_der)
668 .map_err(|e| KipukaError::BadRequest(format!("CMC PKIData parse failed: {e}")))?;
669
670 let transaction_id = synta_cmc::controls::extract_transaction_id(&pki_data.control_sequence);
671 let sender_nonce = synta_cmc::controls::extract_sender_nonce(&pki_data.control_sequence);
672
673 tracing::info!(
674 ca_id = %ca_id,
675 identity = %identity,
676 transaction_id = ?transaction_id,
677 num_requests = pki_data.req_sequence.len(),
678 "CMS fullcmc: PKIData parsed"
679 );
680
681 if pki_data.req_sequence.is_empty() {
682 return Err(KipukaError::BadRequest(
683 "CMC request contains no certification requests".into(),
684 ));
685 }
686
687 let mut issued_certs: Vec<Vec<u8>> = Vec::new();
689 let mut body_part_ids: Vec<u32> = Vec::new();
690 let mut failed_body_part_ids: Vec<u32> = Vec::new();
691
692 let requests = crate::ca::protocol::cmc_requests(&pki_data)?;
693 for entry in &requests {
694 let body_part_id = entry.body_part_id;
695
696 if entry.request_type == 0 {
697 crate::auth::enroll_authz::authorize_csr_der(
698 &entry.der,
699 identity,
700 &label.enroll_policy(),
701 )
702 .map_err(KipukaError::Forbidden)?;
703 match issue_certificate_from_csr(&state, ca_id, &entry.der).await {
704 Ok(cert) => {
705 issued_certs.push(cert);
706 body_part_ids.push(body_part_id);
707 }
708 Err(e) => {
709 tracing::error!(body_part_id, error = %e, "CMS fullcmc: certificate issuance failed");
710 failed_body_part_ids.push(body_part_id);
711 }
712 }
713 } else {
714 tracing::warn!(body_part_id, request_type = ?entry.request_type, "CMS fullcmc: unsupported request type (only PKCS#10 supported)");
715 failed_body_part_ids.push(body_part_id);
716 }
717 }
718
719 let mut resp_builder = synta_cmc::builder::PKIResponseBuilder::new();
721
722 if !body_part_ids.is_empty() {
723 resp_builder = resp_builder
724 .add_status(&body_part_ids)
725 .map_err(|e| KipukaError::Ca(format!("CMC response builder failed (status): {e}")))?;
726 }
727
728 if !failed_body_part_ids.is_empty() {
729 resp_builder = resp_builder
730 .add_failed(
731 &failed_body_part_ids,
732 synta_cmc::status::CMCFailInfo::InternalCaError,
733 )
734 .map_err(|e| {
735 KipukaError::Ca(format!("CMC response builder failed (failed status): {e}"))
736 })?;
737 }
738
739 if let Some(nonce) = &sender_nonce {
741 resp_builder = resp_builder.recipient_nonce(nonce).map_err(|e| {
742 KipukaError::Ca(format!(
743 "CMC response builder failed (recipient nonce): {e}"
744 ))
745 })?;
746 }
747
748 let cmc_response_der = resp_builder
749 .build()
750 .map_err(|e| KipukaError::Ca(format!("CMC PKIResponse build failed: {e}")))?;
751
752 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
753 let ca_cfg = state
754 .config
755 .cas
756 .iter()
757 .find(|c| c.id == ca_id)
758 .ok_or(KipukaError::NotFound)?;
759 let key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
760 let cmc_response_der = crate::ca::protocol::cmc_transaction(&cmc_response_der, transaction_id)?;
761 let cmc_response_der = crate::ca::protocol::signed_cmc(
762 &cmc_response_der,
763 &issued_certs,
764 &ca.cert_der,
765 key.as_signing_key(),
766 &ca.hash_algorithm,
767 )?;
768 let response_body = if cms_config.encrypt_responses {
769 let enc_alg = cms_config
770 .allowed_content_encryption
771 .first()
772 .map(|s| s.as_str())
773 .unwrap_or("AES-256-GCM");
774
775 cms_auth::build_cms_enveloped_data(&cmc_response_der, &cms_result.signer_cert_der, enc_alg)?
776 } else {
777 cmc_response_der
778 };
779
780 state
781 .record_audit_event(
782 "cms_fullcmc_success",
783 &format!("ca_id={ca_id}, identity={identity}"),
784 )
785 .await;
786
787 build_cms_response(StatusCode::OK, &response_body)
788}
789
790fn build_cms_response(status: StatusCode, body: &[u8]) -> Result<Response, KipukaError> {
796 let mut resp = (status, body.to_vec()).into_response();
797 resp.headers_mut().insert(
798 header::CONTENT_TYPE,
799 HeaderValue::from_static(CONTENT_TYPE_PKCS7),
800 );
801 Ok(resp)
802}
803
804async fn issue_certificate_from_csr(
816 state: &AppState,
817 ca_id: &str,
818 csr_der: &[u8],
819) -> Result<Vec<u8>, KipukaError> {
820 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
821
822 let ca_cfg = state
823 .config
824 .cas
825 .iter()
826 .find(|c| c.id == ca_id)
827 .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
828
829 let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
831
832 let profile = crate::ca::issue::EnrollmentProfile {
833 max_validity_days: ca
834 .validity_days
835 .min(crate::ca::issue::cab_forum_max_validity_days()),
836 ..crate::ca::issue::EnrollmentProfile::default()
837 };
838
839 let result = crate::ca::issue::issue_certificate(
840 csr_der,
841 &profile,
842 &ca.cert_der,
843 resolved_key.as_signing_key(),
844 &ca.hash_algorithm,
845 ca.ocsp_url.as_deref(),
846 ca.crl_url.as_deref(),
847 )
848 .map_err(KipukaError::from)?;
849
850 crate::ca::issue::persist_certificate(state, ca_id, &profile.name, &result).await?;
851 Ok(result.certificate_der)
852}