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