krabka_security/mtls.rs
1//! mTLS principal extraction.
2//!
3//! From a DER-encoded X.509 client certificate, this module derives the
4//! principal name that the broker uses for ACL lookups.
5//!
6//! Kafka's `DefaultKafkaPrincipalBuilder` uses the cert's Subject
7//! Distinguished Name in RFC 2253 format, for example `CN=alice,OU=foo,O=org`,
8//! as the principal name. Operators and `KafkaUser` mTLS provisioning pin ACLs
9//! by that DN, so Krabka matches it byte-for-byte.
10
11use tracing::field;
12use x509_parser::prelude::{FromDer, X509Certificate};
13
14/// Parse `cert_der` and return the Subject DN in RFC 2253 format.
15///
16/// This function returns `None` if the bytes do not parse as a valid X.509
17/// cert.
18// mTLS principal extraction. skip_all keeps the raw `cert_der` bytes out of
19// span fields; the derived Subject DN (the principal, non-secret) is recorded
20// mid-fn. Returns `Option`, not `Result`, so no `err`.
21#[must_use]
22#[tracing::instrument(
23 level = "debug",
24 skip_all,
25 fields(mechanism = "mTLS", principal = field::Empty)
26)]
27pub fn extract_principal_from_cert(cert_der: &[u8]) -> Option<String> {
28 let (_, cert) = X509Certificate::from_der(cert_der).ok()?;
29 let dn = cert.subject().to_string();
30 tracing::Span::current().record("principal", dn.as_str());
31 Some(dn)
32}
33
34#[cfg(test)]
35mod tests {
36
37 use rustls::pki_types::{CertificateDer, pem::PemObject};
38
39 use super::*;
40
41 #[test]
42 fn extracts_subject_dn_from_fixture_client_cert() {
43 let pem = include_bytes!("../tests/fixtures/dev_client_cert.pem");
44 let mut iter = CertificateDer::pem_slice_iter(pem);
45 let cert: CertificateDer<'static> = iter.next().expect("at least one cert").unwrap();
46 let dn = extract_principal_from_cert(cert.as_ref()).expect("parse fixture cert");
47 // openssl-rendered subject is CN=test-client,OU=integration,O=krabka.
48 // x509-parser preserves that order (most-significant first),
49 // comma-separated, no spaces. Operators pin this DN verbatim in
50 // ACLs and super_users.
51 assert2::assert!(dn == "CN=test-client,OU=integration,O=krabka");
52 }
53
54 #[test]
55 fn malformed_cert_returns_none() {
56 for (_name, input) in [
57 ("garbage", b"not-a-cert".as_slice()),
58 ("empty", b"".as_slice()),
59 ] {
60 assert2::assert!(extract_principal_from_cert(input).is_none());
61 }
62 }
63}