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(¬_before_str)
233 .bind(¬_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}