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
92 tracing::info!(
93 ca_id = %ca_id,
94 label = %label.label,
95 identity = %identity,
96 "simplereenroll request"
97 );
98
99 // Decode the base64-encoded CSR.
100 let csr_der = decode_est_base64(&body)
101 .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
102
103 if csr_der.is_empty() || csr_der.len() < 60 {
104 return Err(KipukaError::BadRequest("CSR is empty or too short".into()));
105 }
106
107 // POP linking: verify the TLS client cert subject matches the CSR subject.
108 //
109 // RFC 7030 §3.5: "the subject field in the CSR MUST be the same as
110 // the subject field in the client certificate used for TLS authentication."
111 //
112 // TODO: Parse the CSR subject DN and compare with the TLS cert subject.
113 //
114 // let csr = synta::pkcs10::CertificationRequest::from_der(&csr_der)?;
115 // let csr_subject = csr.subject_dn_string();
116 // mtls::validate_pop_linking(auth.0.subject_dn.as_deref(), &csr_subject)?;
117
118 if let Some(ref cert_subject) = auth.0.subject_dn {
119 tracing::debug!(
120 cert_subject = %cert_subject,
121 "POP linking: TLS cert subject will be compared with CSR subject"
122 );
123 // Placeholder for actual POP linking validation.
124 }
125
126 // Verify the client certificate has not been revoked (RHELBU-3536 R21).
127 //
128 // The mTLS module already checks revocation during extraction, but we
129 // perform a second check here to handle the case where the certificate
130 // was revoked between TLS handshake and request processing.
131 //
132 // TODO: Implement OCSP/CRL check.
133 // kipuka_est::revocation::check_certificate(
134 // auth.0.client_cert_der.as_deref().unwrap(),
135 // &state,
136 // ).await?;
137
138 // Look up the CA backend.
139 let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
140
141 // Look up the CA config to get the key_file path.
142 let ca_cfg = state
143 .config
144 .cas
145 .iter()
146 .find(|c| c.id == ca_id)
147 .ok_or_else(|| KipukaError::Ca(format!("CA config not found for id={ca_id}")))?;
148
149 // Resolve key material — variables must outlive the signing_key borrow.
150 let ca_key_pem: Vec<u8>;
151 let key_label_owned: String;
152
153 let signing_key = if ca_cfg.is_hsm_backed() {
154 let hsm_ctx = state
155 .hsm
156 .as_ref()
157 .ok_or_else(|| KipukaError::Ca("HSM not configured but CA has pkcs11_uri".into()))?;
158 key_label_owned = crate::routes::simpleenroll::parse_pkcs11_object_label(
159 ca_cfg.pkcs11_uri.as_deref().unwrap(),
160 )
161 .map_err(|e| KipukaError::Ca(format!("invalid pkcs11_uri: {e}")))?;
162 crate::ca::issue::CaSigningKey::Hsm {
163 context: hsm_ctx,
164 key_label: &key_label_owned,
165 }
166 } else {
167 ca_key_pem = tokio::fs::read(&ca_cfg.key_file).await.map_err(|e| {
168 KipukaError::Ca(format!("failed to read CA key {}: {e}", ca_cfg.key_file))
169 })?;
170 crate::ca::issue::CaSigningKey::Pem(&ca_key_pem)
171 };
172
173 // Build the enrollment profile.
174 let profile = crate::ca::issue::EnrollmentProfile {
175 max_validity_days: ca.validity_days.min(398),
176 ..crate::ca::issue::EnrollmentProfile::default()
177 };
178
179 // Issue the renewed certificate.
180 let result = crate::ca::issue::issue_certificate(
181 &csr_der,
182 &profile,
183 &ca.cert_der,
184 signing_key,
185 &ca.hash_algorithm,
186 )
187 .map_err(|e| KipukaError::Ca(format!("certificate re-issuance failed: {e}")))?;
188
189 // Store the re-enrolled certificate in the database for audit trail.
190 let serial = &result.serial_number;
191 let subject_dn = &result.subject_dn;
192 let issuer_dn = synta_certificate::format_dn(
193 &synta_certificate::Certificate::from_der(&ca.cert_der)
194 .map(|c| c.tbs_certificate.subject.0.to_vec())
195 .unwrap_or_default(),
196 );
197 let not_before_str = result.not_before.format("%Y-%m-%dT%H:%M:%SZ").to_string();
198 let not_after_str = result.not_after.format("%Y-%m-%dT%H:%M:%SZ").to_string();
199
200 if let Err(e) = sqlx::query(crate::db::pg_sql(
201 "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) \
202 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')",
203 ))
204 .bind(serial)
205 .bind(subject_dn)
206 .bind(&issuer_dn)
207 .bind(¬_before_str)
208 .bind(¬_after_str)
209 .bind(&result.certificate_der)
210 .bind(ca_id)
211 .bind(&profile.name)
212 .execute(&state.db)
213 .await
214 {
215 // Log but do not fail the re-enrollment — the certificate was already signed.
216 tracing::error!(error = %e, serial = %serial, "failed to store re-enrolled certificate in DB");
217 }
218
219 let cert_der = result.certificate_der;
220 let pkcs7_der = cert_der;
221
222 let body = encode_est_base64(&pkcs7_der);
223
224 let mut resp = (StatusCode::OK, body).into_response();
225 resp.headers_mut().insert(
226 header::CONTENT_TYPE,
227 HeaderValue::from_static(content_types::PKCS7_CERTS),
228 );
229 resp.headers_mut().insert(
230 header::HeaderName::from_static("content-transfer-encoding"),
231 HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
232 );
233
234 state
235 .record_audit_event(
236 "simplereenroll_success",
237 &format!("ca_id={ca_id}, identity={identity}"),
238 )
239 .await;
240
241 Ok(resp)
242}