Skip to main content

kipuka/routes/
simplereenroll.rs

1//! `POST /.well-known/est/simplereenroll` — Simple Re-enrollment.
2//!
3//! RFC 7030 §4.2.2: EST clients submit a PKCS#10 CSR to renew an
4//! existing certificate.  The client MUST authenticate via mTLS by
5//! presenting the certificate being renewed.
6//!
7//! POP linking (§3.5): the TLS client certificate subject MUST match
8//! the CSR subject, proving the client possesses the private key of
9//! the certificate being renewed.
10//!
11//! The server additionally verifies the client certificate has not been
12//! revoked (OCSP/CRL check per RHELBU-3536 R21).
13
14use std::sync::Arc;
15
16use axum::body::Bytes;
17use axum::extract::State;
18use axum::http::{HeaderValue, StatusCode, header};
19use axum::response::{IntoResponse, Response};
20
21use crate::auth::{AuthMethod, EstAuth};
22use crate::error::KipukaError;
23use crate::routes::LabelExtractor;
24use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
25use crate::state::AppState;
26
27/// `POST /.well-known/est/simplereenroll`
28///
29/// Accepts a PKCS#10 CSR (base64-encoded) and returns a PKCS#7 certs-only
30/// response containing the renewed certificate.
31///
32/// # Authentication
33///
34/// MUST authenticate via mTLS — the client presents the certificate being
35/// renewed.  OTP and GSSAPI are not accepted for re-enrollment.
36///
37/// # POP Linking (RFC 7030 §3.5)
38///
39/// The TLS client certificate subject MUST match the CSR subject.  This
40/// prevents an attacker from using a compromised certificate to request
41/// a certificate for a different identity.
42///
43/// # Revocation Check (RHELBU-3536 R21)
44///
45/// The server verifies the client certificate has not been revoked before
46/// accepting the re-enrollment request.  This prevents revoked certificates
47/// from being used to obtain new certificates.
48///
49/// # Request
50///
51/// | Header         | Value                |
52/// |----------------|----------------------|
53/// | Content-Type   | `application/pkcs10` |
54/// | Body           | Base64-encoded DER PKCS#10 CSR |
55///
56/// # Response
57///
58/// | Header         | Value                                        |
59/// |----------------|----------------------------------------------|
60/// | Status         | `200 OK` or `202 Accepted`                   |
61/// | Content-Type   | `application/pkcs7-mime; smime-type=certs-only` |
62///
63/// # Errors
64///
65/// - `400 Bad Request` — malformed CSR, POP linking failure
66/// - `401 Unauthorized` — mTLS required but not provided
67/// - `403 Forbidden` — client certificate revoked
68/// - `415 Unsupported Media Type` — wrong Content-Type
69/// - `500 Internal Server Error` — CA signing failure
70pub async fn post_simplereenroll(
71    auth: EstAuth,
72    label: LabelExtractor,
73    State(state): State<Arc<AppState>>,
74    body: Bytes,
75) -> Result<Response, KipukaError> {
76    let ca_id = label.ca_id();
77
78    // Re-enrollment MUST use mTLS authentication.
79    if auth.0.method != AuthMethod::Mtls {
80        tracing::warn!(
81            identity = %auth.0.identity,
82            method = ?auth.0.method,
83            "simplereenroll rejected: mTLS required"
84        );
85        return Err(KipukaError::Auth(
86            "re-enrollment requires mTLS client certificate authentication".into(),
87        ));
88    }
89
90    let identity = &auth.0.identity;
91    state.admit_enrollment(identity, "simplereenroll").await?;
92
93    tracing::info!(
94        ca_id = %ca_id,
95        label = %label.label,
96        identity = %identity,
97        "simplereenroll request"
98    );
99
100    // Decode the base64-encoded CSR.
101    let csr_der = decode_est_base64(&body)
102        .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
103
104    if csr_der.is_empty() || csr_der.len() < 60 {
105        return Err(KipukaError::BadRequest("CSR is empty or too short".into()));
106    }
107
108    // POP linking: verify the TLS client cert subject matches the CSR subject.
109    //
110    // RFC 7030 §3.5: "the subject field in the CSR MUST be the same as
111    // the subject field in the client certificate used for TLS authentication."
112    validate_pop_linking_from_csr(&csr_der, &auth.0)?;
113
114    // Verify the client certificate has not been revoked (RHELBU-3536 R21).
115    //
116    // The mTLS module already checks revocation during extraction, but we
117    // perform a second check here to handle the case where the certificate
118    // was revoked between TLS handshake and request processing.
119    if let Some(cert_der) = auth.0.client_cert_der.as_deref()
120        && let Err(e) = crate::auth::mtls::check_revocation(cert_der, &state).await
121    {
122        tracing::warn!(
123            identity = %identity,
124            error = %e,
125            "simplereenroll rejected: client certificate revocation check failed"
126        );
127        return Err(KipukaError::Forbidden(format!(
128            "client certificate revocation check failed: {e}"
129        )));
130    }
131
132    // Enrollment authorization (NIAP CA PP FDP_ACF.1).
133    //
134    // POP linking above proves the CSR subject equals the TLS client-cert
135    // subject, but the per-label SAN identity binding and the name allowlist
136    // are independent controls: a renewing client can still add SANs it was
137    // never authorized for.  Apply the same policy the /simpleenroll and
138    // CMS-EST reenroll paths enforce, so re-enrollment is not a bypass.  A
139    // no-op unless the label opts in.
140    if let Err(reason) =
141        crate::auth::enroll_authz::authorize_csr_der(&csr_der, identity, &label.enroll_policy())
142    {
143        tracing::warn!(
144            ca_id = %ca_id,
145            identity = %identity,
146            %reason,
147            "simplereenroll rejected: CSR not authorized for requester"
148        );
149        state
150            .record_audit_event_with_actor(
151                "simplereenroll_denied",
152                identity,
153                &format!("ca_id={ca_id}, identity={identity}, reason={reason}"),
154            )
155            .await;
156        return Err(KipukaError::Forbidden(format!(
157            "enrollment not authorized: {reason}"
158        )));
159    }
160
161    // Look up the CA backend.
162    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
163
164    // Look up the CA config to get the key_file path.
165    let ca_cfg = state
166        .config
167        .cas
168        .iter()
169        .find(|c| c.id == ca_id)
170        .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
171
172    // Resolve key material.
173    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
174
175    // Build the enrollment profile.
176    let profile = crate::ca::issue::EnrollmentProfile {
177        max_validity_days: ca
178            .validity_days
179            .min(crate::ca::issue::cab_forum_max_validity_days()),
180        ..crate::ca::issue::EnrollmentProfile::default()
181    };
182
183    // Issue the renewed certificate.
184    let result = match crate::ca::issue::issue_certificate(
185        &csr_der,
186        &profile,
187        &ca.cert_der,
188        resolved_key.as_signing_key(),
189        &ca.hash_algorithm,
190        ca.ocsp_url.as_deref(),
191        ca.crl_url.as_deref(),
192    ) {
193        Ok(result) => result,
194        Err(e) => {
195            // `From<IssuanceError>` maps a bad client CSR (e.g. failed
196            // proof-of-possession) to BadRequest (400) and server faults to
197            // Ca (500).  A client-caused rejection is a security-relevant
198            // event: audit it as enroll.reject before returning, mirroring the
199            // authorization-denial path (NIAP FAU_GEN.1).
200            let err = KipukaError::from(e);
201            if matches!(err, KipukaError::BadRequest(_)) {
202                state
203                    .record_audit_event_with_actor(
204                        "simplereenroll_denied",
205                        identity,
206                        &format!("ca_id={ca_id}, identity={identity}, reason={err}"),
207                    )
208                    .await;
209            }
210            return Err(err);
211        }
212    };
213
214    // Store the re-enrolled certificate in the database for audit trail.
215    let serial = &result.serial_number;
216    let subject_dn = &result.subject_dn;
217    let issuer_dn = synta_certificate::format_dn(
218        &synta_certificate::Certificate::from_der(&ca.cert_der)
219            .map(|c| c.tbs_certificate.subject.0.to_vec())
220            .unwrap_or_default(),
221    );
222    let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
223    let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
224
225    if let Err(e) = sqlx::query(crate::db::pg_sql(
226        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
227         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
228    ))
229    .bind(serial)
230    .bind(subject_dn)
231    .bind(&issuer_dn)
232    .bind(&not_before_str)
233    .bind(&not_after_str)
234    .bind(&result.certificate_der)
235    .bind(ca_id)
236    .bind(&profile.name)
237    .execute(&state.db)
238    .await
239    {
240        // Log but do not fail the re-enrollment — the certificate was already signed.
241        tracing::error!(error = %e, serial = %serial, "failed to store re-enrolled certificate in DB");
242    }
243
244    let cert_der = result.certificate_der;
245    let pkcs7_der =
246        crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(&cert_der))?;
247
248    let body = encode_est_base64(&pkcs7_der);
249
250    let mut resp = (StatusCode::OK, body).into_response();
251    resp.headers_mut().insert(
252        header::CONTENT_TYPE,
253        HeaderValue::from_static(content_types::PKCS7_CERTS),
254    );
255    resp.headers_mut().insert(
256        header::HeaderName::from_static("content-transfer-encoding"),
257        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
258    );
259
260    state
261        .record_audit_event(
262            "simplereenroll_success",
263            &format!("ca_id={ca_id}, identity={identity}"),
264        )
265        .await;
266
267    Ok(resp)
268}
269
270/// Validate POP linking by parsing the CSR and comparing its subject with the
271/// TLS client certificate subject.
272///
273/// RFC 7030 §3.5: the CSR subject MUST match the TLS client certificate subject
274/// to prove the client possesses the private key of the certificate being renewed.
275///
276/// Additionally checks for a `challengePassword` attribute (OID 1.2.840.113549.1.9.7)
277/// in the CSR.  When present, this attribute provides cryptographic binding between
278/// the CSR and the TLS session.  When absent, the mTLS client certificate alone
279/// provides POP via the TLS handshake.
280fn validate_pop_linking_from_csr(
281    csr_der: &[u8],
282    auth: &crate::auth::AuthResult,
283) -> Result<(), KipukaError> {
284    // Parse the CSR.
285    let csr = synta_certificate::csr::CertificationRequest::from_der(csr_der)
286        .map_err(|e| KipukaError::BadRequest(format!("CSR parse failed for POP linking: {e}")))?;
287
288    // Extract the CSR subject DN by encoding the Name to DER and formatting.
289    let csr_subject_der = csr
290        .certification_request_info
291        .subject
292        .to_der()
293        .map_err(|e| KipukaError::BadRequest(format!("CSR subject encode failed: {e}")))?;
294    let csr_subject = synta_certificate::format_dn(&csr_subject_der);
295
296    tracing::debug!(
297        csr_subject = %csr_subject,
298        cert_subject = ?auth.subject_dn,
299        "POP linking: comparing CSR subject with TLS cert subject"
300    );
301
302    // Check for challengePassword attribute (RFC 7030 §3.5 binding value).
303    if let Some(ref attrs) = csr.certification_request_info.attributes {
304        for attr in attrs.elements() {
305            if attr.attr_type.components() == synta_certificate::oids::PKCS9_CHALLENGE_PASSWORD {
306                tracing::debug!("POP linking: challengePassword attribute present in CSR");
307                // The challengePassword provides additional binding between the
308                // CSR and the authenticated TLS session.  A full implementation
309                // would verify the value against a computed binding (e.g., hash of
310                // client cert + server nonce).  For now, its presence is logged
311                // as evidence of POP intent.
312                break;
313            }
314        }
315    }
316
317    // Validate subject DN match using the mtls module's RFC 6125-compliant matcher.
318    crate::auth::mtls::validate_pop_linking(auth, &csr_subject)
319        .map_err(|e| KipukaError::BadRequest(format!("POP linking validation failed: {e}")))?;
320
321    tracing::info!("POP linking: CSR subject matches TLS certificate subject");
322
323    Ok(())
324}