Skip to main content

krabka_security/
tls.rs

1use std::{io, path::PathBuf, sync::Arc};
2
3use rustls::{
4    pki_types::{CertificateDer, PrivateKeyDer},
5    server::WebPkiClientVerifier,
6};
7use thiserror::Error;
8
9/// Whether the server requests and verifies a client certificate during
10/// the TLS handshake, per RFC 5246 §7.4.6, which is Kafka's mTLS path.
11///
12/// `Required` rejects connections that don't present a cert chaining to
13/// `client_ca_path`. `Optional` requests a cert but still accepts anonymous
14/// handshakes. The dispatch layer must surface the `Anonymous` outcome to the
15/// gating logic. `Disabled` requests no client cert.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ClientAuthMode {
18    /// No client certificate requested. The handshake completes
19    /// without `CertificateRequest`.
20    #[default]
21    Disabled,
22    /// Client certificate is requested but the handshake also accepts
23    /// peers that don't present one. The dispatch layer keeps such
24    /// connections as ANONYMOUS.
25    Optional,
26    /// Client certificate is required. Handshake fails if the peer
27    /// doesn't present a cert chaining to `client_ca_path`.
28    Required,
29}
30
31#[derive(Debug, Clone)]
32pub struct TlsConfig {
33    pub cert_chain_path: PathBuf,
34    pub private_key_path: PathBuf,
35    /// Roots that the *client* side uses to verify server certs. The client
36    /// side is this broker as an outbound inter-broker dialer. Mirrors Kafka's
37    /// `ssl.truststore.location` on the client.
38    pub trust_roots_path: Option<PathBuf>,
39    /// PEM file with the CA(s) that verify *incoming* client certs when
40    /// `client_auth != Disabled`. Mirrors Kafka's
41    /// `ssl.client.auth.truststore.location`, the operator-supplied clients CA
42    /// secret.
43    pub client_ca_path: Option<PathBuf>,
44    /// Client-cert request mode. Default: `Disabled`, which requests no client
45    /// cert.
46    pub client_auth: ClientAuthMode,
47}
48
49#[derive(Debug, Error)]
50pub enum TlsError {
51    #[error("io: {0}")]
52    Io(#[from] io::Error),
53    #[error("rustls: {0}")]
54    Rustls(#[from] rustls::Error),
55    #[error("PEM: {0}")]
56    Pem(String),
57    #[error("no private key in {0}")]
58    NoPrivateKey(PathBuf),
59    #[error("no certificates in {0}")]
60    NoCerts(PathBuf),
61    /// `client_auth` is `Optional`/`Required` but `client_ca_path` is
62    /// unset. A client-cert verifier needs at least one trust root.
63    #[error("client_auth is enabled but no client_ca_path configured")]
64    MissingClientCa,
65    /// rustls's `WebPkiClientVerifier::builder` rejected the supplied trust
66    /// roots. Usually the cert is not a CA, or rustls does not support the
67    /// public key algorithm.
68    #[error("client cert verifier build failed: {0}")]
69    VerifierBuild(String),
70}
71
72impl TlsConfig {
73    /// Build an mTLS client directly from Kubernetes Secret PEM bytes.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error when certificates, private keys, or trust roots are
78    /// missing, malformed, or rejected by rustls.
79    pub fn build_client_config_from_pem(
80        cert_chain_pem: &[u8],
81        private_key_pem: &[u8],
82        trust_roots_pem: &[u8],
83    ) -> Result<Arc<rustls::ClientConfig>, TlsError> {
84        use rustls::pki_types::pem::PemObject;
85
86        let mut roots = rustls::RootCertStore::empty();
87        for cert in CertificateDer::pem_slice_iter(trust_roots_pem) {
88            roots.add(cert.map_err(|error| TlsError::Pem(error.to_string()))?)?;
89        }
90        let certs = CertificateDer::pem_slice_iter(cert_chain_pem)
91            .collect::<Result<Vec<_>, _>>()
92            .map_err(|error| TlsError::Pem(error.to_string()))?;
93        if certs.is_empty() {
94            return Err(TlsError::NoCerts(PathBuf::from("<memory>")));
95        }
96        let key = PrivateKeyDer::from_pem_slice(private_key_pem)
97            .map_err(|error| TlsError::Pem(error.to_string()))?;
98        Ok(Arc::new(
99            rustls::ClientConfig::builder()
100                .with_root_certificates(roots)
101                .with_client_auth_cert(certs, key)?,
102        ))
103    }
104
105    // TLS server-config setup. skip_all keeps the loaded certs/key material out
106    // of span fields; only the non-sensitive client-auth mode is recorded.
107    // `err` surfaces cert/key loading + verifier-build failures (Debug).
108    #[tracing::instrument(level = "info", skip_all, fields(client_auth = ?self.client_auth), err)]
109    /// # Errors
110    /// Returns an error when credentials or key material are invalid, cryptographic verification fails, or the TLS, SASL, or Kerberos exchange is rejected.
111    pub fn build_server_config(&self) -> Result<Arc<rustls::ServerConfig>, TlsError> {
112        let certs = load_certs(&self.cert_chain_path)?;
113        let key = load_private_key(&self.private_key_path)?;
114        let builder = rustls::ServerConfig::builder();
115        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
116        let mut cfg = match self.client_auth {
117            ClientAuthMode::Disabled => {
118                builder.with_no_client_auth().with_single_cert(certs, key)?
119            }
120            ClientAuthMode::Optional | ClientAuthMode::Required => {
121                let ca_path = self
122                    .client_ca_path
123                    .as_ref()
124                    .ok_or(TlsError::MissingClientCa)?;
125                let mut roots = rustls::RootCertStore::empty();
126                for cert in load_certs(ca_path)? {
127                    roots.add(cert)?;
128                }
129                let verifier_builder = WebPkiClientVerifier::builder(Arc::new(roots));
130                let verifier = match self.client_auth {
131                    ClientAuthMode::Optional => verifier_builder.allow_unauthenticated().build(),
132                    ClientAuthMode::Required => verifier_builder.build(),
133                    ClientAuthMode::Disabled => unreachable!(),
134                }
135                .map_err(|e| TlsError::VerifierBuild(e.to_string()))?;
136                builder
137                    .with_client_cert_verifier(verifier)
138                    .with_single_cert(certs, key)?
139            }
140        };
141        // Linux kTLS (Increment F): the kernel TLS offload needs rustls to
142        // surface the negotiated traffic secrets so `ktls` can install them
143        // into the socket's `crypto_info`. This is same-trust-boundary — the
144        // broker already holds the private key and the plaintext records — and
145        // it is a no-op on connections that never get configured for kTLS.
146        // Flows through `DynamicServerConfig::from_tls_config`/`reload_from`
147        // automatically, so TLS hot-reload keeps extraction enabled.
148        #[cfg(target_os = "linux")]
149        {
150            cfg.enable_secret_extraction = true;
151        }
152        Ok(Arc::new(cfg))
153    }
154
155    // TLS client-config setup (outbound dialer, no client identity). skip_all
156    // keeps loaded trust roots out of span fields. `err` surfaces cert-loading
157    // failures (Debug).
158    #[tracing::instrument(level = "info", skip_all, err)]
159    /// # Errors
160    /// Returns an error when credentials or key material are invalid, cryptographic verification fails, or the TLS, SASL, or Kerberos exchange is rejected.
161    pub fn build_client_config(&self) -> Result<Arc<rustls::ClientConfig>, TlsError> {
162        let mut roots = rustls::RootCertStore::empty();
163        if let Some(path) = &self.trust_roots_path {
164            for cert in load_certs(path)? {
165                roots.add(cert)?;
166            }
167        }
168        let cfg = rustls::ClientConfig::builder()
169            .with_root_certificates(roots)
170            .with_no_client_auth();
171        Ok(Arc::new(cfg))
172    }
173
174    /// Build a rustls `ClientConfig` that BOTH verifies the peer's server cert
175    /// against `trust_roots_path` AND presents this node's own
176    /// `cert_chain_path`/`private_key_path` as a client certificate, which is
177    /// mTLS.
178    ///
179    /// Peer-to-peer dialers that must mutually authenticate use this, for
180    /// example the gRPC gateway when it forwards to an owning replica.
181    ///
182    /// # Errors
183    /// Propagates `TlsError` from cert/key loading or rustls config building.
184    // mTLS client-config setup (presents this node's own identity). skip_all
185    // keeps loaded certs/key material out of span fields. `err` surfaces
186    // cert/key loading + config-build failures (Debug).
187    #[tracing::instrument(level = "info", skip_all, err)]
188    pub fn build_client_config_with_identity(&self) -> Result<Arc<rustls::ClientConfig>, TlsError> {
189        let mut roots = rustls::RootCertStore::empty();
190        if let Some(path) = &self.trust_roots_path {
191            for cert in load_certs(path)? {
192                roots.add(cert)?;
193            }
194        }
195        let certs = load_certs(&self.cert_chain_path)?;
196        let key = load_private_key(&self.private_key_path)?;
197        let cfg = rustls::ClientConfig::builder()
198            .with_root_certificates(roots)
199            .with_client_auth_cert(certs, key)
200            .map_err(TlsError::Rustls)?;
201        Ok(Arc::new(cfg))
202    }
203}
204
205fn load_certs(path: &PathBuf) -> Result<Vec<CertificateDer<'static>>, TlsError> {
206    use rustls::pki_types::pem::PemObject;
207    let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
208        .map_err(|e| TlsError::Io(io::Error::other(e.to_string())))?
209        .collect::<Result<Vec<_>, _>>()
210        .map_err(|e| TlsError::Io(io::Error::other(e.to_string())))?;
211    if certs.is_empty() {
212        return Err(TlsError::NoCerts(path.clone()));
213    }
214    Ok(certs)
215}
216
217fn load_private_key(path: &PathBuf) -> Result<PrivateKeyDer<'static>, TlsError> {
218    use rustls::pki_types::pem::PemObject;
219    PrivateKeyDer::from_pem_file(path).map_err(|_| TlsError::NoPrivateKey(path.clone()))
220}
221
222#[cfg(test)]
223mod tests {
224    use std::{fs::File, io::Write, path::Path};
225
226    use rustls::crypto::ring::default_provider;
227
228    use super::*;
229    use crate::ca::{generate_clients_ca, issue_user_cert};
230
231    fn install_provider() {
232        // rustls requires an explicit CryptoProvider when no default feature is
233        // compiled in.  We use ring, which is already in the workspace.
234        let _ = default_provider().install_default();
235    }
236
237    fn write_self_signed(dir: &Path) -> (PathBuf, PathBuf) {
238        // Reuse a deterministic dev cert; for the unit test we just need
239        // valid PEM. We embed pre-generated PEMs as constants.
240        // (Generated with: openssl req -x509 -newkey ed25519 -nodes -days 36500 \
241        //   -subj "//CN=krabka-dev" -keyout key.pem -out cert.pem)
242        let cert_pem = include_str!("../tests/fixtures/dev_cert.pem");
243        let key_pem = include_str!("../tests/fixtures/dev_key.pem");
244        let cert_path = dir.join("cert.pem");
245        let key_path = dir.join("key.pem");
246        File::create(&cert_path)
247            .unwrap()
248            .write_all(cert_pem.as_bytes())
249            .unwrap();
250        File::create(&key_path)
251            .unwrap()
252            .write_all(key_pem.as_bytes())
253            .unwrap();
254        (cert_path, key_path)
255    }
256
257    fn write_client_ca(dir: &Path) -> PathBuf {
258        // Self-signed dev client CA. Generated with:
259        //   openssl ecparam -name prime256v1 -genkey -noout -out ca.key
260        //   openssl req -x509 -new -key ca.key -days 36500 \
261        //     -subj "/CN=krabka-dev-client-ca" -out ca.pem
262        let pem = include_str!("../tests/fixtures/dev_client_ca.pem");
263        let p = dir.join("client_ca.pem");
264        File::create(&p).unwrap().write_all(pem.as_bytes()).unwrap();
265        p
266    }
267
268    #[test]
269    fn valid_cert_and_key_loads() {
270        install_provider();
271        let dir = tempfile::tempdir().unwrap();
272        let (cert_path, key_path) = write_self_signed(dir.path());
273        let cfg = TlsConfig {
274            cert_chain_path: cert_path,
275            private_key_path: key_path,
276            trust_roots_path: None,
277            client_ca_path: None,
278            client_auth: ClientAuthMode::Disabled,
279        };
280        cfg.build_server_config().expect("build server cfg");
281    }
282
283    #[test]
284    fn missing_cert_errors() {
285        let cfg = TlsConfig {
286            cert_chain_path: PathBuf::from("/nonexistent/cert.pem"),
287            private_key_path: PathBuf::from("/nonexistent/key.pem"),
288            trust_roots_path: None,
289            client_ca_path: None,
290            client_auth: ClientAuthMode::Disabled,
291        };
292        assert2::assert!(cfg.build_server_config().is_err());
293    }
294
295    #[test]
296    fn client_auth_required_without_ca_errors() {
297        install_provider();
298        let dir = tempfile::tempdir().unwrap();
299        let (cert_path, key_path) = write_self_signed(dir.path());
300        let cfg = TlsConfig {
301            cert_chain_path: cert_path,
302            private_key_path: key_path,
303            trust_roots_path: None,
304            client_ca_path: None,
305            client_auth: ClientAuthMode::Required,
306        };
307        let err = cfg.build_server_config().unwrap_err();
308        assert2::assert!(matches!(err, TlsError::MissingClientCa));
309    }
310
311    #[test]
312    fn client_auth_required_with_ca_builds() {
313        install_provider();
314        let dir = tempfile::tempdir().unwrap();
315        let (cert_path, key_path) = write_self_signed(dir.path());
316        let ca_path = write_client_ca(dir.path());
317        let cfg = TlsConfig {
318            cert_chain_path: cert_path,
319            private_key_path: key_path,
320            trust_roots_path: None,
321            client_ca_path: Some(ca_path),
322            client_auth: ClientAuthMode::Required,
323        };
324        cfg.build_server_config()
325            .expect("build with client cert verifier");
326    }
327
328    #[test]
329    fn client_auth_optional_with_ca_builds() {
330        install_provider();
331        let dir = tempfile::tempdir().unwrap();
332        let (cert_path, key_path) = write_self_signed(dir.path());
333        let ca_path = write_client_ca(dir.path());
334        let cfg = TlsConfig {
335            cert_chain_path: cert_path,
336            private_key_path: key_path,
337            trust_roots_path: None,
338            client_ca_path: Some(ca_path),
339            client_auth: ClientAuthMode::Optional,
340        };
341        cfg.build_server_config()
342            .expect("build with optional client cert verifier");
343    }
344
345    #[test]
346    fn client_config_with_identity_builds() {
347        install_provider();
348        let dir = tempfile::tempdir().unwrap();
349        let ca = generate_clients_ca("p4-ca", 365).expect("ca");
350        let leaf = issue_user_cert(&ca.cert_pem, &ca.key_pem, "gw", 365).expect("leaf");
351        let cert_path = dir.path().join("c.pem");
352        let key_path = dir.path().join("k.pem");
353        let ca_path = dir.path().join("ca.pem");
354        File::create(&cert_path)
355            .unwrap()
356            .write_all(leaf.cert_pem.as_bytes())
357            .unwrap();
358        File::create(&key_path)
359            .unwrap()
360            .write_all(leaf.key_pem.as_bytes())
361            .unwrap();
362        File::create(&ca_path)
363            .unwrap()
364            .write_all(ca.cert_pem.as_bytes())
365            .unwrap();
366        let cfg = TlsConfig {
367            cert_chain_path: cert_path,
368            private_key_path: key_path,
369            trust_roots_path: Some(ca_path),
370            client_ca_path: None,
371            client_auth: ClientAuthMode::Disabled,
372        };
373        cfg.build_client_config_with_identity()
374            .expect("client cfg with identity");
375    }
376}