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
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(&not_before_str)
208    .bind(&not_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}