Skip to main content

kipuka/routes/
simpleenroll.rs

1//! `POST /.well-known/est/simpleenroll` — Simple Enrollment.
2//!
3//! RFC 7030 §4.2: EST clients submit a PKCS#10 CSR to request a new
4//! certificate.  The client authenticates via mTLS or OTP (HTTP Basic).
5//!
6//! The server validates the CSR, forwards it to the CA backend for
7//! certificate issuance, and returns the issued certificate in a
8//! PKCS#7 certs-only response.
9
10use std::sync::Arc;
11
12use axum::body::Bytes;
13use axum::extract::State;
14use axum::http::{HeaderValue, StatusCode, header};
15use axum::response::{IntoResponse, Response};
16
17use crate::auth::EstAuth;
18use crate::error::KipukaError;
19use crate::routes::LabelExtractor;
20use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
21use crate::state::AppState;
22
23/// `POST /.well-known/est/simpleenroll`
24///
25/// Accepts a PKCS#10 CSR (base64-encoded) and returns a PKCS#7 certs-only
26/// response containing the issued certificate.
27///
28/// # Authentication
29///
30/// Requires one of:
31/// - mTLS client certificate (validated against EST truststore)
32/// - HTTP Basic with OTP (entity-id as username, OTP as password)
33///
34/// # Request
35///
36/// | Header         | Value                |
37/// |----------------|----------------------|
38/// | Content-Type   | `application/pkcs10` |
39/// | Body           | Base64-encoded DER PKCS#10 CSR |
40///
41/// # Response
42///
43/// | Header         | Value                                        |
44/// |----------------|----------------------------------------------|
45/// | Status         | `200 OK` or `202 Accepted`                   |
46/// | Content-Type   | `application/pkcs7-mime; smime-type=certs-only` |
47/// | Retry-After    | (present only with 202)                      |
48///
49/// # Errors
50///
51/// - `400 Bad Request` — malformed CSR, invalid base64, self-signature failure
52/// - `401 Unauthorized` — authentication failed
53/// - `415 Unsupported Media Type` — wrong Content-Type
54/// - `500 Internal Server Error` — CA signing failure
55/// - `503 Service Unavailable` — CA backend unavailable (with Retry-After)
56pub async fn post_simpleenroll(
57    auth: EstAuth,
58    label: LabelExtractor,
59    State(state): State<Arc<AppState>>,
60    body: Bytes,
61) -> Result<Response, KipukaError> {
62    let ca_id = label.ca_id();
63    let identity = &auth.0.identity;
64    state.admit_enrollment(identity, "simpleenroll").await?;
65
66    tracing::info!(
67        ca_id = %ca_id,
68        label = %label.label,
69        identity = %identity,
70        method = ?auth.0.method,
71        "simpleenroll request"
72    );
73
74    // Decode the base64-encoded CSR.
75    let csr_der = decode_est_base64(&body)
76        .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
77
78    // Validate the CSR.  Both an authorization denial (FDP_ACF.1, 403) and a
79    // rejected CSR — a failed proof-of-possession / self-signature (RFC 7030
80    // §4.2, 400) — are security-relevant events: record them before returning
81    // so the direct transport is audited exactly like the CMS-EST path,
82    // satisfying NIAP FAU_GEN.1 (audit every security-relevant event).
83    if let Err(e) = validate_csr(&csr_der, &auth.0, &label) {
84        if matches!(e, KipukaError::Forbidden(_) | KipukaError::BadRequest(_)) {
85            state
86                .record_audit_event_with_actor(
87                    "simpleenroll_denied",
88                    identity,
89                    &format!("ca_id={ca_id}, identity={identity}, reason={e}"),
90                )
91                .await;
92        }
93        return Err(e);
94    }
95
96    // Check if disconnected mode is active for this label.
97    let disconnected = label.disconnected.unwrap_or(state.config.est.disconnected);
98
99    if disconnected {
100        // RHELBU-3536 R7-Disconnected: queue CSR for deferred signing.
101        tracing::info!(
102            ca_id = %ca_id,
103            identity = %identity,
104            "disconnected mode: queuing CSR for deferred signing"
105        );
106
107        // Extract subject DN from the CSR for display/lookup.
108        let subject_dn = synta_certificate::csr::CertificationRequest::from_der(&csr_der)
109            .ok()
110            .map(|csr| {
111                synta_certificate::format_dn(
112                    &csr.certification_request_info
113                        .subject
114                        .to_der()
115                        .unwrap_or_default(),
116                )
117            });
118
119        // Persist the CSR for deferred signing.
120        let auth_method = format!("{:?}", auth.0.method);
121        if let Err(e) = sqlx::query(crate::db::pg_sql(
122            "INSERT INTO pending_csrs (csr_der, ca_id, subject_dn, identity, auth_method) \
123             VALUES (?, ?, ?, ?, ?)",
124        ))
125        .bind(&csr_der)
126        .bind(ca_id)
127        .bind(&subject_dn)
128        .bind(identity)
129        .bind(&auth_method)
130        .execute(&state.db)
131        .await
132        {
133            tracing::error!(error = %e, "failed to persist CSR for deferred signing");
134            return Err(KipukaError::Ca(format!(
135                "failed to persist CSR for deferred signing: {e}"
136            )));
137        }
138
139        let retry_after = state.config.est.disconnected_retry_after_secs;
140
141        let mut resp = StatusCode::ACCEPTED.into_response();
142        if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
143            resp.headers_mut().insert(header::RETRY_AFTER, hv);
144        }
145
146        state
147            .record_audit_event(
148                "simpleenroll_deferred",
149                &format!("ca_id={ca_id}, identity={identity}"),
150            )
151            .await;
152
153        return Ok(resp);
154    }
155
156    // ── Dogtag backend path ────────────────────────────────────────────────
157    //
158    // If a Dogtag PKI backend is configured, forward the enrollment to
159    // Dogtag CA instead of using direct signing.  The direct-signing path
160    // below remains the fallback when `[dogtag]` is absent.
161    if let Some(ref dogtag_pool) = state.dogtag {
162        let client = dogtag_pool
163            .get_client()
164            .map_err(|e| KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}")))?;
165
166        // Convert DER CSR to PEM for the Dogtag REST API.
167        use base64::Engine;
168        let csr_b64 = base64::engine::general_purpose::STANDARD.encode(&csr_der);
169        let csr_pem = format!(
170            "-----BEGIN CERTIFICATE REQUEST-----\n{csr_b64}\n-----END CERTIFICATE REQUEST-----"
171        );
172
173        let profile_id = &state
174            .config
175            .dogtag
176            .as_ref()
177            .expect("dogtag config present when pool is set")
178            .profile_id;
179
180        tracing::info!(
181            ca_id = %ca_id,
182            identity = %identity,
183            profile_id = %profile_id,
184            "forwarding enrollment to Dogtag CA"
185        );
186
187        let enroll_result = client
188            .enroll_certificate(&csr_pem, profile_id)
189            .await
190            .map_err(|e| KipukaError::Ca(format!("Dogtag enrollment failed: {e}")))?;
191
192        match enroll_result.status {
193            kipuka_dogtag::EnrollStatus::Complete => {
194                let cert_der = enroll_result.certificate_der.ok_or_else(|| {
195                    KipukaError::Ca("Dogtag returned complete status but no certificate".into())
196                })?;
197
198                // Store the Dogtag-issued certificate in our DB for audit trail.
199                if let Err(e) = sqlx::query(crate::db::pg_sql(
200                    "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
201                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
202                ))
203                .bind(&enroll_result.request_id)
204                .bind("(dogtag-issued)")
205                .bind("(dogtag)")
206                .bind("")
207                .bind("")
208                .bind(&cert_der)
209                .bind(ca_id)
210                .bind(profile_id.as_str())
211                .execute(&state.db)
212                .await
213                {
214                    tracing::error!(
215                        error = %e,
216                        request_id = %enroll_result.request_id,
217                        "failed to store Dogtag-issued certificate in DB"
218                    );
219                }
220
221                let pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(
222                    std::slice::from_ref(&cert_der),
223                )?;
224                let body = encode_est_base64(&pkcs7_der);
225                let mut resp = (StatusCode::OK, body).into_response();
226                resp.headers_mut().insert(
227                    header::CONTENT_TYPE,
228                    HeaderValue::from_static(content_types::PKCS7_CERTS),
229                );
230                resp.headers_mut().insert(
231                    header::HeaderName::from_static("content-transfer-encoding"),
232                    HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
233                );
234
235                state
236                    .record_audit_event(
237                        "simpleenroll_success",
238                        &format!(
239                            "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
240                            enroll_result.request_id
241                        ),
242                    )
243                    .await;
244
245                return Ok(resp);
246            }
247            kipuka_dogtag::EnrollStatus::Pending => {
248                // Dogtag profile requires agent approval — return 202 Accepted
249                // with Retry-After per RFC 7030 §4.2.3.
250                tracing::info!(
251                    request_id = %enroll_result.request_id,
252                    "Dogtag enrollment pending agent approval"
253                );
254
255                let retry_after = state.config.est.disconnected_retry_after_secs;
256                let mut resp = StatusCode::ACCEPTED.into_response();
257                if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
258                    resp.headers_mut().insert(header::RETRY_AFTER, hv);
259                }
260
261                state
262                    .record_audit_event(
263                        "simpleenroll_deferred",
264                        &format!(
265                            "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
266                            enroll_result.request_id
267                        ),
268                    )
269                    .await;
270
271                return Ok(resp);
272            }
273            kipuka_dogtag::EnrollStatus::Rejected => {
274                return Err(KipukaError::Ca(format!(
275                    "Dogtag CA rejected enrollment: request_id={}",
276                    enroll_result.request_id
277                )));
278            }
279            kipuka_dogtag::EnrollStatus::Canceled => {
280                return Err(KipukaError::Ca(format!(
281                    "Dogtag enrollment was canceled: request_id={}",
282                    enroll_result.request_id
283                )));
284            }
285        }
286    }
287
288    // ── Direct-signing path (no Dogtag) ─────────────────────────────────────
289
290    // Look up the CA backend.
291    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
292
293    // Look up the CA config to get the key_file path.
294    let ca_cfg = state
295        .config
296        .cas
297        .iter()
298        .find(|c| c.id == ca_id)
299        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
300
301    // Resolve key material.
302    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
303
304    // Build the enrollment profile (use defaults for now; a full implementation
305    // would load a named profile from the label config).
306    let profile = crate::ca::issue::EnrollmentProfile {
307        max_validity_days: ca
308            .validity_days
309            .min(crate::ca::issue::cab_forum_max_validity_days()),
310        ..crate::ca::issue::EnrollmentProfile::default()
311    };
312
313    // Issue the certificate.
314    let result = match crate::ca::issue::issue_certificate(
315        &csr_der,
316        &profile,
317        &ca.cert_der,
318        resolved_key.as_signing_key(),
319        &ca.hash_algorithm,
320        ca.ocsp_url.as_deref(),
321        ca.crl_url.as_deref(),
322    ) {
323        Ok(result) => result,
324        Err(e) => {
325            // Client-content faults not caught by validate_csr above (e.g. an
326            // undersized key or a disallowed algorithm) map to BadRequest (400);
327            // server faults map to Ca (500).  Audit a client-caused rejection as
328            // enroll.reject (NIAP FAU_GEN.1).
329            let err = KipukaError::from(e);
330            if matches!(err, KipukaError::BadRequest(_)) {
331                state
332                    .record_audit_event_with_actor(
333                        "simpleenroll_denied",
334                        identity,
335                        &format!("ca_id={ca_id}, identity={identity}, reason={err}"),
336                    )
337                    .await;
338            }
339            return Err(err);
340        }
341    };
342
343    // Store the issued certificate in the database for audit trail.
344    let serial = &result.serial_number;
345    let subject_dn = &result.subject_dn;
346    let issuer_dn = synta_certificate::format_dn(
347        &synta_certificate::Certificate::from_der(&ca.cert_der)
348            .map(|c| c.tbs_certificate.subject.0.to_vec())
349            .unwrap_or_default(),
350    );
351    let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
352    let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
353
354    if let Err(e) = sqlx::query(crate::db::pg_sql(
355        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
356         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
357    ))
358    .bind(serial)
359    .bind(subject_dn)
360    .bind(&issuer_dn)
361    .bind(&not_before_str)
362    .bind(&not_after_str)
363    .bind(&result.certificate_der)
364    .bind(ca_id)
365    .bind(&profile.name)
366    .execute(&state.db)
367    .await
368    {
369        // Log but do not fail the enrollment — the certificate was already signed.
370        tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
371    }
372
373    let cert_der = result.certificate_der;
374
375    // Wrap in PKCS#7 degenerate certs-only per RFC 7030 §4.2.3.
376    let pkcs7_der =
377        crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
378
379    let body = encode_est_base64(&pkcs7_der);
380
381    let mut resp = (StatusCode::OK, body).into_response();
382    resp.headers_mut().insert(
383        header::CONTENT_TYPE,
384        HeaderValue::from_static(content_types::PKCS7_CERTS),
385    );
386    resp.headers_mut().insert(
387        header::HeaderName::from_static("content-transfer-encoding"),
388        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
389    );
390
391    state
392        .record_audit_event(
393            "simpleenroll_success",
394            &format!("ca_id={ca_id}, identity={identity}"),
395        )
396        .await;
397
398    Ok(resp)
399}
400
401/// Validate a PKCS#10 CSR for enrollment.
402///
403/// RFC 7030 §4.2 and §3.5 validation checks:
404///
405/// 1. **Self-signature** — the CSR must be signed by the included public key,
406///    proving the client possesses the corresponding private key.
407///
408/// 2. **Required attributes** — the CSR must contain attributes required by
409///    the enrollment profile (as advertised via `/csrattrs`).
410///
411/// 3. **POP linking (§3.5)** — when the client authenticates via mTLS, the
412///    CSR SHOULD contain a `challengePassword` attribute binding the CSR to
413///    the TLS session.  This prevents an attacker from capturing a valid
414///    CSR and submitting it from a different TLS session.
415///
416/// 4. **CN match** — when `require_cn_match` is configured for the label,
417///    the CSR subject CN must match the authenticated identity.
418fn validate_csr(
419    csr_der: &[u8],
420    auth: &crate::auth::AuthResult,
421    label: &LabelExtractor,
422) -> Result<(), KipukaError> {
423    if csr_der.is_empty() {
424        return Err(KipukaError::BadRequest("empty CSR".into()));
425    }
426
427    // Minimal size check — a valid PKCS#10 CSR is at least ~60 bytes.
428    if csr_der.len() < 60 {
429        return Err(KipukaError::BadRequest(
430            "CSR is too short to be valid".into(),
431        ));
432    }
433
434    // ── Step 1: Parse the CSR ──────────────────────────────────────────────
435    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
436        .map_err(|e| KipukaError::BadRequest(format!("CSR parse failed: {e}")))?;
437
438    tracing::debug!(csr_len = csr_der.len(), "CSR parsed successfully");
439
440    // ── Step 2: Verify CSR self-signature (RFC 7030 §4.2) ──────────────────
441    //
442    // The CSR is self-signed: the signature is created with the private key
443    // corresponding to the public key in the CSR.  Verifying the self-signature
444    // proves the client possesses the private key (proof-of-possession).
445    //
446    // Per RFC 7030 §4.2, an EST server MUST verify the CSR self-signature.
447    {
448        use synta_certificate::SignatureVerifier;
449
450        // Encode the CertificationRequestInfo (TBS portion) to DER.
451        let cri_der = csr
452            .certification_request_info
453            .to_der()
454            .map_err(|e| KipukaError::BadRequest(format!("CSR CRI encode failed: {e}")))?;
455
456        // Encode the signature algorithm to DER.
457        let sig_alg_der = csr
458            .signature_algorithm
459            .to_der()
460            .map_err(|e| KipukaError::BadRequest(format!("CSR sig alg encode failed: {e}")))?;
461
462        // Extract the raw signature bytes from the BIT STRING.
463        let signature_bits = csr.signature.as_bytes();
464
465        // Encode the subject public key info to DER (the CSR is self-signed,
466        // so the issuer SPKI is the CSR's own SPKI).
467        let spki_der = csr
468            .certification_request_info
469            .subject_pkinfo
470            .to_der()
471            .map_err(|e| KipukaError::BadRequest(format!("CSR SPKI encode failed: {e}")))?;
472
473        let verifier = synta_certificate::default_signature_verifier();
474        verifier
475            .verify_certificate_signature(&cri_der, &sig_alg_der, signature_bits, &spki_der)
476            .map_err(|e| {
477                tracing::warn!(error = %e, "CSR self-signature verification failed");
478                KipukaError::BadRequest(format!("CSR self-signature verification failed: {e}"))
479            })?;
480
481        tracing::debug!("CSR self-signature verified");
482    }
483
484    // ── Step 2b: Enrollment authorization (NIAP CA PP FDP_ACF.1) ───────────
485    //
486    // Bind the authenticated requester to the CSR and enforce the per-label
487    // name-authorization allowlist.  Without this, any authenticated principal
488    // could request a certificate for names it does not own.  A no-op unless
489    // the label opts in (`require_cn_match`, `require_san_match`, or a
490    // `permitted_*` allowlist is configured).
491    {
492        let policy = label.enroll_policy();
493        if let Err(reason) = crate::auth::enroll_authz::authorize_csr(&csr, &auth.identity, &policy)
494        {
495            tracing::warn!(
496                identity = %auth.identity,
497                label = %label.label,
498                reason = %reason,
499                "enrollment rejected: CSR not authorized for requester"
500            );
501            return Err(KipukaError::Forbidden(format!(
502                "enrollment not authorized: {reason}"
503            )));
504        }
505        tracing::debug!("enrollment authorization passed");
506    }
507
508    // ── Step 3: Validate key size ──────────────────────────────────────────
509    //
510    // Enforce minimum key sizes per CA/B Forum Baseline Requirements:
511    // - RSA: >= 2048 bits
512    // - ECDSA: >= P-256 (256 bits)
513    {
514        let spki = &csr.certification_request_info.subject_pkinfo;
515        let key_bit_len = spki.subject_public_key.bit_len();
516
517        let pk_info = synta_certificate::decode_public_key_info(
518            &spki.algorithm.algorithm,
519            spki.algorithm.parameters.as_ref(),
520            spki.subject_public_key.as_bytes(),
521            key_bit_len,
522        );
523
524        match &pk_info {
525            synta_certificate::PublicKeyInfo::Rsa { bit_count, .. } => {
526                tracing::debug!(algorithm = "RSA", key_bits = bit_count, "CSR key info");
527                if *bit_count < 2048 {
528                    return Err(KipukaError::BadRequest(format!(
529                        "RSA key too small: {bit_count}-bit (minimum 2048-bit required)"
530                    )));
531                }
532            }
533            synta_certificate::PublicKeyInfo::Ec {
534                bit_count,
535                curve_nist_name,
536                ..
537            } => {
538                let curve = curve_nist_name.unwrap_or("unknown");
539                tracing::debug!(
540                    algorithm = "EC",
541                    curve = curve,
542                    key_bits = bit_count,
543                    "CSR key info"
544                );
545                if *bit_count < 256 {
546                    return Err(KipukaError::BadRequest(format!(
547                        "EC key too small: {curve} {bit_count}-bit (minimum P-256 required)"
548                    )));
549                }
550            }
551            synta_certificate::PublicKeyInfo::Unknown {
552                alg_name,
553                bit_count,
554                ..
555            } => {
556                // Unknown algorithm — log but allow (may be PQC or other
557                // algorithm not yet recognized by the key decoder).
558                tracing::debug!(
559                    algorithm = %alg_name,
560                    key_bits = bit_count,
561                    "CSR key: unknown algorithm, skipping size check"
562                );
563            }
564        }
565    }
566
567    // ── Step 4: Extract and log requested extensions ───────────────────────
568    //
569    // CSR attributes may contain an extensionRequest (PKCS#9 OID
570    // 1.2.840.113549.1.9.14) listing X.509v3 extensions the client would
571    // like in the issued certificate.  We log these at debug level for
572    // audit visibility.
573    if let Some(ref attrs) = csr.certification_request_info.attributes {
574        for attr in attrs.elements() {
575            let oid_components = attr.attr_type.components();
576            if oid_components == synta_certificate::oids::PKCS9_EXTENSION_REQUEST {
577                tracing::debug!("CSR contains extensionRequest attribute");
578                // Each attr_values element is a raw DER blob containing
579                // SEQUENCE OF Extension.  Log the count for visibility.
580                let ext_count = attr.attr_values.len();
581                tracing::debug!(
582                    extension_values = ext_count,
583                    "CSR extensionRequest attribute value count"
584                );
585            } else if oid_components == synta_certificate::oids::PKCS9_CHALLENGE_PASSWORD {
586                tracing::debug!("CSR contains challengePassword attribute");
587            } else {
588                let oid_str: String = oid_components
589                    .iter()
590                    .map(|n| n.to_string())
591                    .collect::<Vec<_>>()
592                    .join(".");
593                tracing::debug!(oid = %oid_str, "CSR contains unknown attribute");
594            }
595        }
596    }
597
598    Ok(())
599}
600
601// parse_pkcs11_object_label and helpers have been moved to crate::ca::issue.