Skip to main content

kipuka_dogtag/
enroll.rs

1//! Certificate enrollment via Dogtag CA REST API.
2//!
3//! Implements profile-based certificate enrollment using PKCS#10 CSRs.
4//! Supports both synchronous enrollment (certificate returned immediately)
5//! and asynchronous enrollment (request ID returned for later polling),
6//! corresponding to EST's standard and Disconnected modes respectively.
7
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11use crate::client::DogtagClient;
12use crate::{DogtagError, DogtagResult};
13
14/// Result of a certificate enrollment request.
15#[derive(Debug, Clone)]
16pub struct EnrollResult {
17    /// Dogtag certificate request ID.
18    pub request_id: String,
19    /// Current status of the enrollment request.
20    pub status: EnrollStatus,
21    /// DER-encoded certificate, if issued synchronously.
22    pub certificate_der: Option<Vec<u8>>,
23}
24
25/// Status of a certificate enrollment request.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum EnrollStatus {
29    /// Request completed, certificate issued.
30    Complete,
31    /// Request is pending agent approval.
32    Pending,
33    /// Request was rejected.
34    Rejected,
35    /// Request was canceled.
36    Canceled,
37}
38
39/// Enrollment request body sent to Dogtag CA.
40///
41/// Maps to the JSON payload for `POST /ca/rest/certrequests`.
42#[derive(Serialize)]
43#[serde(rename_all = "PascalCase")]
44struct EnrollmentRequest {
45    /// Enrollment profile ID (e.g., "caServerCert").
46    profile_id: String,
47    /// Renewal flag (false for new enrollments).
48    renewal: bool,
49    /// Input containing the PKCS#10 CSR.
50    input: Vec<ProfileInput>,
51}
52
53#[derive(Serialize)]
54#[serde(rename_all = "PascalCase")]
55struct ProfileInput {
56    /// Class name identifying the input type.
57    class_id: String,
58    /// Input attributes (the CSR content).
59    attributes: Vec<ProfileAttribute>,
60}
61
62#[derive(Serialize)]
63#[serde(rename_all = "PascalCase")]
64struct ProfileAttribute {
65    /// Attribute name (e.g., "cert_request").
66    name: String,
67    /// Attribute value (the PEM-encoded CSR).
68    value: String,
69}
70
71/// Response from Dogtag certificate enrollment.
72#[derive(Deserialize)]
73#[serde(rename_all = "PascalCase")]
74struct EnrollmentResponse {
75    /// List of enrollment results (typically one).
76    #[serde(default)]
77    entries: Vec<EnrollmentEntry>,
78}
79
80#[derive(Deserialize)]
81#[serde(rename_all = "PascalCase")]
82struct EnrollmentEntry {
83    request_id: Option<String>,
84    request_status: Option<String>,
85    #[serde(default)]
86    cert_id: Option<String>,
87}
88
89/// Certificate data response.
90#[derive(Deserialize)]
91#[serde(rename_all = "PascalCase")]
92struct CertDataResponse {
93    /// Base64-encoded certificate (PKCS#7 or raw).
94    #[serde(default)]
95    encoded: Option<String>,
96}
97
98impl DogtagClient {
99    /// Enroll a certificate using a PKCS#10 CSR and enrollment profile.
100    ///
101    /// Sends `POST /ca/rest/certrequests` with the CSR embedded in the
102    /// specified enrollment profile. The profile controls certificate
103    /// extensions, key usage, validity period, and approval workflow.
104    ///
105    /// # Arguments
106    ///
107    /// * `csr_pem` - PEM-encoded PKCS#10 certificate signing request.
108    /// * `profile_id` - Dogtag enrollment profile ID (e.g., "caServerCert").
109    ///
110    /// # Returns
111    ///
112    /// An [`EnrollResult`] containing the request ID, status, and the
113    /// DER-encoded certificate if the profile uses auto-approval.
114    /// If the profile requires agent approval, the status will be
115    /// [`EnrollStatus::Pending`] and the certificate will be `None`.
116    pub async fn enroll_certificate(
117        &self,
118        csr_pem: &str,
119        profile_id: &str,
120    ) -> DogtagResult<EnrollResult> {
121        debug!(profile = profile_id, "Submitting enrollment request");
122
123        let request = EnrollmentRequest {
124            profile_id: profile_id.to_owned(),
125            renewal: false,
126            input: vec![ProfileInput {
127                class_id: "certReqInputImpl".to_owned(),
128                attributes: vec![ProfileAttribute {
129                    name: "cert_request".to_owned(),
130                    value: csr_pem.to_owned(),
131                }],
132            }],
133        };
134
135        let resp = self.post_json("/ca/rest/certrequests", &request).await?;
136        let enrollment: EnrollmentResponse = Self::json_response(resp).await?;
137
138        let entry = enrollment
139            .entries
140            .into_iter()
141            .next()
142            .ok_or_else(|| DogtagError::ParseError("Empty enrollment response".into()))?;
143
144        let request_id = entry
145            .request_id
146            .ok_or_else(|| DogtagError::ParseError("Missing request_id in response".into()))?;
147
148        let status = match entry.request_status.as_deref() {
149            Some("complete") => EnrollStatus::Complete,
150            Some("pending") => EnrollStatus::Pending,
151            Some("rejected") => EnrollStatus::Rejected,
152            Some("canceled") => EnrollStatus::Canceled,
153            Some(other) => {
154                return Err(DogtagError::ParseError(format!(
155                    "Unknown request status: {other}"
156                )));
157            }
158            None => {
159                return Err(DogtagError::ParseError(
160                    "Missing request_status in response".into(),
161                ));
162            }
163        };
164
165        // If complete and a cert_id is present, fetch the issued certificate.
166        let certificate_der = if status == EnrollStatus::Complete {
167            if let Some(cert_id) = &entry.cert_id {
168                Some(self.fetch_cert_der(cert_id).await?)
169            } else {
170                None
171            }
172        } else {
173            None
174        };
175
176        Ok(EnrollResult {
177            request_id,
178            status,
179            certificate_der,
180        })
181    }
182
183    /// Poll the status of an enrollment request.
184    ///
185    /// Sends `GET /ca/rest/certrequests/{request_id}` to check whether
186    /// a pending enrollment has been approved or rejected. Used for
187    /// EST Disconnected mode (RFC 7030 S4.4.2) where the CA requires
188    /// out-of-band approval before issuing the certificate.
189    pub async fn get_enrollment_status(&self, request_id: &str) -> DogtagResult<EnrollResult> {
190        debug!(request_id, "Checking enrollment status");
191
192        let resp = self
193            .get(&format!("/ca/rest/certrequests/{request_id}"))
194            .await?;
195        let entry: EnrollmentEntry = Self::json_response(resp).await?;
196
197        let status = match entry.request_status.as_deref() {
198            Some("complete") => EnrollStatus::Complete,
199            Some("pending") => EnrollStatus::Pending,
200            Some("rejected") => EnrollStatus::Rejected,
201            Some("canceled") => EnrollStatus::Canceled,
202            other => {
203                return Err(DogtagError::ParseError(format!(
204                    "Unknown request status: {other:?}"
205                )));
206            }
207        };
208
209        let certificate_der = if status == EnrollStatus::Complete {
210            if let Some(cert_id) = &entry.cert_id {
211                Some(self.fetch_cert_der(cert_id).await?)
212            } else {
213                None
214            }
215        } else {
216            None
217        };
218
219        Ok(EnrollResult {
220            request_id: request_id.to_owned(),
221            status,
222            certificate_der,
223        })
224    }
225
226    /// Fetch DER-encoded certificate by serial/cert ID.
227    async fn fetch_cert_der(&self, cert_id: &str) -> DogtagResult<Vec<u8>> {
228        let resp = self.get(&format!("/ca/rest/certs/{cert_id}")).await?;
229        let cert_data: CertDataResponse = Self::json_response(resp).await?;
230
231        let encoded = cert_data
232            .encoded
233            .ok_or_else(|| DogtagError::ParseError("Missing certificate data".into()))?;
234
235        // Strip PEM headers if present, then decode base64.
236        let b64: String = encoded
237            .lines()
238            .filter(|l| !l.starts_with("-----"))
239            .collect();
240
241        use base64::Engine;
242        base64::engine::general_purpose::STANDARD
243            .decode(&b64)
244            .map_err(|e| DogtagError::ParseError(format!("Invalid base64 in certificate: {e}")))
245    }
246}