Skip to main content

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}