Skip to main content

seq_runtime/
tls.rs

1//! TLS client for Seq.
2//!
3//! Wraps a connected `may::net::TcpStream` in a `rustls::ClientConnection`
4//! and stores the result in the shared `STREAMS` registry as
5//! `StreamKind::Tls`. Existing `net.tcp.read` / `net.tcp.write` /
6//! `net.tcp.close` builtins dispatch over the `StreamKind` enum
7//! transparently — the user upgrades a Socket and keeps using it.
8//!
9//! ## Surface
10//!
11//! `net.tls.client ( Socket String -- Socket Bool )` — consumes a
12//! connected TCP socket and a hostname, returns the *same* Socket id
13//! now pointing at a TLS-wrapped stream. The hostname drives SNI and
14//! webpki certificate validation; trust roots come from `webpki-roots`.
15//!
16//! ## Handshake timing
17//!
18//! Eager: the handshake completes inside this builtin via
19//! `conn.complete_io(&mut tcp)`. A bad cert, expired cert, hostname
20//! mismatch, or any other TLS-layer error surfaces as
21//! `(0, false)` — matching the way every other fallible Seq
22//! networking word reports failure. A subsequent `net.tcp.read` reads
23//! application data only.
24//!
25//! ## Known limitations (v1)
26//!
27//! - `net.tcp.close` on a TLS-wrapped socket is a *hard* close — the
28//!   underlying `TcpStream` is dropped without first sending the TLS
29//!   `close_notify` alert. RFC 5246 expects clients to send the alert
30//!   before closing; modern servers tolerate truncation but some older
31//!   stacks log it as a truncation-attack indicator. A graceful-shutdown
32//!   variant is a planned follow-up.
33//! - No client-certificate authentication (mTLS).
34//! - No caller-side ALPN selection — rustls defaults apply.
35//! - No way to inspect the negotiated cipher / peer certificate from
36//!   Seq. Planned follow-ups once the four-layer stack stabilises.
37
38use crate::http_client::conn::Conn;
39use crate::stack::{Stack, pop, push};
40use crate::value::Value;
41use rustls::pki_types::ServerName;
42use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
43#[cfg(test)]
44use std::sync::Mutex;
45use std::sync::{Arc, LazyLock};
46
47/// Process-wide TLS client config. Trust roots are the Mozilla CA
48/// bundle shipped by `webpki-roots`; the `ring` crypto provider is
49/// installed defensively here so we don't depend on rustls's
50/// crate-features auto-install — if any transitive dep ever enables
51/// `aws_lc_rs` alongside `ring`, the auto-install path would panic at
52/// first use ("multiple default providers"). Cached for the process
53/// lifetime — the `Arc<ClientConfig>` is cheap to clone into each
54/// handshake.
55static TLS_CONFIG: LazyLock<Arc<ClientConfig>> = LazyLock::new(|| {
56    // Ignore the "already installed" Err — if another module beat us
57    // to it (or the crate-features path raced us), the provider is
58    // still ring, which is the only one this build pulls.
59    let _ = rustls::crypto::ring::default_provider().install_default();
60
61    let mut roots = RootCertStore::empty();
62    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
63    let config = ClientConfig::builder()
64        .with_root_certificates(roots)
65        .with_no_client_auth();
66    Arc::new(config)
67});
68
69// -----------------------------------------------------------------------------
70// Test-only trust-root override
71// -----------------------------------------------------------------------------
72//
73// Lets the happy-path TLS integration test install a `ClientConfig`
74// whose trust roots include the test's self-signed CA. Without the
75// override, the handshake would fail validation (the CA isn't in
76// webpki-roots). Production builds never see this hook.
77
78#[cfg(test)]
79static TEST_TLS_CONFIG: LazyLock<Mutex<Option<Arc<ClientConfig>>>> =
80    LazyLock::new(|| Mutex::new(None));
81
82#[cfg(test)]
83pub(crate) fn install_test_tls_config(cfg: Arc<ClientConfig>) {
84    *TEST_TLS_CONFIG.lock().unwrap() = Some(cfg);
85}
86
87#[cfg(test)]
88pub(crate) fn clear_test_tls_config() {
89    *TEST_TLS_CONFIG.lock().unwrap() = None;
90}
91
92/// Returns whichever `ClientConfig` should drive the next handshake:
93/// the test override if one is installed, otherwise the
94/// `webpki-roots`-backed production config.
95fn current_tls_config() -> Arc<ClientConfig> {
96    #[cfg(test)]
97    if let Some(cfg) = TEST_TLS_CONFIG.lock().unwrap().as_ref() {
98        return cfg.clone();
99    }
100    TLS_CONFIG.clone()
101}
102
103/// Upgrade a connected Socket to TLS.
104///
105/// Stack effect: `( Socket String -- Socket Bool )` — top of stack
106/// is the hostname (String), with the existing TCP socket id beneath
107/// it. On success, returns `(socket_id, true)` where `socket_id` is
108/// the *same* id the caller passed in: the registry slot is upgraded
109/// in place from `Tcp` to `Tls`, so any caller-side data structures
110/// keyed on the socket id remain valid. On failure (empty hostname,
111/// type mismatch, wrong-kind socket, handshake error, no slot found),
112/// returns `(0, false)`; on the failure paths that already took the
113/// stream out of the registry, the underlying socket is closed (the
114/// `TcpStream` is dropped) and the slot is freed.
115///
116/// # Safety
117/// Stack must have a String (hostname) on top of a Socket (Int).
118#[unsafe(no_mangle)]
119pub unsafe extern "C" fn patch_seq_tls_client(stack: Stack) -> Stack {
120    unsafe {
121        let (stack, host_val) = pop(stack);
122        let host = match host_val {
123            Value::String(s) => s,
124            _ => return push_failure(stack),
125        };
126        let (stack, sock_val) = pop(stack);
127        let socket_id = match sock_val {
128            Value::Int(id) => id as usize,
129            _ => return push_failure(stack),
130        };
131        let hostname = host.as_str_or_empty().to_string();
132        if hostname.is_empty() {
133            return push_failure(stack);
134        }
135
136        // The take/reinstall/free dance against the STREAMS registry
137        // lives inside tcp.rs so the "slot is reserved across a
138        // strand-yielding operation" invariant stays co-located with
139        // the registry itself. We just provide the callback that
140        // produces the TLS-wrapped stream.
141        let ok = crate::tcp::upgrade_tcp_in_place(socket_id, |tcp| build_tls(tcp, hostname));
142        if !ok {
143            return push_failure(stack);
144        }
145        let stack = push(stack, Value::Int(socket_id as i64));
146        push(stack, Value::Bool(true))
147    }
148}
149
150/// Build a fully-handshaked TLS stream over `tcp`. The TCP stream is
151/// consumed regardless of outcome — on Err, it is dropped (which
152/// closes the socket). The hostname is moved in: rustls's
153/// `ServerName<'static>` takes an owned `String`, so threading the
154/// caller's owned hostname through avoids a redundant clone.
155/// Per-handshake read/write timeout in milliseconds. Default 10 000ms.
156///
157/// Bounds each individual read/write inside `complete_io`. rustls
158/// has no native deadline knob; the underlying stream's per-op
159/// timeout is what catches a peer that stops responding mid-handshake.
160/// A handshake with many small rounds takes at most N × timeout, but
161/// any single stall lasting longer than `TLS_HANDSHAKE_TIMEOUT`
162/// surfaces as a handshake error.
163const DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS: u64 = 10_000;
164
165static TLS_HANDSHAKE_TIMEOUT: LazyLock<std::time::Duration> = LazyLock::new(|| {
166    let ms = std::env::var("SEQ_TLS_HANDSHAKE_TIMEOUT_MS")
167        .ok()
168        .and_then(|v| v.parse::<u64>().ok())
169        .filter(|n| *n > 0)
170        .unwrap_or(DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS);
171    std::time::Duration::from_millis(ms)
172});
173
174/// Test-only override for `TLS_HANDSHAKE_TIMEOUT`. When `Some`, takes
175/// precedence over the LazyLock-cached value. Mirrors the HTTP-side
176/// hook in `request::set_test_http_request_timeout`.
177#[cfg(test)]
178static TLS_HANDSHAKE_TIMEOUT_OVERRIDE: Mutex<Option<std::time::Duration>> = Mutex::new(None);
179
180#[cfg(test)]
181pub(crate) fn set_test_tls_handshake_timeout(dur: Option<std::time::Duration>) {
182    *TLS_HANDSHAKE_TIMEOUT_OVERRIDE.lock().unwrap() = dur;
183}
184
185fn tls_handshake_timeout() -> std::time::Duration {
186    #[cfg(test)]
187    if let Some(dur) = *TLS_HANDSHAKE_TIMEOUT_OVERRIDE.lock().unwrap() {
188        return dur;
189    }
190    *TLS_HANDSHAKE_TIMEOUT
191}
192
193fn build_tls(
194    mut tcp: may::net::TcpStream,
195    hostname: String,
196) -> Result<StreamOwned<ClientConnection, may::net::TcpStream>, ()> {
197    // Bound each individual read/write inside the handshake. rustls's
198    // complete_io calls plain `read`/`write` on the wrapped stream,
199    // so a may::net read/write timeout is what catches a peer that
200    // goes silent partway through.
201    let handshake_timeout = Some(tls_handshake_timeout());
202    tcp.set_read_timeout(handshake_timeout).map_err(|_| ())?;
203    tcp.set_write_timeout(handshake_timeout).map_err(|_| ())?;
204
205    let server_name = ServerName::try_from(hostname).map_err(|_| ())?;
206    let mut conn = ClientConnection::new(current_tls_config(), server_name).map_err(|_| ())?;
207    conn.complete_io(&mut tcp).map_err(|_| ())?;
208
209    // Reset timeouts before handing the stream over. The application
210    // IO phase (HTTP request / response) sets its own per-op timeout
211    // from a different env var — leaving the handshake's short
212    // deadline in place would cap every subsequent read/write at the
213    // handshake's bound, which is the wrong budget for app traffic.
214    //
215    // Errors from set_*_timeout(None) are intentionally swallowed.
216    // `setsockopt(SO_*TIMEO)` on a healthy fd that just completed a
217    // handshake essentially can't fail; the only realistic failure
218    // mode is the fd being closed concurrently, in which case the
219    // returned stream is already dead and the next read/write will
220    // surface that. Propagating the clear-failure here would mask the
221    // underlying state with a synthetic handshake error. HTTP-client
222    // callers also re-set the timeout per request in `run_once`, so
223    // a stale handshake deadline can't leak into their app IO.
224    let _ = tcp.set_read_timeout(None);
225    let _ = tcp.set_write_timeout(None);
226    Ok(StreamOwned::new(conn, tcp))
227}
228
229/// Variant of `build_tls` exposed to the HTTP client. Returns the
230/// handshaked stream already type-erased as `Conn` (a
231/// `Box<dyn HttpStream + Send>`).
232///
233/// Why erase here: the `Box::new(stream) as Conn` cast emits the
234/// vtable for `dyn HttpStream` over `StreamOwned<ClientConnection,
235/// TcpStream>`, and *that* vtable references rustls's drop chain.
236/// Keeping the cast inside `tls.rs` means the vtable is reachable
237/// only via `dial_tls`, which is itself reachable only via the HTTP
238/// client's HTTPS path. When no Seq program reaches that path,
239/// `--gc-sections` strips the vtable and the rustls drop chain
240/// disappears from the binary. The HTTP client never holds a
241/// concretely-typed TLS stream — it only ever sees `Conn`.
242pub(crate) fn dial_tls(tcp: may::net::TcpStream, hostname: String) -> Result<Conn, ()> {
243    let stream = build_tls(tcp, hostname)?;
244    Ok(Box::new(stream) as Conn)
245}
246
247unsafe fn push_failure(stack: Stack) -> Stack {
248    unsafe {
249        let stack = push(stack, Value::Int(0));
250        push(stack, Value::Bool(false))
251    }
252}