Skip to main content

crabka_security/
tls.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use rustls::pki_types::{CertificateDer, PrivateKeyDer};
5use thiserror::Error;
6
7/// Whether the server requests and verifies a client certificate during
8/// the TLS handshake (RFC 5246 §7.4.6 — Kafka's mTLS path).
9///
10/// `Required` rejects connections that don't present a cert chaining to
11/// `client_ca_path`. `Optional` requests a cert but still accepts
12/// anonymous handshakes — the dispatch layer is responsible for
13/// surfacing the `Anonymous` outcome to gating logic. `Disabled` requests
14/// no client cert.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum ClientAuthMode {
17    /// No client certificate requested. The handshake completes
18    /// without `CertificateRequest`.
19    #[default]
20    Disabled,
21    /// Client certificate is requested but the handshake also accepts
22    /// peers that don't present one. The dispatch layer keeps such
23    /// connections as ANONYMOUS.
24    Optional,
25    /// Client certificate is required. Handshake fails if the peer
26    /// doesn't present a cert chaining to `client_ca_path`.
27    Required,
28}
29
30#[derive(Debug, Clone)]
31pub struct TlsConfig {
32    pub cert_chain_path: PathBuf,
33    pub private_key_path: PathBuf,
34    /// Roots used by the *client* side (this broker as an outbound
35    /// inter-broker dialer) to verify server certs. Mirrors Kafka's
36    /// `ssl.truststore.location` on the client.
37    pub trust_roots_path: Option<PathBuf>,
38    /// PEM file containing the CA(s) used to verify
39    /// *incoming* client certs when `client_auth != Disabled`. Mirrors
40    /// Kafka's `ssl.client.auth.truststore.location` (operator-supplied
41    /// clients CA secret).
42    pub client_ca_path: Option<PathBuf>,
43    /// Client-cert request mode. Defaults to `Disabled`
44    /// (no client cert requested).
45    pub client_auth: ClientAuthMode,
46}
47
48#[derive(Debug, Error)]
49pub enum TlsError {
50    #[error("io: {0}")]
51    Io(#[from] std::io::Error),
52    #[error("rustls: {0}")]
53    Rustls(#[from] rustls::Error),
54    #[error("no private key in {0}")]
55    NoPrivateKey(PathBuf),
56    #[error("no certificates in {0}")]
57    NoCerts(PathBuf),
58    /// `client_auth` is `Optional`/`Required` but `client_ca_path` is
59    /// unset. A client-cert verifier needs at least one trust root.
60    #[error("client_auth is enabled but no client_ca_path configured")]
61    MissingClientCa,
62    /// rustls's `WebPkiClientVerifier::builder` rejected the supplied
63    /// trust roots (typically: cert isn't a CA, or the public key
64    /// algorithm isn't supported).
65    #[error("client cert verifier build failed: {0}")]
66    VerifierBuild(String),
67}
68
69impl TlsConfig {
70    pub fn build_server_config(&self) -> Result<Arc<rustls::ServerConfig>, TlsError> {
71        let certs = load_certs(&self.cert_chain_path)?;
72        let key = load_private_key(&self.private_key_path)?;
73        let builder = rustls::ServerConfig::builder();
74        let cfg = match self.client_auth {
75            ClientAuthMode::Disabled => {
76                builder.with_no_client_auth().with_single_cert(certs, key)?
77            }
78            ClientAuthMode::Optional | ClientAuthMode::Required => {
79                let ca_path = self
80                    .client_ca_path
81                    .as_ref()
82                    .ok_or(TlsError::MissingClientCa)?;
83                let mut roots = rustls::RootCertStore::empty();
84                for cert in load_certs(ca_path)? {
85                    roots.add(cert)?;
86                }
87                let verifier_builder =
88                    rustls::server::WebPkiClientVerifier::builder(Arc::new(roots));
89                let verifier = match self.client_auth {
90                    ClientAuthMode::Optional => verifier_builder.allow_unauthenticated().build(),
91                    ClientAuthMode::Required => verifier_builder.build(),
92                    ClientAuthMode::Disabled => unreachable!(),
93                }
94                .map_err(|e| TlsError::VerifierBuild(e.to_string()))?;
95                builder
96                    .with_client_cert_verifier(verifier)
97                    .with_single_cert(certs, key)?
98            }
99        };
100        Ok(Arc::new(cfg))
101    }
102
103    pub fn build_client_config(&self) -> Result<Arc<rustls::ClientConfig>, TlsError> {
104        let mut roots = rustls::RootCertStore::empty();
105        if let Some(path) = &self.trust_roots_path {
106            for cert in load_certs(path)? {
107                roots.add(cert)?;
108            }
109        }
110        let cfg = rustls::ClientConfig::builder()
111            .with_root_certificates(roots)
112            .with_no_client_auth();
113        Ok(Arc::new(cfg))
114    }
115
116    /// Build a rustls `ClientConfig` that BOTH verifies the peer's server cert
117    /// against `trust_roots_path` AND presents this node's own
118    /// `cert_chain_path`/`private_key_path` as a client certificate (mTLS).
119    /// Used by peer-to-peer dialers (e.g. the gRPC gateway forwarding to an
120    /// owning replica) that must mutually authenticate.
121    ///
122    /// # Errors
123    /// Propagates `TlsError` from cert/key loading or rustls config building.
124    pub fn build_client_config_with_identity(&self) -> Result<Arc<rustls::ClientConfig>, TlsError> {
125        let mut roots = rustls::RootCertStore::empty();
126        if let Some(path) = &self.trust_roots_path {
127            for cert in load_certs(path)? {
128                roots.add(cert)?;
129            }
130        }
131        let certs = load_certs(&self.cert_chain_path)?;
132        let key = load_private_key(&self.private_key_path)?;
133        let cfg = rustls::ClientConfig::builder()
134            .with_root_certificates(roots)
135            .with_client_auth_cert(certs, key)
136            .map_err(TlsError::Rustls)?;
137        Ok(Arc::new(cfg))
138    }
139}
140
141fn load_certs(path: &PathBuf) -> Result<Vec<CertificateDer<'static>>, TlsError> {
142    use rustls::pki_types::pem::PemObject;
143    let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
144        .map_err(|e| TlsError::Io(std::io::Error::other(e.to_string())))?
145        .collect::<Result<Vec<_>, _>>()
146        .map_err(|e| TlsError::Io(std::io::Error::other(e.to_string())))?;
147    if certs.is_empty() {
148        return Err(TlsError::NoCerts(path.clone()));
149    }
150    Ok(certs)
151}
152
153fn load_private_key(path: &PathBuf) -> Result<PrivateKeyDer<'static>, TlsError> {
154    use rustls::pki_types::pem::PemObject;
155    PrivateKeyDer::from_pem_file(path).map_err(|_| TlsError::NoPrivateKey(path.clone()))
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use assert2::assert;
162    use std::fs::File;
163    use std::io::Write;
164
165    fn install_provider() {
166        // rustls requires an explicit CryptoProvider when no default feature is
167        // compiled in.  We use ring, which is already in the workspace.
168        let _ = rustls::crypto::ring::default_provider().install_default();
169    }
170
171    fn write_self_signed(dir: &std::path::Path) -> (PathBuf, PathBuf) {
172        // Reuse a deterministic dev cert; for the unit test we just need
173        // valid PEM. We embed pre-generated PEMs as constants.
174        // (Generated with: openssl req -x509 -newkey ed25519 -nodes -days 36500 \
175        //   -subj "//CN=crabka-dev" -keyout key.pem -out cert.pem)
176        let cert_pem = include_str!("../tests/fixtures/dev_cert.pem");
177        let key_pem = include_str!("../tests/fixtures/dev_key.pem");
178        let cert_path = dir.join("cert.pem");
179        let key_path = dir.join("key.pem");
180        File::create(&cert_path)
181            .unwrap()
182            .write_all(cert_pem.as_bytes())
183            .unwrap();
184        File::create(&key_path)
185            .unwrap()
186            .write_all(key_pem.as_bytes())
187            .unwrap();
188        (cert_path, key_path)
189    }
190
191    fn write_client_ca(dir: &std::path::Path) -> PathBuf {
192        // Self-signed dev client CA. Generated with:
193        //   openssl ecparam -name prime256v1 -genkey -noout -out ca.key
194        //   openssl req -x509 -new -key ca.key -days 36500 \
195        //     -subj "/CN=crabka-dev-client-ca" -out ca.pem
196        let pem = include_str!("../tests/fixtures/dev_client_ca.pem");
197        let p = dir.join("client_ca.pem");
198        File::create(&p).unwrap().write_all(pem.as_bytes()).unwrap();
199        p
200    }
201
202    #[test]
203    fn valid_cert_and_key_loads() {
204        install_provider();
205        let dir = tempfile::tempdir().unwrap();
206        let (cert_path, key_path) = write_self_signed(dir.path());
207        let cfg = TlsConfig {
208            cert_chain_path: cert_path,
209            private_key_path: key_path,
210            trust_roots_path: None,
211            client_ca_path: None,
212            client_auth: ClientAuthMode::Disabled,
213        };
214        cfg.build_server_config().expect("build server cfg");
215    }
216
217    #[test]
218    fn missing_cert_errors() {
219        let cfg = TlsConfig {
220            cert_chain_path: PathBuf::from("/nonexistent/cert.pem"),
221            private_key_path: PathBuf::from("/nonexistent/key.pem"),
222            trust_roots_path: None,
223            client_ca_path: None,
224            client_auth: ClientAuthMode::Disabled,
225        };
226        assert!(cfg.build_server_config().is_err());
227    }
228
229    #[test]
230    fn client_auth_required_without_ca_errors() {
231        install_provider();
232        let dir = tempfile::tempdir().unwrap();
233        let (cert_path, key_path) = write_self_signed(dir.path());
234        let cfg = TlsConfig {
235            cert_chain_path: cert_path,
236            private_key_path: key_path,
237            trust_roots_path: None,
238            client_ca_path: None,
239            client_auth: ClientAuthMode::Required,
240        };
241        let err = cfg.build_server_config().unwrap_err();
242        assert!(
243            matches!(err, TlsError::MissingClientCa),
244            "expected MissingClientCa, got {err:?}"
245        );
246    }
247
248    #[test]
249    fn client_auth_required_with_ca_builds() {
250        install_provider();
251        let dir = tempfile::tempdir().unwrap();
252        let (cert_path, key_path) = write_self_signed(dir.path());
253        let ca_path = write_client_ca(dir.path());
254        let cfg = TlsConfig {
255            cert_chain_path: cert_path,
256            private_key_path: key_path,
257            trust_roots_path: None,
258            client_ca_path: Some(ca_path),
259            client_auth: ClientAuthMode::Required,
260        };
261        cfg.build_server_config()
262            .expect("build with client cert verifier");
263    }
264
265    #[test]
266    fn client_auth_optional_with_ca_builds() {
267        install_provider();
268        let dir = tempfile::tempdir().unwrap();
269        let (cert_path, key_path) = write_self_signed(dir.path());
270        let ca_path = write_client_ca(dir.path());
271        let cfg = TlsConfig {
272            cert_chain_path: cert_path,
273            private_key_path: key_path,
274            trust_roots_path: None,
275            client_ca_path: Some(ca_path),
276            client_auth: ClientAuthMode::Optional,
277        };
278        cfg.build_server_config()
279            .expect("build with optional client cert verifier");
280    }
281
282    #[test]
283    fn client_config_with_identity_builds() {
284        install_provider();
285        let dir = tempfile::tempdir().unwrap();
286        let ca = crate::ca::generate_clients_ca("p4-ca", 365).expect("ca");
287        let leaf = crate::ca::issue_user_cert(&ca.cert_pem, &ca.key_pem, "gw", 365).expect("leaf");
288        let cert_path = dir.path().join("c.pem");
289        let key_path = dir.path().join("k.pem");
290        let ca_path = dir.path().join("ca.pem");
291        File::create(&cert_path)
292            .unwrap()
293            .write_all(leaf.cert_pem.as_bytes())
294            .unwrap();
295        File::create(&key_path)
296            .unwrap()
297            .write_all(leaf.key_pem.as_bytes())
298            .unwrap();
299        File::create(&ca_path)
300            .unwrap()
301            .write_all(ca.cert_pem.as_bytes())
302            .unwrap();
303        let cfg = TlsConfig {
304            cert_chain_path: cert_path,
305            private_key_path: key_path,
306            trust_roots_path: Some(ca_path),
307            client_ca_path: None,
308            client_auth: ClientAuthMode::Disabled,
309        };
310        cfg.build_client_config_with_identity()
311            .expect("client cfg with identity");
312    }
313}