Skip to main content

featherbit/outbound/
tls.rs

1//! Per-upstream TLS identities — mTLS to upstream backends.
2//!
3//! An [`UpstreamTls`] bundles what an `upstream` node needs to talk to a
4//! mutual-TLS or private-PKI backend: an optional client cert/key pair, an
5//! optional CA bundle (which *replaces* the native roots for that upstream),
6//! and the verify flag. Materials are read, parsed, and dry-built into a
7//! rustls config once at policy-compile time, so bad files fail the policy
8//! load rather than a live request.
9//!
10//! Consumers cache built clients/connectors keyed by [`UpstreamTls::cache_key`],
11//! a hash of the PEM contents + flags: rotated cert files hash to a new key
12//! and naturally get a fresh connection pool after a config reload. Cache and
13//! registry entries are never evicted; the population is bounded by the number
14//! of distinct identities ever configured, which is small in practice.
15
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::sync::{Arc, OnceLock, RwLock};
19
20use rustls::pki_types::pem::PemObject;
21use rustls::pki_types::{CertificateDer, PrivateKeyDer};
22
23#[derive(Debug)]
24pub struct UpstreamTls {
25    client: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
26    ca: Option<Vec<CertificateDer<'static>>>,
27    /// Verify the upstream's certificate. `false` still presents the client
28    /// cert — the handshake is mutual, verification of the peer is skipped.
29    pub verify: bool,
30    key: u64,
31}
32
33/// Process-wide identity registry, so code that only sees JSON context values
34/// (the WebSocket relay) can resolve an identity from its cache key.
35fn registry() -> &'static RwLock<HashMap<u64, Arc<UpstreamTls>>> {
36    static REGISTRY: OnceLock<RwLock<HashMap<u64, Arc<UpstreamTls>>>> = OnceLock::new();
37    REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
38}
39
40impl UpstreamTls {
41    /// Loads and validates an identity. `client_paths` is `(cert, key)` —
42    /// pairing of the two YAML keys is enforced by the caller's config parse.
43    /// Reads + PEM-parses every file and dry-builds the rustls config so
44    /// unreadable files, empty bundles, and cert/key mismatches all fail here,
45    /// at policy-compile time.
46    pub fn load(
47        client_paths: Option<(&str, &str)>,
48        ca_path: Option<&str>,
49        verify: bool,
50    ) -> Result<Arc<Self>, String> {
51        let mut hasher = std::collections::hash_map::DefaultHasher::new();
52        verify.hash(&mut hasher);
53
54        let client = match client_paths {
55            Some((cert_path, key_path)) => {
56                let cert_pem = std::fs::read(cert_path)
57                    .map_err(|e| format!("client_cert_path '{}': {}", cert_path, e))?;
58                let key_pem = std::fs::read(key_path)
59                    .map_err(|e| format!("client_key_path '{}': {}", key_path, e))?;
60                cert_pem.hash(&mut hasher);
61                key_pem.hash(&mut hasher);
62                let certs = CertificateDer::pem_slice_iter(&cert_pem)
63                    .collect::<Result<Vec<_>, _>>()
64                    .map_err(|e| format!("client_cert_path '{}': {}", cert_path, e))?;
65                if certs.is_empty() {
66                    return Err(format!(
67                        "client_cert_path '{}': no certificates found",
68                        cert_path
69                    ));
70                }
71                let key = match PrivateKeyDer::pem_slice_iter(&key_pem).next() {
72                    Some(Ok(key)) => key,
73                    Some(Err(e)) => return Err(format!("client_key_path '{}': {}", key_path, e)),
74                    None => {
75                        return Err(format!(
76                            "client_key_path '{}': no private key found",
77                            key_path
78                        ))
79                    }
80                };
81                Some((certs, key))
82            }
83            None => None,
84        };
85
86        let ca = match ca_path {
87            Some(path) => {
88                let pem =
89                    std::fs::read(path).map_err(|e| format!("ca_cert_path '{}': {}", path, e))?;
90                pem.hash(&mut hasher);
91                let certs = CertificateDer::pem_slice_iter(&pem)
92                    .collect::<Result<Vec<_>, _>>()
93                    .map_err(|e| format!("ca_cert_path '{}': {}", path, e))?;
94                if certs.is_empty() {
95                    return Err(format!("ca_cert_path '{}': no certificates found", path));
96                }
97                Some(certs)
98            }
99            None => None,
100        };
101
102        let identity = Arc::new(Self {
103            client,
104            ca,
105            verify,
106            key: hasher.finish(),
107        });
108        identity.client_config()?; // dry build: catches cert/key mismatch now
109        Ok(identity)
110    }
111
112    /// Content-hash key (PEM bytes + verify flag). Stable within a process —
113    /// exactly the lifetime of the caches it keys.
114    pub fn cache_key(&self) -> u64 {
115        self.key
116    }
117
118    /// Builds a rustls client config from the loaded materials. ALPN is left
119    /// unset — the HTTP client and the wss connector want different values.
120    pub fn client_config(&self) -> Result<rustls::ClientConfig, String> {
121        crate::server::tls::install_crypto_provider();
122
123        let builder = if self.verify {
124            let mut roots = rustls::RootCertStore::empty();
125            match &self.ca {
126                // A configured CA bundle *replaces* the native roots.
127                Some(certs) => {
128                    let (added, _ignored) = roots.add_parsable_certificates(certs.iter().cloned());
129                    if added == 0 {
130                        return Err("ca_cert_path: no usable certificates".to_string());
131                    }
132                }
133                None => {
134                    let loaded = rustls_native_certs::load_native_certs();
135                    let (added, _ignored) = roots.add_parsable_certificates(loaded.certs);
136                    if added == 0 {
137                        return Err(format!(
138                            "no usable native root certificates ({} load error(s))",
139                            loaded.errors.len()
140                        ));
141                    }
142                }
143            }
144            rustls::ClientConfig::builder().with_root_certificates(roots)
145        } else {
146            rustls::ClientConfig::builder()
147                .dangerous()
148                .with_custom_certificate_verifier(Arc::new(super::NoVerification(
149                    rustls::crypto::ring::default_provider(),
150                )))
151        };
152
153        match &self.client {
154            Some((certs, key)) => builder
155                .with_client_auth_cert(certs.clone(), key.clone_key())
156                .map_err(|e| format!("client cert/key rejected: {}", e)),
157            None => Ok(builder.with_no_client_auth()),
158        }
159    }
160
161    /// Publishes the identity in the process-wide registry (idempotent).
162    pub fn register(this: &Arc<Self>) {
163        registry()
164            .write()
165            .unwrap()
166            .entry(this.key)
167            .or_insert_with(|| this.clone());
168    }
169
170    /// Resolves a previously [`register`](Self::register)ed identity.
171    pub fn lookup(key: u64) -> Option<Arc<UpstreamTls>> {
172        registry().read().unwrap().get(&key).cloned()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    /// CA + a leaf signed by it, written as PEM files. Returns
181    /// (cert_path, key_path, ca_path).
182    fn write_identity(tag: &str) -> (String, String, String) {
183        let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
184        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
185        let ca_key = rcgen::KeyPair::generate().unwrap();
186        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
187        let ca_issuer = rcgen::Issuer::from_ca_cert_der(ca_cert.der(), &ca_key).unwrap();
188
189        let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
190        let leaf_key = rcgen::KeyPair::generate().unwrap();
191        let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_issuer).unwrap();
192
193        let dir = std::env::temp_dir();
194        let pid = std::process::id();
195        let cert = dir.join(format!("featherbit_utls_{}_{}.crt", tag, pid));
196        let key = dir.join(format!("featherbit_utls_{}_{}.key", tag, pid));
197        let ca = dir.join(format!("featherbit_utls_{}_{}.ca.crt", tag, pid));
198        std::fs::write(&cert, leaf_cert.pem()).unwrap();
199        std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
200        std::fs::write(&ca, ca_cert.pem()).unwrap();
201        (
202            cert.to_str().unwrap().to_string(),
203            key.to_str().unwrap().to_string(),
204            ca.to_str().unwrap().to_string(),
205        )
206    }
207
208    #[test]
209    fn test_upstream_tls_load_client_and_ca() {
210        let (cert, key, ca) = write_identity("happy");
211        let id = UpstreamTls::load(Some((&cert, &key)), Some(&ca), true).unwrap();
212        assert!(id.verify);
213        // Dry-built once in load(); building again also works.
214        assert!(id.client_config().is_ok());
215    }
216
217    #[test]
218    fn test_upstream_tls_load_ca_only() {
219        let (_, _, ca) = write_identity("caonly");
220        assert!(UpstreamTls::load(None, Some(&ca), true).is_ok());
221    }
222
223    #[test]
224    fn test_upstream_tls_load_missing_file_errors() {
225        let err = UpstreamTls::load(Some(("/nonexistent.crt", "/nonexistent.key")), None, true)
226            .unwrap_err();
227        assert!(err.contains("/nonexistent.crt"), "err was: {}", err);
228    }
229
230    #[test]
231    fn test_upstream_tls_load_garbage_key_errors() {
232        let (cert, key, _) = write_identity("garbage");
233        std::fs::write(&key, "not a pem").unwrap();
234        assert!(UpstreamTls::load(Some((&cert, &key)), None, true).is_err());
235    }
236
237    #[test]
238    fn test_upstream_tls_load_mismatched_key_errors() {
239        // Key from a *different* identity: rustls 0.23 rejects the pair
240        // (InconsistentKeys) during the dry ClientConfig build.
241        let (cert, _, _) = write_identity("mismatch_a");
242        let (_, other_key, _) = write_identity("mismatch_b");
243        assert!(UpstreamTls::load(Some((&cert, &other_key)), None, true).is_err());
244    }
245
246    #[test]
247    fn test_upstream_tls_cache_key_content_hash() {
248        let (cert, key, ca) = write_identity("hash");
249        let a = UpstreamTls::load(Some((&cert, &key)), Some(&ca), true).unwrap();
250        let b = UpstreamTls::load(Some((&cert, &key)), Some(&ca), true).unwrap();
251        // Same bytes -> same key; verify flag flips the key; different
252        // materials -> different key.
253        assert_eq!(a.cache_key(), b.cache_key());
254        let c = UpstreamTls::load(Some((&cert, &key)), Some(&ca), false).unwrap();
255        assert_ne!(a.cache_key(), c.cache_key());
256        let (cert2, key2, _) = write_identity("hash2");
257        let d = UpstreamTls::load(Some((&cert2, &key2)), None, true).unwrap();
258        assert_ne!(a.cache_key(), d.cache_key());
259    }
260
261    #[test]
262    fn test_upstream_tls_registry_roundtrip() {
263        let (cert, key, _) = write_identity("registry");
264        let id = UpstreamTls::load(Some((&cert, &key)), None, true).unwrap();
265        UpstreamTls::register(&id);
266        let found = UpstreamTls::lookup(id.cache_key()).expect("registered identity");
267        assert_eq!(found.cache_key(), id.cache_key());
268        assert!(UpstreamTls::lookup(id.cache_key().wrapping_add(1)).is_none());
269    }
270}