Skip to main content

crabka_client_core/
security.rs

1//! Client-side TLS/SASL security surface for [`crate::Client`].
2//!
3//! Mirrors the broker's inter-broker credential + TLS shapes so the
4//! public clients and the inter-broker dialer negotiate the same way.
5
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use crabka_security::ListenerProtocol;
10use rustls_pki_types::pem::PemObject;
11use tokio_rustls::TlsConnector;
12
13pub use crate::sasl::SaslCredentials;
14
15/// Client-side TLS trust + SNI. Mirrors the trust-roots half of the
16/// broker's `crabka_security::TlsConfig::build_client_config`.
17#[derive(Debug, Clone)]
18pub struct TlsConnectorConfig {
19    /// PEM file of CA certs the client trusts to verify the broker's
20    /// server cert. `None` → empty root store (handshake fails unless
21    /// the server cert chains to a webpki default, which we do not
22    /// install — mirrors the broker's strict `build_client_config`).
23    pub trust_roots_pem: Option<PathBuf>,
24    /// SNI / server-name used for the TLS handshake and as the
25    /// canonical hostname for any GSSAPI SPN.
26    pub server_name: String,
27    /// Optional mTLS client identity: `(cert_chain_pem, private_key_pem)`.
28    ///
29    /// When `Some`, the cert chain and key are loaded from the given PEM
30    /// files and presented to the server during the TLS handshake
31    /// (mutual TLS / client authentication).  `None` → one-way TLS; the
32    /// client does not present a certificate (`with_no_client_auth`).
33    pub client_identity: Option<(PathBuf, PathBuf)>,
34}
35
36impl TlsConnectorConfig {
37    /// Build a `rustls::ClientConfig`.
38    ///
39    /// When [`Self::client_identity`] is `Some`, the cert chain and key are
40    /// loaded and the config is built with mutual TLS client authentication.
41    /// When `None`, the client presents no certificate (`with_no_client_auth`).
42    ///
43    /// # Errors
44    /// Returns a string error if any PEM file fails to load or parse.
45    pub fn build(&self) -> Result<Arc<rustls::ClientConfig>, String> {
46        let mut roots = rustls::RootCertStore::empty();
47        if let Some(path) = &self.trust_roots_pem {
48            for cert in rustls::pki_types::CertificateDer::pem_file_iter(path)
49                .map_err(|e| format!("trust roots load {}: {e}", path.display()))?
50            {
51                let cert = cert.map_err(|e| format!("trust roots parse: {e}"))?;
52                roots
53                    .add(cert)
54                    .map_err(|e| format!("trust roots add: {e}"))?;
55            }
56        }
57        let cfg = if let Some((cert_pem, key_pem)) = &self.client_identity {
58            let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
59                rustls::pki_types::CertificateDer::pem_file_iter(cert_pem)
60                    .map_err(|e| format!("client cert load {}: {e}", cert_pem.display()))?
61                    .collect::<Result<Vec<_>, _>>()
62                    .map_err(|e| format!("client cert parse: {e}"))?;
63            let key = rustls::pki_types::PrivateKeyDer::from_pem_file(key_pem)
64                .map_err(|e| format!("client key load {}: {e}", key_pem.display()))?;
65            rustls::ClientConfig::builder()
66                .with_root_certificates(roots)
67                .with_client_auth_cert(certs, key)
68                .map_err(|e| format!("client auth cert: {e}"))?
69        } else {
70            rustls::ClientConfig::builder()
71                .with_root_certificates(roots)
72                .with_no_client_auth()
73        };
74        Ok(Arc::new(cfg))
75    }
76
77    /// Build a ready `TlsConnector`.
78    ///
79    /// # Errors
80    /// Propagates [`Self::build`] failures.
81    pub fn connector(&self) -> Result<TlsConnector, String> {
82        Ok(TlsConnector::from(self.build()?))
83    }
84}
85
86/// Full client security policy: which listener protocol to speak, plus
87/// the TLS and SASL material it implies. `None` fields are required to
88/// match `protocol` (a `SaslSsl` policy needs both `tls` and `sasl`).
89#[derive(Debug, Clone)]
90pub struct ClientSecurity {
91    pub protocol: ListenerProtocol,
92    pub tls: Option<TlsConnectorConfig>,
93    pub sasl: Option<SaslCredentials>,
94    /// Canonical hostname for the SASL handshake — the GSSAPI service
95    /// principal host (`service_name/<sasl_host>`). Meaningful whenever
96    /// the protocol [`requires_sasl`], independent of TLS: a
97    /// `SASL_PLAINTEXT` listener has no `tls` to source the host from, so
98    /// without this GSSAPI would fall back to `localhost` and Kerberos
99    /// would reject the principal. `None` falls back to `tls.server_name`
100    /// then the connection's target host. PLAIN/SCRAM ignore it.
101    ///
102    /// [`requires_sasl`]: ListenerProtocol::requires_sasl
103    pub sasl_host: Option<String>,
104}
105
106impl ClientSecurity {
107    /// Resolve the hostname handed to the SASL handshake (the GSSAPI SPN
108    /// host). Prefers the explicit [`Self::sasl_host`], then the TLS SNI
109    /// ([`TlsConnectorConfig::server_name`]), then the connection's target
110    /// `host` if known, falling back to `"localhost"`.
111    ///
112    /// TLS SNI is unaffected — it is always sourced from `tls.server_name`.
113    #[must_use]
114    pub fn sasl_handshake_host<'a>(&'a self, target_host: Option<&'a str>) -> &'a str {
115        if let Some(h) = self.sasl_host.as_deref() {
116            h
117        } else if let Some(tls) = self.tls.as_ref() {
118            tls.server_name.as_str()
119        } else if let Some(h) = target_host {
120            h
121        } else {
122            "localhost"
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use assert2::assert;
131    use crabka_security::ListenerProtocol;
132
133    #[test]
134    fn plaintext_security_has_no_tls_or_sasl() {
135        let s = ClientSecurity {
136            protocol: ListenerProtocol::Plaintext,
137            tls: None,
138            sasl: None,
139            sasl_host: None,
140        };
141        assert!(!s.protocol.requires_tls());
142        assert!(!s.protocol.requires_sasl());
143    }
144
145    #[test]
146    fn sasl_plaintext_carries_creds() {
147        let s = ClientSecurity {
148            protocol: ListenerProtocol::SaslPlaintext,
149            tls: None,
150            sasl: Some(SaslCredentials::Plain {
151                username: "u".into(),
152                password: "p".into(),
153            }),
154            sasl_host: None,
155        };
156        assert!(s.protocol.requires_sasl());
157        assert!(matches!(s.sasl, Some(SaslCredentials::Plain { .. })));
158    }
159
160    #[test]
161    fn sasl_handshake_host_prefers_explicit_field() {
162        // SASL_PLAINTEXT (no TLS) with an explicit host: GSSAPI must get
163        // the real SPN host, not "localhost" or the target host.
164        let s = ClientSecurity {
165            protocol: ListenerProtocol::SaslPlaintext,
166            tls: None,
167            sasl: None,
168            sasl_host: Some("kdc-broker.example.com".into()),
169        };
170        assert!(s.sasl_handshake_host(Some("10.0.0.5")) == "kdc-broker.example.com");
171    }
172
173    #[test]
174    fn sasl_handshake_host_falls_back_to_tls_then_target_then_localhost() {
175        // No explicit sasl_host → TLS SNI wins.
176        let with_tls = ClientSecurity {
177            protocol: ListenerProtocol::SaslSsl,
178            tls: Some(TlsConnectorConfig {
179                trust_roots_pem: None,
180                server_name: "tls-host".into(),
181                client_identity: None,
182            }),
183            sasl: None,
184            sasl_host: None,
185        };
186        assert!(with_tls.sasl_handshake_host(Some("10.0.0.5")) == "tls-host");
187
188        // No sasl_host, no TLS → target host wins.
189        let no_tls = ClientSecurity {
190            protocol: ListenerProtocol::SaslPlaintext,
191            tls: None,
192            sasl: None,
193            sasl_host: None,
194        };
195        assert!(no_tls.sasl_handshake_host(Some("10.0.0.5")) == "10.0.0.5");
196
197        // Nothing set at all → localhost.
198        assert!(no_tls.sasl_handshake_host(None) == "localhost");
199    }
200
201    #[test]
202    fn tls_connector_config_builds_client_config() {
203        // Empty trust roots → webpki defaults disabled; we only assert it builds.
204        let _ = rustls::crypto::ring::default_provider().install_default();
205        let cfg = TlsConnectorConfig {
206            trust_roots_pem: None,
207            server_name: "broker".into(),
208            client_identity: None,
209        };
210        cfg.build().expect("client config builds with empty roots");
211    }
212
213    #[test]
214    fn tls_connector_config_client_identity_none_builds_and_bogus_path_errors() {
215        let _ = rustls::crypto::ring::default_provider().install_default();
216
217        // None path: one-way TLS builds successfully.
218        let no_id = TlsConnectorConfig {
219            trust_roots_pem: None,
220            server_name: "broker".into(),
221            client_identity: None,
222        };
223        no_id
224            .build()
225            .expect("one-way TLS builds with client_identity=None");
226
227        // Bogus path: mTLS arm must return Err (files don't exist).
228        let bogus = TlsConnectorConfig {
229            trust_roots_pem: None,
230            server_name: "broker".into(),
231            client_identity: Some((
232                "/nonexistent/cert.pem".into(),
233                "/nonexistent/key.pem".into(),
234            )),
235        };
236        assert!(
237            bogus.build().is_err(),
238            "bogus client-identity path returns Err"
239        );
240    }
241}