Skip to main content

solti_tls/
client.rs

1//! # Client-side TLS configuration.
2//!
3//! [`ClientTlsConfig`] (built via [`ClientTlsConfigBuilder`]) describes a TLS client:
4//! the trust roots (CA) used to verify the server, an optional client cert/key pair for mTLS, and ALPN.
5//! [`ClientTlsConfig::into_rustls_config`] yields a [`rustls::ClientConfig`].
6//!
7//! **Hostname verification is performed by `rustls` at connect time**, not here - see [`ClientTlsConfig::into_rustls_config`].
8
9use std::path::PathBuf;
10
11use crate::{PemSource, TlsError};
12
13/// Client-side TLS configuration.
14///
15/// Construct via [`ClientTlsConfig::builder`].
16/// `client_cert` and `client_key` are paired: supply both (mTLS) or neither - the builder rejects one without the other.
17///
18/// ## Security
19///
20/// `client_key` is held as a [`PemSource`] whose `Bytes` variant keeps the raw key; the derived `Debug` redacts it (see [`PemSource`]).
21/// The key is not zeroized while the config is alive.
22///
23/// ## Also
24///
25/// - [`ServerTlsConfig`](crate::ServerTlsConfig) - the peer side.
26/// - [`ClientTlsConfigBuilder`] - the builder.
27/// - [`PemSource`], [`TlsError`].
28#[derive(Debug, Clone)]
29pub struct ClientTlsConfig {
30    /// Trusted CA bundle for verifying the server's certificate.
31    pub ca: PemSource,
32    /// Client certificate chain (`None` = no client cert).
33    pub client_cert: Option<PemSource>,
34    /// Client private key.
35    pub client_key: Option<PemSource>,
36    /// ALPN protocol list, in preference order.
37    pub alpn: Vec<Vec<u8>>,
38}
39
40impl ClientTlsConfig {
41    /// Start a new builder.
42    pub fn builder() -> ClientTlsConfigBuilder {
43        ClientTlsConfigBuilder::default()
44    }
45
46    /// Build a [`rustls::ClientConfig`].
47    ///
48    /// Reads the PEM sources, builds a `RootCertStore` from `ca`, optionally adds the client cert+key for mTLS, and applies ALPN.
49    /// Auto-installs the `ring` [`CryptoProvider`](crate::ensure_default_provider) if none is set.
50    ///
51    /// ## Security: read this!
52    ///
53    /// The resulting config verifies that the server's certificate **chains to the `ca` bundle** you supplied (`rustls`' `WebPkiServerVerifier`; trust roots come only from your PEM, not the OS store).
54    /// It does **not** itself check the server *hostname*: SAN/identity matching is done by `rustls` when you connect, against the [`ServerName`](rustls::pki_types::ServerName)
55    /// you pass to `TlsConnector::connect(server_name, ..)` (or the tonic/reqwest equivalent).
56    ///
57    /// **Pass the real server name** - a wrong or placeholder name silently defeats identity checking even though the chain still validates.
58    /// Do not install a `dangerous()` certificate verifier on the returned config.
59    /// Revocation (OCSP/CRL) is not checked.
60    ///
61    /// If `client_cert` + `client_key` are set, they are presented for mTLS.
62    ///
63    /// ## Errors
64    ///
65    /// [`TlsError::Io`] (PEM read), [`TlsError::NoCertificates`] / [`TlsError::NoPrivateKey`] (parse), [`TlsError::Rustls`].
66    pub fn into_rustls_config(self) -> Result<rustls::ClientConfig, TlsError> {
67        crate::ensure_default_provider();
68
69        let ca_bytes = self.ca.read()?;
70        let ca_certs = crate::load_certs_from_pem(ca_bytes.as_slice())?;
71        let mut roots = rustls::RootCertStore::empty();
72        for ca in ca_certs {
73            roots.add(ca)?;
74        }
75
76        let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
77
78        let mut config = match (self.client_cert, self.client_key) {
79            (Some(cert_src), Some(key_src)) => {
80                let cert_bytes = cert_src.read()?;
81                let key_bytes = key_src.read()?;
82                let certs = crate::load_certs_from_pem(cert_bytes.as_slice())?;
83                let key = crate::load_key_from_pem(key_bytes.as_slice())?;
84                builder.with_client_auth_cert(certs, key)?
85            }
86            _ => builder.with_no_client_auth(),
87        };
88
89        config.alpn_protocols = self.alpn;
90        Ok(config)
91    }
92}
93
94/// Incremental builder for [`ClientTlsConfig`].
95#[derive(Debug, Default, Clone)]
96pub struct ClientTlsConfigBuilder {
97    client_cert: Option<PemSource>,
98    client_key: Option<PemSource>,
99    ca: Option<PemSource>,
100    alpn: Vec<Vec<u8>>,
101}
102
103impl ClientTlsConfigBuilder {
104    /// Set the trusted CA bundle (verifies the server's certificate).
105    pub fn ca(mut self, src: PemSource) -> Self {
106        self.ca = Some(src);
107        self
108    }
109
110    /// Set the client certificate chain.
111    pub fn client_cert(mut self, src: PemSource) -> Self {
112        self.client_cert = Some(src);
113        self
114    }
115
116    /// Set the client private key.
117    pub fn client_key(mut self, src: PemSource) -> Self {
118        self.client_key = Some(src);
119        self
120    }
121
122    /// Set the ALPN protocol list, in preference order.
123    ///
124    /// Pass `["h2"]` for gRPC-only, `["h2", "http/1.1"]` for HTTP (default is empty).
125    pub fn with_alpn<I, S>(mut self, protocols: I) -> Self
126    where
127        I: IntoIterator<Item = S>,
128        S: Into<Vec<u8>>,
129    {
130        self.alpn = protocols.into_iter().map(Into::into).collect();
131        self
132    }
133
134    /// Convenience: trusted CA bundle from a file path.
135    pub fn ca_pem_file(self, path: impl Into<PathBuf>) -> Self {
136        self.ca(PemSource::Path(path.into()))
137    }
138
139    /// Convenience: trusted CA bundle from in-memory bytes.
140    pub fn ca_pem_bytes(self, bytes: impl Into<Vec<u8>>) -> Self {
141        self.ca(PemSource::Bytes(bytes.into()))
142    }
143
144    /// Convenience: client cert chain from a file path.
145    pub fn client_cert_pem_file(self, path: impl Into<PathBuf>) -> Self {
146        self.client_cert(PemSource::Path(path.into()))
147    }
148
149    /// Convenience: client cert chain from in-memory bytes.
150    pub fn client_cert_pem_bytes(self, bytes: impl Into<Vec<u8>>) -> Self {
151        self.client_cert(PemSource::Bytes(bytes.into()))
152    }
153
154    /// Convenience: client private key from a file path.
155    pub fn client_key_pem_file(self, path: impl Into<PathBuf>) -> Self {
156        self.client_key(PemSource::Path(path.into()))
157    }
158
159    /// Convenience: client private key from in-memory bytes.
160    pub fn client_key_pem_bytes(self, bytes: impl Into<Vec<u8>>) -> Self {
161        self.client_key(PemSource::Bytes(bytes.into()))
162    }
163
164    /// Finalize the configuration.
165    ///
166    /// Requires `ca`.
167    /// Rejects an unpaired client cert/key with [`TlsError::MissingField`] (`"client_cert"` or `"client_key"`).
168    /// Does no I/O: the PEM sources are read by [`ClientTlsConfig::into_rustls_config`].
169    ///
170    /// ## Example
171    ///
172    /// ```
173    /// use solti_tls::{ClientTlsConfig, TlsError};
174    ///
175    /// // A client cert without its key is rejected.
176    /// let err = ClientTlsConfig::builder()
177    ///     .ca_pem_bytes(b"-----BEGIN CERTIFICATE-----\n...".to_vec())
178    ///     .client_cert_pem_bytes(b"cert".to_vec())
179    ///     .build()
180    ///     .unwrap_err();
181    /// assert!(matches!(err, TlsError::MissingField("client_key")));
182    /// ```
183    pub fn build(self) -> Result<ClientTlsConfig, TlsError> {
184        let ca = self.ca.ok_or(TlsError::MissingField("ca"))?;
185        match (&self.client_cert, &self.client_key) {
186            (Some(_), None) => return Err(TlsError::MissingField("client_key")),
187            (None, Some(_)) => return Err(TlsError::MissingField("client_cert")),
188            _ => {}
189        }
190        Ok(ClientTlsConfig {
191            ca,
192            client_cert: self.client_cert,
193            client_key: self.client_key,
194            alpn: self.alpn,
195        })
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::PemSource;
203
204    #[test]
205    fn builder_returns_config_with_ca() {
206        let cfg = ClientTlsConfig::builder()
207            .ca_pem_bytes(b"--FAKE CA--".to_vec())
208            .build()
209            .unwrap();
210        assert!(matches!(cfg.ca, PemSource::Bytes(_)));
211        assert!(cfg.client_cert.is_none());
212        assert!(cfg.client_key.is_none());
213        assert!(cfg.alpn.is_empty());
214    }
215
216    #[test]
217    fn builder_errors_when_ca_is_missing() {
218        let err = ClientTlsConfig::builder().build().unwrap_err();
219        assert!(matches!(err, TlsError::MissingField("ca")));
220    }
221
222    #[test]
223    fn with_client_cert_pair_enables_mtls() {
224        let cfg = ClientTlsConfig::builder()
225            .ca_pem_bytes(vec![1])
226            .client_cert_pem_bytes(b"cert".to_vec())
227            .client_key_pem_bytes(b"key".to_vec())
228            .build()
229            .unwrap();
230        assert!(matches!(cfg.client_cert, Some(PemSource::Bytes(_))));
231        assert!(matches!(cfg.client_key, Some(PemSource::Bytes(_))));
232    }
233
234    #[test]
235    fn builder_errors_when_client_cert_without_key() {
236        let err = ClientTlsConfig::builder()
237            .ca_pem_bytes(vec![1])
238            .client_cert_pem_bytes(b"cert".to_vec())
239            .build()
240            .unwrap_err();
241        assert!(matches!(err, TlsError::MissingField("client_key")));
242    }
243
244    #[test]
245    fn builder_errors_when_client_key_without_cert() {
246        let err = ClientTlsConfig::builder()
247            .ca_pem_bytes(vec![1])
248            .client_key_pem_bytes(b"key".to_vec())
249            .build()
250            .unwrap_err();
251        assert!(matches!(err, TlsError::MissingField("client_cert")));
252    }
253
254    #[test]
255    fn with_alpn_sets_protocols() {
256        let cfg = ClientTlsConfig::builder()
257            .ca_pem_bytes(vec![1])
258            .with_alpn(["h2", "http/1.1"])
259            .build()
260            .unwrap();
261        assert_eq!(cfg.alpn, vec![b"h2".to_vec(), b"http/1.1".to_vec()]);
262    }
263
264    fn rcgen_self_signed() -> (Vec<u8>, Vec<u8>) {
265        let b = rcgen::generate_simple_self_signed(vec!["example.com".into()]).unwrap();
266        (
267            b.cert.pem().into_bytes(),
268            b.signing_key.serialize_pem().into_bytes(),
269        )
270    }
271
272    #[test]
273    fn into_rustls_config_succeeds_with_ca_only() {
274        let (ca, _) = rcgen_self_signed();
275        let cfg = ClientTlsConfig::builder().ca_pem_bytes(ca).build().unwrap();
276        let _rustls = cfg.into_rustls_config().unwrap();
277    }
278
279    #[test]
280    fn into_rustls_config_succeeds_with_mtls_client_cert() {
281        let (ca, _) = rcgen_self_signed();
282        let (cert, key) = rcgen_self_signed();
283        let cfg = ClientTlsConfig::builder()
284            .ca_pem_bytes(ca)
285            .client_cert_pem_bytes(cert)
286            .client_key_pem_bytes(key)
287            .build()
288            .unwrap();
289        let _rustls = cfg.into_rustls_config().unwrap();
290    }
291
292    #[test]
293    fn into_rustls_config_propagates_alpn_to_rustls() {
294        let (ca, _) = rcgen_self_signed();
295        let cfg = ClientTlsConfig::builder()
296            .ca_pem_bytes(ca)
297            .with_alpn(["h2"])
298            .build()
299            .unwrap();
300        let rustls = cfg.into_rustls_config().unwrap();
301        assert_eq!(rustls.alpn_protocols, vec![b"h2".to_vec()]);
302    }
303}