1use std::sync::Arc;
11
12use axum::body::Bytes;
13use axum::extract::State;
14use axum::http::{HeaderValue, StatusCode, header};
15use axum::response::{IntoResponse, Response};
16
17use crate::auth::EstAuth;
18use crate::error::KipukaError;
19use crate::routes::LabelExtractor;
20use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
21use crate::state::AppState;
22
23pub async fn post_simpleenroll(
57 auth: EstAuth,
58 label: LabelExtractor,
59 State(state): State<Arc<AppState>>,
60 body: Bytes,
61) -> Result<Response, KipukaError> {
62 let ca_id = label.ca_id();
63 let identity = &auth.0.identity;
64 state.admit_enrollment(identity, "simpleenroll").await?;
65
66 tracing::info!(
67 ca_id = %ca_id,
68 label = %label.label,
69 identity = %identity,
70 method = ?auth.0.method,
71 "simpleenroll request"
72 );
73
74 let csr_der = decode_est_base64(&body)
76 .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
77
78 if let Err(e) = validate_csr(&csr_der, &auth.0, &label) {
84 if matches!(e, KipukaError::Forbidden(_) | KipukaError::BadRequest(_)) {
85 state
86 .record_audit_event_with_actor(
87 "simpleenroll_denied",
88 identity,
89 &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
90 )
91 .await;
92 }
93 return Err(e);
94 }
95
96 let disconnected = label.disconnected.unwrap_or(state.config.est.disconnected);
98
99 if disconnected {
100 tracing::info!(
102 ca_id = %ca_id,
103 identity = %identity,
104 "disconnected mode: queuing CSR for deferred signing"
105 );
106
107 let subject_dn = synta_certificate::csr::CertificationRequest::from_der(&csr_der)
109 .ok()
110 .map(|csr| {
111 synta_certificate::format_dn(
112 &csr.certification_request_info
113 .subject
114 .to_der()
115 .unwrap_or_default(),
116 )
117 });
118
119 let auth_method = format!("{:?}", auth.0.method);
121 if let Err(e) = sqlx::query(crate::db::pg_sql(
122 "INSERT INTO pending_csrs (csr_der, ca_id, subject_dn, identity, auth_method) \
123 VALUES (?, ?, ?, ?, ?)",
124 ))
125 .bind(&csr_der)
126 .bind(ca_id)
127 .bind(&subject_dn)
128 .bind(identity)
129 .bind(&auth_method)
130 .execute(&state.db)
131 .await
132 {
133 tracing::error!(error = %e, "failed to persist CSR for deferred signing");
134 return Err(KipukaError::Ca(format!(
135 "failed to persist CSR for deferred signing: {e}"
136 )));
137 }
138
139 let retry_after = state.config.est.disconnected_retry_after_secs;
140
141 let mut resp = StatusCode::ACCEPTED.into_response();
142 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
143 resp.headers_mut().insert(header::RETRY_AFTER, hv);
144 }
145
146 state
147 .record_audit_event(
148 "simpleenroll_deferred",
149 &format!("ca_id={ca_id}, identity={identity}"),
150 )
151 .await;
152
153 return Ok(resp);
154 }
155
156 if let Some(ref dogtag_pool) = state.dogtag {
162 let client = dogtag_pool
163 .get_client()
164 .map_err(|e| KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}")))?;
165
166 use base64::Engine;
168 let csr_b64 = base64::engine::general_purpose::STANDARD.encode(&csr_der);
169 let csr_pem = format!(
170 "-----BEGIN CERTIFICATE REQUEST-----\n{csr_b64}\n-----END CERTIFICATE REQUEST-----"
171 );
172
173 let profile_id = &state
174 .config
175 .dogtag
176 .as_ref()
177 .expect("dogtag config present when pool is set")
178 .profile_id;
179
180 tracing::info!(
181 ca_id = %ca_id,
182 identity = %identity,
183 profile_id = %profile_id,
184 "forwarding enrollment to Dogtag CA"
185 );
186
187 let enroll_result = client
188 .enroll_certificate(&csr_pem, profile_id)
189 .await
190 .map_err(|e| KipukaError::Ca(format!("Dogtag enrollment failed: {e}")))?;
191
192 match enroll_result.status {
193 kipuka_dogtag::EnrollStatus::Complete => {
194 let cert_der = enroll_result.certificate_der.ok_or_else(|| {
195 KipukaError::Ca("Dogtag returned complete status but no certificate".into())
196 })?;
197
198 if let Err(e) = sqlx::query(crate::db::pg_sql(
200 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
201 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
202 ))
203 .bind(&enroll_result.request_id)
204 .bind("(dogtag-issued)")
205 .bind("(dogtag)")
206 .bind("")
207 .bind("")
208 .bind(&cert_der)
209 .bind(ca_id)
210 .bind(profile_id.as_str())
211 .execute(&state.db)
212 .await
213 {
214 tracing::error!(
215 error = %e,
216 request_id = %enroll_result.request_id,
217 "failed to store Dogtag-issued certificate in DB"
218 );
219 }
220
221 let pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(
222 std::slice::from_ref(&cert_der),
223 )?;
224 let body = encode_est_base64(&pkcs7_der);
225 let mut resp = (StatusCode::OK, body).into_response();
226 resp.headers_mut().insert(
227 header::CONTENT_TYPE,
228 HeaderValue::from_static(content_types::PKCS7_CERTS),
229 );
230 resp.headers_mut().insert(
231 header::HeaderName::from_static("content-transfer-encoding"),
232 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
233 );
234
235 state
236 .record_audit_event(
237 "simpleenroll_success",
238 &format!(
239 "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
240 enroll_result.request_id
241 ),
242 )
243 .await;
244
245 return Ok(resp);
246 }
247 kipuka_dogtag::EnrollStatus::Pending => {
248 tracing::info!(
251 request_id = %enroll_result.request_id,
252 "Dogtag enrollment pending agent approval"
253 );
254
255 let retry_after = state.config.est.disconnected_retry_after_secs;
256 let mut resp = StatusCode::ACCEPTED.into_response();
257 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
258 resp.headers_mut().insert(header::RETRY_AFTER, hv);
259 }
260
261 state
262 .record_audit_event(
263 "simpleenroll_deferred",
264 &format!(
265 "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
266 enroll_result.request_id
267 ),
268 )
269 .await;
270
271 return Ok(resp);
272 }
273 kipuka_dogtag::EnrollStatus::Rejected => {
274 return Err(KipukaError::Ca(format!(
275 "Dogtag CA rejected enrollment: request_id={}",
276 enroll_result.request_id
277 )));
278 }
279 kipuka_dogtag::EnrollStatus::Canceled => {
280 return Err(KipukaError::Ca(format!(
281 "Dogtag enrollment was canceled: request_id={}",
282 enroll_result.request_id
283 )));
284 }
285 }
286 }
287
288 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
292
293 let ca_cfg = state
295 .config
296 .cas
297 .iter()
298 .find(|c| c.id == ca_id)
299 .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
300
301 let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
303
304 let profile = crate::ca::issue::EnrollmentProfile {
307 max_validity_days: ca
308 .validity_days
309 .min(crate::ca::issue::cab_forum_max_validity_days()),
310 ..crate::ca::issue::EnrollmentProfile::default()
311 };
312
313 let result = match crate::ca::issue::issue_certificate(
315 &csr_der,
316 &profile,
317 &ca.cert_der,
318 resolved_key.as_signing_key(),
319 &ca.hash_algorithm,
320 ca.ocsp_url.as_deref(),
321 ca.crl_url.as_deref(),
322 ) {
323 Ok(result) => result,
324 Err(e) => {
325 let err = KipukaError::from(e);
330 if matches!(err, KipukaError::BadRequest(_)) {
331 state
332 .record_audit_event_with_actor(
333 "simpleenroll_denied",
334 identity,
335 &format!("ca_id={ca_id}, identity={identity}, reason={err}"),
336 )
337 .await;
338 }
339 return Err(err);
340 }
341 };
342
343 let serial = &result.serial_number;
345 let subject_dn = &result.subject_dn;
346 let issuer_dn = synta_certificate::format_dn(
347 &synta_certificate::Certificate::from_der(&ca.cert_der)
348 .map(|c| c.tbs_certificate.subject.0.to_vec())
349 .unwrap_or_default(),
350 );
351 let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
352 let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
353
354 if let Err(e) = sqlx::query(crate::db::pg_sql(
355 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
356 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
357 ))
358 .bind(serial)
359 .bind(subject_dn)
360 .bind(&issuer_dn)
361 .bind(¬_before_str)
362 .bind(¬_after_str)
363 .bind(&result.certificate_der)
364 .bind(ca_id)
365 .bind(&profile.name)
366 .execute(&state.db)
367 .await
368 {
369 tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
371 }
372
373 let cert_der = result.certificate_der;
374
375 let pkcs7_der =
377 crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
378
379 let body = encode_est_base64(&pkcs7_der);
380
381 let mut resp = (StatusCode::OK, body).into_response();
382 resp.headers_mut().insert(
383 header::CONTENT_TYPE,
384 HeaderValue::from_static(content_types::PKCS7_CERTS),
385 );
386 resp.headers_mut().insert(
387 header::HeaderName::from_static("content-transfer-encoding"),
388 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
389 );
390
391 state
392 .record_audit_event(
393 "simpleenroll_success",
394 &format!("ca_id={ca_id}, identity={identity}"),
395 )
396 .await;
397
398 Ok(resp)
399}
400
401fn validate_csr(
419 csr_der: &[u8],
420 auth: &crate::auth::AuthResult,
421 label: &LabelExtractor,
422) -> Result<(), KipukaError> {
423 if csr_der.is_empty() {
424 return Err(KipukaError::BadRequest("empty CSR".into()));
425 }
426
427 if csr_der.len() < 60 {
429 return Err(KipukaError::BadRequest(
430 "CSR is too short to be valid".into(),
431 ));
432 }
433
434 let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
436 .map_err(|e| KipukaError::BadRequest(format!("CSR parse failed: {e}")))?;
437
438 tracing::debug!(csr_len = csr_der.len(), "CSR parsed successfully");
439
440 {
448 use synta_certificate::SignatureVerifier;
449
450 let cri_der = csr
452 .certification_request_info
453 .to_der()
454 .map_err(|e| KipukaError::BadRequest(format!("CSR CRI encode failed: {e}")))?;
455
456 let sig_alg_der = csr
458 .signature_algorithm
459 .to_der()
460 .map_err(|e| KipukaError::BadRequest(format!("CSR sig alg encode failed: {e}")))?;
461
462 let signature_bits = csr.signature.as_bytes();
464
465 let spki_der = csr
468 .certification_request_info
469 .subject_pkinfo
470 .to_der()
471 .map_err(|e| KipukaError::BadRequest(format!("CSR SPKI encode failed: {e}")))?;
472
473 let verifier = synta_certificate::default_signature_verifier();
474 verifier
475 .verify_certificate_signature(&cri_der, &sig_alg_der, signature_bits, &spki_der)
476 .map_err(|e| {
477 tracing::warn!(error = %e, "CSR self-signature verification failed");
478 KipukaError::BadRequest(format!("CSR self-signature verification failed: {e}"))
479 })?;
480
481 tracing::debug!("CSR self-signature verified");
482 }
483
484 {
492 let policy = label.enroll_policy();
493 if let Err(reason) = crate::auth::enroll_authz::authorize_csr(&csr, &auth.identity, &policy)
494 {
495 tracing::warn!(
496 identity = %auth.identity,
497 label = %label.label,
498 reason = %reason,
499 "enrollment rejected: CSR not authorized for requester"
500 );
501 return Err(KipukaError::Forbidden(format!(
502 "enrollment not authorized: {reason}"
503 )));
504 }
505 tracing::debug!("enrollment authorization passed");
506 }
507
508 {
514 let spki = &csr.certification_request_info.subject_pkinfo;
515 let key_bit_len = spki.subject_public_key.bit_len();
516
517 let pk_info = synta_certificate::decode_public_key_info(
518 &spki.algorithm.algorithm,
519 spki.algorithm.parameters.as_ref(),
520 spki.subject_public_key.as_bytes(),
521 key_bit_len,
522 );
523
524 match &pk_info {
525 synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
526 tracing::debug!(algorithm = "RSA", key_bits = bit_count, "CSR key info");
527 if *bit_count < 2048 {
528 return Err(KipukaError::BadRequest(format!(
529 "RSA key too small: {bit_count}-bit (minimum 2048-bit required)"
530 )));
531 }
532 }
533 synta_certificate::PublicKeyInfo::Ec {
534 bit_count,
535 curve_nist_name,
536 ..
537 } => {
538 let curve = curve_nist_name.unwrap_or("unknown");
539 tracing::debug!(
540 algorithm = "EC",
541 curve = curve,
542 key_bits = bit_count,
543 "CSR key info"
544 );
545 if *bit_count < 256 {
546 return Err(KipukaError::BadRequest(format!(
547 "EC key too small: {curve} {bit_count}-bit (minimum P-256 required)"
548 )));
549 }
550 }
551 synta_certificate::PublicKeyInfo::Unknown {
552 alg_name,
553 bit_count,
554 ..
555 } => {
556 tracing::debug!(
559 algorithm = %alg_name,
560 key_bits = bit_count,
561 "CSR key: unknown algorithm, skipping size check"
562 );
563 }
564 }
565 }
566
567 if let Some(ref attrs) = csr.certification_request_info.attributes {
574 for attr in attrs.elements() {
575 let oid_components = attr.attr_type.components();
576 if oid_components == synta_certificate::oids::PKCS9_EXTENSION_REQUEST {
577 tracing::debug!("CSR contains extensionRequest attribute");
578 let ext_count = attr.attr_values.len();
581 tracing::debug!(
582 extension_values = ext_count,
583 "CSR extensionRequest attribute value count"
584 );
585 } else if oid_components == synta_certificate::oids::PKCS9_CHALLENGE_PASSWORD {
586 tracing::debug!("CSR contains challengePassword attribute");
587 } else {
588 let oid_str: String = oid_components
589 .iter()
590 .map(|n| n.to_string())
591 .collect::<Vec<_>>()
592 .join(".");
593 tracing::debug!(oid = %oid_str, "CSR contains unknown attribute");
594 }
595 }
596 }
597
598 Ok(())
599}
600
601