1use serde::{Deserialize, Serialize};
4use std::time::Duration;
5use tracing::debug;
6use zeroize::Zeroizing;
7
8use reqwest::{Certificate, Client, Identity};
9
10use crate::config::DogtagConfig;
11use crate::{DogtagError, DogtagResult};
12
13pub struct KraClient {
14 http: Client,
15 base_url: String,
16 basic_auth: Option<(String, String)>,
17}
18
19pub struct KeyGenResult {
21 pub key_id: String,
22 pub request_id: String,
23 pub public_key: Option<String>,
24 pub wrapped_private_key: Option<Vec<u8>>,
25}
26
27#[derive(Debug, Clone)]
28pub struct KeySearchEntry {
29 pub key_id: String,
30 pub client_key_id: String,
31 pub algorithm: String,
32 pub size: u32,
33 pub status: String,
34}
35
36#[derive(Serialize)]
39struct RestAttribute {
40 name: String,
41 value: String,
42}
43
44#[derive(Serialize)]
50struct KeyGenRequest {
51 #[serde(rename = "ClassName")]
52 class_name: String,
53 #[serde(rename = "Attributes")]
54 attributes: RestAttributes,
55}
56
57#[derive(Serialize)]
58struct RestAttributes {
59 #[serde(rename = "Attribute")]
60 attribute: Vec<RestAttribute>,
61}
62
63#[derive(Deserialize)]
64struct KeyGenResponse {
65 #[serde(rename = "requestInfo")]
66 request_info: Option<RequestInfo>,
67}
68
69#[derive(Deserialize)]
70struct RequestInfo {
71 #[serde(rename = "requestID")]
72 request_id: Option<String>,
73 #[serde(rename = "keyURL")]
74 key_url: Option<String>,
75}
76
77#[derive(Deserialize)]
78struct RecoverResponse {
79 #[serde(rename = "wrappedPrivateData")]
80 data: Option<String>,
81}
82
83#[derive(Deserialize)]
84struct ArchiveResponse {
85 #[serde(rename = "requestInfo")]
86 request_info: Option<RequestInfo>,
87}
88
89impl KraClient {
90 pub fn new(config: &DogtagConfig) -> DogtagResult<Self> {
91 let kra_url = config.kra_url.as_ref().ok_or_else(|| {
92 DogtagError::ConfigError("kra_url is required for KRA operations".into())
93 })?;
94
95 let is_http = kra_url.scheme() == "http";
96
97 let mut builder = Client::builder()
98 .cookie_store(true)
99 .redirect(reqwest::redirect::Policy::none())
100 .danger_accept_invalid_certs(config.accept_invalid_certs)
101 .timeout(Duration::from_secs(config.timeout_secs));
102
103 if config.accept_invalid_certs {
104 tracing::warn!(
105 "KRA client: TLS certificate validation disabled (accept_invalid_certs=true)"
106 );
107 }
108
109 let use_mtls = !is_http && !config.skip_mtls;
110 if use_mtls {
111 let cert_pem = std::fs::read(&config.agent_cert_file)?;
112 let key_pem = std::fs::read(&config.agent_key_file)?;
113 let identity = Identity::from_pkcs8_pem(&cert_pem, &key_pem).map_err(|e| {
114 DogtagError::TlsError(format!("Failed to load agent identity: {e}"))
115 })?;
116 builder = builder.identity(identity);
117 tracing::info!(
118 "KRA client: HTTPS with mTLS agent cert (cert-only auth, no basic auth)"
119 );
120 }
121
122 let ca_pem = std::fs::read(&config.ca_cert_file)?;
123 let ca_cert = Certificate::from_pem(&ca_pem)
124 .map_err(|e| DogtagError::TlsError(format!("Failed to load CA certificate: {e}")))?;
125 builder = builder.add_root_certificate(ca_cert);
126
127 let http = builder
128 .build()
129 .map_err(|e| DogtagError::TlsError(format!("KRA client build failed: {e:?}")))?;
130
131 let base_url = kra_url.as_str().trim_end_matches('/').to_owned();
132
133 let basic_auth = if use_mtls {
139 None
140 } else {
141 match (
142 config.kra_username.as_ref().or(config.username.as_ref()),
143 config.kra_password.as_ref().or(config.password.as_ref()),
144 ) {
145 (Some(user), Some(pass)) => Some((user.clone(), pass.clone())),
146 _ if is_http => {
147 tracing::error!(
148 "KRA HTTP mode requires explicit kra_username/kra_password in config"
149 );
150 return Err(DogtagError::ConfigError(
151 "kra_username and kra_password are required for HTTP connections".into(),
152 ));
153 }
154 _ => None,
155 }
156 };
157
158 Ok(Self {
159 http,
160 base_url,
161 basic_auth,
162 })
163 }
164
165 pub async fn login(&self) {
166 let login_resp = self.get_json("/kra/v2/account/login").await;
169 match login_resp {
170 Ok(r) if r.status().is_success() => {
171 tracing::info!("KRA session login succeeded (v2 GET)");
172 return;
173 }
174 Ok(r) => {
175 tracing::debug!(
176 status = r.status().as_u16(),
177 "KRA v2 GET login returned non-200, trying v1 POST"
178 );
179 }
180 Err(e) => {
181 tracing::debug!(error = %e, "KRA v2 GET login failed, trying v1 POST");
182 }
183 }
184 let login_resp = self
185 .post_json("/kra/rest/account/login", &serde_json::json!({}))
186 .await;
187 match login_resp {
188 Ok(r) if r.status().is_success() => {
189 tracing::info!("KRA session login succeeded (v1 POST)");
190 }
191 Ok(r) => {
192 tracing::warn!(
193 status = r.status().as_u16(),
194 "KRA session login returned non-200 (continuing)"
195 );
196 }
197 Err(e) => {
198 tracing::warn!(error = %e, "KRA session login failed (continuing without session)");
199 }
200 }
201 }
202
203 pub async fn generate_key(&self, key_type: &str, key_size: u32) -> DogtagResult<KeyGenResult> {
204 self.login().await;
205 debug!(key_type, key_size, "Generating key on KRA");
206
207 let class_name =
208 if key_type.eq_ignore_ascii_case("AES") || key_type.eq_ignore_ascii_case("DESede") {
209 "com.netscape.certsrv.key.SymKeyGenerationRequest"
210 } else {
211 "com.netscape.certsrv.key.AsymKeyGenerationRequest"
212 };
213
214 let client_key_id = format!(
215 "kipuka-{:x}",
216 std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .unwrap_or_default()
219 .as_nanos()
220 );
221
222 let request = KeyGenRequest {
223 class_name: class_name.to_owned(),
224 attributes: RestAttributes {
225 attribute: vec![
226 RestAttribute {
227 name: "clientKeyID".into(),
228 value: client_key_id,
229 },
230 RestAttribute {
231 name: "keyAlgorithm".into(),
232 value: key_type.to_owned(),
233 },
234 RestAttribute {
235 name: "keySize".into(),
236 value: key_size.to_string(),
237 },
238 RestAttribute {
239 name: "keyUsage".into(),
240 value: "wrap,unwrap".into(),
241 },
242 ],
243 },
244 };
245
246 let resp = self
247 .post_json("/kra/v2/agent/keyrequests", &request)
248 .await?;
249 let keygen_resp: KeyGenResponse = Self::json_response(resp).await?;
250
251 let info = keygen_resp
252 .request_info
253 .ok_or_else(|| DogtagError::KraError("Missing request_info in response".into()))?;
254
255 let key_id = info
256 .key_url
257 .as_ref()
258 .and_then(|url| url.rsplit('/').next())
259 .filter(|s| !s.is_empty())
260 .ok_or_else(|| DogtagError::KraError("KRA response missing keyURL or key ID".into()))?
261 .to_owned();
262
263 let public_key = self.get_public_key(&key_id).await?;
267
268 Ok(KeyGenResult {
269 key_id,
270 request_id: info.request_id.unwrap_or_default(),
271 public_key: Some(public_key),
272 wrapped_private_key: None,
273 })
274 }
275
276 pub async fn archive_key(&self, key_data: &[u8], algorithm: &str) -> DogtagResult<String> {
277 use base64::Engine;
278 let wrapped = base64::engine::general_purpose::STANDARD.encode(key_data);
279
280 let body = serde_json::json!({
281 "clientKeyID": format!("kipuka-archive-{:x}", std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .unwrap_or_default()
284 .as_nanos()),
285 "dataType": "symmetricKey",
286 "keyAlgorithm": algorithm,
287 "wrappedPrivateData": wrapped,
288 });
289
290 let resp = self.post_json("/kra/v2/agent/keys/archive", &body).await?;
291 let archive: ArchiveResponse = Self::json_response(resp).await?;
292
293 archive
294 .request_info
295 .and_then(|i| i.request_id)
296 .ok_or_else(|| DogtagError::KraError("No request_id in archive response".into()))
297 }
298
299 pub async fn recover_key(&self, key_id: &str) -> DogtagResult<Vec<u8>> {
300 let body = serde_json::json!({});
301 let resp = self
302 .post_json(&format!("/kra/v2/agent/keys/{key_id}/recover"), &body)
303 .await?;
304 let recover: RecoverResponse = Self::json_response(resp).await?;
305
306 let data_b64 = recover
307 .data
308 .ok_or_else(|| DogtagError::KraError("Missing data in recovery response".into()))?;
309
310 use base64::Engine;
311 base64::engine::general_purpose::STANDARD
312 .decode(&data_b64)
313 .map_err(|e| DogtagError::KraError(format!("Invalid base64 in recovered key: {e}")))
314 }
315
316 pub async fn recover_key_p12(&self, key_id: &str, cert_der: &[u8]) -> DogtagResult<Vec<u8>> {
325 self.recover_key_inner(key_id, Some(cert_der)).await
326 }
327
328 pub async fn recover_key_no_cert(&self, key_id: &str) -> DogtagResult<Vec<u8>> {
333 self.recover_key_inner(key_id, None).await
334 }
335
336 async fn recover_key_inner(
345 &self,
346 key_id: &str,
347 _cert_der: Option<&[u8]>,
348 ) -> DogtagResult<Vec<u8>> {
349 use base64::Engine;
350
351 self.login().await;
352
353 let mut session_key = Zeroizing::new(vec![0u8; 16]);
354 openssl::rand::rand_bytes(&mut session_key)
355 .map_err(|e| DogtagError::KraError(format!("Failed to generate session key: {e}")))?;
356
357 let transport_der = self.get_transport_cert().await?;
358 let trans_wrapped_b64 = wrap_session_key(&transport_der, &session_key, false)?;
359
360 let retrieve_req = KeyGenRequest {
361 class_name: "com.netscape.certsrv.key.KeyRecoveryRequest".to_owned(),
362 attributes: RestAttributes {
363 attribute: vec![
364 RestAttribute {
365 name: "keyId".into(),
366 value: key_id.to_owned(),
367 },
368 RestAttribute {
369 name: "transWrappedSessionKey".into(),
370 value: trans_wrapped_b64,
371 },
372 ],
373 },
374 };
375 tracing::info!(key_id = %key_id, "retrieving key via single-call session-key wrapping");
376 let resp = self
377 .post_json("/kra/v2/agent/keys/retrieve", &retrieve_req)
378 .await?;
379 if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
380 return Err(DogtagError::KraError(
381 "Recovery retrieve returned 401 — session may have expired".into(),
382 ));
383 }
384
385 let body: serde_json::Value = Self::json_response(resp).await?;
386
387 let wrapped_b64 = body
388 .get("wrappedPrivateData")
389 .and_then(|v| v.as_str())
390 .ok_or_else(|| {
391 DogtagError::KraError(format!(
392 "No wrappedPrivateData in retrieve response: {}",
393 crate::truncate_str(&body.to_string(), 300)
394 ))
395 })?;
396 let wrapped = base64::engine::general_purpose::STANDARD
397 .decode(wrapped_b64)
398 .map_err(|e| {
399 DogtagError::KraError(format!("Invalid base64 in wrappedPrivateData: {e}"))
400 })?;
401
402 let der = match body.get("nonceData").and_then(|v| v.as_str()) {
404 Some(nonce_b64) => {
405 let iv = base64::engine::general_purpose::STANDARD
406 .decode(nonce_b64)
407 .map_err(|e| DogtagError::KraError(format!("Invalid nonceData: {e}")))?;
408 let cipher = match session_key.len() {
409 16 => openssl::symm::Cipher::aes_128_cbc(),
410 32 => openssl::symm::Cipher::aes_256_cbc(),
411 n => {
412 return Err(DogtagError::KraError(format!(
413 "Unsupported session key length {n} for AES-CBC"
414 )));
415 }
416 };
417 Zeroizing::new(
418 openssl::symm::decrypt(cipher, &session_key, Some(&iv), &wrapped).map_err(
419 |e| DogtagError::KraError(format!("AES-CBC unwrap failed: {e}")),
420 )?,
421 )
422 }
423 None => aes_kwp_unwrap(&session_key, &wrapped)?,
424 };
425
426 tracing::info!(key_len = der.len(), "private key recovered (PKCS#8 DER)");
427 let out = der.to_vec();
428 Ok(out)
430 }
431
432 pub async fn get_transport_cert(&self) -> DogtagResult<Vec<u8>> {
437 let resp = self.get_json("/kra/v2/config/cert/transport").await?;
438 let body: serde_json::Value = Self::json_response(resp).await?;
439 let pem = body
440 .get("Encoded")
441 .and_then(|v| v.as_str())
442 .ok_or_else(|| {
443 DogtagError::KraError("No Encoded field in transport cert response".into())
444 })?;
445
446 let b64: String = pem.lines().filter(|l| !l.starts_with("-----")).collect();
448 use base64::Engine;
449 base64::engine::general_purpose::STANDARD
450 .decode(&b64)
451 .map_err(|e| DogtagError::KraError(format!("Transport cert base64 decode failed: {e}")))
452 }
453
454 pub async fn search_keys(
460 &self,
461 client_key_id: Option<&str>,
462 max_results: u32,
463 ) -> DogtagResult<Vec<KeySearchEntry>> {
464 self.login().await;
465
466 let mut path = format!("/kra/rest/agent/keys?maxResults={max_results}&status=active");
467 if let Some(ckid) = client_key_id {
468 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
469 let encoded = utf8_percent_encode(ckid, NON_ALPHANUMERIC);
470 path.push_str(&format!("&clientKeyID={encoded}"));
471 }
472
473 let resp = self.get_json(&path).await?;
474 let body: serde_json::Value = Self::json_response(resp).await?;
475
476 let entries = body
477 .get("entries")
478 .and_then(|v| v.as_array())
479 .map(|arr| {
480 arr.iter()
481 .filter_map(|e| {
482 let key_url = e.get("keyURL").and_then(|v| v.as_str()).unwrap_or("");
483 let key_id = key_url.rsplit('/').next().unwrap_or("").to_owned();
484 if key_id.is_empty() {
485 return None;
486 }
487 Some(KeySearchEntry {
488 key_id,
489 client_key_id: e
490 .get("clientKeyID")
491 .and_then(|v| v.as_str())
492 .unwrap_or("")
493 .to_owned(),
494 algorithm: e
495 .get("algorithm")
496 .and_then(|v| v.as_str())
497 .unwrap_or("")
498 .to_owned(),
499 size: e.get("size").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
500 status: e
501 .get("status")
502 .and_then(|v| v.as_str())
503 .unwrap_or("")
504 .to_owned(),
505 })
506 })
507 .collect()
508 })
509 .unwrap_or_default();
510
511 Ok(entries)
512 }
513
514 async fn get_public_key(&self, key_id: &str) -> DogtagResult<String> {
516 let path = format!("/kra/v2/agent/keys/{key_id}");
517 let resp = self.get_json(&path).await?;
518 let body: serde_json::Value = Self::json_response(resp).await?;
519 body.get("publicKey")
520 .and_then(|v| v.as_str())
521 .map(|s| s.to_owned())
522 .ok_or_else(|| DogtagError::KraError("No publicKey in key info response".into()))
523 }
524
525 async fn get_json(&self, path: &str) -> DogtagResult<reqwest::Response> {
526 let url = format!("{}{}", self.base_url, path);
527 let mut req = self.http.get(&url).header("Accept", "application/json");
528 if let Some((ref user, ref pass)) = self.basic_auth {
529 req = req.basic_auth(user, Some(pass));
530 }
531 req.send()
532 .await
533 .map_err(|e| DogtagError::HttpError(e.to_string()))
534 }
535
536 async fn post_json<T: serde::Serialize + ?Sized>(
537 &self,
538 path: &str,
539 body: &T,
540 ) -> DogtagResult<reqwest::Response> {
541 let url = format!("{}{}", self.base_url, path);
542 let mut req = self
543 .http
544 .post(&url)
545 .header("Accept", "application/json")
546 .json(body);
547 if let Some((ref user, ref pass)) = self.basic_auth {
548 req = req.basic_auth(user, Some(pass));
549 }
550 req.send()
551 .await
552 .map_err(|e| DogtagError::HttpError(e.to_string()))
553 }
554
555 async fn json_response<T: serde::de::DeserializeOwned>(
556 resp: reqwest::Response,
557 ) -> DogtagResult<T> {
558 let status = resp.status();
559 let content_type = resp
560 .headers()
561 .get(reqwest::header::CONTENT_TYPE)
562 .and_then(|v| v.to_str().ok())
563 .unwrap_or("unknown")
564 .to_owned();
565
566 let body = crate::bounded_text(resp).await?;
567
568 if !status.is_success() {
569 return Err(DogtagError::ApiError {
570 status: status.as_u16(),
571 body,
572 });
573 }
574
575 tracing::debug!(
576 body_len = body.len(),
577 content_type = %content_type,
578 "parsing KRA response"
579 );
580
581 serde_json::from_str(&body).map_err(|e| {
582 let preview = crate::truncate_str(&body, 500);
583 DogtagError::ParseError(format!(
584 "JSON parse failed (content-type: {content_type}): {e}; body: {preview}"
585 ))
586 })
587 }
588}
589
590fn wrap_session_key(
595 transport_cert_der: &[u8],
596 session_key: &[u8],
597 use_oaep: bool,
598) -> DogtagResult<String> {
599 use base64::Engine;
600
601 let transport_cert = openssl::x509::X509::from_der(transport_cert_der)
602 .map_err(|e| DogtagError::KraError(format!("Failed to parse transport cert: {e}")))?;
603 let rsa = transport_cert
604 .public_key()
605 .map_err(|e| DogtagError::KraError(format!("Failed to extract transport public key: {e}")))?
606 .rsa()
607 .map_err(|e| DogtagError::KraError(format!("Transport cert is not RSA: {e}")))?;
608
609 let padding = if use_oaep {
610 openssl::rsa::Padding::PKCS1_OAEP
611 } else {
612 openssl::rsa::Padding::PKCS1
613 };
614
615 let mut out = vec![0u8; rsa.size() as usize];
616 let n = rsa
617 .public_encrypt(session_key, &mut out, padding)
618 .map_err(|e| DogtagError::KraError(format!("RSA session key wrap failed: {e}")))?;
619 out.truncate(n);
620
621 Ok(base64::engine::general_purpose::STANDARD.encode(&out))
622}
623
624fn aes_kwp_unwrap(key: &[u8], wrapped: &[u8]) -> DogtagResult<Zeroizing<Vec<u8>>> {
629 unsafe {
630 let cipher_name = match key.len() {
631 16 => c"AES-128-WRAP-PAD",
632 24 => c"AES-192-WRAP-PAD",
633 32 => c"AES-256-WRAP-PAD",
634 _ => {
635 return Err(DogtagError::KraError(format!(
636 "Invalid KEK length {}",
637 key.len()
638 )));
639 }
640 };
641
642 let cipher = openssl_sys::EVP_CIPHER_fetch(
643 std::ptr::null_mut(),
644 cipher_name.as_ptr(),
645 std::ptr::null(),
646 );
647 if cipher.is_null() {
648 return Err(DogtagError::KraError("AES-KWP cipher not available".into()));
649 }
650
651 let ctx = openssl_sys::EVP_CIPHER_CTX_new();
652 if ctx.is_null() {
653 openssl_sys::EVP_CIPHER_free(cipher as *mut _);
654 return Err(DogtagError::KraError("EVP_CIPHER_CTX_new failed".into()));
655 }
656
657 openssl_sys::EVP_CIPHER_CTX_set_flags(ctx, openssl_sys::EVP_CIPHER_CTX_FLAG_WRAP_ALLOW);
658
659 let rc = openssl_sys::EVP_DecryptInit_ex(
660 ctx,
661 cipher,
662 std::ptr::null_mut(),
663 key.as_ptr(),
664 std::ptr::null(),
665 );
666 openssl_sys::EVP_CIPHER_free(cipher as *mut _);
667 if rc != 1 {
668 openssl_sys::EVP_CIPHER_CTX_free(ctx);
669 return Err(DogtagError::KraError(
670 "EVP_DecryptInit_ex failed for AES-KWP".into(),
671 ));
672 }
673
674 let max_out = wrapped.len() + 32;
675 let mut output = Zeroizing::new(vec![0u8; max_out]);
676 let mut out_len: i32 = 0;
677
678 let rc = openssl_sys::EVP_DecryptUpdate(
679 ctx,
680 output.as_mut_ptr(),
681 &mut out_len,
682 wrapped.as_ptr(),
683 wrapped.len() as i32,
684 );
685 if rc != 1 {
686 openssl_sys::EVP_CIPHER_CTX_free(ctx);
687 return Err(DogtagError::KraError(
688 "AES-KWP unwrap failed (DecryptUpdate)".into(),
689 ));
690 }
691
692 let mut final_len: i32 = 0;
693 let rc = openssl_sys::EVP_DecryptFinal_ex(
694 ctx,
695 output.as_mut_ptr().add(out_len as usize),
696 &mut final_len,
697 );
698 openssl_sys::EVP_CIPHER_CTX_free(ctx);
699
700 if rc != 1 {
701 return Err(DogtagError::KraError(
702 "AES-KWP unwrap failed (DecryptFinal)".into(),
703 ));
704 }
705
706 let total = (out_len + final_len) as usize;
707 output.truncate(total);
708 Ok(output)
709 }
710}