1use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11use crate::client::DogtagClient;
12use crate::{DogtagError, DogtagResult};
13
14#[derive(Debug, Clone)]
16pub struct EnrollResult {
17 pub request_id: String,
19 pub status: EnrollStatus,
21 pub certificate_der: Option<Vec<u8>>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum EnrollStatus {
29 Complete,
31 Pending,
33 Rejected,
35 Canceled,
37}
38
39#[derive(Debug, Clone)]
41pub struct ServerKeygenResult {
42 pub request_id: String,
43 pub certificate_der: Option<Vec<u8>>,
44 pub pkcs12_b64: Option<String>,
45}
46
47#[derive(Serialize)]
51struct EnrollmentRequest {
52 #[serde(rename = "ProfileID")]
53 profile_id: String,
54 #[serde(rename = "Renewal")]
55 renewal: bool,
56 #[serde(rename = "Input")]
57 input: Vec<ProfileInput>,
58}
59
60#[derive(Serialize)]
61struct ProfileInput {
62 #[serde(rename = "ClassID")]
63 class_id: String,
64 #[serde(rename = "Attribute")]
65 attributes: Vec<ProfileAttribute>,
66}
67
68#[derive(Serialize)]
69struct ProfileAttribute {
70 name: String,
71 #[serde(rename = "Value")]
72 value: String,
73}
74
75#[derive(Deserialize)]
77struct EnrollmentResponse {
78 #[serde(default)]
79 entries: Vec<EnrollmentEntry>,
80}
81
82#[derive(Deserialize)]
83struct EnrollmentEntry {
84 #[serde(rename = "requestId")]
85 request_id: Option<String>,
86 #[serde(rename = "requestStatus")]
87 request_status: Option<String>,
88 #[serde(default, rename = "certId")]
89 cert_id: Option<String>,
90}
91
92#[derive(Deserialize)]
94struct CertDataResponse {
95 #[serde(default, alias = "Encoded")]
96 encoded: Option<String>,
97}
98
99impl DogtagClient {
100 pub async fn enroll_certificate(
118 &self,
119 csr_pem: &str,
120 profile_id: &str,
121 ) -> DogtagResult<EnrollResult> {
122 self.enroll_certificate_with_type(csr_pem, profile_id, "pkcs10")
123 .await
124 }
125
126 pub async fn enroll_certificate_with_type(
128 &self,
129 request_data: &str,
130 profile_id: &str,
131 request_type: &str,
132 ) -> DogtagResult<EnrollResult> {
133 debug!(
134 profile = profile_id,
135 request_type, "Submitting enrollment request"
136 );
137
138 let login_resp = self
141 .post_json("/ca/rest/account/login", &serde_json::json!({}))
142 .await;
143 match &login_resp {
144 Ok(r) if r.status().is_success() => {
145 tracing::info!("Dogtag session login succeeded");
146 }
147 Ok(r) => {
148 tracing::warn!(
149 status = r.status().as_u16(),
150 "Dogtag session login returned non-200 (continuing)"
151 );
152 }
153 Err(e) => {
154 tracing::warn!(error = %e, "Dogtag session login failed (continuing without session)");
155 }
156 }
157
158 let request = EnrollmentRequest {
159 profile_id: profile_id.to_owned(),
160 renewal: false,
161 input: vec![
162 ProfileInput {
163 class_id: "certReqInputImpl".to_owned(),
164 attributes: vec![
165 ProfileAttribute {
166 name: "cert_request_type".to_owned(),
167 value: request_type.to_owned(),
168 },
169 ProfileAttribute {
170 name: "cert_request".to_owned(),
171 value: request_data.to_owned(),
172 },
173 ],
174 },
175 ProfileInput {
176 class_id: "submitterInfoInputImpl".to_owned(),
177 attributes: vec![
178 ProfileAttribute {
179 name: "requestor_name".to_owned(),
180 value: "kipuka EST Server".to_owned(),
181 },
182 ProfileAttribute {
183 name: "requestor_email".to_owned(),
184 value: String::new(),
185 },
186 ProfileAttribute {
187 name: "requestor_phone".to_owned(),
188 value: String::new(),
189 },
190 ],
191 },
192 ],
193 };
194
195 let resp = self.post_json("/ca/rest/certrequests", &request).await?;
196 let enrollment: EnrollmentResponse = Self::json_response(resp).await?;
197
198 let entry = enrollment
199 .entries
200 .into_iter()
201 .next()
202 .ok_or_else(|| DogtagError::ParseError("Empty enrollment response".into()))?;
203
204 let request_id = entry
205 .request_id
206 .ok_or_else(|| DogtagError::ParseError("Missing request_id in response".into()))?;
207
208 let status = match entry.request_status.as_deref() {
209 Some("complete") => EnrollStatus::Complete,
210 Some("pending") => EnrollStatus::Pending,
211 Some("rejected") => EnrollStatus::Rejected,
212 Some("canceled") => EnrollStatus::Canceled,
213 Some(other) => {
214 return Err(DogtagError::ParseError(format!(
215 "Unknown request status: {other}"
216 )));
217 }
218 None => {
219 return Err(DogtagError::ParseError(
220 "Missing request_status in response".into(),
221 ));
222 }
223 };
224
225 let (status, certificate_der) = if status == EnrollStatus::Pending {
231 tracing::info!(request_id = %request_id, "auto-approving pending enrollment");
232
233 let review_url = format!("/ca/rest/agent/certrequests/{request_id}");
236 let review_resp = self.get(&review_url).await;
237
238 let approve_resp = match review_resp {
239 Ok(resp) => {
240 let resp_status = resp.status();
241 if !resp_status.is_success() {
242 let body = crate::bounded_text(resp).await?;
243 tracing::error!(
244 status = resp_status.as_u16(),
245 body_preview = %crate::truncate_str(&body, 200),
246 "GET review form failed"
247 );
248 self.post_json(
249 &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
250 &serde_json::json!({}),
251 )
252 .await
253 } else {
254 let review_body: serde_json::Value = match Self::json_response(resp).await {
255 Ok(body) => {
256 tracing::info!("review form retrieved, posting approval");
257 body
258 }
259 Err(e) => {
260 tracing::error!(error = %e, "review form not valid JSON (missing Accept header?)");
261 serde_json::json!({})
262 }
263 };
264 self.post_json(
265 &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
266 &review_body,
267 )
268 .await
269 }
270 }
271 Err(e) => {
272 tracing::error!(error = %e, "failed to GET review form");
273 self.post_json(
274 &format!("/ca/rest/agent/certrequests/{request_id}/approve"),
275 &serde_json::json!({}),
276 )
277 .await
278 }
279 };
280
281 match approve_resp {
282 Ok(resp) => {
283 let resp_status = resp.status();
284 if !resp_status.is_success() {
285 let body = crate::bounded_text(resp).await?;
286 tracing::error!(
287 status = resp_status.as_u16(),
288 body_preview = %crate::truncate_str(&body, 300),
289 "approve POST returned error"
290 );
291 (EnrollStatus::Pending, None)
292 } else {
293 let approved: serde_json::Value = match serde_json::from_slice(
296 &crate::bounded_bytes(resp).await?,
297 ) {
298 Ok(v) => v,
299 Err(e) => {
300 tracing::warn!(error = %e, "approve response not JSON, polling request status");
301 let poll = self.get_enrollment_status(&request_id).await?;
302 let status = poll.status;
303 let cert = poll.certificate_der;
304 return Ok(EnrollResult {
305 request_id: request_id.to_owned(),
306 status,
307 certificate_der: cert,
308 });
309 }
310 };
311 let status_str = approved
312 .get("requestStatus")
313 .or_else(|| approved.get("RequestStatus"))
314 .and_then(|v| v.as_str())
315 .unwrap_or("unknown");
316 let cert_id = approved
317 .get("certId")
318 .or_else(|| approved.get("CertId"))
319 .and_then(|v| v.as_str())
320 .map(String::from);
321
322 tracing::info!(request_id = %request_id, status = %status_str, "approve result");
323
324 let new_status = match status_str {
325 "complete" => EnrollStatus::Complete,
326 "pending" => EnrollStatus::Pending,
327 other => {
328 tracing::warn!(request_id = %request_id, status = %other, "unexpected status after approval");
329 EnrollStatus::Pending
330 }
331 };
332 let cert = if new_status == EnrollStatus::Complete {
333 if let Some(ref cid) = cert_id {
334 Some(self.fetch_cert_der(cid).await?)
335 } else {
336 let alt_id = approved
338 .get("certificateId")
339 .or_else(|| approved.get("CertificateID"))
340 .and_then(|v| v.as_str());
341 if let Some(aid) = alt_id {
342 Some(self.fetch_cert_der(aid).await?)
343 } else {
344 tracing::warn!(
345 "approve succeeded but no cert ID found in response"
346 );
347 None
348 }
349 }
350 } else {
351 None
352 };
353 (new_status, cert)
354 }
355 }
356 Err(e) => {
357 tracing::error!(error = %e, "auto-approve POST failed");
358 (EnrollStatus::Pending, None)
359 }
360 }
361 } else if status == EnrollStatus::Complete {
362 let cert = if let Some(cert_id) = &entry.cert_id {
363 Some(self.fetch_cert_der(cert_id).await?)
364 } else {
365 None
366 };
367 (status, cert)
368 } else {
369 (status, None)
370 };
371
372 Ok(EnrollResult {
373 request_id,
374 status,
375 certificate_der,
376 })
377 }
378
379 pub async fn server_keygen(
385 &self,
386 profile_id: &str,
387 subject_uid: &str,
388 subject_cn: &str,
389 p12_password: &str,
390 key_type: &str,
391 key_size: u32,
392 ) -> DogtagResult<ServerKeygenResult> {
393 debug!(
394 profile_id,
395 subject_cn, key_type, key_size, "Server-side key generation via SSKG profile"
396 );
397
398 match self.get("/ca/v2/account/login").await {
400 Ok(r) if r.status().is_success() => {
401 tracing::info!("CA session login succeeded (v2 GET)");
402 }
403 _ => {
404 let _ = self
405 .post_json("/ca/rest/account/login", &serde_json::json!({}))
406 .await;
407 }
408 }
409
410 let request = EnrollmentRequest {
411 profile_id: profile_id.to_owned(),
412 renewal: false,
413 input: vec![
414 ProfileInput {
415 class_id: "serverKeygenInputImpl".to_owned(),
416 attributes: vec![
417 ProfileAttribute {
418 name: "serverSideKeygenP12Passwd".to_owned(),
419 value: p12_password.to_owned(),
420 },
421 ProfileAttribute {
422 name: "keyType".to_owned(),
423 value: key_type.to_owned(),
424 },
425 ProfileAttribute {
426 name: "keySize".to_owned(),
427 value: key_size.to_string(),
428 },
429 ],
430 },
431 ProfileInput {
432 class_id: "subjectNameInputImpl".to_owned(),
433 attributes: vec![
434 ProfileAttribute {
435 name: "sn_uid".to_owned(),
436 value: subject_uid.to_owned(),
437 },
438 ProfileAttribute {
439 name: "sn_cn".to_owned(),
440 value: subject_cn.to_owned(),
441 },
442 ],
443 },
444 ProfileInput {
445 class_id: "submitterInfoInputImpl".to_owned(),
446 attributes: vec![
447 ProfileAttribute {
448 name: "requestor_name".to_owned(),
449 value: "kipuka EST Server".to_owned(),
450 },
451 ProfileAttribute {
452 name: "requestor_email".to_owned(),
453 value: String::new(),
454 },
455 ProfileAttribute {
456 name: "requestor_phone".to_owned(),
457 value: String::new(),
458 },
459 ],
460 },
461 ],
462 };
463
464 let resp = self.post_json("/ca/rest/certrequests", &request).await?;
465 let resp_body: serde_json::Value = Self::json_response(resp).await?;
466
467 let entries = resp_body
469 .get("entries")
470 .and_then(|v| v.as_array())
471 .ok_or_else(|| {
472 DogtagError::ParseError("No entries in server-keygen response".into())
473 })?;
474
475 let entry = entries.first().ok_or_else(|| {
476 DogtagError::ParseError("Empty entries in server-keygen response".into())
477 })?;
478
479 let status = entry
480 .get("requestStatus")
481 .and_then(|v| v.as_str())
482 .unwrap_or("unknown")
483 .to_owned();
484
485 let request_id = entry
486 .get("requestID")
487 .or_else(|| entry.get("requestId"))
488 .and_then(|v| v.as_str())
489 .unwrap_or("")
490 .to_owned();
491
492 if status == "pending" && !request_id.is_empty() {
501 tracing::info!(request_id = %request_id, "SSKG: approving via agent servlet (P12 in response)");
502
503 let review_url = format!("/ca/agent/ca/profileReview?requestId={request_id}&xml=true");
504 let review_resp = self.get_raw(&review_url).await?;
505 let review_xml = crate::bounded_text(review_resp).await?;
506
507 if review_xml.contains("errorReason") && !review_xml.contains("<defId>") {
508 return Err(DogtagError::EnrollmentRejected {
509 reason: format!(
510 "SSKG review failed: {}",
511 crate::truncate_str(&review_xml, 200)
512 ),
513 });
514 }
515
516 let form_body = build_approval_form(&review_xml, &request_id);
517 tracing::info!(
518 form_len = form_body.len(),
519 "SSKG: posting approval to profileProcess"
520 );
521
522 let approve_resp = self
523 .post_bytes(
524 "/ca/agent/ca/profileProcess",
525 form_body.into_bytes(),
526 "application/x-www-form-urlencoded",
527 )
528 .await?;
529
530 let resp_status = approve_resp.status();
531 let p12_bytes = crate::bounded_bytes(approve_resp).await?;
532
533 if !resp_status.is_success() || p12_bytes.len() < 100 {
534 let preview = String::from_utf8_lossy(&p12_bytes[..p12_bytes.len().min(300)]);
535 return Err(DogtagError::EnrollmentRejected {
536 reason: format!("SSKG approval failed (HTTP {resp_status}): {preview}"),
537 });
538 }
539
540 tracing::info!(
541 p12_len = p12_bytes.len(),
542 "SSKG: P12 received from profileProcess"
543 );
544
545 use base64::Engine;
546 return Ok(ServerKeygenResult {
547 request_id,
548 certificate_der: None,
549 pkcs12_b64: Some(base64::engine::general_purpose::STANDARD.encode(&p12_bytes)),
550 });
551 }
552
553 if status != "complete" {
554 let error_msg = entry
555 .get("errorMessage")
556 .and_then(|v| v.as_str())
557 .unwrap_or("unknown error");
558 return Err(DogtagError::EnrollmentRejected {
559 reason: format!("status={status}: {error_msg}"),
560 });
561 }
562
563 let cert_id_str = entry
565 .get("certId")
566 .and_then(|v| v.as_str())
567 .map(|s| s.to_owned())
568 .or_else(|| {
569 entry
570 .get("certURL")
571 .and_then(|u| u.as_str())
572 .and_then(|u| u.rsplit('/').next())
573 .map(|s| s.to_owned())
574 })
575 .unwrap_or_default();
576 let cert_id = cert_id_str.as_str();
577
578 let certificate_der = if !cert_id.is_empty() {
579 Some(self.fetch_cert_der(cert_id).await?)
580 } else {
581 None
582 };
583
584 Ok(ServerKeygenResult {
585 request_id,
586 certificate_der,
587 pkcs12_b64: None,
588 })
589 }
590
591 pub async fn get_enrollment_status(&self, request_id: &str) -> DogtagResult<EnrollResult> {
598 debug!(request_id, "Checking enrollment status");
599
600 let resp = self
601 .get(&format!("/ca/rest/certrequests/{request_id}"))
602 .await?;
603 let entry: EnrollmentEntry = Self::json_response(resp).await?;
604
605 let status = match entry.request_status.as_deref() {
606 Some("complete") => EnrollStatus::Complete,
607 Some("pending") => EnrollStatus::Pending,
608 Some("rejected") => EnrollStatus::Rejected,
609 Some("canceled") => EnrollStatus::Canceled,
610 other => {
611 return Err(DogtagError::ParseError(format!(
612 "Unknown request status: {other:?}"
613 )));
614 }
615 };
616
617 let certificate_der = if status == EnrollStatus::Complete {
618 if let Some(cert_id) = &entry.cert_id {
619 Some(self.fetch_cert_der(cert_id).await?)
620 } else {
621 None
622 }
623 } else {
624 None
625 };
626
627 Ok(EnrollResult {
628 request_id: request_id.to_owned(),
629 status,
630 certificate_der,
631 })
632 }
633
634 async fn fetch_cert_der(&self, cert_id: &str) -> DogtagResult<Vec<u8>> {
636 let resp = self.get(&format!("/ca/rest/certs/{cert_id}")).await?;
637 let status = resp.status();
638 let body = crate::bounded_text(resp).await?;
639
640 if !status.is_success() {
641 return Err(DogtagError::ApiError {
642 status: status.as_u16(),
643 body,
644 });
645 }
646
647 if let Ok(cert_data) = serde_json::from_str::<CertDataResponse>(&body)
649 && let Some(ref encoded) = cert_data.encoded
650 {
651 return Self::decode_pem_to_der(encoded);
652 }
653
654 if body.contains("-----BEGIN CERTIFICATE-----") {
656 tracing::debug!("response is raw PEM, extracting directly");
657 return Self::decode_pem_to_der(&body);
658 }
659
660 if let Some(pem) = Self::extract_cert_from_xml(&body) {
662 tracing::debug!("extracted certificate from XML response");
663 return Self::decode_pem_to_der(&pem);
664 }
665
666 Err(DogtagError::ParseError(format!(
667 "cannot extract certificate from response; body: {}",
668 crate::truncate_str(&body, 500)
669 )))
670 }
671
672 fn decode_pem_to_der(pem: &str) -> DogtagResult<Vec<u8>> {
673 let b64: String = pem.lines().filter(|l| !l.starts_with("-----")).collect();
674 use base64::Engine;
675 base64::engine::general_purpose::STANDARD
676 .decode(&b64)
677 .map_err(|e| DogtagError::ParseError(format!("invalid base64 in certificate: {e}")))
678 }
679
680 fn extract_cert_from_xml(xml: &str) -> Option<String> {
681 for (open, close) in [("<Encoded>", "</Encoded>"), ("<encoded>", "</encoded>")] {
682 if let Some(start) = xml.find(open) {
683 let start = start + open.len();
684 if let Some(end) = xml[start..].find(close) {
685 return Some(xml[start..start + end].to_owned());
686 }
687 }
688 }
689 None
690 }
691}
692
693fn build_approval_form(xml: &str, request_id: &str) -> String {
699 let mut pairs: Vec<(String, String)> = Vec::new();
700
701 for (id_tag, val_tag) in [("defId", "defVal"), ("inputId", "inputVal")] {
703 let open_id = format!("<{id_tag}>");
704 let close_id = format!("</{id_tag}>");
705 let open_val = format!("<{val_tag}>");
706 let close_val = format!("</{val_tag}>");
707
708 let mut pos = 0;
709 while let Some(id_start) = xml[pos..].find(&open_id) {
710 let id_start = pos + id_start + open_id.len();
711 let Some(id_end) = xml[id_start..].find(&close_id) else {
712 break;
713 };
714 let name = xml[id_start..id_start + id_end].trim().to_owned();
715
716 let search_from = id_start + id_end;
717 let value = if let Some(val_start) = xml[search_from..].find(&open_val) {
718 let val_start = search_from + val_start + open_val.len();
719 if let Some(val_end) = xml[val_start..].find(&close_val) {
720 xml[val_start..val_start + val_end].trim().to_owned()
721 } else {
722 String::new()
723 }
724 } else {
725 String::new()
726 };
727
728 if !name.is_empty() {
729 pairs.push((name, value));
730 }
731 pos = id_start + id_end;
732 }
733 }
734
735 pairs.push(("requestId".into(), request_id.to_owned()));
736 pairs.push(("op".into(), "approve".into()));
737 pairs.push(("submit".into(), "submit".into()));
738 pairs.push(("xml".into(), "true".into()));
739
740 pairs
741 .iter()
742 .map(|(k, v)| format!("{}={}", urlencoded(k), urlencoded(v)))
743 .collect::<Vec<_>>()
744 .join("&")
745}
746
747fn urlencoded(s: &str) -> String {
748 let mut out = String::with_capacity(s.len() * 2);
749 for b in s.bytes() {
750 match b {
751 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
752 out.push(b as char);
753 }
754 b' ' => out.push('+'),
755 _ => {
756 out.push('%');
757 out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
758 out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
759 }
760 }
761 }
762 out
763}