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    // Look up the CA backend.
112    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
113
114    // Look up the CA config to get the key_file path.
115    let ca_cfg = state
116        .config
117        .cas
118        .iter()
119        .find(|c| c.id == ca_id)
120        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
121
122    // Resolve key material — variables must outlive the signing_key borrow.
123    let ca_key_pem: Vec<u8>;
124    let key_label_owned: String;
125
126    let signing_key = if ca_cfg.is_hsm_backed() {
127        let hsm_ctx = state
128            .hsm
129            .as_ref()
130            .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
131        key_label_owned = parse_pkcs11_object_label(ca_cfg.pkcs11_uri.as_deref().unwrap())
132            .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
133        crate::ca::issue::CaSigningKey::Hsm {
134            context: hsm_ctx,
135            key_label: &key_label_owned,
136        }
137    } else {
138        ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
139            KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
140        })?;
141        crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
142    };
143
144    // Build the enrollment profile (use defaults for now; a full implementation
145    // would load a named profile from the label config).
146    let profile = crate::ca::issue::EnrollmentProfile {
147        max_validity_days: ca.validity_days.min(398),
148        ..crate::ca::issue::EnrollmentProfile::default()
149    };
150
151    // Issue the certificate.
152    let result = crate::ca::issue::issue_certificate(
153        &csr_der,
154        &profile,
155        &ca.cert_der,
156        signing_key,
157        &ca.hash_algorithm,
158    )
159    .map_err(|e| KipukaError::Ca(format!("certificate issuance failed: {e}")))?;
160
161    // Store the issued certificate in the database for audit trail.
162    let serial = &result.serial_number;
163    let subject_dn = &result.subject_dn;
164    let issuer_dn = synta_certificate::format_dn(
165        &synta_certificate::Certificate::from_der(&ca.cert_der)
166            .map(|c| c.tbs_certificate.subject.0.to_vec())
167            .unwrap_or_default(),
168    );
169    let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
170    let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
171
172    if let Err(e) = sqlx::query(crate::db::pg_sql(
173        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
174         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
175    ))
176    .bind(serial)
177    .bind(subject_dn)
178    .bind(&issuer_dn)
179    .bind(&not_before_str)
180    .bind(&not_after_str)
181    .bind(&result.certificate_der)
182    .bind(ca_id)
183    .bind(&profile.name)
184    .execute(&state.db)
185    .await
186    {
187        // Log but do not fail the enrollment — the certificate was already signed.
188        tracing::error!(error = %e, serial = %serial, "failed to store issued certificate in DB");
189    }
190
191    let cert_der = result.certificate_der;
192
193    // Return the DER-encoded certificate directly (base64-wrapped).
194    // A full implementation would wrap in PKCS#7 certs-only:
195    // let pkcs7_der = kipuka_est::pkcs7::build_certs_only(&[cert_der, ca.cert_der]);
196    let pkcs7_der = cert_der;
197
198    let body = encode_est_base64(&pkcs7_der);
199
200    let mut resp = (StatusCode::OK, body).into_response();
201    resp.headers_mut().insert(
202        header::CONTENT_TYPE,
203        HeaderValue::from_static(content_types::PKCS7_CERTS),
204    );
205    resp.headers_mut().insert(
206        header::HeaderName::from_static("content-transfer-encoding"),
207        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
208    );
209
210    state
211        .record_audit_event(
212            "simpleenroll_success",
213            &format!("ca_id={ca_id}, identity={identity}"),
214        )
215        .await;
216
217    Ok(resp)
218}
219
220/// Validate a PKCS#10 CSR for enrollment.
221///
222/// RFC 7030 §4.2 and §3.5 validation checks:
223///
224/// 1. **Self-signature** — the CSR must be signed by the included public key,
225///    proving the client possesses the corresponding private key.
226///
227/// 2. **Required attributes** — the CSR must contain attributes required by
228///    the enrollment profile (as advertised via `/csrattrs`).
229///
230/// 3. **POP linking (§3.5)** — when the client authenticates via mTLS, the
231///    CSR SHOULD contain a `challengePassword` attribute binding the CSR to
232///    the TLS session.  This prevents an attacker from capturing a valid
233///    CSR and submitting it from a different TLS session.
234///
235/// 4. **CN match** — when `require_cn_match` is configured for the label,
236///    the CSR subject CN must match the authenticated identity.
237fn validate_csr(
238    csr_der: &[u8],
239    _auth: &crate::auth::AuthResult,
240    _label: &LabelExtractor,
241) -> Result<(), KipukaError> {
242    if csr_der.is_empty() {
243        return Err(KipukaError::BadRequest("empty CSR".into()));
244    }
245
246    // TODO: Parse the CSR using `synta` or `x509-cert` and perform:
247    //
248    // 1. Self-signature verification:
249    //    let csr = synta::pkcs10::CertificationRequest::from_der(csr_der)?;
250    //    csr.verify_self_signature()?;
251    //
252    // 2. Required attribute check:
253    //    for required_oid in &label.csr_attributes {
254    //        if !csr.has_attribute(required_oid) {
255    //            return Err(KipukaError::BadRequest(...));
256    //        }
257    //    }
258    //
259    // 3. POP linking (RFC 7030 §3.5):
260    //    if auth.method == AuthMethod::Mtls {
261    //        // Verify challengePassword attribute matches TLS session binding
262    //    }
263    //
264    // 4. CN match (when configured):
265    //    if label.require_cn_match {
266    //        let cn = csr.subject_cn()?;
267    //        if cn != auth.identity {
268    //            return Err(KipukaError::BadRequest(...));
269    //        }
270    //    }
271
272    // Minimal size check — a valid PKCS#10 CSR is at least ~60 bytes.
273    if csr_der.len() < 60 {
274        return Err(KipukaError::BadRequest(
275            "CSR is too short to be valid".into(),
276        ));
277    }
278
279    Ok(())
280}
281
282/// Extract the `object` (key label) from a PKCS#11 URI.
283///
284/// PKCS#11 URI format: `pkcs11:token=TOKEN;object=KEY_LABEL;type=private`
285///
286/// Returns the value of the `object` attribute, which is the CKA_LABEL
287/// used to find the private key in the PKCS#11 token.
288///
289/// Per RFC 7512 §2.3, values may be percent-encoded; this function
290/// decodes `%XX` sequences.
291pub fn parse_pkcs11_object_label(uri: &str) -> Result<String, String> {
292    // Strip the "pkcs11:" prefix
293    let path = uri
294        .strip_prefix("pkcs11:")
295        .ok_or_else(|| format!("not a pkcs11: URI: {uri}"))?;
296
297    // Parse semicolon-separated key=value pairs
298    for part in path.split(';') {
299        if let Some((key, value)) = part.split_once('=')
300            && key == "object"
301        {
302            return pkcs11_percent_decode(value);
303        }
304    }
305
306    Err(format!("pkcs11 URI missing 'object' attribute: {uri}"))
307}
308
309/// Percent-decode a PKCS#11 URI value per RFC 7512 §2.3.
310fn pkcs11_percent_decode(s: &str) -> Result<String, String> {
311    let mut result = Vec::with_capacity(s.len());
312    let bytes = s.as_bytes();
313    let mut i = 0;
314    while i < bytes.len() {
315        if bytes[i] == b'%' && i + 2 < bytes.len() {
316            let hi = hex_digit(bytes[i + 1])
317                .ok_or_else(|| format!("invalid percent-encoding at position {i}"))?;
318            let lo = hex_digit(bytes[i + 2])
319                .ok_or_else(|| format!("invalid percent-encoding at position {}", i + 1))?;
320            result.push((hi << 4) | lo);
321            i += 3;
322        } else {
323            result.push(bytes[i]);
324            i += 1;
325        }
326    }
327    String::from_utf8(result).map_err(|e| format!("invalid UTF-8 after percent-decoding: {e}"))
328}
329
330fn hex_digit(b: u8) -> Option<u8> {
331    match b {
332        b'0'..=b'9' => Some(b - b'0'),
333        b'a'..=b'f' => Some(b - b'a' + 10),
334        b'A'..=b'F' => Some(b - b'A' + 10),
335        _ => None,
336    }
337}