Skip to main content

wire/
tls.rs

1//! Shared rustls `ClientConfig` for every wire HTTPS surface.
2//!
3//! ## Why this exists
4//!
5//! Pre-#176 wire used reqwest's `rustls-tls-native-roots` feature, which
6//! loads the OS native trust store via `rustls-native-certs`. That gave
7//! us corporate-CA / AV-resign transparency for free in shell-launched
8//! daemons. But once #170's `--all-sessions` supervisor moved every
9//! daemon into launchd, every TLS handshake to wireup.net failed
10//! `UnknownIssuer`: launchd-spawned processes on macOS don't inherit
11//! the operator's Aqua-session keychain context, so the system query
12//! returned an empty root set.
13//!
14//! #176 unblocked the supervisor by swapping the reqwest feature to
15//! `rustls-tls-webpki-roots` (Mozilla bundled CA set) and accepting
16//! the corp-CA trade-off. This module restores both behaviours: webpki
17//! bundled roots are ALWAYS loaded (works in any process context); the
18//! OS native trust store is ALSO loaded when accessible (corp CAs +
19//! AV-resign keep working in shell context, gracefully empty in
20//! launchd). reqwest consumes the resulting `rustls::ClientConfig` via
21//! its `use_preconfigured_tls` builder method.
22//!
23//! ## Design
24//!
25//! - **One `ClientConfig` per process, cached.** Building a
26//!   `RootCertStore` walks ~200 webpki roots + however many native
27//!   certs are accessible; ~3–5 ms cost. We pay it once per process.
28//!   Every `relay_client::build_blocking_client` call clones a
29//!   shared `Arc<ClientConfig>`.
30//! - **Fail-soft on native-cert errors.** If `rustls-native-certs`
31//!   panics, returns Err, or returns malformed certs, we log and fall
32//!   through to webpki-roots only. Better one missing corp CA than no
33//!   HTTPS at all.
34//! - **`WIRE_INSECURE_SKIP_TLS_VERIFY=1` still works** — handled at
35//!   the `relay_client::build_blocking_client` layer via reqwest's
36//!   `danger_accept_invalid_certs(true)`. This module's config is
37//!   only consulted when the env var is unset.
38
39use std::sync::Arc;
40use std::sync::OnceLock;
41
42use rustls::ClientConfig;
43use rustls::RootCertStore;
44
45/// Return the shared `Arc<ClientConfig>` — built lazily on first call,
46/// cached for the process lifetime.
47pub fn shared_client_config() -> Arc<ClientConfig> {
48    static CONFIG: OnceLock<Arc<ClientConfig>> = OnceLock::new();
49    CONFIG.get_or_init(build).clone()
50}
51
52fn build() -> Arc<ClientConfig> {
53    // Ensure rustls's default CryptoProvider is installed before we
54    // build a ClientConfig. With reqwest's `rustls-tls-webpki-roots`
55    // feature, reqwest auto-installs `ring` as the default provider
56    // for its own clients, but a freshly-spawned `wire` process
57    // building a config via `use_preconfigured_tls` may reach here
58    // before reqwest has done that. Setting it ourselves is
59    // idempotent — set_default_provider() is no-op once installed
60    // (returns Err that we ignore).
61    let _ =
62        rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider());
63
64    let mut roots = RootCertStore::empty();
65
66    // Mozilla bundled webpki-roots — always loaded. Works in any
67    // process context (no OS dep).
68    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
69    let bundled_added = roots.len();
70
71    // OS native trust store — additive when accessible. Loads corp
72    // CAs / AV-resign roots / on-prem CAs in shell context; returns
73    // empty on launchd-context macOS (the original #176 failure
74    // mode). Fail-soft: log + continue with bundled roots only.
75    let native_added = match rustls_native_certs::load_native_certs() {
76        result if result.errors.is_empty() => {
77            let mut count = 0usize;
78            for cert in result.certs {
79                if roots.add(cert).is_ok() {
80                    count += 1;
81                }
82            }
83            count
84        }
85        result => {
86            // Partial / total failure to enumerate native certs.
87            // Loud-but-non-fatal: stderr so launchd's StandardErrorPath
88            // captures it for diagnosability without breaking the
89            // handshake.
90            eprintln!(
91                "wire tls: rustls-native-certs reported {} error(s); continuing with bundled webpki roots only",
92                result.errors.len()
93            );
94            let mut count = 0usize;
95            for cert in result.certs {
96                if roots.add(cert).is_ok() {
97                    count += 1;
98                }
99            }
100            count
101        }
102    };
103
104    // One-line breadcrumb at process start so operators can confirm
105    // both root sources contributed (and which one the process
106    // landed in). Single fprintln, no log spam — only fires on the
107    // first config build per process.
108    eprintln!(
109        "wire tls: trust roots loaded — {bundled_added} webpki + {native_added} native = {} total",
110        roots.len()
111    );
112
113    let config = ClientConfig::builder()
114        .with_root_certificates(roots)
115        .with_no_client_auth();
116    Arc::new(config)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn shared_client_config_returns_clones_of_same_arc() {
125        let a = shared_client_config();
126        let b = shared_client_config();
127        assert!(
128            Arc::ptr_eq(&a, &b),
129            "shared_client_config must return clones of one cached Arc"
130        );
131    }
132
133    #[test]
134    fn shared_client_config_has_webpki_roots_loaded() {
135        // Webpki-roots ships ~150–200 Mozilla CA certs; the exact count
136        // varies across crate versions, but >50 is a safe floor.
137        let cfg = shared_client_config();
138        let store_len = cfg
139            .crypto_provider()
140            .signature_verification_algorithms
141            .all
142            .len();
143        // We can't directly inspect RootCertStore from outside —
144        // assert on a side-effect proxy: the config built without
145        // panic + has a valid crypto provider.
146        assert!(store_len > 0, "crypto provider must have verification algs");
147    }
148}