Skip to main content

blitz_net/
tls.rs

1//! Native trust anchors and nagoya socket transport.
2//!
3//! DNS always runs on a nagoya worker, including when the fetch future is
4//! polled directly by a caller or a local reactor.
5
6use super::{Error, pool::Origin};
7use blitz_traits::tls_policy::TlsPolicyError;
8use bytes::BytesMut;
9use nago_rustls::{
10    TlsSession,
11    rustls::{ClientConfig, ClientConnection, RootCertStore},
12    rustls_pki_types::ServerName,
13};
14use nagoya::{net::TcpStream, reactor::Handle};
15use ps_rustcrypto::policy;
16use std::{
17    collections::HashMap,
18    sync::{Arc, Mutex, OnceLock},
19};
20
21const READ_CHUNK: usize = 16 * 1024;
22
23pub(super) fn default_config() -> Result<Arc<ClientConfig>, Error> {
24    static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
25    match CONFIG.get_or_init(build_config) {
26        Ok(config) => Ok(config.clone()),
27        Err(error) => Err(Error::Tls(error.clone())),
28    }
29}
30
31fn native_roots() -> Result<RootCertStore, String> {
32    let native = rustls_native_certs::load_native_certs();
33    let mut roots = RootCertStore::empty();
34    let (accepted, rejected) = roots.add_parsable_certificates(native.certs);
35    if accepted == 0 {
36        return Err(format!(
37            "no usable native TLS trust anchors: {} loading errors, {rejected} rejected certificates",
38            native.errors.len(),
39        ));
40    }
41    if !native.errors.is_empty() || rejected != 0 {
42        eprintln!(
43            "TLS trust store: {} loading errors, {rejected} rejected certificates",
44            native.errors.len(),
45        );
46    }
47    #[cfg(feature = "sandbox2-witness")]
48    crate::chacha_policy::add_witness_roots(&mut roots)?;
49    Ok(roots)
50}
51
52/// The native roots that are also built-in public roots, matched by subject
53/// and key. Like Chrome, CT is enforced only on chains ending in one of
54/// these; locally added anchors (enterprise or test CAs) skip it.
55fn public_roots(native: &RootCertStore) -> RootCertStore {
56    let mut public = RootCertStore::empty();
57    public.roots = native
58        .roots
59        .iter()
60        .filter(|root| {
61            webpki_roots::TLS_SERVER_ROOTS.iter().any(|built_in| {
62                root.subject.as_ref() == built_in.subject.as_ref()
63                    && root.subject_public_key_info.as_ref()
64                        == built_in.subject_public_key_info.as_ref()
65            })
66        })
67        .cloned()
68        .collect();
69    public
70}
71
72/// HTTP/2 preferred; HTTP/1.1 for servers that do not offer it.
73pub(super) fn alpn() -> Vec<Vec<u8>> {
74    vec![b"h2".to_vec(), b"http/1.1".to_vec()]
75}
76
77fn build_config() -> Result<Arc<ClientConfig>, String> {
78    let roots = native_roots()?;
79    let public = public_roots(&roots);
80    let provider = crate::chacha_provider();
81    let mut config = ClientConfig::builder_with_provider(provider)
82        .with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])
83        .map_err(|error| error.to_string())?
84        .dangerous()
85        .with_custom_certificate_verifier(crate::certificate_verifier(roots, public)?)
86        .with_no_client_auth();
87    config.alpn_protocols = alpn();
88    #[cfg(feature = "sandbox2-witness")]
89    if std::env::var("SANDBOX2_TLS_DEFECT").ok().as_deref() == Some("chacha-load") {
90        // Plant a real incompatible ALPN in the production TLS entry point.
91        config.alpn_protocols = vec![b"sandbox2-incompatible".to_vec()];
92    }
93    Ok(Arc::new(config))
94}
95
96/// Connection counters and the latest TLS policy refusal for each host.
97static HOSTS: Mutex<Option<HashMap<String, HostStats>>> = Mutex::new(None);
98
99/// Connection counters for one host, since the process started.
100#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
101pub struct HostStats {
102    /// New TCP connections opened, TLS attempts included.
103    pub connections: u32,
104    /// Handshakes refused under the strict TLS policy.
105    pub refusals: u32,
106    /// TLS handshakes that completed under the strict policy.
107    pub handshakes: u32,
108    /// The latest TLS policy refusal, cleared by the next TLS attempt.
109    pub last_tls_policy_error: Option<TlsPolicyError>,
110    /// The protocol the latest connection to this host settled on: `h2`,
111    /// `h2 (coalesced)` onto another host's connection, or `http/1.1`.
112    pub last_alpn: Option<&'static str>,
113}
114
115fn update(host: &str, change: impl FnOnce(&mut HostStats)) -> HostStats {
116    let Ok(mut map) = HOSTS.lock() else {
117        return HostStats::default();
118    };
119    let stats = map
120        .get_or_insert_with(HashMap::new)
121        .entry(host.to_ascii_lowercase())
122        .or_default();
123    change(stats);
124    *stats
125}
126
127pub(super) fn record_alpn(host: &str, alpn: &'static str) {
128    update(host, |stats| stats.last_alpn = Some(alpn));
129}
130
131/// Connection counters for every host contacted so far, sorted by host.
132pub fn connection_stats() -> Vec<(String, HostStats)> {
133    let mut all: Vec<_> = HOSTS
134        .lock()
135        .ok()
136        .and_then(|map| {
137            map.as_ref()
138                .map(|map| map.iter().map(|(h, s)| (h.clone(), *s)).collect())
139        })
140        .unwrap_or_default();
141    all.sort_by(|a, b| a.0.cmp(&b.0));
142    all
143}
144
145pub(super) async fn open(
146    origin: &Origin,
147    handle: &Handle,
148    config: Arc<ClientConfig>,
149    addresses: &[nagoya::reactor::socket::Addr],
150) -> Result<Transport, Error> {
151    if !origin.tls {
152        return Ok(Transport::Plain(connect(origin, handle, addresses).await?));
153    }
154    update(&origin.host, |stats| stats.last_tls_policy_error = None);
155    // One connection and one policy. A refusal is terminal for this request.
156    match handshake(origin, handle, config, addresses).await {
157        Ok(tls) => {
158            update(&origin.host, |stats| stats.handshakes += 1);
159            Ok(Transport::Tls(tls))
160        }
161        Err(error) => {
162            if let Error::TlsPolicy(reason) = &error {
163                update(&origin.host, |stats| {
164                    stats.refusals += 1;
165                    stats.last_tls_policy_error = Some(*reason);
166                });
167            }
168            // Resource loads may discard the returned error, so log it here.
169            info(format_args!("TLS {}: {error}", origin.host));
170            Err(error)
171        }
172    }
173}
174
175fn info(message: std::fmt::Arguments<'_>) {
176    #[cfg(feature = "tracing")]
177    tracing::info!("{message}");
178    #[cfg(not(feature = "tracing"))]
179    eprintln!("info: {message}");
180}
181
182pub(super) async fn resolve_addresses(
183    origin: &Origin,
184    initiator: &blitz_traits::net::Initiator,
185) -> Result<Vec<nagoya::reactor::socket::Addr>, Error> {
186    let host = origin.host.clone();
187    let port = origin.port;
188    let addresses = nagoya::spawn(async move {
189        nagoya::net::resolve(&host, port).map_err(|error| Error::Dns(error.to_string()))
190    })
191    .await
192    .ok_or_else(|| Error::Dns("resolver task was cancelled".to_owned()))??;
193
194    for address in &addresses {
195        use nagoya::reactor::socket::Addr;
196        let ip = match address {
197            Addr::V4(bytes, _) => std::net::IpAddr::from(*bytes),
198            Addr::V6(bytes, _) | Addr::V6Scoped(bytes, _, _) => std::net::IpAddr::from(*bytes),
199            Addr::Path(_) => return Err(Error::Blocked("resource-private-address".into())),
200        };
201        initiator.check_address(ip).map_err(Error::Blocked)?;
202    }
203    Ok(addresses)
204}
205
206async fn connect(
207    origin: &Origin,
208    handle: &Handle,
209    addresses: &[nagoya::reactor::socket::Addr],
210) -> Result<TcpStream, Error> {
211    let stats = update(&origin.host, |stats| stats.connections += 1);
212    info(format_args!(
213        "net {}: new connection #{} (tls {})",
214        origin.host, stats.connections, origin.tls
215    ));
216    nagoya::net::connect_any(addresses, handle)
217        .await
218        .map_err(|error| Error::Io(error.to_string()))
219}
220
221/// One TLS connection attempt, retaining the reason for any policy refusal.
222async fn handshake(
223    origin: &Origin,
224    handle: &Handle,
225    config: Arc<ClientConfig>,
226    addresses: &[nagoya::reactor::socket::Addr],
227) -> Result<Box<TlsSession<TcpStream>>, Error> {
228    let stream = connect(origin, handle, addresses).await?;
229    let name = ServerName::try_from(origin.host.to_string())
230        .map_err(|_| Error::InvalidRequest("invalid TLS server name"))?;
231    let session =
232        ClientConnection::new(config, name).map_err(|error| Error::Tls(error.to_string()))?;
233    let mut tls = TlsSession::client(stream, session);
234    if let Err(errno) = tls.handshake().await {
235        return Err(handshake_failure(tls.tls_error(), errno));
236    }
237    if tls
238        .alpn_protocol()
239        .is_some_and(|protocol| protocol != b"http/1.1" && protocol != b"h2")
240    {
241        return Err(Error::Tls(
242            "peer negotiated an unsupported ALPN protocol".to_owned(),
243        ));
244    }
245    #[cfg(feature = "sandbox2-witness")]
246    if std::env::var("SANDBOX2_TLS_DEFECT").ok().as_deref() == Some("chacha-load") {
247        // Plant the acceptance defect after a real successful handshake.
248        return Err(Error::TlsPolicy(TlsPolicyError::CipherSuite));
249    }
250    Ok(Box::new(tls))
251}
252
253/// Preserve the observed reason without probing a second, weaker connection.
254///
255/// An alert 40 or 71 does not say whether the server needs RSA, AES, or some
256/// other algorithm. Report the rejected profile without inventing that fact.
257fn handshake_failure(
258    error: Option<&nago_rustls::rustls::Error>,
259    errno: impl std::fmt::Display,
260) -> Error {
261    use nago_rustls::rustls::Error as TlsError;
262    use nago_rustls::rustls::{
263        AlertDescription, CertificateError, PeerIncompatible, PeerMisbehaved,
264    };
265
266    let reason = match error {
267        Some(error) if policy::is_rsa_refusal(error) => TlsPolicyError::RsaCertificateChain,
268        Some(error) if policy::is_key_type_refusal(error) => TlsPolicyError::CertificateKey,
269        Some(TlsError::InvalidCertificate(
270            CertificateError::UnsupportedSignatureAlgorithmContext { .. }
271            | CertificateError::UnsupportedSignatureAlgorithmForPublicKeyContext { .. },
272        )) => TlsPolicyError::CertificateSignature,
273        Some(TlsError::PeerMisbehaved(PeerMisbehaved::SelectedUnofferedCipherSuite)) => {
274            TlsPolicyError::CipherSuite
275        }
276        Some(TlsError::PeerMisbehaved(
277            PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme,
278        ))
279        | Some(TlsError::PeerIncompatible(PeerIncompatible::NoSignatureSchemesInCommon)) => {
280            TlsPolicyError::HandshakeSignature
281        }
282        Some(TlsError::AlertReceived(AlertDescription::HandshakeFailure)) => {
283            TlsPolicyError::PeerRejectedAlgorithms { alert: 40 }
284        }
285        Some(TlsError::AlertReceived(AlertDescription::InsufficientSecurity)) => {
286            TlsPolicyError::PeerRejectedAlgorithms { alert: 71 }
287        }
288        Some(error) if policy::ct::is_not_logged(error) => {
289            return Error::Tls("certificate not publicly logged".to_owned());
290        }
291        Some(error) => return Error::Tls(format!("handshake: {error}")),
292        None => return Error::Tls(format!("handshake: {errno}")),
293    };
294    Error::TlsPolicy(reason)
295}
296
297pub(super) enum Transport {
298    Plain(TcpStream),
299    Tls(Box<TlsSession<TcpStream>>),
300}
301
302impl Transport {
303    pub async fn read_into(&mut self, buffer: &mut BytesMut) -> Result<usize, Error> {
304        match self {
305            Self::Plain(stream) => {
306                // Read directly into the destination's spare capacity.
307                buffer.reserve(READ_CHUNK);
308                stream
309                    .read_buf(buffer)
310                    .await
311                    .map_err(|error| Error::Io(error.to_string()))
312            }
313            Self::Tls(stream) => {
314                // TlsSession exposes an initialised plaintext slice rather
315                // than a read_buf operation. Avoid a second staging buffer.
316                let start = buffer.len();
317                buffer.resize(start + READ_CHUNK, 0);
318                match stream.read(&mut buffer[start..]).await {
319                    Ok(read) => {
320                        buffer.truncate(start + read);
321                        Ok(read)
322                    }
323                    Err(error) => {
324                        buffer.truncate(start);
325                        Err(Error::Io(error.to_string()))
326                    }
327                }
328            }
329        }
330    }
331
332    pub async fn write_all(&mut self, bytes: &[u8]) -> Result<(), Error> {
333        match self {
334            Self::Plain(stream) => stream
335                .write_all(bytes)
336                .await
337                .map_err(|error| Error::Write(error.to_string())),
338            Self::Tls(stream) => stream
339                .write_all(bytes)
340                .await
341                .map_err(|error| Error::Write(error.to_string())),
342        }
343    }
344}