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
65 tracing::info!(
66 ca_id = %ca_id,
67 label = %label.label,
68 identity = %identity,
69 method = ?auth.0.method,
70 "simpleenroll request"
71 );
72
73 let csr_der = decode_est_base64(&body)
75 .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
76
77 validate_csr(&csr_der, &auth.0, &label)?;
79
80 let disconnected = label.disconnected.unwrap_or(state.config.est.disconnected);
82
83 if disconnected {
84 tracing::info!(
86 ca_id = %ca_id,
87 identity = %identity,
88 "disconnected mode: queuing CSR for deferred signing"
89 );
90
91 let retry_after = state.config.est.disconnected_retry_after_secs;
95
96 let mut resp = StatusCode::ACCEPTED.into_response();
97 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
98 resp.headers_mut().insert(header::RETRY_AFTER, hv);
99 }
100
101 state
102 .record_audit_event(
103 "simpleenroll_deferred",
104 &format!("ca_id={ca_id}, identity={identity}"),
105 )
106 .await;
107
108 return Ok(resp);
109 }
110
111 if let Some(ref dogtag_pool) = state.dogtag {
117 let client = dogtag_pool.get_client().map_err(|e| {
118 KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}"))
119 })?;
120
121 use base64::Engine;
123 let csr_b64 = base64::engine::general_purpose::STANDARD.encode(&csr_der);
124 let csr_pem = format!(
125 "-----BEGIN CERTIFICATE REQUEST-----\n{}\n-----END CERTIFICATE REQUEST-----",
126 csr_b64
127 );
128
129 let profile_id = &state
130 .config
131 .dogtag
132 .as_ref()
133 .expect("dogtag config present when pool is set")
134 .profile_id;
135
136 tracing::info!(
137 ca_id = %ca_id,
138 identity = %identity,
139 profile_id = %profile_id,
140 "forwarding enrollment to Dogtag CA"
141 );
142
143 let enroll_result = client
144 .enroll_certificate(&csr_pem, profile_id)
145 .await
146 .map_err(|e| KipukaError::Ca(format!("Dogtag enrollment failed: {e}")))?;
147
148 match enroll_result.status {
149 kipuka_dogtag::EnrollStatus::Complete => {
150 let cert_der = enroll_result.certificate_der.ok_or_else(|| {
151 KipukaError::Ca(
152 "Dogtag returned complete status but no certificate".into(),
153 )
154 })?;
155
156 if let Err(e) = sqlx::query(crate::db::pg_sql(
158 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
159 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
160 ))
161 .bind(&enroll_result.request_id)
162 .bind("(dogtag-issued)")
163 .bind("(dogtag)")
164 .bind("")
165 .bind("")
166 .bind(&cert_der)
167 .bind(ca_id)
168 .bind(profile_id.as_str())
169 .execute(&state.db)
170 .await
171 {
172 tracing::error!(
173 error = %e,
174 request_id = %enroll_result.request_id,
175 "failed to store Dogtag-issued certificate in DB"
176 );
177 }
178
179 let body = encode_est_base64(&cert_der);
180 let mut resp = (StatusCode::OK, body).into_response();
181 resp.headers_mut().insert(
182 header::CONTENT_TYPE,
183 HeaderValue::from_static(content_types::PKCS7_CERTS),
184 );
185 resp.headers_mut().insert(
186 header::HeaderName::from_static("content-transfer-encoding"),
187 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
188 );
189
190 state
191 .record_audit_event(
192 "simpleenroll_success",
193 &format!(
194 "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
195 enroll_result.request_id
196 ),
197 )
198 .await;
199
200 return Ok(resp);
201 }
202 kipuka_dogtag::EnrollStatus::Pending => {
203 tracing::info!(
206 request_id = %enroll_result.request_id,
207 "Dogtag enrollment pending agent approval"
208 );
209
210 let retry_after = state.config.est.disconnected_retry_after_secs;
211 let mut resp = StatusCode::ACCEPTED.into_response();
212 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
213 resp.headers_mut().insert(header::RETRY_AFTER, hv);
214 }
215
216 state
217 .record_audit_event(
218 "simpleenroll_deferred",
219 &format!(
220 "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
221 enroll_result.request_id
222 ),
223 )
224 .await;
225
226 return Ok(resp);
227 }
228 kipuka_dogtag::EnrollStatus::Rejected => {
229 return Err(KipukaError::Ca(format!(
230 "Dogtag CA rejected enrollment: request_id={}",
231 enroll_result.request_id
232 )));
233 }
234 kipuka_dogtag::EnrollStatus::Canceled => {
235 return Err(KipukaError::Ca(format!(
236 "Dogtag enrollment was canceled: request_id={}",
237 enroll_result.request_id
238 )));
239 }
240 }
241 }
242
243 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
247
248 let ca_cfg = state
250 .config
251 .cas
252 .iter()
253 .find(|c| c.id == ca_id)
254 .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
255
256 let ca_key_pem: Vec<u8>;
258 let key_label_owned: String;
259
260 let signing_key = if ca_cfg.is_hsm_backed() {
261 let hsm_ctx = state
262 .hsm
263 .as_ref()
264 .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
265 key_label_owned = parse_pkcs11_object_label(ca_cfg.pkcs11_uri.as_deref().unwrap())
266 .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
267 crate::ca::issue::CaSigningKey::Hsm {
268 context: hsm_ctx,
269 key_label: &key_label_owned,
270 }
271 } else {
272 ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
273 KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
274 })?;
275 crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
276 };
277
278 let profile = crate::ca::issue::EnrollmentProfile {
281 max_validity_days: ca.validity_days.min(398),
282 ..crate::ca::issue::EnrollmentProfile::default()
283 };
284
285 let result = crate::ca::issue::issue_certificate(
287 &csr_der,
288 &profile,
289 &ca.cert_der,
290 signing_key,
291 &ca.hash_algorithm,
292 )
293 .map_err(|e| KipukaError::Ca(format!("certificate issuance failed: {e}")))?;
294
295 let serial = &result.serial_number;
297 let subject_dn = &result.subject_dn;
298 let issuer_dn = synta_certificate::format_dn(
299 &synta_certificate::Certificate::from_der(&ca.cert_der)
300 .map(|c| c.tbs_certificate.subject.0.to_vec())
301 .unwrap_or_default(),
302 );
303 let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
304 let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
305
306 if let Err(e) = sqlx::query(crate::db::pg_sql(
307 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
308 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
309 ))
310 .bind(serial)
311 .bind(subject_dn)
312 .bind(&issuer_dn)
313 .bind(¬_before_str)
314 .bind(¬_after_str)
315 .bind(&result.certificate_der)
316 .bind(ca_id)
317 .bind(&profile.name)
318 .execute(&state.db)
319 .await
320 {
321 tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
323 }
324
325 let cert_der = result.certificate_der;
326
327 let pkcs7_der = cert_der;
331
332 let body = encode_est_base64(&pkcs7_der);
333
334 let mut resp = (StatusCode::OK, body).into_response();
335 resp.headers_mut().insert(
336 header::CONTENT_TYPE,
337 HeaderValue::from_static(content_types::PKCS7_CERTS),
338 );
339 resp.headers_mut().insert(
340 header::HeaderName::from_static("content-transfer-encoding"),
341 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
342 );
343
344 state
345 .record_audit_event(
346 "simpleenroll_success",
347 &format!("ca_id={ca_id}, identity={identity}"),
348 )
349 .await;
350
351 Ok(resp)
352}
353
354fn validate_csr(
372 csr_der: &[u8],
373 _auth: &crate::auth::AuthResult,
374 _label: &LabelExtractor,
375) -> Result<(), KipukaError> {
376 if csr_der.is_empty() {
377 return Err(KipukaError::BadRequest("empty CSR".into()));
378 }
379
380 if csr_der.len() < 60 {
408 return Err(KipukaError::BadRequest(
409 "CSR is too short to be valid".into(),
410 ));
411 }
412
413 Ok(())
414}
415
416pub fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
426 let path = uri
428 .strip_prefix("pkcs11:")
429 .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
430
431 for part in path.split(';') {
433 if let Some((key, value)) = part.split_once('=')
434 && key == "object"
435 {
436 return pkcs11_percent_decode(value);
437 }
438 }
439
440 Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
441}
442
443fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
445 let mut result = Vec::with_capacity(s.len());
446 let bytes = s.as_bytes();
447 let mut i = 0;
448 while i < bytes.len() {
449 if bytes[i] == b'%' && i + 2 < bytes.len() {
450 let hi = hex_digit(bytes[i + 1])
451 .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
452 let lo = hex_digit(bytes[i + 2])
453 .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
454 result.push((hi << 4) | lo);
455 i += 3;
456 } else {
457 result.push(bytes[i]);
458 i += 1;
459 }
460 }
461 String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
462}
463
464fn hex_digit(b: u8) -> Option<u8> {
465 match b {
466 b'0'..=b'9' => Some(b - b'0'),
467 b'a'..=b'f' => Some(b - b'a' + 10),
468 b'A'..=b'F' => Some(b - b'A' + 10),
469 _ => None,
470 }
471}