Skip to main content

kipuka/routes/
serverkeygen.rs

1//! `POST /.well-known/est/serverkeygen` — Server-Side Key Generation.
2//!
3//! RFC 7030 §4.4: The EST server generates a key pair on behalf of the
4//! client, signs a certificate, and returns both the certificate and
5//! the private key.
6//!
7//! The response is `multipart/mixed` containing two parts:
8//! - Part 1: `application/pkcs7-mime; smime-type=certs-only` (certificate)
9//! - Part 2: `application/pkcs8` (DER-encoded private key)
10//!
11//! RHELBU-3536 R27: Authentication (mTLS or OTP) is required.
12//! Server-side key generation requires HSM or software key generation
13//! capability per configuration.
14
15use std::sync::Arc;
16
17use axum::body::Bytes;
18use axum::extract::State;
19use axum::http::{HeaderValue, StatusCode, header};
20use axum::response::{IntoResponse, Response};
21
22use crate::auth::EstAuth;
23use crate::error::KipukaError;
24use crate::routes::LabelExtractor;
25use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
26use crate::state::AppState;
27
28/// MIME boundary for the multipart/mixed response.
29///
30/// RFC 7030 §4.4.2: The server returns the certificate and private key
31/// as separate MIME parts in a multipart/mixed response.
32const MULTIPART_BOUNDARY: &str = "estServerKeyGenBoundary";
33
34/// `POST /.well-known/est/serverkeygen`
35///
36/// Accepts a PKCS#10 CSR (with placeholder key or desired attributes) and
37/// returns a multipart response with the issued certificate and the
38/// server-generated private key.
39///
40/// # Authentication
41///
42/// Requires mTLS or OTP authentication (RHELBU-3536 R27).
43///
44/// # Request
45///
46/// | Header         | Value                |
47/// |----------------|----------------------|
48/// | Content-Type   | `application/pkcs10` |
49/// | Body           | Base64-encoded DER PKCS#10 CSR |
50///
51/// The CSR may contain a placeholder public key; the server replaces it
52/// with the generated key pair.  The CSR's requested subject and extensions
53/// are used as a template for the issued certificate.
54///
55/// # Response
56///
57/// | Header         | Value                         |
58/// |----------------|-------------------------------|
59/// | Status         | `200 OK`                      |
60/// | Content-Type   | `multipart/mixed; boundary=...` |
61///
62/// Response body parts:
63///
64/// ```text
65/// --estServerKeyGenBoundary
66/// Content-Type: application/pkcs7-mime; smime-type=certs-only
67/// Content-Transfer-Encoding: base64
68///
69/// <base64 PKCS#7 certificate>
70/// --estServerKeyGenBoundary
71/// Content-Type: application/pkcs8
72/// Content-Transfer-Encoding: base64
73///
74/// <base64 PKCS#8 private key>
75/// --estServerKeyGenBoundary--
76/// ```
77///
78/// # Errors
79///
80/// - `400 Bad Request` — malformed CSR
81/// - `401 Unauthorized` — authentication failed
82/// - `403 Forbidden` — serverkeygen not enabled
83/// - `500 Internal Server Error` — key generation or CA signing failure
84/// - `503 Service Unavailable` — HSM offline
85pub async fn post_serverkeygen(
86    auth: EstAuth,
87    label: LabelExtractor,
88    State(state): State<Arc<AppState>>,
89    body: Bytes,
90) -> Result<Response, KipukaError> {
91    let ca_id = label.ca_id();
92    let identity = &auth.0.identity;
93    state.admit_enrollment(identity, "serverkeygen").await?;
94
95    // Check that serverkeygen is enabled.
96    if !state.config.est.serverkeygen {
97        return Err(KipukaError::Est(
98            "server-side key generation is not enabled".into(),
99        ));
100    }
101
102    tracing::info!(
103        ca_id = %ca_id,
104        label = %label.label,
105        identity = %identity,
106        method = ?auth.0.method,
107        "serverkeygen request"
108    );
109
110    // Decode the base64-encoded CSR template.
111    let csr_der = decode_est_base64(&body)
112        .map_err(|e| KipukaError::BadRequest(format!("CSR template decoding failed: {e}")))?;
113
114    if csr_der.is_empty() {
115        return Err(KipukaError::BadRequest("empty CSR template".into()));
116    }
117
118    // Enrollment authorization (NIAP CA PP FDP_ACF.1).
119    //
120    // The client supplies a CSR *template* naming the subject/SANs it wants the
121    // server-generated key certified for.  Bind those names to the
122    // authenticated requester and enforce the per-label allowlist — the same
123    // control /simpleenroll and CMS-EST serverkeygen apply — so a client cannot
124    // obtain a server-generated key for an identity it does not own.  A no-op
125    // unless the label opts in.
126    if let Err(reason) =
127        crate::auth::enroll_authz::authorize_csr_der(&csr_der, identity, &label.enroll_policy())
128    {
129        tracing::warn!(
130            ca_id = %ca_id,
131            identity = %identity,
132            %reason,
133            "serverkeygen rejected: CSR template not authorized for requester"
134        );
135        state
136            .record_audit_event_with_actor(
137                "serverkeygen_denied",
138                identity,
139                &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
140            )
141            .await;
142        return Err(KipukaError::Forbidden(format!(
143            "enrollment not authorized: {reason}"
144        )));
145    }
146
147    // Look up the CA backend.
148    let _ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
149
150    // ── CA-driven SSKG via serverKeygenInputImpl profile ──────────────────────
151    //
152    // One authenticated enrollment to the SSKG profile. The CA↔KRA connector
153    // handles keygen + archival server-side. PKCS#12 comes back in the
154    // enrollment output. No KRA agent credentials in Kipuka.
155    if let Some(ref dogtag_pool) = state.dogtag {
156        let dogtag_cfg = state
157            .config
158            .dogtag
159            .as_ref()
160            .expect("dogtag config present when pool is set");
161
162        let sskg_profile = dogtag_cfg
163            .sskg_profile_id
164            .as_deref()
165            .unwrap_or("caServerKeygenEST");
166
167        // Extract subject from the CSR template
168        let template = openssl::x509::X509Req::from_der(&csr_der)
169            .map_err(|e| KipukaError::BadRequest(format!("CSR template parse: {e}")))?;
170        let subject_cn = {
171            let sn = template.subject_name();
172            sn.entries_by_nid(openssl::nid::Nid::COMMONNAME)
173                .next()
174                .and_then(|e| String::from_utf8(e.data().as_slice().to_vec()).ok())
175                .unwrap_or_else(|| identity.to_string())
176        };
177
178        // Key params from CSR template or defaults
179        let key_type = detect_key_type_from_csr(&csr_der);
180        let (kt_str, ks_str) = match &key_type {
181            crate::ca::keygen::KeyType::Rsa(bits) => ("RSA".to_string(), bits.to_string()),
182            crate::ca::keygen::KeyType::Ecdsa(curve) => {
183                let bits = match curve {
184                    crate::ca::keygen::EcCurve::P256 => 256,
185                    crate::ca::keygen::EcCurve::P384 => 384,
186                };
187                ("EC".to_string(), bits.to_string())
188            }
189            // Dogtag SSKG (serverKeygenUserKeyDefaultImpl) generates RSA/EC only
190            // in 11.x. PQ serverkeygen is served by the software/HSM path below.
191            other => {
192                return Err(KipukaError::BadRequest(format!(
193                    "Dogtag server-side keygen supports RSA/EC only; \
194                     requested key type {other:?} requires a local CA label"
195                )));
196            }
197        };
198
199        // One-time P12 passphrase — generated by Kipuka, never leaves it
200        let mut rand = [0u8; 24];
201        openssl::rand::rand_bytes(&mut rand).map_err(|e| KipukaError::Ca(format!("rand: {e}")))?;
202        let p12_password = hex::encode(rand);
203
204        tracing::info!(
205            ca_id = %ca_id,
206            identity = %identity,
207            sskg_profile = %sskg_profile,
208            subject_cn = %subject_cn,
209            key_type = %kt_str,
210            key_size = %ks_str,
211            "SSKG: submitting to CA SSKG profile"
212        );
213
214        let ca_client = dogtag_pool
215            .get_client()
216            .map_err(|e| KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}")))?;
217
218        let sskg_result = ca_client
219            .server_keygen(
220                sskg_profile,
221                &subject_cn,
222                &subject_cn,
223                &p12_password,
224                &kt_str,
225                ks_str.parse().unwrap_or(2048),
226            )
227            .await
228            .map_err(|e| KipukaError::Ca(format!("SSKG enrollment failed: {e}")))?;
229
230        // Get cert + private key. Two paths:
231        // Path A: PKCS#12 in the enrollment output (single-step auto-approve)
232        // Path B: Recover key from KRA (agent-approved flow — Dogtag REST API
233        //         doesn't populate pkcs12OutputImpl through the approval path)
234        let (cert_der, private_key_der) = if let Some(ref p12_b64) = sskg_result.pkcs12_b64 {
235            // Path A: P12 available
236            use base64::Engine;
237            let p12_der = base64::engine::general_purpose::STANDARD
238                .decode(p12_b64)
239                .map_err(|e| KipukaError::Ca(format!("PKCS#12 base64 decode: {e}")))?;
240
241            let parsed = openssl::pkcs12::Pkcs12::from_der(&p12_der)
242                .map_err(|e| KipukaError::Ca(format!("PKCS#12 parse: {e}")))?
243                .parse2(&p12_password)
244                .map_err(|e| KipukaError::Ca(format!("PKCS#12 decrypt: {e}")))?;
245
246            let pkey = parsed
247                .pkey
248                .ok_or_else(|| KipukaError::Ca("No private key in PKCS#12".into()))?;
249            let key_der = encode_pkcs8_private_key(&pkey)?;
250
251            let cert = if let Some(cert) = &parsed.cert {
252                cert.to_der()
253                    .map_err(|e| KipukaError::Ca(format!("cert DER: {e}")))?
254            } else {
255                sskg_result
256                    .certificate_der
257                    .clone()
258                    .ok_or_else(|| KipukaError::Ca("No certificate in SSKG response".into()))?
259            };
260            (cert, key_der)
261        } else {
262            // Path B: Recover key from KRA (agent-approved flow)
263            tracing::info!("SSKG: no P12 in enrollment output — recovering key from KRA");
264
265            let cert = sskg_result.certificate_der.clone().ok_or_else(|| {
266                KipukaError::Ca("No certificate in SSKG response (needed for KRA recovery)".into())
267            })?;
268
269            // Build a KRA client from the dogtag config
270            let kra_client = kipuka_dogtag::KraClient::new(dogtag_cfg)
271                .map_err(|e| KipukaError::Ca(format!("KRA client init: {e}")))?;
272
273            // Search KRA for the archived key using the cert subject CN as
274            // the clientKeyID (Dogtag CA uses the subject DN as clientKeyID
275            // when archiving through the connector).
276            let keys = kra_client
277                .search_keys(Some(&subject_cn), 3)
278                .await
279                .map_err(|e| KipukaError::Ca(format!("KRA key search: {e}")))?;
280
281            // Fall back to unfiltered search if CN-based search returns nothing
282            let keys = if keys.is_empty() {
283                tracing::warn!("KRA key search by CN returned nothing, trying unfiltered");
284                kra_client
285                    .search_keys(None, 1)
286                    .await
287                    .map_err(|e| KipukaError::Ca(format!("KRA key search (unfiltered): {e}")))?
288            } else {
289                keys
290            };
291
292            let key_entry = keys.first().ok_or_else(|| {
293                KipukaError::Ca("No keys found on KRA after SSKG enrollment".into())
294            })?;
295
296            tracing::info!(
297                key_id = %key_entry.key_id,
298                client_key_id = %key_entry.client_key_id,
299                algorithm = %key_entry.algorithm,
300                size = key_entry.size,
301                "SSKG: found archived key on KRA, recovering"
302            );
303
304            state
305                .record_audit_event(
306                    "sskg_kra_recovery",
307                    &format!(
308                        "ca_id={ca_id}, identity={identity}, key_id={}, algorithm={}",
309                        key_entry.key_id, key_entry.algorithm
310                    ),
311                )
312                .await;
313
314            // Recover the private key using session-key wrapping (not plain
315            // recovery which requires kra.allowEncDecrypt.recovery=true)
316            let key_der = kra_client
317                .recover_key_no_cert(&key_entry.key_id)
318                .await
319                .map_err(|e| KipukaError::Ca(format!("KRA key recovery: {e}")))?;
320
321            (cert, key_der)
322        };
323
324        tracing::info!(
325            cert_len = cert_der.len(),
326            key_len = private_key_der.len(),
327            request_id = %sskg_result.request_id,
328            "SSKG complete via CA profile — cert + key ready"
329        );
330
331        // Build multipart response
332        let cert_pkcs7_der =
333            crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
334        let response_body = build_multipart_response(&cert_pkcs7_der, &private_key_der);
335
336        let content_type = format!(
337            "{}; boundary={}",
338            content_types::MULTIPART_MIXED,
339            MULTIPART_BOUNDARY
340        );
341
342        let mut resp = (StatusCode::OK, response_body).into_response();
343        if let Ok(hv) = HeaderValue::from_str(&content_type) {
344            resp.headers_mut().insert(header::CONTENT_TYPE, hv);
345        }
346
347        state
348            .record_audit_event(
349                "serverkeygen_success",
350                &format!(
351                    "ca_id={ca_id}, identity={identity}, backend=sskg-profile, profile={sskg_profile}, request_id={}",
352                    sskg_result.request_id
353                ),
354            )
355            .await;
356
357        return Ok(resp);
358    }
359
360    // ── Software/HSM key generation path (no Dogtag) ────────────────────────
361
362    // Look up the CA backend (already checked above, but borrow it for signing).
363    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
364
365    // Step 1: Determine key type from the CSR template.
366    //
367    // Parse the CSR to extract the SubjectPublicKeyInfo algorithm.
368    // Default to RSA-2048 if the template CSR uses a placeholder key.
369    let key_type = detect_key_type_from_csr(&csr_der);
370
371    tracing::info!(
372        ca_id = %ca_id,
373        identity = %identity,
374        key_type = ?key_type,
375        "generating software key pair for serverkeygen"
376    );
377
378    // Step 2: Generate the key pair.
379    let keygen_config = crate::ca::keygen::KeyGenConfig::default();
380    let keygen_result = crate::ca::keygen::generate_key_pair(&key_type, &keygen_config)
381        .map_err(|e| KipukaError::Ca(format!("software key generation failed: {e}")))?;
382
383    // Step 3: Extract the subject from the template CSR and build a new CSR
384    // with the generated public key, signed by the generated private key.
385    //
386    // NOTE: The CSR construction and signing is done in a non-async block
387    // because `Box<dyn ErasedCertificateSigner>` is not Send, and we need
388    // to cross await points below for file I/O.
389    let new_csr_der = build_keygen_csr(
390        &csr_der,
391        &keygen_result.public_key_der,
392        &keygen_result.private_key_der,
393    )?;
394
395    // Step 4: Look up the CA config and resolve the CA signing key.
396    let ca_cfg = state
397        .config
398        .cas
399        .iter()
400        .find(|c| c.id == ca_id)
401        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
402
403    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
404
405    // Step 5: Issue the certificate (CA signs using its own key).
406    let profile = crate::ca::issue::EnrollmentProfile {
407        max_validity_days: ca
408            .validity_days
409            .min(crate::ca::issue::cab_forum_max_validity_days()),
410        ..crate::ca::issue::EnrollmentProfile::default()
411    };
412
413    let issuance_result = crate::ca::issue::issue_certificate(
414        &new_csr_der,
415        &profile,
416        &ca.cert_der,
417        resolved_key.as_signing_key(),
418        &ca.hash_algorithm,
419        ca.ocsp_url.as_deref(),
420        ca.crl_url.as_deref(),
421    )
422    .map_err(|e| KipukaError::Ca(format!("certificate issuance for keygen failed: {e}")))?;
423
424    crate::ca::issue::persist_certificate(&state, ca_id, &profile.name, &issuance_result).await?;
425
426    // Step 6: Wrap the certificate in PKCS#7 certs-only.
427    let cert_pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(
428        &issuance_result.certificate_der,
429    ))?;
430
431    // Step 7: Build the multipart/mixed response with cert + PKCS#8 private key.
432    let response_body = build_multipart_response(&cert_pkcs7_der, &keygen_result.private_key_der);
433
434    let content_type = format!(
435        "{}; boundary={}",
436        content_types::MULTIPART_MIXED,
437        MULTIPART_BOUNDARY
438    );
439
440    let mut resp = (StatusCode::OK, response_body).into_response();
441    if let Ok(hv) = HeaderValue::from_str(&content_type) {
442        resp.headers_mut().insert(header::CONTENT_TYPE, hv);
443    }
444
445    state
446        .record_audit_event(
447            "serverkeygen_success",
448            &format!(
449                "ca_id={ca_id}, identity={identity}, backend=software, key_type={key_type:?}, serial={}",
450                issuance_result.serial_number
451            ),
452        )
453        .await;
454
455    Ok(resp)
456}
457
458/// Build a new CSR from the template subject and generated key pair.
459///
460/// Constructs a PKCS#10 CSR with the subject DN from the template CSR and the
461/// SPKI from the generated public key, signed by the generated private key
462/// (proof of possession). This CSR is then fed to `issue_certificate()`.
463///
464/// This is a synchronous function to avoid holding `Box<dyn ErasedCertificateSigner>`
465/// (which is not `Send`) across async await points.
466pub(crate) fn build_keygen_csr(
467    template_csr_der: &[u8],
468    public_key_der: &[u8],
469    private_key_pkcs8_der: &[u8],
470) -> Result<Vec<u8>, KipukaError> {
471    let template_csr = synta_certificate::csr::CertificationRequest::from_der(template_csr_der)
472        .map_err(|e| KipukaError::BadRequest(format!("CSR template parse failed: {e}")))?;
473
474    let subject_der = template_csr
475        .certification_request_info
476        .subject
477        .to_der()
478        .map_err(|e| KipukaError::Ca(format!("CSR subject encode failed: {e}")))?;
479
480    // Load the generated private key for CSR signing.
481    let generated_key = synta_certificate::BackendPrivateKey::from_der(private_key_pkcs8_der)
482        .map_err(|e| KipukaError::Ca(format!("failed to load generated key: {e}")))?;
483
484    let signer = {
485        use synta_certificate::PrivateKey as _;
486        generated_key.as_signer("sha256")
487    };
488
489    let mut builder = synta_certificate::CsrBuilder::new()
490        .subject_name(&subject_der)
491        .public_key_der(public_key_der);
492    for (oid, critical, value) in crate::ca::issue::requested_extensions(template_csr_der)
493        .map_err(|e| KipukaError::BadRequest(e.to_string()))?
494    {
495        builder = builder.add_extension(oid, critical, &value);
496    }
497    builder
498        .sign(&signer)
499        .map_err(|e| KipukaError::Ca(format!("CSR construction failed: {e}")))
500}
501
502/// Detect the desired key type from the CSR template's SubjectPublicKeyInfo.
503///
504/// Parses the CSR to inspect the public key algorithm OID and extracts
505/// the key type. Falls back to RSA-2048 if the CSR cannot be parsed or
506/// uses an unrecognised algorithm (e.g., a placeholder key).
507pub(crate) fn detect_key_type_from_csr(csr_der: &[u8]) -> crate::ca::keygen::KeyType {
508    use crate::ca::keygen::{EcCurve, KeyType};
509
510    let Ok(csr) = synta_certificate::csr::CertificationRequest::from_der(csr_der) else {
511        tracing::debug!("CSR template unparseable; defaulting to RSA-2048");
512        return KeyType::Rsa(2048);
513    };
514
515    let spki = &csr.certification_request_info.subject_pkinfo;
516    let key_bits = spki.subject_public_key.bit_len();
517
518    let pk_info = synta_certificate::decode_public_key_info(
519        &spki.algorithm.algorithm,
520        spki.algorithm.parameters.as_ref(),
521        spki.subject_public_key.as_bytes(),
522        key_bits,
523    );
524
525    match &pk_info {
526        synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
527            // Use the CSR's RSA key size, clamped to allowed values.
528            let bits = match *bit_count {
529                0..=2048 => 2048u32,
530                2049..=3072 => 3072,
531                _ => 4096,
532            };
533            tracing::debug!(bits, "detected RSA key type from CSR template");
534            KeyType::Rsa(bits)
535        }
536        synta_certificate::PublicKeyInfo::Ec {
537            curve_nist_name, ..
538        } => {
539            let curve = match curve_nist_name {
540                Some("P-384") => EcCurve::P384,
541                _ => EcCurve::P256, // default to P-256
542            };
543            tracing::debug!(curve = %curve, "detected EC key type from CSR template");
544            KeyType::Ecdsa(curve)
545        }
546        synta_certificate::PublicKeyInfo::Unknown { alg_name, .. } => {
547            tracing::debug!(
548                algorithm = %alg_name,
549                "unrecognised CSR template algorithm; defaulting to RSA-2048"
550            );
551            KeyType::Rsa(2048)
552        }
553    }
554}
555
556/// Build a `multipart/mixed` response body with the certificate and private key.
557///
558/// RFC 7030 §4.4.2: the response contains two MIME parts:
559/// 1. The certificate chain (PKCS#7 certs-only, base64-encoded)
560/// 2. The private key (PKCS#8 DER, base64-encoded)
561pub(crate) fn build_multipart_response(cert_pkcs7_der: &[u8], private_key_pkcs8: &[u8]) -> String {
562    let cert_b64 = encode_est_base64(cert_pkcs7_der);
563    let key_b64 = encode_est_base64(private_key_pkcs8);
564
565    format!(
566        "\r\n--{boundary}\r\n\
567         Content-Type: {cert_type}\r\n\
568         Content-Transfer-Encoding: base64\r\n\
569         \r\n\
570         {cert_b64}\r\n\
571         --{boundary}\r\n\
572         Content-Type: {key_type}\r\n\
573         Content-Transfer-Encoding: base64\r\n\
574         \r\n\
575         {key_b64}\r\n\
576         --{boundary}--\r\n",
577        boundary = MULTIPART_BOUNDARY,
578        cert_type = content_types::PKCS7_CERTS,
579        key_type = content_types::PKCS8,
580    )
581}
582
583/// Export PrivateKeyInfo, not the algorithm-specific RSAPrivateKey/ECPrivateKey.
584fn encode_pkcs8_private_key(
585    key: &openssl::pkey::PKeyRef<openssl::pkey::Private>,
586) -> Result<Vec<u8>, KipukaError> {
587    key.private_key_to_pkcs8()
588        .map_err(|e| KipukaError::Ca(format!("PKCS#8 DER: {e}")))
589}
590
591#[cfg(test)]
592mod pkcs8_response_regression {
593    #[test]
594    fn recovered_rsa_and_ec_keys_use_strict_pkcs8_encoding() {
595        use openssl::{
596            ec::{EcGroup, EcKey},
597            nid::Nid,
598            pkey::PKey,
599            rsa::Rsa,
600        };
601        let rsa = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap();
602        let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap();
603        let ec = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap();
604        for key in [rsa, ec] {
605            let der = super::encode_pkcs8_private_key(&key).unwrap();
606            let parsed = PKey::private_key_from_pkcs8(&der)
607                .expect("application/pkcs8 must contain PrivateKeyInfo");
608            assert!(parsed.public_eq(&key));
609            assert!(PKey::private_key_from_pkcs8(&key.private_key_to_der().unwrap()).is_err());
610        }
611    }
612}