Skip to main content

kipuka_dogtag/
kra.rs

1//! KRA (Key Recovery Authority) operations for server-side key generation.
2//!
3//! Supports kipuka's `/serverkeygen` EST endpoint (RFC 7030 S4.4) by
4//! generating key pairs on the Dogtag KRA subsystem and archiving the
5//! private key for optional recovery.
6//!
7//! The KRA communicates over a separate base URL from the CA and requires
8//! its own agent-level authentication.
9
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12use tracing::debug;
13
14use reqwest::{Certificate, Client, Identity};
15
16use crate::config::DogtagConfig;
17use crate::{DogtagError, DogtagResult};
18
19/// Client for Dogtag KRA REST API operations.
20///
21/// Manages a separate HTTP client configured for the KRA subsystem.
22/// The KRA may run on the same host as the CA but uses a different
23/// subsystem path (`/kra/rest/...`).
24pub struct KraClient {
25    http: Client,
26    base_url: String,
27    retry_max: u32,
28    retry_delay: Duration,
29}
30
31/// Result of a server-side key generation request.
32#[derive(Debug, Clone)]
33pub struct KeyGenResult {
34    /// KRA key identifier for the archived key.
35    pub key_id: String,
36    /// DER-encoded public key.
37    pub public_key_der: Vec<u8>,
38    /// Wrapped (encrypted) private key bytes, if returned.
39    ///
40    /// The wrapping key is typically the transport certificate of the
41    /// requesting agent. The EST client must unwrap this using the
42    /// corresponding private key.
43    pub wrapped_private_key: Option<Vec<u8>>,
44}
45
46/// Key generation request body.
47#[derive(Serialize)]
48#[serde(rename_all = "PascalCase")]
49struct KeyGenRequest {
50    /// Key algorithm (e.g., "RSA", "EC", "ML-KEM-768").
51    key_algorithm: String,
52    /// Key size in bits (for RSA) or named curve/parameter set.
53    key_size: u32,
54    /// Client key ID for tracking.
55    client_key_id: String,
56}
57
58/// Key archival request body.
59#[derive(Serialize)]
60#[serde(rename_all = "PascalCase")]
61struct ArchiveRequest {
62    /// Client key ID.
63    client_key_id: String,
64    /// Base64-encoded wrapped key data.
65    wrapped_private_data: String,
66    /// Algorithm OID of the wrapped key.
67    algorithm_oid: Option<String>,
68}
69
70/// Key recovery request body.
71#[derive(Serialize)]
72#[serde(rename_all = "PascalCase")]
73struct RecoverRequest {
74    /// Key ID to recover.
75    key_id: String,
76}
77
78/// Response from key generation.
79#[derive(Deserialize)]
80#[serde(rename_all = "PascalCase")]
81struct KeyGenResponse {
82    /// Key request info containing the generated key data.
83    #[serde(default)]
84    request_info: Option<KeyRequestInfo>,
85}
86
87#[derive(Deserialize)]
88#[serde(rename_all = "PascalCase")]
89struct KeyRequestInfo {
90    #[serde(default)]
91    key_id: Option<String>,
92    #[serde(default)]
93    public_key: Option<String>,
94    #[serde(default)]
95    wrapped_private_data: Option<String>,
96}
97
98/// Response from key recovery.
99#[derive(Deserialize)]
100#[serde(rename_all = "PascalCase")]
101struct RecoverResponse {
102    /// Base64-encoded recovered key data.
103    #[serde(default)]
104    data: Option<String>,
105}
106
107/// Response from key archival.
108#[derive(Deserialize)]
109#[serde(rename_all = "PascalCase")]
110struct ArchiveResponse {
111    /// Archived key ID.
112    #[serde(default)]
113    key_id: Option<String>,
114}
115
116impl KraClient {
117    /// Create a new KRA client from the Dogtag configuration.
118    ///
119    /// Uses the same agent credentials as the CA client but connects
120    /// to the KRA subsystem URL. Returns an error if `kra_url` is not
121    /// configured.
122    pub fn new(config: &DogtagConfig) -> DogtagResult<Self> {
123        let kra_url = config.kra_url.as_ref().ok_or_else(|| {
124            DogtagError::ConfigError("kra_url is required for KRA operations".into())
125        })?;
126
127        let cert_pem = std::fs::read(&config.agent_cert_file)?;
128        let key_pem = std::fs::read(&config.agent_key_file)?;
129        let ca_pem = std::fs::read(&config.ca_cert_file)?;
130
131        let mut identity_pem = cert_pem;
132        identity_pem.extend_from_slice(b"\n");
133        identity_pem.extend_from_slice(&key_pem);
134
135        let identity = Identity::from_pem(&identity_pem)
136            .map_err(|e| DogtagError::TlsError(format!("Failed to load agent identity: {e}")))?;
137
138        let ca_cert = Certificate::from_pem(&ca_pem)
139            .map_err(|e| DogtagError::TlsError(format!("Failed to load CA certificate: {e}")))?;
140
141        let http = Client::builder()
142            .identity(identity)
143            .add_root_certificate(ca_cert)
144            .timeout(Duration::from_secs(config.timeout_secs))
145            .build()?;
146
147        let base_url = kra_url.as_str().trim_end_matches('/').to_owned();
148
149        Ok(Self {
150            http,
151            base_url,
152            retry_max: config.retry_max,
153            retry_delay: Duration::from_millis(config.retry_delay_ms),
154        })
155    }
156
157    /// Generate a key pair on the KRA.
158    ///
159    /// Sends `POST /kra/rest/agent/keys/generate` to create a new key pair.
160    /// The private key is archived in the KRA and the public key is returned
161    /// for inclusion in the certificate request.
162    ///
163    /// # Supported Algorithms
164    ///
165    /// - RSA: `key_type = "RSA"`, `key_size = 2048 | 3072 | 4096`
166    /// - ECDSA: `key_type = "EC"`, `key_size = 256 | 384 | 521`
167    /// - ML-KEM: `key_type = "ML-KEM-512" | "ML-KEM-768" | "ML-KEM-1024"`, `key_size = 0`
168    pub async fn generate_key(&self, key_type: &str, key_size: u32) -> DogtagResult<KeyGenResult> {
169        debug!(key_type, key_size, "Generating key on KRA");
170
171        let request = KeyGenRequest {
172            key_algorithm: key_type.to_owned(),
173            key_size,
174            client_key_id: uuid_v4(),
175        };
176
177        let resp = self
178            .post_json("/kra/rest/agent/keys/generate", &request)
179            .await?;
180
181        let keygen_resp: KeyGenResponse = json_response(resp).await?;
182
183        let info = keygen_resp
184            .request_info
185            .ok_or_else(|| DogtagError::KraError("Missing request_info in response".into()))?;
186
187        let key_id = info
188            .key_id
189            .ok_or_else(|| DogtagError::KraError("Missing key_id in response".into()))?;
190
191        let public_key_b64 = info
192            .public_key
193            .ok_or_else(|| DogtagError::KraError("Missing public_key in response".into()))?;
194
195        use base64::Engine;
196        let public_key_der = base64::engine::general_purpose::STANDARD
197            .decode(&public_key_b64)
198            .map_err(|e| DogtagError::KraError(format!("Invalid base64 in public key: {e}")))?;
199
200        let wrapped_private_key = if let Some(ref wrapped) = info.wrapped_private_data {
201            Some(
202                base64::engine::general_purpose::STANDARD
203                    .decode(wrapped)
204                    .map_err(|e| {
205                        DogtagError::KraError(format!("Invalid base64 in wrapped key: {e}"))
206                    })?,
207            )
208        } else {
209            None
210        };
211
212        Ok(KeyGenResult {
213            key_id,
214            public_key_der,
215            wrapped_private_key,
216        })
217    }
218
219    /// Archive a private key in the KRA.
220    ///
221    /// Sends `POST /kra/rest/agent/keys/archive` to store a wrapped
222    /// private key for later recovery. Returns the KRA key identifier.
223    pub async fn archive_key(&self, key_id: &str, wrapped_key: &[u8]) -> DogtagResult<String> {
224        debug!(key_id, size = wrapped_key.len(), "Archiving key in KRA");
225
226        use base64::Engine;
227        let wrapped_b64 = base64::engine::general_purpose::STANDARD.encode(wrapped_key);
228
229        let request = ArchiveRequest {
230            client_key_id: key_id.to_owned(),
231            wrapped_private_data: wrapped_b64,
232            algorithm_oid: None,
233        };
234
235        let resp = self
236            .post_json("/kra/rest/agent/keys/archive", &request)
237            .await?;
238
239        let archive: ArchiveResponse = json_response(resp).await?;
240
241        archive
242            .key_id
243            .ok_or_else(|| DogtagError::KraError("Missing key_id in archive response".into()))
244    }
245
246    /// Recover an archived private key from the KRA.
247    ///
248    /// Sends `POST /kra/rest/agent/keys/{key_id}/recover` to retrieve
249    /// a previously archived key. The key is returned in its wrapped form.
250    pub async fn recover_key(&self, key_id: &str) -> DogtagResult<Vec<u8>> {
251        debug!(key_id, "Recovering key from KRA");
252
253        let request = RecoverRequest {
254            key_id: key_id.to_owned(),
255        };
256
257        let resp = self
258            .post_json(&format!("/kra/rest/agent/keys/{key_id}/recover"), &request)
259            .await?;
260
261        let recover: RecoverResponse = json_response(resp).await?;
262
263        let data_b64 = recover
264            .data
265            .ok_or_else(|| DogtagError::KraError("Missing data in recovery response".into()))?;
266
267        use base64::Engine;
268        base64::engine::general_purpose::STANDARD
269            .decode(&data_b64)
270            .map_err(|e| DogtagError::KraError(format!("Invalid base64 in recovered key: {e}")))
271    }
272
273    /// Send a POST request with a JSON body and retry.
274    async fn post_json<T: serde::Serialize + ?Sized>(
275        &self,
276        path: &str,
277        body: &T,
278    ) -> DogtagResult<reqwest::Response> {
279        let url = format!("{}{}", self.base_url, path);
280        let mut last_error = None;
281
282        for attempt in 0..=self.retry_max {
283            if attempt > 0 {
284                debug!(attempt, max = self.retry_max, "Retrying KRA request");
285                tokio::time::sleep(self.retry_delay).await;
286            }
287
288            match self.http.post(&url).json(body).send().await {
289                Ok(resp) if resp.status().is_server_error() => {
290                    let status = resp.status();
291                    let body = resp.text().await.unwrap_or_default();
292                    tracing::warn!(attempt, status = status.as_u16(), "KRA server error");
293                    last_error = Some(DogtagError::ApiError {
294                        status: status.as_u16(),
295                        body,
296                    });
297                }
298                Ok(resp) => return Ok(resp),
299                Err(e) => {
300                    tracing::warn!(attempt, error = %e, "KRA request failed");
301                    last_error = Some(DogtagError::Http(e));
302                }
303            }
304        }
305
306        Err(last_error.unwrap_or(DogtagError::KraError("All retry attempts exhausted".into())))
307    }
308}
309
310/// Extract a successful JSON response or return an API error.
311async fn json_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> DogtagResult<T> {
312    let status = resp.status();
313    if !status.is_success() {
314        let body = resp.text().await.unwrap_or_default();
315        return Err(DogtagError::ApiError {
316            status: status.as_u16(),
317            body,
318        });
319    }
320    resp.json::<T>()
321        .await
322        .map_err(|e| DogtagError::ParseError(e.to_string()))
323}
324
325/// Generate a simple UUID v4 for client key IDs.
326fn uuid_v4() -> String {
327    use std::time::{SystemTime, UNIX_EPOCH};
328    let nanos = SystemTime::now()
329        .duration_since(UNIX_EPOCH)
330        .unwrap_or_default()
331        .as_nanos();
332    format!("kipuka-{nanos:x}")
333}