Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/mailtls.rs

17.9 KiB, 1 run

created by r2519314175:985, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The browser end of the blind mail tunnel: a TLS client that runs in the page, so the
2//! gateway relays ciphertext and holds no keys.
3//!
4//! Sans-io. This module never owns a socket. JavaScript holds the WebSocket to
5//! `GET /api/mail/tunnel` and pumps bytes through [`mail_tunnel_feed`] and
6//! [`mail_tunnel_take`]; the plaintext side is [`mail_tunnel_write`] and
7//! [`mail_tunnel_read`]. The mail password therefore never leaves the browser, and the
8//! gateway sees only which host, when, how many bytes and for how long.
9//!
10//! Four facts here were found by running the spike at `~/usr/code/rust/spike-wasmtls`
11//! on 2026-08-17 and none of them is derivable from the rustls documentation:
12//!
13//! - `std::time::SystemTime::now()` panics on `wasm32-unknown-unknown`, so rustls's
14//! default time provider cannot be used. The connection is built through
15//! `ClientConfig::builder_with_details` and expiry is checked against `Date.now()`.
16//! That makes the browser's clock the expiry oracle, which the mail feature owes its
17//! users as a disclosure: a machine with its clock set far enough back will accept a
18//! certificate that has expired.
19//! - `rustls-pki-types` needs its `web` feature or nothing compiles. It deliberately
20//! removes `UnixTime::now()` on this target, while rustls calls it unconditionally
21//! under `std` from `ticketer.rs`, `client/handy.rs` and `time_provider.rs` -- call
22//! sites a caller never touches, so supplying a `TimeProvider` does not save you.
23//! - ring has no `SecureRandom` for this target unless its `wasm32_unknown_unknown_js`
24//! feature is on, and rustls's `ring` feature does not turn it on. ring has to be
25//! named as a direct dependency to reach it.
26//! - rustls's default feature set reaches `aws-lc-sys`, which is C and does not
27//! cross-compile, so `default-features = false` is load bearing rather than tidy.
28//!
29//! # Certificate verification cannot be switched off here
30//!
31//! Verification is the whole security boundary: a client that can be talked into
32//! accepting any chain hands the gateway the interception it has been promised it cannot
33//! do. So [`Verify::Off`], the accept-anything verifier and the extra-trust-root hook
34//! exist only under the `insecure_testing` feature, which is **off by default**. In a
35//! production build [`mail_tunnel_open`] takes a host, a port and a security mode and
36//! cannot express any other choice; the types that could weaken it are not in the
37//! shipped wasm at all. A runtime switch that disables certificate verification,
38//! compiled into a shipping bundle, is one mistaken call site away from being the whole
39//! vulnerability, so it is not compiled into one. [`mail_tunnel_flavour`] names the
40//! build, so a page cannot silently drive a module that checks less than it believes.
41
42use crate::wasm::to_js_err;
43
44use oxedyne_fe2o3_core::prelude::*;
45
46use std::cell::RefCell;
47use std::io::Cursor;
48use std::io::ErrorKind;
49use std::io::Read;
50use std::io::Write as _;
51use std::sync::Arc;
52use std::time::Duration;
53
54use rustls::pki_types::CertificateDer;
55use rustls::pki_types::ServerName;
56use rustls::pki_types::UnixTime;
57use rustls::time_provider::TimeProvider;
58use rustls::ClientConfig;
59use rustls::ClientConnection;
60use rustls::RootCertStore;
61use wasm_bindgen::prelude::*;
62
63#[cfg(feature = "insecure_testing")]
64use rustls::client::danger::HandshakeSignatureValid;
65#[cfg(feature = "insecure_testing")]
66use rustls::client::danger::ServerCertVerified;
67#[cfg(feature = "insecure_testing")]
68use rustls::client::danger::ServerCertVerifier;
69#[cfg(feature = "insecure_testing")]
70use rustls::crypto::verify_tls12_signature;
71#[cfg(feature = "insecure_testing")]
72use rustls::crypto::verify_tls13_signature;
73#[cfg(feature = "insecure_testing")]
74use rustls::crypto::CryptoProvider;
75#[cfg(feature = "insecure_testing")]
76use rustls::DigitallySignedStruct;
77#[cfg(feature = "insecure_testing")]
78use rustls::SignatureScheme;
79
80/// How the peer's certificate chain is to be judged. Only the first variant survives a
81/// production build.
82pub enum Verify {
83 Roots, // the bundled Mozilla store, and nothing else
84 #[cfg(feature = "insecure_testing")]
85 RootsAndTestCa(Vec<u8>), // the store plus one extra CA, for the local fixtures
86 #[cfg(feature = "insecure_testing")]
87 Off, // deliberately disabled, so a refusal can be shown to go red
88}
89
90#[cfg(feature = "insecure_testing")]
91impl Verify {
92 fn parse(s: &str, test_ca: Option<&[u8]>) -> Outcome<Self> {
93 match s {
94 "roots" => Ok(Self::Roots),
95 "roots+testca" => {
96 let der = res!(test_ca.ok_or_else(|| err!(
97 "Verification mode roots+testca was asked for with no test CA \
98 supplied."; Missing, Input)));
99 Ok(Self::RootsAndTestCa(der.to_vec()))
100 },
101 "off" => Ok(Self::Off),
102 other => Err(err!(
103 "Verification mode {:?} is not one of roots, roots+testca, off.",
104 other; Invalid, Input)),
105 }
106 }
107}
108
109/// Whether the wire is still in the clear, as it is between a STARTTLS command and the
110/// handshake that answers it.
111enum Phase {
112 Clear, // bytes pass through untouched; only reachable via `security = "starttls"`
113 Tls, // rustls owns the wire
114}
115
116/// The browser's wall clock, standing in for the `std` clock this target lacks.
117#[derive(Debug)]
118struct BrowserClock;
119
120impl TimeProvider for BrowserClock {
121 fn current_time(&self) -> Option<UnixTime> {
122 let ms = js_sys::Date::now();
123 if !ms.is_finite() || ms < 0.0 {
124 return None;
125 }
126 Some(UnixTime::since_unix_epoch(Duration::from_millis(ms as u64)))
127 }
128}
129
130/// A verifier that accepts every chain, present only so a test run can watch the
131/// refusals turn into acceptances -- four refusals that were never seen to go the other
132/// way are equally consistent with a client that cannot connect at all.
133#[cfg(feature = "insecure_testing")]
134#[derive(Debug)]
135struct AcceptAny {
136 provider: Arc<CryptoProvider>,
137}
138
139#[cfg(feature = "insecure_testing")]
140impl ServerCertVerifier for AcceptAny {
141 fn verify_server_cert(
142 &self,
143 _end_entity: &CertificateDer<'_>,
144 _intermediates: &[CertificateDer<'_>],
145 _server_name: &ServerName<'_>,
146 _ocsp: &[u8],
147 _now: UnixTime,
148 )
149 -> Result<ServerCertVerified, rustls::Error>
150 {
151 Ok(ServerCertVerified::assertion())
152 }
153
154 fn verify_tls12_signature(
155 &self,
156 message: &[u8],
157 cert: &CertificateDer<'_>,
158 dss: &DigitallySignedStruct,
159 )
160 -> Result<HandshakeSignatureValid, rustls::Error>
161 {
162 verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
163 }
164
165 fn verify_tls13_signature(
166 &self,
167 message: &[u8],
168 cert: &CertificateDer<'_>,
169 dss: &DigitallySignedStruct,
170 )
171 -> Result<HandshakeSignatureValid, rustls::Error>
172 {
173 verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
174 }
175
176 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
177 self.provider.signature_verification_algorithms.supported_schemes()
178 }
179}
180
181struct Tunnel {
182 host: String,
183 port: u16,
184 conn: ClientConnection,
185 phase: Phase,
186 wire: Vec<u8>, // bytes waiting to go out to the socket
187 plain: Vec<u8>, // bytes already readable by the caller
188 eof: bool, // the peer closed cleanly
189 fault: Option<String>, // the first protocol failure, kept verbatim
190}
191
192thread_local! {
193 static TUNNELS: RefCell<Vec<Option<Tunnel>>> = RefCell::new(Vec::new());
194}
195
196fn root_store(extra_ca: Option<&[u8]>) -> Outcome<RootCertStore> {
197 let mut store = RootCertStore::empty();
198 store.roots = webpki_roots::TLS_SERVER_ROOTS.to_vec();
199
200 if let Some(der) = extra_ca {
201 let cert = CertificateDer::from(der.to_vec());
202 res!(store.add(cert).map_err(|e| err!(
203 "The supplied test CA ({} bytes) was rejected by the root store: {:?}",
204 der.len(), e; Invalid, Input)));
205 }
206
207 Ok(store)
208}
209
210fn build_config(mode: &Verify) -> Outcome<ClientConfig> {
211 let provider = Arc::new(rustls::crypto::ring::default_provider());
212 // `builder_with_details` lands in `WantsVersions`, not `WantsVerifier`: naming a
213 // provider means naming the protocol versions too, since the provider's suites
214 // might not cover them.
215 let builder = res!(ClientConfig::builder_with_details(provider.clone(), Arc::new(BrowserClock))
216 .with_safe_default_protocol_versions()
217 .map_err(|e| err!("The ring provider does not support the default protocol \
218 versions: {:?}", e; Configuration)));
219
220 let cfg = match mode {
221 Verify::Roots => {
222 let store = res!(root_store(None));
223 builder.with_root_certificates(store).with_no_client_auth()
224 },
225 #[cfg(feature = "insecure_testing")]
226 Verify::RootsAndTestCa(der) => {
227 let store = res!(root_store(Some(der)));
228 builder.with_root_certificates(store).with_no_client_auth()
229 },
230 #[cfg(feature = "insecure_testing")]
231 Verify::Off => builder
232 .dangerous()
233 .with_custom_certificate_verifier(Arc::new(AcceptAny { provider }))
234 .with_no_client_auth(),
235 };
236
237 Ok(cfg)
238}
239
240fn open(host: &str, port: u16, security: &str, mode: Verify) -> Outcome<u32> {
241 // The gateway is the authority on which host and port may be reached -- it holds
242 // the account's bound mailboxes and closes 4403 on anything else. This end checks
243 // only what it can know by itself, so the two cannot drift apart.
244 let phase = match security {
245 "tls" => Phase::Tls,
246 "starttls" => Phase::Clear,
247 other => return Err(err!(
248 "Security mode {:?} for {}:{} is not one of tls, starttls.",
249 other, host, port; Invalid, Input)),
250 };
251
252 let cfg = res!(build_config(&mode));
253
254 // The name the certificate is checked against is the host the caller asked for,
255 // never anything the peer says about itself.
256 let name = res!(ServerName::try_from(host.to_string()).map_err(|e| err!(
257 "Mail host {:?} is not a valid DNS name or IP address: {:?}",
258 host, e; Invalid, Input)));
259
260 let conn = res!(ClientConnection::new(Arc::new(cfg), name).map_err(|e| err!(
261 "rustls refused to start a connection for {}:{}: {:?}",
262 host, port, e; Init)));
263
264 let tunnel = Tunnel {
265 host: host.to_string(),
266 port,
267 conn,
268 phase,
269 wire: Vec::new(),
270 plain: Vec::new(),
271 eof: false,
272 fault: None,
273 };
274
275 TUNNELS.with(|reg| {
276 let mut reg = reg.borrow_mut();
277 reg.push(Some(tunnel));
278 Ok(reg.len() as u32) // handles are one-based, so zero is never a live handle
279 })
280}
281
282/// Runs `f` against a live tunnel, or fails naming the handle.
283fn with_tunnel<T, F>(h: u32, f: F) -> Outcome<T>
284where
285 F: FnOnce(&mut Tunnel) -> Outcome<T>,
286{
287 TUNNELS.with(|reg| {
288 let mut reg = reg.borrow_mut();
289 if h == 0 || h as usize > reg.len() {
290 return Err(err!("Mail tunnel handle {} was never issued.", h; Invalid, Input));
291 }
292 match reg[(h - 1) as usize].as_mut() {
293 Some(t) => f(t),
294 None => Err(err!("Mail tunnel handle {} has been closed.", h; Invalid, Input)),
295 }
296 })
297}
298
299/// Drains everything rustls wants to put on the wire into the out-queue.
300fn drain_out(t: &mut Tunnel) -> Outcome<()> {
301 while t.conn.wants_write() {
302 let n = res!(t.conn.write_tls(&mut t.wire).map_err(|e| err!(
303 "Writing TLS records out to {} failed after {} queued bytes: {}",
304 t.host, t.wire.len(), e; IO)));
305 if n == 0 {
306 break;
307 }
308 }
309 Ok(())
310}
311
312/// Moves whatever rustls can now decrypt into the plaintext queue. `WouldBlock` is the
313/// normal answer mid-handshake and is not an error.
314fn drain_in(t: &mut Tunnel) -> Outcome<()> {
315 let mut buf = [0u8; 4096];
316 loop {
317 match t.conn.reader().read(&mut buf) {
318 Ok(0) => {
319 t.eof = true;
320 break;
321 },
322 Ok(n) => t.plain.extend_from_slice(&buf[..n]),
323 Err(ref e) if e.kind() == ErrorKind::WouldBlock => break,
324 Err(e) => return Err(err!(
325 "Reading decrypted bytes from {} failed with {} bytes already held: {}",
326 t.host, t.plain.len(), e; IO)),
327 }
328 }
329 Ok(())
330}
331
332fn feed(t: &mut Tunnel, bytes: &[u8]) -> Outcome<()> {
333 if let Phase::Clear = t.phase {
334 // Before STARTTLS the wire is the conversation, so it goes straight through.
335 t.plain.extend_from_slice(bytes);
336 return Ok(());
337 }
338
339 let mut rd = Cursor::new(bytes);
340 // `read_tls` takes one record's worth at a time, so a single WebSocket frame
341 // carrying several records needs more than one call.
342 while (rd.position() as usize) < bytes.len() {
343 let n = res!(t.conn.read_tls(&mut rd).map_err(|e| err!(
344 "Reading TLS records in from {} failed at offset {} of {}: {}",
345 t.host, rd.position(), bytes.len(), e; IO)));
346 if n == 0 {
347 break;
348 }
349 match t.conn.process_new_packets() {
350 Ok(_) => {},
351 Err(e) => {
352 // Record the failure and still drain, because rustls has queued
353 // the alert that tells the peer why the connection is ending.
354 if t.fault.is_none() {
355 t.fault = Some(fmt!("{:?}", e));
356 }
357 res!(drain_out(t));
358 return Ok(());
359 },
360 }
361 }
362
363 res!(drain_out(t));
364 res!(drain_in(t));
365 Ok(())
366}
367
368fn secure(t: &mut Tunnel) -> Outcome<()> {
369 match t.phase {
370 Phase::Clear => {},
371 Phase::Tls => return Err(err!(
372 "Mail tunnel to {}:{} is already carrying TLS; STARTTLS cannot be run twice.",
373 t.host, t.port; Invalid, Input)),
374 }
375
376 // Anything the server sent before the handshake and the caller has not read is
377 // either a pipelined response or an injection, and there is no way to tell them
378 // apart. Cleartext held across the handshake is the STARTTLS command injection
379 // flaw (CVE-2011-0411): the client is meant to discard its pre-TLS buffer, and a
380 // buffer that still has bytes in it means the caller would have read them as
381 // though they had arrived under the certificate it is about to check.
382 if !t.plain.is_empty() {
383 return Err(err!(
384 "STARTTLS to {}:{} was asked for with {} unread cleartext bytes still \
385 buffered; they cannot be carried across the handshake.",
386 t.host, t.port, t.plain.len(); Invalid, Input));
387 }
388
389 t.phase = Phase::Tls;
390 res!(drain_out(t)); // releases the ClientHello, held back until now
391 Ok(())
392}
393
394// ── The exported surface ────────────────────────────────────────────────
395//
396// Ciphertext in, ciphertext out. The caller owns the socket and never sees a key.
397
398/// Opens a tunnel to `host`:`port`, verified against the bundled Mozilla roots, with no
399/// way to ask for anything weaker. `security` is `tls` or `starttls`; a `starttls`
400/// tunnel starts in the clear and is promoted by [`mail_tunnel_secure`].
401#[cfg(not(feature = "insecure_testing"))]
402#[wasm_bindgen]
403pub fn mail_tunnel_open(host: &str, port: u16, security: &str) -> Result<u32, JsValue> {
404 open(host, port, security, Verify::Roots).map_err(to_js_err)
405}
406
407/// The testing entry point, which can also weaken or disable verification. Compiled
408/// only under `insecure_testing`, so a shipped bundle has no such door.
409#[cfg(feature = "insecure_testing")]
410#[wasm_bindgen]
411pub fn mail_tunnel_open(
412 host: &str,
413 port: u16,
414 security: &str,
415 mode: &str,
416 test_ca: Option<Box<[u8]>>,
417)
418 -> Result<u32, JsValue>
419{
420 let mode = ok!(Verify::parse(mode, test_ca.as_deref()).map_err(to_js_err));
421 open(host, port, security, mode).map_err(to_js_err)
422}
423
424/// Ciphertext in from the socket.
425#[wasm_bindgen]
426pub fn mail_tunnel_feed(h: u32, bytes: &[u8]) -> Result<(), JsValue> {
427 with_tunnel(h, |t| feed(t, bytes)).map_err(to_js_err)
428}
429
430/// Ciphertext out to the socket. Empty when there is nothing to send.
431#[wasm_bindgen]
432pub fn mail_tunnel_take(h: u32) -> Result<Box<[u8]>, JsValue> {
433 with_tunnel(h, |t| {
434 if let Phase::Tls = t.phase {
435 res!(drain_out(t));
436 }
437 Ok(std::mem::take(&mut t.wire).into_boxed_slice())
438 }).map_err(to_js_err)
439}
440
441/// Plaintext the peer has sent, decrypted.
442#[wasm_bindgen]
443pub fn mail_tunnel_read(h: u32) -> Result<Box<[u8]>, JsValue> {
444 with_tunnel(h, |t| Ok(std::mem::take(&mut t.plain).into_boxed_slice())).map_err(to_js_err)
445}
446
447/// Queues plaintext for the peer, encrypted on the way out.
448#[wasm_bindgen]
449pub fn mail_tunnel_write(h: u32, bytes: &[u8]) -> Result<(), JsValue> {
450 with_tunnel(h, |t| {
451 if let Phase::Clear = t.phase {
452 // The STARTTLS command itself, which by definition goes in the clear.
453 t.wire.extend_from_slice(bytes);
454 return Ok(());
455 }
456 res!(t.conn.writer().write_all(bytes).map_err(|e| err!(
457 "Queuing {} plaintext bytes for {} failed: {}", bytes.len(), t.host, e; IO)));
458 drain_out(t)
459 }).map_err(to_js_err)
460}
461
462/// Starts the handshake on a `starttls` tunnel, once the server has agreed. Refuses
463/// while unread cleartext is still buffered.
464#[wasm_bindgen]
465pub fn mail_tunnel_secure(h: u32) -> Result<(), JsValue> {
466 with_tunnel(h, secure).map_err(to_js_err)
467}
468
469/// One of `clear`, `handshaking`, `open`, `closed`, `failed`.
470///
471/// `failed` comes first, because a refused certificate leaves rustls mid-handshake: a
472/// caller watching only the state would otherwise wait on a `handshaking` that can never
473/// finish.
474#[wasm_bindgen]
475pub fn mail_tunnel_state(h: u32) -> Result<String, JsValue> {
476 with_tunnel(h, |t| Ok(if t.fault.is_some() {
477 "failed"
478 } else {
479 match t.phase {
480 Phase::Clear => "clear",
481 Phase::Tls if t.conn.is_handshaking() => "handshaking",
482 Phase::Tls if t.eof && t.plain.is_empty() => "closed",
483 Phase::Tls => "open",
484 }
485 }.to_string())).map_err(to_js_err)
486}
487
488/// The first protocol failure, verbatim, or `None` while the connection is healthy. A
489/// refused certificate arrives here as rustls's own discriminant.
490#[wasm_bindgen]
491pub fn mail_tunnel_fault(h: u32) -> Result<Option<String>, JsValue> {
492 with_tunnel(h, |t| Ok(t.fault.clone())).map_err(to_js_err)
493}
494
495/// The negotiated protocol version, once the handshake has finished.
496#[wasm_bindgen]
497pub fn mail_tunnel_version(h: u32) -> Result<Option<String>, JsValue> {
498 with_tunnel(h, |t| Ok(t.conn.protocol_version().map(|v| fmt!("{:?}", v))))
499 .map_err(to_js_err)
500}
501
502#[wasm_bindgen]
503pub fn mail_tunnel_close(h: u32) -> Result<(), JsValue> {
504 TUNNELS.with(|reg| {
505 let mut reg = reg.borrow_mut();
506 if h == 0 || h as usize > reg.len() {
507 return Err(err!("Mail tunnel handle {} was never issued.", h; Invalid, Input));
508 }
509 reg[(h - 1) as usize] = None;
510 Ok(())
511 }).map_err(to_js_err)
512}
513
514/// Names the build, so a page cannot silently be driving a module that checks less than
515/// the page believes it does. A production bundle answers `mailtls/verify-always`.
516#[wasm_bindgen]
517pub fn mail_tunnel_flavour() -> String {
518 let safety = if cfg!(feature = "insecure_testing") {
519 "INSECURE-TESTING"
520 } else {
521 "verify-always"
522 };
523 fmt!("mailtls/{}", safety)
524}