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}