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
65    tracing::info!(
66        ca_id = %ca_id,
67        label = %label.label,
68        identity = %identity,
69        method = ?auth.0.method,
70        "simpleenroll request"
71    );
72
73    // Decode the base64-encoded CSR.
74    let csr_der = decode_est_base64(&body)
75        .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
76
77    // Validate the CSR.
78    validate_csr(&csr_der, &auth.0, &label)?;
79
80    // Check if disconnected mode is active for this label.
81    let disconnected = label.disconnected.unwrap_or(state.config.est.disconnected);
82
83    if disconnected {
84        // RHELBU-3536 R7-Disconnected: queue CSR for deferred signing.
85        tracing::info!(
86            ca_id = %ca_id,
87            identity = %identity,
88            "disconnected mode: queuing CSR for deferred signing"
89        );
90
91        // TODO: Persist the CSR for later signing.
92        // kipuka_est::deferred::queue_csr(&state.db, ca_id, &csr_der, identity).await?;
93
94        let retry_after = state.config.est.disconnected_retry_after_secs;
95
96        let mut resp = StatusCode::ACCEPTED.into_response();
97        if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
98            resp.headers_mut().insert(header::RETRY_AFTER, hv);
99        }
100
101        state
102            .record_audit_event(
103                "simpleenroll_deferred",
104                &format!("ca_id={ca_id}, identity={identity}"),
105            )
106            .await;
107
108        return Ok(resp);
109    }
110
111    // ── Dogtag backend path ────────────────────────────────────────────────
112    //
113    // If a Dogtag PKI backend is configured, forward the enrollment to
114    // Dogtag CA instead of using direct signing.  The direct-signing path
115    // below remains the fallback when `[dogtag]` is absent.
116    if let Some(ref dogtag_pool) = state.dogtag {
117        let client = dogtag_pool.get_client().map_err(|e| {
118            KipukaError::ServiceUnavailable(format!("Dogtag CA unavailable: {e}"))
119        })?;
120
121        // Convert DER CSR to PEM for the Dogtag REST API.
122        use base64::Engine;
123        let csr_b64 = base64::engine::general_purpose::STANDARD.encode(&csr_der);
124        let csr_pem = format!(
125            "-----BEGIN CERTIFICATE REQUEST-----\n{}\n-----END CERTIFICATE REQUEST-----",
126            csr_b64
127        );
128
129        let profile_id = &state
130            .config
131            .dogtag
132            .as_ref()
133            .expect("dogtag config present when pool is set")
134            .profile_id;
135
136        tracing::info!(
137            ca_id = %ca_id,
138            identity = %identity,
139            profile_id = %profile_id,
140            "forwarding enrollment to Dogtag CA"
141        );
142
143        let enroll_result = client
144            .enroll_certificate(&csr_pem, profile_id)
145            .await
146            .map_err(|e| KipukaError::Ca(format!("Dogtag enrollment failed: {e}")))?;
147
148        match enroll_result.status {
149            kipuka_dogtag::EnrollStatus::Complete => {
150                let cert_der = enroll_result.certificate_der.ok_or_else(|| {
151                    KipukaError::Ca(
152                        "Dogtag returned complete status but no certificate".into(),
153                    )
154                })?;
155
156                // Store the Dogtag-issued certificate in our DB for audit trail.
157                if let Err(e) = sqlx::query(crate::db::pg_sql(
158                    "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
159                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
160                ))
161                .bind(&enroll_result.request_id)
162                .bind("(dogtag-issued)")
163                .bind("(dogtag)")
164                .bind("")
165                .bind("")
166                .bind(&cert_der)
167                .bind(ca_id)
168                .bind(profile_id.as_str())
169                .execute(&state.db)
170                .await
171                {
172                    tracing::error!(
173                        error = %e,
174                        request_id = %enroll_result.request_id,
175                        "failed to store Dogtag-issued certificate in DB"
176                    );
177                }
178
179                let body = encode_est_base64(&cert_der);
180                let mut resp = (StatusCode::OK, body).into_response();
181                resp.headers_mut().insert(
182                    header::CONTENT_TYPE,
183                    HeaderValue::from_static(content_types::PKCS7_CERTS),
184                );
185                resp.headers_mut().insert(
186                    header::HeaderName::from_static("content-transfer-encoding"),
187                    HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
188                );
189
190                state
191                    .record_audit_event(
192                        "simpleenroll_success",
193                        &format!(
194                            "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
195                            enroll_result.request_id
196                        ),
197                    )
198                    .await;
199
200                return Ok(resp);
201            }
202            kipuka_dogtag::EnrollStatus::Pending => {
203                // Dogtag profile requires agent approval — return 202 Accepted
204                // with Retry-After per RFC 7030 §4.2.3.
205                tracing::info!(
206                    request_id = %enroll_result.request_id,
207                    "Dogtag enrollment pending agent approval"
208                );
209
210                let retry_after = state.config.est.disconnected_retry_after_secs;
211                let mut resp = StatusCode::ACCEPTED.into_response();
212                if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
213                    resp.headers_mut().insert(header::RETRY_AFTER, hv);
214                }
215
216                state
217                    .record_audit_event(
218                        "simpleenroll_deferred",
219                        &format!(
220                            "ca_id={ca_id}, identity={identity}, backend=dogtag, request_id={}",
221                            enroll_result.request_id
222                        ),
223                    )
224                    .await;
225
226                return Ok(resp);
227            }
228            kipuka_dogtag::EnrollStatus::Rejected => {
229                return Err(KipukaError::Ca(format!(
230                    "Dogtag CA rejected enrollment: request_id={}",
231                    enroll_result.request_id
232                )));
233            }
234            kipuka_dogtag::EnrollStatus::Canceled => {
235                return Err(KipukaError::Ca(format!(
236                    "Dogtag enrollment was canceled: request_id={}",
237                    enroll_result.request_id
238                )));
239            }
240        }
241    }
242
243    // ── Direct-signing path (no Dogtag) ─────────────────────────────────────
244
245    // Look up the CA backend.
246    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
247
248    // Look up the CA config to get the key_file path.
249    let ca_cfg = state
250        .config
251        .cas
252        .iter()
253        .find(|c| c.id == ca_id)
254        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
255
256    // Resolve key material — variables must outlive the signing_key borrow.
257    let ca_key_pem: Vec<u8>;
258    let key_label_owned: String;
259
260    let signing_key = if ca_cfg.is_hsm_backed() {
261        let hsm_ctx = state
262            .hsm
263            .as_ref()
264            .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
265        key_label_owned = parse_pkcs11_object_label(ca_cfg.pkcs11_uri.as_deref().unwrap())
266            .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
267        crate::ca::issue::CaSigningKey::Hsm {
268            context: hsm_ctx,
269            key_label: &key_label_owned,
270        }
271    } else {
272        ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
273            KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
274        })?;
275        crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
276    };
277
278    // Build the enrollment profile (use defaults for now; a full implementation
279    // would load a named profile from the label config).
280    let profile = crate::ca::issue::EnrollmentProfile {
281        max_validity_days: ca.validity_days.min(398),
282        ..crate::ca::issue::EnrollmentProfile::default()
283    };
284
285    // Issue the certificate.
286    let result = crate::ca::issue::issue_certificate(
287        &csr_der,
288        &profile,
289        &ca.cert_der,
290        signing_key,
291        &ca.hash_algorithm,
292    )
293    .map_err(|e| KipukaError::Ca(format!("certificate issuance failed: {e}")))?;
294
295    // Store the issued certificate in the database for audit trail.
296    let serial = &result.serial_number;
297    let subject_dn = &result.subject_dn;
298    let issuer_dn = synta_certificate::format_dn(
299        &synta_certificate::Certificate::from_der(&ca.cert_der)
300            .map(|c| c.tbs_certificate.subject.0.to_vec())
301            .unwrap_or_default(),
302    );
303    let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
304    let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
305
306    if let Err(e) = sqlx::query(crate::db::pg_sql(
307        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
308         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
309    ))
310    .bind(serial)
311    .bind(subject_dn)
312    .bind(&issuer_dn)
313    .bind(&not_before_str)
314    .bind(&not_after_str)
315    .bind(&result.certificate_der)
316    .bind(ca_id)
317    .bind(&profile.name)
318    .execute(&state.db)
319    .await
320    {
321        // Log but do not fail the enrollment — the certificate was already signed.
322        tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
323    }
324
325    let cert_der = result.certificate_der;
326
327    // Return the DER-encoded certificate directly (base64-wrapped).
328    // A full implementation would wrap in PKCS#7 certs-only:
329    // let pkcs7_der = kipuka_est::pkcs7::build_certs_only(&[cert_der, ca.cert_der]);
330    let pkcs7_der = cert_der;
331
332    let body = encode_est_base64(&pkcs7_der);
333
334    let mut resp = (StatusCode::OK, body).into_response();
335    resp.headers_mut().insert(
336        header::CONTENT_TYPE,
337        HeaderValue::from_static(content_types::PKCS7_CERTS),
338    );
339    resp.headers_mut().insert(
340        header::HeaderName::from_static("content-transfer-encoding"),
341        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
342    );
343
344    state
345        .record_audit_event(
346            "simpleenroll_success",
347            &format!("ca_id={ca_id}, identity={identity}"),
348        )
349        .await;
350
351    Ok(resp)
352}
353
354/// Validate a PKCS#10 CSR for enrollment.
355///
356/// RFC 7030 §4.2 and §3.5 validation checks:
357///
358/// 1. **Self-signature** — the CSR must be signed by the included public key,
359///    proving the client possesses the corresponding private key.
360///
361/// 2. **Required attributes** — the CSR must contain attributes required by
362///    the enrollment profile (as advertised via `/csrattrs`).
363///
364/// 3. **POP linking (§3.5)** — when the client authenticates via mTLS, the
365///    CSR SHOULD contain a `challengePassword` attribute binding the CSR to
366///    the TLS session.  This prevents an attacker from capturing a valid
367///    CSR and submitting it from a different TLS session.
368///
369/// 4. **CN match** — when `require_cn_match` is configured for the label,
370///    the CSR subject CN must match the authenticated identity.
371fn validate_csr(
372    csr_der: &[u8],
373    _auth: &crate::auth::AuthResult,
374    _label: &LabelExtractor,
375) -> Result<(), KipukaError> {
376    if csr_der.is_empty() {
377        return Err(KipukaError::BadRequest("empty CSR".into()));
378    }
379
380    // TODO: Parse the CSR using `synta` or `x509-cert` and perform:
381    //
382    // 1. Self-signature verification:
383    //    let csr = synta::pkcs10::CertificationRequest::from_der(csr_der)?;
384    //    csr.verify_self_signature()?;
385    //
386    // 2. Required attribute check:
387    //    for required_oid in &label.csr_attributes {
388    //        if !csr.has_attribute(required_oid) {
389    //            return Err(KipukaError::BadRequest(...));
390    //        }
391    //    }
392    //
393    // 3. POP linking (RFC 7030 §3.5):
394    //    if auth.method == AuthMethod::Mtls {
395    //        // Verify challengePassword attribute matches TLS session binding
396    //    }
397    //
398    // 4. CN match (when configured):
399    //    if label.require_cn_match {
400    //        let cn = csr.subject_cn()?;
401    //        if cn != auth.identity {
402    //            return Err(KipukaError::BadRequest(...));
403    //        }
404    //    }
405
406    // Minimal size check — a valid PKCS#10 CSR is at least ~60 bytes.
407    if csr_der.len() < 60 {
408        return Err(KipukaError::BadRequest(
409            "CSR is too short to be valid".into(),
410        ));
411    }
412
413    Ok(())
414}
415
416/// Extract the `object` (key label) from a PKCS#11 URI.
417///
418/// PKCS#11 URI format: `pkcs11:token=TOKEN;object=KEY_LABEL;type=private`
419///
420/// Returns the value of the `object` attribute, which is the CKA_LABEL
421/// used to find the private key in the PKCS#11 token.
422///
423/// Per RFC 7512 §2.3, values may be percent-encoded; this function
424/// decodes `%XX` sequences.
425pub fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
426    // Strip the "pkcs11:" prefix
427    let path = uri
428        .strip_prefix("pkcs11:")
429        .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
430
431    // Parse semicolon-separated key=value pairs
432    for part in path.split(';') {
433        if let Some((key, value)) = part.split_once('=')
434            && key == "object"
435        {
436            return pkcs11_percent_decode(value);
437        }
438    }
439
440    Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
441}
442
443/// Percent-decode a PKCS#11 URI value per RFC 7512 §2.3.
444fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
445    let mut result = Vec::with_capacity(s.len());
446    let bytes = s.as_bytes();
447    let mut i = 0;
448    while i < bytes.len() {
449        if bytes[i] == b'%' && i + 2 < bytes.len() {
450            let hi = hex_digit(bytes[i + 1])
451                .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
452            let lo = hex_digit(bytes[i + 2])
453                .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
454            result.push((hi << 4) | lo);
455            i += 3;
456        } else {
457            result.push(bytes[i]);
458            i += 1;
459        }
460    }
461    String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
462}
463
464fn hex_digit(b: u8) -> Option<u8> {
465    match b {
466        b'0'..=b'9' => Some(b - b'0'),
467        b'a'..=b'f' => Some(b - b'a' + 10),
468        b'A'..=b'F' => Some(b - b'A' + 10),
469        _ => None,
470    }
471}