1use crate::auth::error::AuthError;
14
15#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
17pub struct MtlsConfig {
18 pub enabled: bool,
20 pub use_cn: bool,
22 pub use_san_dns: bool,
24 pub use_san_email: bool,
26 #[serde(default)]
31 pub allowed_cns: Option<Vec<String>>,
32 #[serde(default)]
36 pub max_identity_len: Option<usize>,
37}
38
39#[derive(Debug, Clone)]
45pub struct CertificateIdentity {
46 principal: String,
47 source: CertIdentitySource,
48}
49
50impl CertificateIdentity {
51 #[must_use]
53 pub fn principal(&self) -> &str {
54 &self.principal
55 }
56
57 #[must_use]
59 pub fn source(&self) -> &CertIdentitySource {
60 &self.source
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum CertIdentitySource {
67 CommonName,
69 SanDns,
71 SanEmail,
73}
74
75#[derive(Debug, Clone)]
77pub struct MtlsValidator {
78 config: MtlsConfig,
79}
80
81impl MtlsValidator {
82 #[must_use]
84 pub fn new(config: MtlsConfig) -> Self {
85 Self { config }
86 }
87
88 const DEFAULT_MAX_LEN: usize = 253;
92
93 fn max_len(&self) -> usize {
95 self.config
96 .max_identity_len
97 .unwrap_or(Self::DEFAULT_MAX_LEN)
98 }
99
100 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 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 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 if let Some(ref allowed) = self.config.allowed_cns {
156 if !allowed.iter().any(|a| a == cn) {
157 return Err(AuthError::InvalidCertificate(format!(
158 "CN \"{cn}\" is not in the allowed list"
159 )));
160 }
161 }
162
163 Ok(CertificateIdentity {
164 principal: cn.to_string(),
165 source: CertIdentitySource::CommonName,
166 })
167 }
168
169 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 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 #[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 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 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!(validator
414 .extract_identity_from_san_email("user\0@evil.com")
415 .is_err());
416 assert!(validator
417 .extract_identity_from_san_email("admin@chronix.io")
418 .is_ok());
419 }
420}