Skip to main content

chronix_security/auth/
mtls.rs

1//! mTLS client certificate authentication.
2//!
3//! Extracts client identity from X.509 certificate Common Name (CN)
4//! or Subject Alternative Name (SAN) fields.
5//!
6//! # Certificate Revocation
7//!
8//! CRL/OCSP checking is not implemented in Chronix — revocation is an
9//! infrastructure concern.  Use short-lived certificates (≤ 24 h TTL),
10//! a service-mesh sidecar with CRL enforcement, or a reverse proxy
11//! with `ssl_crl`.  See `docs/security.md` for recommended approaches.
12
13use crate::auth::error::AuthError;
14
15/// mTLS certificate validation configuration.
16#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
17pub struct MtlsConfig {
18    /// Whether mTLS is enabled.
19    pub enabled: bool,
20    /// Extract identity from certificate CN (Common Name).
21    pub use_cn: bool,
22    /// Extract identity from SAN (Subject Alternative Name) DNS entries.
23    pub use_san_dns: bool,
24    /// Extract identity from SAN email entries.
25    pub use_san_email: bool,
26    /// Optional allowlist of acceptable CN values.
27    ///
28    /// When set, only certificates whose CN appears in this list are
29    /// accepted.  When `None`, any syntactically valid CN is accepted.
30    #[serde(default)]
31    pub allowed_cns: Option<Vec<String>>,
32    /// Maximum acceptable length for any identity string (CN / SAN).
33    ///
34    /// Defaults to 256 when absent (via [`MtlsConfig::max_identity_len`]).
35    #[serde(default)]
36    pub max_identity_len: Option<usize>,
37}
38
39/// Client identity extracted and verified from a TLS certificate.
40///
41/// Fields are private — instances can only be created through
42/// [`MtlsValidator`] methods, which require TLS-layer inputs. This
43/// prevents callers from fabricating identities via HTTP headers.
44#[derive(Debug, Clone)]
45pub struct CertificateIdentity {
46    principal: String,
47    source: CertIdentitySource,
48}
49
50impl CertificateIdentity {
51    /// The verified principal identifier (CN, SAN DNS, or SAN email).
52    #[must_use]
53    pub fn principal(&self) -> &str {
54        &self.principal
55    }
56
57    /// How the identity was extracted from the certificate.
58    #[must_use]
59    pub fn source(&self) -> &CertIdentitySource {
60        &self.source
61    }
62}
63
64/// How the certificate identity was determined.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum CertIdentitySource {
67    /// Extracted from Common Name (CN).
68    CommonName,
69    /// Extracted from SAN DNS entry.
70    SanDns,
71    /// Extracted from SAN email entry.
72    SanEmail,
73}
74
75/// mTLS validator that extracts identity from client certificates.
76#[derive(Debug, Clone)]
77pub struct MtlsValidator {
78    config: MtlsConfig,
79}
80
81impl MtlsValidator {
82    /// Create a new mTLS validator.
83    #[must_use]
84    pub fn new(config: MtlsConfig) -> Self {
85        Self { config }
86    }
87
88    /// Default maximum identity string length.
89    /// RFC 1035: DNS labels ≤63, FQDNs ≤253. Using 253 as a
90    /// realistic default for SAN DNS names / CN values.
91    const DEFAULT_MAX_LEN: usize = 253;
92
93    /// Effective maximum identity length from config.
94    fn max_len(&self) -> usize {
95        self.config
96            .max_identity_len
97            .unwrap_or(Self::DEFAULT_MAX_LEN)
98    }
99
100    /// Validate an identity string for sanity.
101    ///
102    /// Rejects empty, too-long, and strings containing control characters
103    /// or path separators (which would be unusual in a valid CN/SAN and
104    /// could indicate injection attempts).
105    fn validate_identity(&self, value: &str, field: &str) -> Result<(), AuthError> {
106        if value.is_empty() {
107            return Err(AuthError::InvalidCertificate(format!("empty {field}")));
108        }
109        if value.len() > self.max_len() {
110            return Err(AuthError::InvalidCertificate(format!(
111                "{field} exceeds maximum length ({})",
112                self.max_len()
113            )));
114        }
115        if value.chars().any(char::is_control) {
116            return Err(AuthError::InvalidCertificate(format!(
117                "{field} contains control characters"
118            )));
119        }
120        // Reject path separators — a valid CN/SAN should not contain these.
121        if value.contains("..") || value.contains('/') || value.contains('\\') {
122            return Err(AuthError::InvalidCertificate(format!(
123                "{field} contains path separators"
124            )));
125        }
126        Ok(())
127    }
128
129    /// Extract identity from a certificate CN.
130    ///
131    /// # Security contract
132    ///
133    /// The `cn` parameter **MUST** come from the TLS layer (e.g., parsed
134    /// from the peer certificate by the TLS terminator).  Passing
135    /// user-controlled strings directly (e.g., from an HTTP header)
136    /// bypasses all certificate verification and is a critical security
137    /// violation.
138    ///
139    /// When [`MtlsConfig::allowed_cns`] is set, only CN values present
140    /// in the allowlist are accepted.
141    pub fn extract_identity_from_cn(&self, cn: &str) -> Result<CertificateIdentity, AuthError> {
142        if !self.config.enabled {
143            return Err(AuthError::Config("mTLS not enabled".into()));
144        }
145
146        if !self.config.use_cn {
147            return Err(AuthError::Config(
148                "CN-based identity extraction not enabled".into(),
149            ));
150        }
151
152        self.validate_identity(cn, "CN")?;
153
154        // Enforce allowlist when configured.
155        if let Some(ref allowed) = self.config.allowed_cns
156            && !allowed.iter().any(|a| a == cn)
157        {
158            return Err(AuthError::InvalidCertificate(format!(
159                "CN \"{cn}\" is not in the allowed list"
160            )));
161        }
162
163        Ok(CertificateIdentity {
164            principal: cn.to_string(),
165            source: CertIdentitySource::CommonName,
166        })
167    }
168
169    /// Extract identity from a SAN DNS name.
170    ///
171    /// # Security contract
172    ///
173    /// The `dns_name` parameter **MUST** come from the TLS layer.
174    pub fn extract_identity_from_san_dns(
175        &self,
176        dns_name: &str,
177    ) -> Result<CertificateIdentity, AuthError> {
178        if !self.config.enabled {
179            return Err(AuthError::Config("mTLS not enabled".into()));
180        }
181
182        if !self.config.use_san_dns {
183            return Err(AuthError::Config(
184                "SAN DNS identity extraction not enabled".into(),
185            ));
186        }
187
188        self.validate_identity(dns_name, "SAN DNS")?;
189
190        Ok(CertificateIdentity {
191            principal: dns_name.to_string(),
192            source: CertIdentitySource::SanDns,
193        })
194    }
195
196    /// Extract identity from a SAN email.
197    ///
198    /// # Security contract
199    ///
200    /// The `email` parameter **MUST** come from the TLS layer.
201    pub fn extract_identity_from_san_email(
202        &self,
203        email: &str,
204    ) -> Result<CertificateIdentity, AuthError> {
205        if !self.config.enabled {
206            return Err(AuthError::Config("mTLS not enabled".into()));
207        }
208
209        if !self.config.use_san_email {
210            return Err(AuthError::Config(
211                "SAN email identity extraction not enabled".into(),
212            ));
213        }
214
215        self.validate_identity(email, "SAN email")?;
216
217        Ok(CertificateIdentity {
218            principal: email.to_string(),
219            source: CertIdentitySource::SanEmail,
220        })
221    }
222
223    /// Check if mTLS is enabled.
224    #[must_use]
225    pub fn is_enabled(&self) -> bool {
226        self.config.enabled
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn extract_cn_identity() {
236        let validator = MtlsValidator::new(MtlsConfig {
237            enabled: true,
238            use_cn: true,
239            use_san_dns: false,
240            use_san_email: false,
241            ..Default::default()
242        });
243
244        let identity = validator
245            .extract_identity_from_cn("client.example.com")
246            .unwrap();
247        assert_eq!(identity.principal(), "client.example.com");
248        assert_eq!(*identity.source(), CertIdentitySource::CommonName);
249    }
250
251    #[test]
252    fn extract_san_dns_identity() {
253        let validator = MtlsValidator::new(MtlsConfig {
254            enabled: true,
255            use_cn: false,
256            use_san_dns: true,
257            use_san_email: false,
258            ..Default::default()
259        });
260
261        let identity = validator
262            .extract_identity_from_san_dns("service.internal")
263            .unwrap();
264        assert_eq!(identity.principal(), "service.internal");
265        assert_eq!(*identity.source(), CertIdentitySource::SanDns);
266    }
267
268    #[test]
269    fn extract_san_email_identity() {
270        let validator = MtlsValidator::new(MtlsConfig {
271            enabled: true,
272            use_cn: false,
273            use_san_dns: false,
274            use_san_email: true,
275            ..Default::default()
276        });
277
278        let identity = validator
279            .extract_identity_from_san_email("admin@chronix.io")
280            .unwrap();
281        assert_eq!(identity.principal(), "admin@chronix.io");
282        assert_eq!(*identity.source(), CertIdentitySource::SanEmail);
283    }
284
285    #[test]
286    fn disabled_mtls_rejects() {
287        let validator = MtlsValidator::new(MtlsConfig {
288            enabled: false,
289            ..Default::default()
290        });
291
292        assert!(validator.extract_identity_from_cn("test").is_err());
293    }
294
295    #[test]
296    fn empty_cn_rejected() {
297        let validator = MtlsValidator::new(MtlsConfig {
298            enabled: true,
299            use_cn: true,
300            ..Default::default()
301        });
302
303        assert!(validator.extract_identity_from_cn("").is_err());
304    }
305
306    #[test]
307    fn cn_allowlist_accepts_listed() {
308        let validator = MtlsValidator::new(MtlsConfig {
309            enabled: true,
310            use_cn: true,
311            allowed_cns: Some(vec!["service-a".into(), "service-b".into()]),
312            ..Default::default()
313        });
314
315        assert!(validator.extract_identity_from_cn("service-a").is_ok());
316        assert!(validator.extract_identity_from_cn("service-b").is_ok());
317    }
318
319    #[test]
320    fn cn_allowlist_rejects_unlisted() {
321        let validator = MtlsValidator::new(MtlsConfig {
322            enabled: true,
323            use_cn: true,
324            allowed_cns: Some(vec!["service-a".into()]),
325            ..Default::default()
326        });
327
328        let err = validator
329            .extract_identity_from_cn("evil-service")
330            .unwrap_err();
331        assert!(err.to_string().contains("not in the allowed list"));
332    }
333
334    #[test]
335    fn cn_no_allowlist_accepts_any_valid() {
336        let validator = MtlsValidator::new(MtlsConfig {
337            enabled: true,
338            use_cn: true,
339            allowed_cns: None,
340            ..Default::default()
341        });
342
343        assert!(validator.extract_identity_from_cn("anything.valid").is_ok());
344    }
345
346    #[test]
347    fn rejects_path_separators() {
348        let validator = MtlsValidator::new(MtlsConfig {
349            enabled: true,
350            use_cn: true,
351            ..Default::default()
352        });
353
354        // Path traversal attempts
355        assert!(validator.extract_identity_from_cn("../etc/passwd").is_err());
356        assert!(validator.extract_identity_from_cn("foo/bar").is_err());
357        assert!(validator.extract_identity_from_cn("foo\\bar").is_err());
358        assert!(validator.extract_identity_from_cn("a..b").is_err());
359    }
360
361    #[test]
362    fn rejects_control_characters() {
363        let validator = MtlsValidator::new(MtlsConfig {
364            enabled: true,
365            use_cn: true,
366            ..Default::default()
367        });
368
369        assert!(validator.extract_identity_from_cn("test\0null").is_err());
370        assert!(validator.extract_identity_from_cn("test\nnewline").is_err());
371    }
372
373    #[test]
374    fn rejects_overly_long_cn() {
375        let validator = MtlsValidator::new(MtlsConfig {
376            enabled: true,
377            use_cn: true,
378            max_identity_len: Some(20),
379            ..Default::default()
380        });
381
382        let long_cn = "a".repeat(21);
383        let err = validator.extract_identity_from_cn(&long_cn).unwrap_err();
384        assert!(err.to_string().contains("exceeds maximum length"));
385
386        // Exactly at limit should pass
387        let ok_cn = "a".repeat(20);
388        assert!(validator.extract_identity_from_cn(&ok_cn).is_ok());
389    }
390
391    #[test]
392    fn san_dns_validated() {
393        let validator = MtlsValidator::new(MtlsConfig {
394            enabled: true,
395            use_san_dns: true,
396            ..Default::default()
397        });
398
399        assert!(validator.extract_identity_from_san_dns("").is_err());
400        assert!(validator.extract_identity_from_san_dns("../evil").is_err());
401        assert!(validator.extract_identity_from_san_dns("good.host").is_ok());
402    }
403
404    #[test]
405    fn san_email_validated() {
406        let validator = MtlsValidator::new(MtlsConfig {
407            enabled: true,
408            use_san_email: true,
409            ..Default::default()
410        });
411
412        assert!(validator.extract_identity_from_san_email("").is_err());
413        assert!(
414            validator
415                .extract_identity_from_san_email("user\0@evil.com")
416                .is_err()
417        );
418        assert!(
419            validator
420                .extract_identity_from_san_email("admin@chronix.io")
421                .is_ok()
422        );
423    }
424}