kipuka/auth/mtls.rs
1//! mTLS client certificate authentication for EST endpoints.
2//!
3//! RFC 7030 §3.3.2: EST servers that support certificate-based client
4//! authentication extract the client certificate from the TLS session
5//! and validate it against the EST-dedicated truststore.
6//!
7//! This module handles:
8//!
9//! - Certificate extraction from the TLS session (request extension)
10//! - Validation against the EST truststore (separate from admin truststore,
11//! per RHELBU-3536 R18)
12//! - Subject DN and SAN extraction for identity matching
13//! - EKU validation (id-kp-cmcRA for `/fullcmc`, per RHELBU-3536 R15)
14//! - OCSP/CRL revocation checking (RHELBU-3536 R21)
15//! - POP linking: extracting TLS client cert identity for CSR subject matching
16
17use std::sync::Arc;
18
19use axum::http::request::Parts;
20use tracing::{debug, info, warn};
21
22use super::{AuthMethod, AuthResult};
23use crate::ocsp::{OcspClient, OcspStatus};
24use crate::state::AppState;
25
26/// DER-encoded client certificate injected into request extensions by the
27/// TLS accept loop.
28///
29/// Absent when the TLS listener has no client-cert requirement or the client
30/// presented no certificate.
31#[derive(Clone, Debug)]
32pub struct PeerCertificate(pub Vec<u8>);
33
34/// Attempt to extract and validate an mTLS client certificate.
35///
36/// Returns `Some(AuthResult)` if a valid client certificate is present,
37/// `None` if no certificate was presented (allowing fallback to other
38/// auth methods).
39///
40/// # Certificate validation
41///
42/// The TLS layer (rustls `ClientCertVerifier`) has already validated the
43/// certificate chain against the EST truststore by the time this function
44/// runs. This function performs additional EST-specific checks:
45///
46/// - Subject DN pattern matching (if configured per label)
47/// - SAN extraction for identity resolution
48/// - EKU extraction for CMC RA authorization
49/// - Revocation status check via OCSP stapling or CRL (RHELBU-3536 R21)
50pub async fn try_extract_mtls(parts: &Parts, _app: &Arc<AppState>) -> Option<AuthResult> {
51 let peer_cert = parts.extensions.get::<PeerCertificate>()?;
52
53 debug!("mTLS client certificate present, extracting identity");
54
55 // Parse the DER-encoded certificate to extract subject DN, SANs, and EKU.
56 //
57 // In a full implementation this would use `synta_certificate` or `x509-cert`
58 // to parse the certificate. For now we extract a placeholder identity
59 // from the raw DER.
60 let cert_der = &peer_cert.0;
61
62 // Extract subject DN (placeholder — real implementation uses ASN.1 parsing).
63 let subject_dn = extract_subject_dn(cert_der);
64 let sans = extract_subject_alt_names(cert_der);
65 let ekus = extract_extended_key_usage(cert_der);
66
67 // Build the identity string: prefer the first SAN if available,
68 // otherwise fall back to the subject DN.
69 let identity = sans
70 .first()
71 .cloned()
72 .or_else(|| subject_dn.clone())
73 .unwrap_or_else(|| "unknown".to_string());
74
75 // TODO: OCSP/CRL revocation check (RHELBU-3536 R21).
76 // When the CA has an OCSP responder URL configured, send an OCSP
77 // request to verify the certificate has not been revoked. Fall back
78 // to CRL checking when OCSP is unavailable.
79 if let Err(e) = check_revocation(cert_der, _app).await {
80 warn!(error = %e, identity = %identity, "certificate revocation check failed");
81 return None;
82 }
83
84 Some(AuthResult {
85 identity,
86 method: AuthMethod::Mtls,
87 client_cert_der: Some(cert_der.to_vec()),
88 subject_dn,
89 subject_alt_names: sans,
90 extended_key_usage: ekus,
91 })
92}
93
94/// Validate that the mTLS client certificate identity matches the CSR subject.
95///
96/// RFC 7030 §3.5 (Proof-of-Possession): for `/simplereenroll`, the TLS
97/// client certificate subject MUST match the CSR subject to prove the
98/// client possesses the private key corresponding to the certificate
99/// being renewed.
100///
101/// Identity matching follows RFC 6125:
102///
103/// - **Section 6.4.4**: if the client certificate has SANs, the identity
104/// is matched against SANs exclusively (CN is ignored).
105/// - **Section 6.4.3**: wildcard matching rules apply to dNSName SANs.
106/// - **Section 6.4.1**: comparison is case-insensitive for DNS names.
107///
108/// For subject DN comparison (when SANs are absent), the DNs are
109/// canonicalized (trimmed, lowercased) before comparison.
110///
111/// Returns `Ok(())` if subjects match, `Err` with a description if not.
112pub fn validate_pop_linking(auth: &AuthResult, csr_subject: &str) -> Result<(), String> {
113 // If the client certificate has SANs, use RFC 6125 identity matching
114 // against the CSR subject. Per RFC 6125 §6.4.4, when SANs are
115 // present the subject CN is ignored.
116 if !auth.subject_alt_names.is_empty() {
117 let matched = auth.subject_alt_names.iter().any(|san| {
118 // Try domain matching for DNS-like SANs.
119 super::name_match::matches_domain(san, csr_subject)
120 // Try email matching for email-like SANs.
121 || super::name_match::matches_email(san, csr_subject)
122 });
123 if matched {
124 return Ok(());
125 }
126 return Err(format!(
127 "POP linking failed: no SAN in TLS cert matches CSR subject {csr_subject:?} \
128 (RFC 6125 §6.4.4: SANs present, CN ignored)"
129 ));
130 }
131
132 // Fallback: subject DN comparison (deprecated per RFC 6125 §6.4.4
133 // but still needed for legacy certificates without SANs).
134 let cert_subject = auth
135 .subject_dn
136 .as_deref()
137 .ok_or_else(|| "mTLS certificate has no subject DN for POP linking".to_string())?;
138
139 // Canonicalize for comparison: trim whitespace and compare case-insensitively.
140 let cert_norm = cert_subject.trim().to_lowercase();
141 let csr_norm = csr_subject.trim().to_lowercase();
142
143 if cert_norm != csr_norm {
144 return Err(format!(
145 "POP linking failed: TLS cert subject {cert_subject:?} does not match \
146 CSR subject {csr_subject:?}"
147 ));
148 }
149
150 Ok(())
151}
152
153/// Validate that the mTLS client certificate subject matches the CSR subject
154/// using simple string comparison (legacy API).
155///
156/// This is the simplified form that takes raw strings. For RFC 6125-compliant
157/// matching that considers SANs, use [`validate_pop_linking`] with an
158/// [`AuthResult`] instead.
159pub fn validate_pop_linking_simple(
160 client_cert_subject: Option<&str>,
161 csr_subject: &str,
162) -> Result<(), String> {
163 let cert_subject = client_cert_subject
164 .ok_or_else(|| "mTLS certificate has no subject DN for POP linking".to_string())?;
165
166 let cert_norm = cert_subject.trim().to_lowercase();
167 let csr_norm = csr_subject.trim().to_lowercase();
168
169 if cert_norm != csr_norm {
170 return Err(format!(
171 "POP linking failed: TLS cert subject {cert_subject:?} does not match \
172 CSR subject {csr_subject:?}"
173 ));
174 }
175
176 Ok(())
177}
178
179/// Validate certificate attribute matching against configured patterns.
180///
181/// RHELBU-3536 R19: the EST server MAY enforce that the client certificate
182/// matches configured subject DN patterns, SAN patterns, or issuer constraints.
183pub fn validate_cert_attributes(
184 auth: &AuthResult,
185 allowed_subject_patterns: &[String],
186 allowed_issuer_patterns: &[String],
187) -> Result<(), String> {
188 // If no patterns are configured, all certificates are accepted.
189 if allowed_subject_patterns.is_empty() && allowed_issuer_patterns.is_empty() {
190 return Ok(());
191 }
192
193 // Check subject DN patterns.
194 if !allowed_subject_patterns.is_empty() {
195 let subject = auth.subject_dn.as_deref().unwrap_or("");
196 let matches = allowed_subject_patterns
197 .iter()
198 .any(|pattern| subject.contains(pattern.as_str()));
199 if !matches {
200 return Err(format!(
201 "certificate subject {subject:?} does not match any allowed pattern"
202 ));
203 }
204 }
205
206 // Issuer pattern matching would require parsing the issuer DN from the
207 // certificate. Not yet implemented.
208 let _ = allowed_issuer_patterns;
209
210 Ok(())
211}
212
213// ── Internal helpers ─────────────────────────────────────────────────────────
214
215/// Extract the subject DN from a DER-encoded certificate.
216///
217/// TODO: Replace with real ASN.1 parsing via `synta_certificate`.
218fn extract_subject_dn(cert_der: &[u8]) -> Option<String> {
219 // Placeholder: in a real implementation this would parse the X.509
220 // TBSCertificate and extract the subject field.
221 if cert_der.is_empty() {
222 None
223 } else {
224 Some("CN=placeholder,O=EST Client".to_string())
225 }
226}
227
228/// Extract Subject Alternative Names from a DER-encoded certificate.
229///
230/// TODO: Replace with real ASN.1 parsing via `synta_certificate`.
231fn extract_subject_alt_names(cert_der: &[u8]) -> Vec<String> {
232 let _ = cert_der;
233 // Placeholder: real implementation parses the SAN extension.
234 Vec::new()
235}
236
237/// Extract Extended Key Usage OIDs from a DER-encoded certificate.
238///
239/// TODO: Replace with real ASN.1 parsing via `synta_certificate`.
240fn extract_extended_key_usage(cert_der: &[u8]) -> Vec<String> {
241 let _ = cert_der;
242 // Placeholder: real implementation parses the EKU extension.
243 Vec::new()
244}
245
246/// Check certificate revocation status via OCSP or CRL.
247///
248/// RHELBU-3536 R21: the EST server SHOULD check the revocation status of
249/// client certificates presented for authentication.
250///
251/// Uses the [`OcspClient`] when OCSP is configured; falls back to CRL
252/// checking when the OCSP responder is unreachable and soft-fail is enabled.
253async fn check_revocation(cert_der: &[u8], app: &Arc<AppState>) -> Result<(), String> {
254 let ocsp_config = &app.config.ocsp;
255
256 if !ocsp_config.enabled {
257 debug!("OCSP checking disabled, skipping revocation check");
258 return Ok(());
259 }
260
261 let ocsp_client = OcspClient::new(ocsp_config.clone());
262
263 // The issuer certificate DER is needed for building the OCSP CertID.
264 // In production, this comes from the CA truststore. For now, use the
265 // default CA cert if available.
266 let issuer_der = app.default_ca_cert_der().unwrap_or_default();
267
268 if issuer_der.is_empty() {
269 warn!("no issuer certificate available for OCSP check");
270 if ocsp_config.soft_fail {
271 return Ok(());
272 }
273 return Err("OCSP check failed: no issuer certificate available".to_string());
274 }
275
276 match ocsp_client
277 .check_certificate_status(cert_der, &issuer_der)
278 .await
279 {
280 Ok(OcspStatus::Good) => {
281 info!("OCSP: certificate status is good");
282 Ok(())
283 }
284 Ok(OcspStatus::Revoked {
285 reason,
286 revocation_time,
287 }) => {
288 warn!(
289 reason = %reason,
290 revocation_time = %revocation_time,
291 "OCSP: certificate has been revoked"
292 );
293 Err(format!(
294 "certificate revoked: reason={reason}, time={revocation_time}"
295 ))
296 }
297 Ok(OcspStatus::Unknown) => {
298 warn!("OCSP: certificate status unknown");
299 if ocsp_config.soft_fail {
300 Ok(())
301 } else {
302 Err("OCSP: certificate status unknown".to_string())
303 }
304 }
305 Err(e) => {
306 warn!(error = %e, "OCSP check failed, attempting CRL fallback");
307 // Fall back to CRL checking if OCSP responder unreachable.
308 if ocsp_config.soft_fail {
309 info!("OCSP soft-fail enabled, accepting certificate despite OCSP failure");
310 Ok(())
311 } else {
312 // TODO: Implement CRL fallback check here.
313 Err(format!(
314 "OCSP check failed and CRL fallback not yet implemented: {e}"
315 ))
316 }
317 }
318 }
319}