Skip to main content

spin_factor_outbound_networking/
tls.rs

1use std::{
2    collections::HashMap,
3    ops::Deref,
4    sync::{Arc, LazyLock},
5};
6
7use anyhow::{Context, ensure};
8
9use crate::runtime_config::{ClientCertRuntimeConfig, ClientTlsRuntimeConfig};
10
11/// TLS client configs
12#[derive(Default)]
13pub struct TlsClientConfigs {
14    /// Shared map of component ID -> HostTlsClientConfigs
15    component_host_tls_client_configs: HashMap<String, HostTlsClientConfigs>,
16    /// The default [`ClientConfig`] for a host if one is not explicitly configured for it.
17    default_tls_client_config: TlsClientConfig,
18}
19
20impl TlsClientConfigs {
21    pub(crate) fn new(
22        client_tls_configs: impl IntoIterator<Item = ClientTlsRuntimeConfig>,
23    ) -> anyhow::Result<Self> {
24        // Construct nested map of <component ID> -> <host authority> -> TLS client config
25        let mut component_host_tls_client_configs = HashMap::<String, HostTlsClientConfigs>::new();
26        for ClientTlsRuntimeConfig {
27            components,
28            hosts,
29            root_certificates,
30            use_platform_roots,
31            use_webpki_roots,
32            client_cert,
33        } in client_tls_configs
34        {
35            ensure!(
36                !components.is_empty(),
37                "client TLS 'components' list may not be empty"
38            );
39            ensure!(
40                !hosts.is_empty(),
41                "client TLS 'hosts' list may not be empty"
42            );
43            let tls_client_config = TlsClientConfig::new(
44                use_platform_roots,
45                use_webpki_roots,
46                root_certificates,
47                client_cert,
48            )
49            .context("error building TLS client config")?;
50            for component in components {
51                let host_configs = component_host_tls_client_configs
52                    .entry(component.clone())
53                    .or_default();
54                for host in &hosts {
55                    validate_host(host)?;
56                    // First matching (component, host) pair wins
57                    Arc::get_mut(host_configs)
58                        .unwrap()
59                        .entry(host.clone())
60                        .or_insert_with(|| tls_client_config.clone());
61                }
62            }
63        }
64
65        Ok(Self {
66            component_host_tls_client_configs,
67            ..Default::default()
68        })
69    }
70
71    /// Returns [`ComponentTlsClientConfigs`] for the given component.
72    pub fn get_component_tls_configs(&self, component_id: &str) -> ComponentTlsClientConfigs {
73        let host_client_configs = self
74            .component_host_tls_client_configs
75            .get(component_id)
76            .cloned();
77        ComponentTlsClientConfigs {
78            host_client_configs,
79            default_client_config: self.default_tls_client_config.clone(),
80        }
81    }
82}
83
84/// Shared maps of host authority -> TlsClientConfig
85type HostTlsClientConfigs = Arc<HashMap<String, TlsClientConfig>>;
86
87/// TLS configurations for a specific component.
88#[derive(Clone)]
89pub struct ComponentTlsClientConfigs {
90    pub(crate) host_client_configs: Option<HostTlsClientConfigs>,
91    pub(crate) default_client_config: TlsClientConfig,
92}
93
94impl ComponentTlsClientConfigs {
95    /// Returns a [`ClientConfig`] for the given host authority.
96    pub fn get_client_config(&self, host: &str) -> &TlsClientConfig {
97        self.host_client_configs
98            .as_ref()
99            .and_then(|configs| configs.get(host))
100            .unwrap_or(&self.default_client_config)
101    }
102}
103
104/// Shared TLS client configuration
105#[derive(Clone)]
106pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
107
108impl TlsClientConfig {
109    fn new(
110        use_platform_roots: bool,
111        use_webpki_roots: bool,
112        root_certificates: Vec<rustls_pki_types::CertificateDer<'static>>,
113        client_cert: Option<ClientCertRuntimeConfig>,
114    ) -> anyhow::Result<Self> {
115        anyhow::ensure!(
116            use_platform_roots || use_webpki_roots || !root_certificates.is_empty(),
117            "at least one of 'use_platform_roots', 'use_webpki_roots', or 'root_certificates' must be set"
118        );
119
120        let mut extra_roots: Box<dyn Iterator<Item = _>> = Box::new(root_certificates.into_iter());
121        if use_webpki_roots {
122            extra_roots = Box::new(
123                extra_roots.chain(webpki_root_certs::TLS_SERVER_ROOT_CERTS.iter().cloned()),
124            );
125        }
126
127        let builder = rustls::ClientConfig::builder();
128        let builder = if use_platform_roots {
129            let verifier = rustls_platform_verifier::Verifier::new_with_extra_roots(
130                extra_roots,
131                builder.crypto_provider().clone(),
132            )
133            .context("failed to initialize platform certificate verifier")?;
134            builder
135                .dangerous()
136                .with_custom_certificate_verifier(Arc::new(verifier))
137        } else {
138            let mut root_store = rustls::RootCertStore::empty();
139            extra_roots.try_for_each(|cert| root_store.add(cert))?;
140            builder.with_root_certificates(root_store)
141        };
142
143        let client_config = if let Some(ClientCertRuntimeConfig {
144            cert_chain,
145            key_der,
146        }) = client_cert
147        {
148            builder.with_client_auth_cert(cert_chain, key_der)?
149        } else {
150            builder.with_no_client_auth()
151        };
152        Ok(Self(client_config.into()))
153    }
154
155    /// Returns the inner [`rustls::ClientConfig`] for consumption by rustls APIs.
156    pub fn inner(&self) -> Arc<rustls::ClientConfig> {
157        self.0.clone()
158    }
159}
160
161impl Deref for TlsClientConfig {
162    type Target = rustls::ClientConfig;
163
164    fn deref(&self) -> &Self::Target {
165        &self.0
166    }
167}
168
169impl Default for TlsClientConfig {
170    fn default() -> Self {
171        static ONCE: LazyLock<TlsClientConfig> = LazyLock::new(|| {
172            TlsClientConfig::new(true, true, vec![], None)
173                .expect("default client config should be valid")
174        });
175        ONCE.clone()
176    }
177}
178
179/// Validate host name (authority without port)
180pub(crate) fn validate_host(host: &str) -> anyhow::Result<()> {
181    let authority: http::uri::Authority = host
182        .parse()
183        .with_context(|| format!("invalid TLS 'host' {host:?}"))?;
184    ensure!(
185        authority.port().is_none(),
186        "invalid TLS 'host' {host:?}; ports not currently supported"
187    );
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use std::path::Path;
194
195    use anyhow::Context;
196    use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
197
198    use super::*;
199
200    #[test]
201    fn test_empty_config() -> anyhow::Result<()> {
202        // Just make sure the default doesn't panic
203        let configs = TlsClientConfigs::default();
204        configs.get_tls_client_config("foo", "bar");
205        Ok(())
206    }
207
208    #[test]
209    fn test_minimal_config() -> anyhow::Result<()> {
210        let configs = TlsClientConfigs::new([ClientTlsRuntimeConfig {
211            components: vec!["test-component".into()],
212            hosts: vec!["test-host".into()],
213            root_certificates: vec![],
214            use_platform_roots: false,
215            use_webpki_roots: true,
216            client_cert: None,
217        }])?;
218        let config = configs.get_tls_client_config("test-component", "test-host");
219        // Check that we didn't just get the default
220        let default_config = configs.get_tls_client_config("other_component", "test-host");
221        assert!(!Arc::ptr_eq(&config.0, &default_config.0));
222        Ok(())
223    }
224
225    #[test]
226    fn test_maximal_config() -> anyhow::Result<()> {
227        let test_certs = test_certs()?;
228        let test_key = test_key()?;
229        let configs = TlsClientConfigs::new([ClientTlsRuntimeConfig {
230            components: vec!["test-component".into()],
231            hosts: vec!["test-host".into()],
232            root_certificates: vec![test_certs[0].clone()],
233            use_platform_roots: false,
234            use_webpki_roots: false,
235            client_cert: Some(ClientCertRuntimeConfig {
236                cert_chain: test_certs,
237                key_der: test_key,
238            }),
239        }])?;
240        let config = configs.get_tls_client_config("test-component", "test-host");
241        assert!(config.client_auth_cert_resolver.has_certs());
242        Ok(())
243    }
244
245    #[test]
246    fn test_config_overrides() -> anyhow::Result<()> {
247        let test_certs = test_certs()?;
248        let test_key = test_key()?;
249        let configs = TlsClientConfigs::new([
250            ClientTlsRuntimeConfig {
251                components: vec!["test-component1".into()],
252                hosts: vec!["test-host".into()],
253                client_cert: Some(ClientCertRuntimeConfig {
254                    cert_chain: test_certs,
255                    key_der: test_key,
256                }),
257                ..Default::default()
258            },
259            ClientTlsRuntimeConfig {
260                components: vec!["test-component1".into(), "test-component2".into()],
261                hosts: vec!["test-host".into()],
262                ..Default::default()
263            },
264        ])?;
265        // First match wins
266        let config1 = configs.get_tls_client_config("test-component1", "test-host");
267        assert!(config1.client_auth_cert_resolver.has_certs());
268
269        // Correctly select by differing component ID
270        let config2 = configs.get_tls_client_config("test-component-2", "test-host");
271        assert!(!config2.client_auth_cert_resolver.has_certs());
272        Ok(())
273    }
274
275    const TESTDATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/testdata");
276
277    fn test_certs() -> anyhow::Result<Vec<CertificateDer<'static>>> {
278        CertificateDer::pem_file_iter(Path::new(TESTDATA_DIR).join("valid-cert.pem"))?
279            .collect::<Result<Vec<_>, _>>()
280            .context("certs")
281    }
282
283    fn test_key() -> anyhow::Result<PrivateKeyDer<'static>> {
284        PrivateKeyDer::from_pem_file(Path::new(TESTDATA_DIR).join("valid-private-key.pem"))
285            .context("key")
286    }
287
288    impl TlsClientConfigs {
289        fn get_tls_client_config(&self, component_id: &str, host: &str) -> TlsClientConfig {
290            let component_config = self.get_component_tls_configs(component_id);
291            component_config.get_client_config(host).clone()
292        }
293    }
294}