Skip to main content

kipuka_dogtag/
certs.rs

1//! Certificate retrieval, listing, and revocation via Dogtag CA REST API.
2//!
3//! Provides operations against the `/ca/rest/certs` and `/ca/rest/agent/certs`
4//! endpoints for certificate lifecycle management.
5
6use serde::{Deserialize, Serialize};
7use tracing::debug;
8
9use crate::client::DogtagClient;
10use crate::{DogtagError, DogtagResult};
11
12/// Information about an issued certificate.
13#[derive(Debug, Clone, Deserialize)]
14#[serde(rename_all = "PascalCase")]
15pub struct CertInfo {
16    /// Certificate serial number (hex string).
17    pub id: String,
18    /// Subject DN of the certificate.
19    #[serde(default)]
20    pub subject_d_n: Option<String>,
21    /// Issuer DN.
22    #[serde(default)]
23    pub issuer_d_n: Option<String>,
24    /// Certificate status (e.g., "VALID", "REVOKED", "EXPIRED").
25    #[serde(default)]
26    pub status: Option<String>,
27    /// Not-before date (ISO 8601).
28    #[serde(default)]
29    pub not_valid_before: Option<String>,
30    /// Not-after date (ISO 8601).
31    #[serde(default)]
32    pub not_valid_after: Option<String>,
33    /// Base64-encoded certificate (if requested via full retrieval).
34    #[serde(default)]
35    pub encoded: Option<String>,
36}
37
38/// Filter parameters for certificate listing.
39#[derive(Debug, Default, Serialize)]
40pub struct CertFilter {
41    /// Filter by subject DN substring.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub subject: Option<String>,
44    /// Filter by certificate status.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub status: Option<String>,
47    /// Maximum number of results.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub size: Option<u32>,
50    /// Starting index for pagination.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub start: Option<u32>,
53}
54
55/// Revocation reason codes per RFC 5280 S5.3.1.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
58pub enum RevocationReason {
59    /// The private key has been compromised.
60    KeyCompromise,
61    /// The CA's private key has been compromised.
62    CaCompromise,
63    /// The certificate holder's affiliation has changed.
64    AffiliationChanged,
65    /// The certificate has been superseded by a new one.
66    Superseded,
67    /// The certificate is no longer needed.
68    CessationOfOperation,
69    /// The certificate is temporarily on hold.
70    CertificateHold,
71    /// Remove a certificate from hold.
72    RemoveFromCrl,
73    /// The certificate holder's privileges have been withdrawn.
74    PrivilegeWithdrawn,
75    /// The attribute authority has been compromised.
76    AaCompromise,
77    /// Unspecified reason.
78    Unspecified,
79}
80
81impl RevocationReason {
82    /// Return the CRL reason code integer value per RFC 5280.
83    fn as_code(self) -> u32 {
84        match self {
85            Self::Unspecified => 0,
86            Self::KeyCompromise => 1,
87            Self::CaCompromise => 2,
88            Self::AffiliationChanged => 3,
89            Self::Superseded => 4,
90            Self::CessationOfOperation => 5,
91            Self::CertificateHold => 6,
92            Self::RemoveFromCrl => 8,
93            Self::PrivilegeWithdrawn => 9,
94            Self::AaCompromise => 10,
95        }
96    }
97}
98
99/// Revocation request body.
100#[derive(Serialize)]
101#[serde(rename_all = "PascalCase")]
102struct RevokeRequest {
103    reason: u32,
104}
105
106/// Response from certificate listing.
107#[derive(Deserialize)]
108#[serde(rename_all = "PascalCase")]
109struct CertListResponse {
110    #[serde(default)]
111    entries: Vec<CertInfo>,
112}
113
114impl DogtagClient {
115    /// Retrieve a single certificate by serial number.
116    ///
117    /// Sends `GET /ca/rest/certs/{serial}`. The serial number should be
118    /// the hex-encoded certificate serial (e.g., "0x1" or "1").
119    pub async fn get_certificate(&self, serial: &str) -> DogtagResult<CertInfo> {
120        debug!(serial, "Fetching certificate");
121        let resp = self.get(&format!("/ca/rest/certs/{serial}")).await?;
122        Self::json_response(resp).await
123    }
124
125    /// Revoke a certificate by serial number.
126    ///
127    /// Sends `POST /ca/rest/agent/certs/{serial}/revoke` with the specified
128    /// revocation reason. Requires agent-level authentication (mTLS with
129    /// an agent certificate).
130    ///
131    /// The revocation reason code follows RFC 5280 S5.3.1.
132    pub async fn revoke_certificate(
133        &self,
134        serial: &str,
135        reason: RevocationReason,
136    ) -> DogtagResult<()> {
137        debug!(serial, reason = ?reason, "Revoking certificate");
138
139        let body = RevokeRequest {
140            reason: reason.as_code(),
141        };
142
143        let resp = self
144            .post_json(&format!("/ca/rest/agent/certs/{serial}/revoke"), &body)
145            .await?;
146
147        if !resp.status().is_success() {
148            let status = resp.status().as_u16();
149            let body = resp.text().await.unwrap_or_default();
150            return Err(DogtagError::ApiError { status, body });
151        }
152
153        Ok(())
154    }
155
156    /// List certificates matching the given filter.
157    ///
158    /// Sends `GET /ca/rest/certs` with query parameters derived from the
159    /// [`CertFilter`]. Supports pagination via `start` and `size` fields.
160    pub async fn list_certificates(&self, filter: CertFilter) -> DogtagResult<Vec<CertInfo>> {
161        debug!(?filter, "Listing certificates");
162
163        // Build query string manually since GET doesn't use post_json.
164        let mut query_parts = Vec::new();
165        if let Some(ref subject) = filter.subject {
166            query_parts.push(format!("subject={subject}"));
167        }
168        if let Some(ref status) = filter.status {
169            query_parts.push(format!("status={status}"));
170        }
171        if let Some(size) = filter.size {
172            query_parts.push(format!("size={size}"));
173        }
174        if let Some(start) = filter.start {
175            query_parts.push(format!("start={start}"));
176        }
177
178        let path = if query_parts.is_empty() {
179            "/ca/rest/certs".to_owned()
180        } else {
181            format!("/ca/rest/certs?{}", query_parts.join("&"))
182        };
183
184        let resp = self.get(&path).await?;
185        let list: CertListResponse = Self::json_response(resp).await?;
186        Ok(list.entries)
187    }
188}