moqtap_client/transport/quic.rs
1//! QUIC transport implementation wrapping quinn.
2
3use std::future::Future;
4use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
5
6use bytes::Bytes;
7
8use super::{RecvStream, SendStream, TransportError};
9
10/// QUIC transport wrapping a `quinn::Connection`.
11pub struct QuicTransport {
12 conn: quinn::Connection,
13}
14
15impl QuicTransport {
16 /// Create a new QUIC transport from a quinn connection.
17 pub fn new(conn: quinn::Connection) -> Self {
18 Self { conn }
19 }
20
21 /// Open a bidirectional stream.
22 pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
23 let (send, recv) = self.conn.open_bi().await.map_err(conn_err)?;
24 Ok((SendStream::Quic(send), RecvStream::Quic(recv)))
25 }
26
27 /// Accept an incoming bidirectional stream.
28 pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), TransportError> {
29 let (send, recv) = self.conn.accept_bi().await.map_err(conn_err)?;
30 Ok((SendStream::Quic(send), RecvStream::Quic(recv)))
31 }
32
33 /// Open a unidirectional send stream.
34 pub async fn open_uni(&self) -> Result<SendStream, TransportError> {
35 let send = self.conn.open_uni().await.map_err(conn_err)?;
36 Ok(SendStream::Quic(send))
37 }
38
39 /// Accept an incoming unidirectional stream.
40 pub async fn accept_uni(&self) -> Result<RecvStream, TransportError> {
41 let recv = self.conn.accept_uni().await.map_err(conn_err)?;
42 Ok(RecvStream::Quic(recv))
43 }
44
45 /// Send a datagram.
46 pub fn send_datagram(&self, data: Bytes) -> Result<(), TransportError> {
47 self.conn.send_datagram(data).map_err(|e| TransportError::SendDatagram(e.to_string()))
48 }
49
50 /// Receive a datagram.
51 pub async fn recv_datagram(&self) -> Result<Bytes, TransportError> {
52 self.conn.read_datagram().await.map_err(conn_err)
53 }
54
55 /// Close the connection.
56 pub fn close(&self, code: u32, reason: &[u8]) {
57 self.conn.close(quinn::VarInt::from_u32(code), reason);
58 }
59
60 /// The quinn connection this transport wraps.
61 pub(crate) fn quinn_connection(&self) -> &quinn::Connection {
62 &self.conn
63 }
64
65 /// A future resolving when the session ends, carrying the peer's close.
66 ///
67 /// Borrows nothing and outlives this transport, for the same reason
68 /// [`SendStream::stopped`] does: the answer arrives after the call that
69 /// wanted it has already returned. A peer refusing a handshake commonly
70 /// finishes the control stream first and closes the session a round trip
71 /// later, so the caller sees an end-of-stream, hands back an error, and
72 /// drops the transport before the code it was refused with ever lands.
73 /// Taking this handle *before* the transport is given away is what makes
74 /// that code readable at all.
75 ///
76 /// Holding the future keeps the connection alive; dropping it lets quinn
77 /// close as usual.
78 pub fn closed(&self) -> impl Future<Output = TransportError> + Send + 'static {
79 let conn = self.conn.clone();
80 async move { connection_lost(conn.closed().await) }
81 }
82
83 /// The certificate chain the peer presented, DER-encoded, leaf first.
84 ///
85 /// Bytes, and no opinion about them: nothing here parses a certificate,
86 /// checks a date, or decides whether a chain is trustworthy, because the
87 /// verdict depends on what the caller is measuring. Empty is not an error —
88 /// a chain is absent for ordinary reasons, including a handshake that has
89 /// not completed.
90 ///
91 /// A chain that a handshake *failed over* never reaches here, since there
92 /// is no connection left to read it off; that is what
93 /// [`QuicDialOptions::observing`] is for.
94 pub fn peer_certificates(&self) -> Vec<Vec<u8>> {
95 peer_certificates(&self.conn)
96 }
97}
98
99/// Read a quinn connection's peer certificate chain as DER, leaf first.
100///
101/// Shared with the WebTransport arm, which reaches the same
102/// `quinn::Connection` through `wtransport`'s `quic_connection()`. One copy
103/// because there is exactly one fragile step here and it should not exist
104/// twice: `Connection::peer_identity` is `Option<Box<dyn Any>>`, since quinn is
105/// generic over its crypto backend and has no type it could name for every
106/// one. The rustls backend documents the concrete type as
107/// `Vec<rustls::pki_types::CertificateDer>` and quinn-proto builds exactly
108/// that, so the downcast is correct — and it is checked by nothing at compile
109/// time, so a quinn release that changed the type would turn this into a
110/// permanently empty chain rather than a build failure. That is what
111/// `tests/the_peer_certificate_chain_reaches_the_caller.rs` is for: it asserts
112/// the bytes handed back are the server's own certificate, byte for byte, so
113/// the silent version of that break cannot pass.
114///
115/// **Bytes, and no opinion about them.** Nothing here parses a certificate,
116/// checks a date, or decides whether a chain is trustworthy. A verdict depends
117/// on what the caller is measuring — a conformance probe grading a public relay
118/// wants "does this chain reach a public root", an operator on a private CA
119/// wants the opposite — and a library that guessed would be wrong for one of
120/// them while looking authoritative to both. The rule that keeps this honest:
121/// the transport reports what the peer sent, the caller decides what it means.
122///
123/// Empty is not an error and is never reported as one. A chain is absent for
124/// ordinary reasons — the handshake has not completed, the peer authenticated
125/// by some other means, the session was resumed without one — and none of them
126/// are faults this connection can do anything about. A caller that needs the
127/// distinction between "no chain" and "a chain we could not read" is asking a
128/// question the `Any` boundary above cannot answer anyway.
129///
130/// The returned `Vec<Vec<u8>>` owns its bytes rather than borrowing the
131/// connection's, which costs a copy per certificate and buys the thing callers
132/// actually need: a chain that outlives the connection it came from. A probe
133/// records the certificate and then closes the connection immediately, so a
134/// borrowed chain would have to be interpreted before the peer is released —
135/// exactly the ordering constraint this API exists to avoid imposing.
136pub(crate) fn peer_certificates(conn: &quinn::Connection) -> Vec<Vec<u8>> {
137 conn.peer_identity()
138 .and_then(|identity| {
139 identity.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>().ok()
140 })
141 .map(|chain| chain.iter().map(|der| der.as_ref().to_vec()).collect())
142 .unwrap_or_default()
143}
144
145/// Convert a quinn connection error to a TransportError.
146fn conn_err(e: quinn::ConnectionError) -> TransportError {
147 TransportError::Connection(e.to_string())
148}
149
150// ── From impls for quinn error types ────────────────────────
151
152impl From<quinn::ConnectionError> for TransportError {
153 fn from(e: quinn::ConnectionError) -> Self {
154 TransportError::Connection(e.to_string())
155 }
156}
157
158/// Take a handshake failure apart into codes plus prose.
159///
160/// The prose is quinn's own `Display`, unchanged, so nothing that reads the
161/// message loses anything by this; the codes are otherwise recoverable only by
162/// finding digits inside that message.
163///
164/// The three variants that carry a number are the three that matter to a
165/// conformance report: a peer's `CONNECTION_CLOSE`, our own stack's transport
166/// error (which is where a locally-detected certificate failure lands), and an
167/// application close. The rest — a timeout, a reset, a version mismatch — are
168/// findings with genuinely no code in them, and inventing one would be worse
169/// than `None`.
170pub(crate) fn handshake_failure(e: &quinn::ConnectionError) -> super::HandshakeFailure {
171 let reason = e.to_string();
172 match e {
173 quinn::ConnectionError::TransportError(inner) => {
174 super::HandshakeFailure::transport(u64::from(inner.code), reason)
175 }
176 quinn::ConnectionError::ConnectionClosed(close) => {
177 super::HandshakeFailure::transport(u64::from(close.error_code), reason)
178 }
179 quinn::ConnectionError::ApplicationClosed(close) => {
180 super::HandshakeFailure::application(close.error_code.into_inner(), reason)
181 }
182 _ => super::HandshakeFailure::bare(reason),
183 }
184}
185
186/// A lost connection, keeping the peer's close code where it named one.
187///
188/// Both stream error enums render `ConnectionLost` as the two words "connection
189/// lost" and drop the cause, so a session a relay ended deliberately — with a
190/// MoQT error code, and often a reason phrase saying why — reaches a caller as
191/// a sentence carrying neither. This reads them off the value instead.
192///
193/// Only `ApplicationClosed` carries an application code. A timeout, a stateless
194/// reset or a local close have genuinely no such number, and those keep
195/// quinn's own message.
196fn connection_lost(e: quinn::ConnectionError) -> TransportError {
197 match &e {
198 quinn::ConnectionError::ApplicationClosed(close) => TransportError::SessionClosed {
199 code: close.error_code.into_inner(),
200 reason: e.to_string(),
201 },
202 _ => TransportError::Connection(e.to_string()),
203 }
204}
205
206impl From<quinn::WriteError> for TransportError {
207 /// `Stopped` keeps the peer's application error code as a typed
208 /// [`TransportError::Stopped`] so a forwarder can mirror it; every
209 /// other cause collapses to a message.
210 fn from(e: quinn::WriteError) -> Self {
211 match e {
212 quinn::WriteError::Stopped(code) => TransportError::Stopped(code.into_inner()),
213 quinn::WriteError::ConnectionLost(lost) => connection_lost(lost),
214 other => TransportError::Write(other.to_string()),
215 }
216 }
217}
218
219impl From<quinn::ReadError> for TransportError {
220 /// `Reset` keeps the peer's application error code as a typed
221 /// [`TransportError::StreamReset`] so a forwarder can mirror it;
222 /// every other cause collapses to a message.
223 fn from(e: quinn::ReadError) -> Self {
224 match e {
225 quinn::ReadError::Reset(code) => TransportError::StreamReset(code.into_inner()),
226 quinn::ReadError::ConnectionLost(lost) => connection_lost(lost),
227 other => TransportError::Read(other.to_string()),
228 }
229 }
230}
231
232impl From<quinn::ReadExactError> for TransportError {
233 fn from(e: quinn::ReadExactError) -> Self {
234 match e {
235 quinn::ReadExactError::ReadError(inner) => inner.into(),
236 other => TransportError::Read(other.to_string()),
237 }
238 }
239}
240
241impl From<quinn::ConnectError> for TransportError {
242 fn from(e: quinn::ConnectError) -> Self {
243 TransportError::Connect(e.to_string())
244 }
245}
246
247impl From<quinn::ClosedStream> for TransportError {
248 fn from(_e: quinn::ClosedStream) -> Self {
249 TransportError::StreamClosed
250 }
251}
252
253impl From<quinn::SendDatagramError> for TransportError {
254 fn from(e: quinn::SendDatagramError) -> Self {
255 TransportError::SendDatagram(e.to_string())
256 }
257}
258
259// ---------------------------------------------------------------------------
260// Dialling
261// ---------------------------------------------------------------------------
262
263/// How a QUIC dial is configured, independent of any draft.
264///
265/// `alpn` is a list because ALPN is: one handshake offers several protocols and
266/// the server picks, which is how a caller that does not know a peer's draft
267/// finds out without dialling once per candidate.
268pub struct QuicDialOptions {
269 /// Skip TLS certificate verification. Testing only.
270 pub skip_cert_verification: bool,
271 /// Additional CA certificates to trust, DER-encoded, on top of the bundled
272 /// Mozilla roots every dial starts from, on either transport.
273 pub ca_certs: Vec<Vec<u8>>,
274 /// ALPN protocols to offer, in preference order.
275 ///
276 /// [`dial_quic`] returns the one the server selected. An empty list offers
277 /// nothing and is refused by any peer that requires ALPN, which every MoQT
278 /// relay does.
279 ///
280 /// A WebTransport dial ignores this field: that session is HTTP/3 by
281 /// definition and offers `h3` alone, so the protocol name a WebTransport
282 /// session negotiates is `WT-Available-Protocols` and not this. See
283 /// [`wt_protocols`](Self::wt_protocols). Everything else here applies to
284 /// both transports.
285 pub alpn: Vec<Vec<u8>>,
286 /// MOQT protocol identifiers to offer in the `WT-Available-Protocols`
287 /// header of a WebTransport dial, in preference order.
288 ///
289 /// WebTransport's answer to ALPN, and the reason a MoQT draft can be
290 /// negotiated over it at all. Drafts 15 and later state it in one sentence:
291 /// "MOQT uses ALPN in QUIC and `WT-Available-Protocols` in WebTransport
292 /// (\[WebTransport\], Section 3.3) to perform version negotiation" —
293 /// draft-15 cites Section 3.4 of the same document and is otherwise
294 /// word-for-word. Drafts 18 through 20 add the client's half of it: "The
295 /// client includes MOQT protocol identifiers in the WT-Available-Protocols
296 /// header". The identifiers are the ALPN names: `moqt-15` … `moqt-20`.
297 ///
298 /// Empty for drafts 07 through 14, which predate the header and settle
299 /// their version in CLIENT_SETUP instead. Empty is not the same as absent
300 /// by accident: a server that implements the negotiation and receives no
301 /// offer has nothing to select, and may reject the session outright.
302 /// imquic does, in as many words — "No WebTransport protocol offered".
303 ///
304 /// Ignored by a QUIC dial, where [`alpn`](Self::alpn) carries the same
305 /// names.
306 ///
307 /// # Reading the answer needs a patched `wtransport`
308 ///
309 /// A server names its choice in a `WT-Protocol` response header, and
310 /// upstream `wtransport` 0.7 drops the CONNECT response once it has judged
311 /// the status code. `Transport::wt_protocol` reads it — spelled as code
312 /// because it exists only behind this crate's `wt-protocol` feature, and a
313 /// link to it would be broken in every build without that feature. The
314 /// feature requires the patch in `moqtap/vendor/wtransport` and does not
315 /// build without it.
316 ///
317 /// Without that feature an accepted session says only that the server took
318 /// one of the offers or ignored the header, and only a *rejected* one is
319 /// conclusive — conclusive, then, about every identifier offered.
320 pub wt_protocols: Vec<Vec<u8>>,
321 /// Called with the peer's certificate chain during the handshake, **before
322 /// it is judged** — so it runs even for a chain that is about to be
323 /// rejected, which is the case it exists for. See [`CertificateHook`].
324 pub on_peer_certificates: Option<CertificateHook>,
325 /// Restrict the TLS 1.3 cipher suites offered, by IANA codepoint.
326 ///
327 /// `None` offers the crypto provider's full set, which is what an ordinary
328 /// client does and what every caller but a measuring one wants.
329 ///
330 /// `Some` exists because **the negotiated suite cannot be read back**.
331 /// quinn's `HandshakeData` carries the ALPN and the server name and nothing
332 /// else, and rustls does not surface the suite through it — so the only way
333 /// to learn which suite a peer accepts is to offer exactly one and see
334 /// whether the handshake completes. A successful dial *is* the measurement.
335 ///
336 /// Codepoints rather than a rustls enum so that a rustls upgrade cannot
337 /// change this crate's public API. The three TLS 1.3 suites are `0x1301`
338 /// AES-128-GCM-SHA256, `0x1302` AES-256-GCM-SHA384 and `0x1303`
339 /// CHACHA20-POLY1305-SHA256. A codepoint the provider does not have is
340 /// [`DialError::TlsConfig`] rather than a silent omission, because silently
341 /// offering fewer suites than asked would make every answer a false
342 /// negative.
343 ///
344 /// Excluding `0x1301` is allowed, and is the interesting case. QUIC's
345 /// Initial packets must use AES-128-GCM and normally that makes such an
346 /// offer unbuildable; the initial keys are taken from the default provider
347 /// separately so that only the *traffic* suites are restricted.
348 ///
349 /// Not honoured by `webtransport::dial_webtransport_to`, which reaches
350 /// `wtransport`'s builder and cannot supply a separate initial suite
351 /// through it. Spelled as code and not as a link on purpose: that module is
352 /// behind the `webtransport` feature, and `just doc-check` builds these
353 /// docs with the feature off.
354 pub cipher_suites: Option<Vec<u16>>,
355}
356
357impl QuicDialOptions {
358 /// Options offering `alpn`, verifying certificates against the bundled
359 /// Mozilla roots, observing nothing.
360 ///
361 /// A constructor and not a `Default` because there is no sensible default
362 /// ALPN: an empty list is refused by every MoQT relay, so a
363 /// `QuicDialOptions::default()` would be a value whose only outcome is TLS
364 /// alert 120 — which in a conformance report reads as a defect in the
365 /// relay rather than in the caller. Naming the offer is the one thing a
366 /// dial cannot do without.
367 ///
368 /// It is also the base for functional update, which is how the fields
369 /// below stay additive:
370 ///
371 /// ```ignore
372 /// QuicDialOptions { skip_cert_verification: true, ..QuicDialOptions::new(alpn) }
373 /// ```
374 pub fn new(alpn: Vec<Vec<u8>>) -> Self {
375 Self {
376 skip_cert_verification: false,
377 ca_certs: Vec::new(),
378 alpn,
379 wt_protocols: Vec::new(),
380 on_peer_certificates: None,
381 cipher_suites: None,
382 }
383 }
384
385 /// Accept any certificate. Testing only — see
386 /// [`skip_cert_verification`](Self::skip_cert_verification).
387 pub fn insecure(mut self, yes: bool) -> Self {
388 self.skip_cert_verification = yes;
389 self
390 }
391
392 /// Trust these DER-encoded CAs on top of the bundled roots.
393 pub fn ca_certs(mut self, certs: Vec<Vec<u8>>) -> Self {
394 self.ca_certs = certs;
395 self
396 }
397
398 /// Observe the peer's certificate chain, accepted or not.
399 ///
400 /// [`CertificateLog`] is the collector most callers want:
401 ///
402 /// ```ignore
403 /// let log = CertificateLog::new();
404 /// let result = dial_quic_to(&target, &QuicDialOptions::new(alpn).observing(log.hook())).await;
405 /// let chain = log.chain();
406 /// ```
407 pub fn observing(mut self, hook: CertificateHook) -> Self {
408 self.on_peer_certificates = Some(hook);
409 self
410 }
411
412 /// Offer only these cipher suites, by IANA codepoint. See
413 /// [`cipher_suites`](Self::cipher_suites).
414 pub fn offering_cipher_suites(mut self, suites: Vec<u16>) -> Self {
415 self.cipher_suites = Some(suites);
416 self
417 }
418
419 /// Offer these MOQT protocol identifiers to a WebTransport dial. See
420 /// [`wt_protocols`](Self::wt_protocols).
421 pub fn offering_wt_protocols(mut self, protocols: Vec<Vec<u8>>) -> Self {
422 self.wt_protocols = protocols;
423 self
424 }
425}
426
427/// The TLS 1.3 cipher suites, as IANA codepoints.
428///
429/// The whole set: TLS 1.3 defines five and rustls implements the three that
430/// QUIC can use. Named here so a caller enumerating support does not have to
431/// hardcode the numbers, and so [`show_cipher_suite`] and this list cannot
432/// drift apart.
433pub const TLS13_CIPHER_SUITES: [u16; 3] = [0x1301, 0x1302, 0x1303];
434
435/// The IANA name of a TLS 1.3 cipher suite codepoint.
436///
437/// Returns `None` for anything outside [`TLS13_CIPHER_SUITES`] rather than
438/// inventing a label, so an unknown number is reported as the number.
439pub fn show_cipher_suite(code: u16) -> Option<&'static str> {
440 match code {
441 0x1301 => Some("TLS_AES_128_GCM_SHA256"),
442 0x1302 => Some("TLS_AES_256_GCM_SHA384"),
443 0x1303 => Some("TLS_CHACHA20_POLY1305_SHA256"),
444 _ => None,
445 }
446}
447
448/// Where packets go, and whose certificate to expect when they arrive.
449///
450/// Two fields and not one `host:port` string, because they are two different
451/// things and a public relay is where that stops being pedantry. The socket
452/// layer needs an address it can send to; the TLS layer needs the name a
453/// certificate was issued for. Deriving the second from the first — which is
454/// what a single string forces — leaves only bad options: dial the hostname
455/// and `parse::<SocketAddr>()` rejects it, or resolve first and then validate
456/// a public relay's certificate against an IP literal that no CA ever put in
457/// a SAN. [`dial_quic`] is where both are avoided.
458pub struct QuicTarget {
459 /// The address packets are sent to. Already resolved — nothing in this
460 /// module does DNS on it.
461 pub addr: SocketAddr,
462 /// The name offered in SNI and validated against the server's certificate.
463 ///
464 /// An IP literal is legal and is what a loopback peer holding an IP-SAN
465 /// certificate wants; a hostname is what a public relay wants. Keeping it
466 /// separate is what lets one caller ask for both against one address.
467 pub server_name: String,
468}
469
470/// Why a QUIC dial did not produce a connection.
471///
472/// Separate from [`TransportError`] so the failures that happen before any
473/// packet is sent — a bad address, a socket this machine would not open, a TLS
474/// config it rejects — stay distinguishable from a peer that would not talk
475/// to us. [`DialError::phase`] is that distinction as a value.
476///
477/// # Why there is a variant for a socket that would not open
478///
479/// A bind this machine refused is this side's failure, and the type is what has
480/// to say so. Folding it into [`InvalidAddress`](Self::InvalidAddress) leaves
481/// the message as the only thing telling the two apart — a substring test
482/// wearing a type's clothes. A consumer reading it that way recovers "this
483/// machine has no IPv6 stack" only where the prose happens to contain `could
484/// not bind`, and files the result at whichever phase the *caller* named, which
485/// for a single call to [`dial_quic_to`] is one word for four stages: a failed
486/// v6 bind then publishes as a relay that failed a QUIC handshake, on a dial
487/// where no packet left the machine.
488///
489/// The WebTransport arm offers no such substring to find. `wtransport`'s
490/// endpoint constructor binds the socket too and its failure carries no `could
491/// not bind` in the message, so a `TransportError` there reaches
492/// [`ErrorCause::Transport`](crate::dispatch::ErrorCause::Transport) through a
493/// draft's `ConnectionError`, and `is_local` answers **false** for it — a
494/// socket this machine could not open, filed against the peer. Both arms raise
495/// this variant.
496///
497/// # Why it goes no further than this type
498///
499/// The per-draft `From<DialError> for ConnectionError` impls map this variant
500/// onto `ConnectionError::InvalidAddress`, which is one of the variants the
501/// facade reads as [`ErrorCause::Facade`](crate::dispatch::ErrorCause::Facade),
502/// and with it `is_local() == true` — the right answer for a failure this side
503/// decided. Splitting it further there would add public variants for a
504/// distinction no caller on that path reads: a draft `Connection` is built by a
505/// caller who named a `host:port`, not by one measuring which stage of a dial
506/// died. The reader of the distinction is a caller of [`dial_quic_to`], and it
507/// reads it off this type directly.
508#[derive(Debug, thiserror::Error)]
509pub enum DialError {
510 /// `addr` is not a `host:port` this machine can resolve to a socket address.
511 #[error("invalid address: {0}")]
512 InvalidAddress(String),
513 /// A local socket could not be opened to send from.
514 ///
515 /// Nothing about the target: the address is fine and this machine would not
516 /// give us a socket to reach it with. The common cause by a distance is
517 /// dialling a v6 address from a host or container with no IPv6 stack, which
518 /// is a routine outcome for anything that enumerates both families of a
519 /// dual-stack name — and a fact about the dialler, never about the peer.
520 #[error("local socket error: {0}")]
521 LocalSocket(String),
522 /// The TLS client configuration could not be built.
523 #[error("TLS configuration error: {0}")]
524 TlsConfig(String),
525 /// The dial itself failed.
526 #[error(transparent)]
527 Transport(#[from] TransportError),
528}
529
530/// Which stage of a dial a [`DialError`] came from.
531///
532/// A dial is not one event. [`dial_quic_to`] builds a TLS configuration, opens a
533/// local socket, asks quinn to start a connection, and only then sends a packet;
534/// the four fail for unrelated reasons and only the last of them involves the
535/// peer at all. This is that sequence as a value, so a caller can switch on it
536/// instead of reading the message a variant renders.
537///
538/// # The caller cannot supply this itself
539///
540/// It knows which *call* it made, not which stage inside the call died — and
541/// the four stages above live inside one call. The one thing it does know that
542/// this cannot is which transport it was dialling, which is why
543/// [`Handshake`](Self::Handshake) does not distinguish a QUIC handshake from a
544/// WebTransport CONNECT: both are a peer answering, and only the caller knows
545/// which it asked.
546///
547/// # Exactly one of these means the peer was involved
548///
549/// [`Handshake`](Self::Handshake), and [`DialError::is_local`] is that sentence
550/// as a predicate. The other four are decided on this machine with nothing on
551/// the wire, so a failure carrying one of them can never be evidence about the
552/// peer — which is the direction this distinction is load-bearing in. The
553/// converse is weaker and deliberately not claimed: a handshake that timed out
554/// is not proof of anything the peer did either, only that the machine got as
555/// far as sending.
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
557pub enum DialPhase {
558 /// Turning what the caller named into somewhere to send — parsing a
559 /// `host:port`, resolving it, finding it resolved to nothing.
560 Address,
561 /// Opening a local socket to send from. See [`DialError::LocalSocket`].
562 LocalSocket,
563 /// Building the TLS client configuration: a `ca_certs` entry that is not a
564 /// certificate, a cipher suite the crypto provider does not implement.
565 TlsConfig,
566 /// The stack refusing to start the connection — a server name rustls will
567 /// not accept, a remote address quinn will not dial. Nothing was sent.
568 Connect,
569 /// The handshake, and the only phase in which the peer is involved.
570 Handshake,
571}
572
573impl DialError {
574 /// Which stage of the dial this failure came from.
575 ///
576 /// Total and structural: every variant answers, and no arm reads a message.
577 /// [`DialError::Transport`] splits on whether the transport error is the
578 /// typed [`TransportError::Handshake`] — which by construction exists only
579 /// where a peer answered — so everything else under it is the stack
580 /// declining to start, which is [`DialPhase::Connect`].
581 pub fn phase(&self) -> DialPhase {
582 match self {
583 DialError::InvalidAddress(_) => DialPhase::Address,
584 DialError::LocalSocket(_) => DialPhase::LocalSocket,
585 DialError::TlsConfig(_) => DialPhase::TlsConfig,
586 DialError::Transport(TransportError::Handshake(_)) => DialPhase::Handshake,
587 DialError::Transport(_) => DialPhase::Connect,
588 }
589 }
590
591 /// Whether the dial failed **before a packet left this machine**.
592 ///
593 /// True for everything but [`DialPhase::Handshake`]. The name matches
594 /// [`AnyConnectionError::is_local`](crate::dispatch::AnyConnectionError::is_local),
595 /// which answers the same question for everything after the dial, and it is
596 /// worth being exact about what each half of the answer buys:
597 ///
598 /// - **`true` is a guarantee.** Nothing was sent, so the failure cannot be
599 /// evidence about the peer, and anything that publishes it as such is
600 /// publishing a finding that never happened.
601 /// - **`false` is not the opposite guarantee.** It says the machine got as
602 /// far as sending, and a handshake can still fail for reasons that are
603 /// nobody's fault in particular — a timeout, a network that dropped the
604 /// packets. Read [`TransportError::Handshake`]'s codes for what the peer
605 /// actually said; this only says whether there was a peer to ask.
606 pub fn is_local(&self) -> bool {
607 self.phase() != DialPhase::Handshake
608 }
609}
610
611/// The one place either transport decides what a server certificate is checked
612/// against.
613///
614/// Shared, and that is the whole point of it. Two transports answering this
615/// question apart from each other answer it differently: a `RootCertStore`
616/// seeded from the `webpki-roots` bundle with `ca_certs` added to it on one
617/// side, `wtransport`'s `with_native_certs()` — the *OS* trust store, which
618/// never sees `ca_certs` — on the other. Two consequences, both of which a
619/// conformance run publishes as facts about the relay:
620///
621/// - The bundled Mozilla set and a machine's own set are not the same set. They
622/// diverge on newly-added roots, on roots a distribution has retired early,
623/// and on whatever a corporate MITM appliance installed. A relay would
624/// therefore pass over QUIC and fail over WebTransport with nothing about the
625/// relay differing between the two dials.
626/// - A caller supplying a private CA would have it honoured on one transport
627/// and silently dropped on the other. Not an error, not a warning — a
628/// handshake failure that looks exactly like a relay presenting a bad chain.
629///
630/// Bundled roots for both, rather than native for both, because a published
631/// measurement has to be reproducible: `webpki-roots` is a fixed set compiled
632/// into the binary, so two runs on two machines validate against the same
633/// anchors and any difference in the outcome is a difference in the relay. The
634/// cost is that a peer whose chain the operator trusts only via the OS store is
635/// not trusted here — that operator passes the CA in `ca_certs`, which is what
636/// the field is for and why it reaches both transports.
637///
638/// `alpn` is a parameter and not read off `options` because it is the one part
639/// of the handshake the two transports do not share: a QUIC dial offers the
640/// draft ALPNs it wants the server to choose between, a WebTransport session
641/// offers `h3` and nothing else. Everything a certificate is judged by comes
642/// from `options`.
643///
644/// Returns [`DialError::TlsConfig`] for a `ca_certs` entry that is not a
645/// parseable certificate, and for a crypto provider without the AES-128-GCM
646/// suite QUIC's initial packets are obliged to use.
647///
648/// # The initial suite is separate from the offered suites
649///
650/// QUIC encrypts its Initial packets with AES-128-GCM and has no say in the
651/// matter (RFC 9001 §5.2), which normally makes that suite impossible to leave
652/// out of an offer — and therefore makes "does this peer accept *only*
653/// ChaCha20" unaskable. `QuicClientConfig::with_initial` exists for exactly
654/// this: it takes the initial keys from one suite and lets the TLS config offer
655/// another. So when [`QuicDialOptions::cipher_suites`] excludes AES-128-GCM,
656/// the initial suite is taken from the process default provider and only the
657/// *traffic* suites are restricted.
658pub(crate) fn client_config(
659 options: &QuicDialOptions,
660 alpn: Vec<Vec<u8>>,
661) -> Result<quinn::ClientConfig, DialError> {
662 use std::sync::Arc;
663
664 let tls_config = Arc::new(rustls_client_config(options, alpn)?);
665
666 let has_initial = tls_config
667 .crypto_provider()
668 .cipher_suites
669 .iter()
670 .any(|cs| cs.suite() == rustls::CipherSuite::TLS13_AES_128_GCM_SHA256);
671
672 let quic_config = if has_initial {
673 quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
674 .map_err(|e| DialError::TlsConfig(format!("{e}")))?
675 } else {
676 quinn::crypto::rustls::QuicClientConfig::with_initial(tls_config, initial_suite()?)
677 .map_err(|e| DialError::TlsConfig(format!("{e}")))?
678 };
679 Ok(quinn::ClientConfig::new(Arc::new(quic_config)))
680}
681
682/// The AES-128-GCM keys QUIC's Initial packets require, from the default
683/// provider.
684///
685/// Read from the process default rather than from the dial's own (possibly
686/// restricted) provider, because the whole point of reaching this function is
687/// that the dial's provider deliberately does not have it.
688fn initial_suite() -> Result<rustls::quic::Suite, DialError> {
689 default_provider()
690 .cipher_suites
691 .iter()
692 .find(|cs| cs.suite() == rustls::CipherSuite::TLS13_AES_128_GCM_SHA256)
693 .and_then(|cs| cs.tls13())
694 .and_then(|cs| cs.quic_suite())
695 .ok_or_else(|| {
696 DialError::TlsConfig(
697 "the crypto provider has no TLS13_AES_128_GCM_SHA256, which QUIC's initial \
698 packets require"
699 .to_string(),
700 )
701 })
702}
703
704/// The process-wide crypto provider, or `ring` if none was installed.
705fn default_provider() -> std::sync::Arc<rustls::crypto::CryptoProvider> {
706 rustls::crypto::CryptoProvider::get_default()
707 .cloned()
708 .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::ring::default_provider()))
709}
710
711/// The rustls half of [`client_config`], before quinn wraps it.
712///
713/// Split out for the WebTransport dial, which cannot use the quinn form: the
714/// only `wtransport` builder state carrying `dns_resolver` — the hook that lets
715/// a caller choose which address a session goes to — is reached through
716/// `with_custom_tls`, which takes a `rustls::ClientConfig`. The state that
717/// accepts a ready-made quinn config has no such hook.
718///
719/// Everything that decides a certificate verdict lives here, so both transports
720/// still get it from one place — including
721/// [`on_peer_certificates`](QuicDialOptions::on_peer_certificates), which is
722/// installed inside the verifier because that is the only place the chain
723/// exists when a handshake is going to fail. Reading it off the finished
724/// connection — what [`super::Transport::peer_certificates`] does — works only
725/// when there *is* a finished connection, and the certificates worth reporting
726/// on are disproportionately the ones that stopped a handshake from finishing.
727pub(crate) fn rustls_client_config(
728 options: &QuicDialOptions,
729 alpn: Vec<Vec<u8>>,
730) -> Result<rustls::ClientConfig, DialError> {
731 use std::sync::Arc;
732
733 // The verifier the caller's options ask for, before any recording.
734 let verifier: Arc<dyn rustls::client::danger::ServerCertVerifier> =
735 if options.skip_cert_verification {
736 Arc::new(SkipVerification::new())
737 } else {
738 let mut roots = rustls::RootCertStore::empty();
739 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
740 for der in &options.ca_certs {
741 roots
742 .add(rustls::pki_types::CertificateDer::from(der.clone()))
743 .map_err(|e| DialError::TlsConfig(format!("bad CA cert: {e}")))?;
744 }
745 rustls::client::WebPkiServerVerifier::builder(Arc::new(roots))
746 .build()
747 .map_err(|e| DialError::TlsConfig(format!("{e}")))?
748 };
749
750 // Wrapping is what keeps observing orthogonal to judging: the decorator
751 // copies the chain and then hands the same arguments to the verifier that
752 // would have run anyway, so an observed dial and an unobserved one reach
753 // identical verdicts. Anything else would make the act of measuring change
754 // the measurement.
755 let verifier = match &options.on_peer_certificates {
756 Some(hook) => Arc::new(ObservingVerifier { inner: verifier, hook: Arc::clone(hook) })
757 as Arc<dyn rustls::client::danger::ServerCertVerifier>,
758 None => verifier,
759 };
760
761 // `builder()` when nothing is restricted, so the default path is byte for
762 // byte what it always was; the provider form only when a caller is
763 // measuring. TLS 1.3 is pinned explicitly there because that builder does
764 // not default to it and QUIC permits nothing else.
765 let builder = match &options.cipher_suites {
766 None => rustls::ClientConfig::builder(),
767 Some(wanted) => {
768 rustls::ClientConfig::builder_with_provider(Arc::new(restricted_provider(wanted)?))
769 .with_protocol_versions(&[&rustls::version::TLS13])
770 .map_err(|e| DialError::TlsConfig(format!("{e}")))?
771 }
772 };
773
774 let mut tls_config =
775 builder.dangerous().with_custom_certificate_verifier(verifier).with_no_client_auth();
776
777 tls_config.alpn_protocols = alpn;
778 Ok(tls_config)
779}
780
781/// The default provider with its cipher suites narrowed to `wanted`.
782///
783/// A requested suite the provider does not implement is an error and not a
784/// silent omission. Offering fewer suites than asked would make a refusal
785/// indistinguishable from a suite that was never on the wire, which turns every
786/// negative result into a possible false one — and this field exists only to
787/// produce negative results that mean something.
788fn restricted_provider(wanted: &[u16]) -> Result<rustls::crypto::CryptoProvider, DialError> {
789 let base = default_provider();
790
791 let mut suites = Vec::with_capacity(wanted.len());
792 for code in wanted {
793 let found = base.cipher_suites.iter().find(|cs| u16::from(cs.suite()) == *code);
794 match found {
795 Some(cs) => suites.push(*cs),
796 None => {
797 let name = show_cipher_suite(*code).unwrap_or("unknown");
798 return Err(DialError::TlsConfig(format!(
799 "the crypto provider does not implement cipher suite {code:#06x} ({name})"
800 )));
801 }
802 }
803 }
804 if suites.is_empty() {
805 return Err(DialError::TlsConfig(
806 "an empty cipher suite list offers nothing and no peer can answer it".to_string(),
807 ));
808 }
809
810 Ok(rustls::crypto::CryptoProvider { cipher_suites: suites, ..(*base).clone() })
811}
812
813/// Dial one already-resolved address, and report which ALPN the server chose.
814///
815/// This is the whole dial with nothing decided for the caller: one address,
816/// one server name, one ALPN offer, one answer. [`dial_quic`] is the
817/// convenience wrapper that resolves a `host:port` and picks an address; a
818/// caller measuring *which* address or *which* name a relay answers on wants
819/// this one, so that each attempt fails on its own and is recorded on its own.
820///
821/// The second return value is the protocol the server chose from `options.alpn`,
822/// `None` if it selected none.
823/// [`DraftVersion::from_alpn`](moqtap_codec::version::DraftVersion::from_alpn)
824/// names a draft for five of the six; drafts 07-14 share `moq-00` and settle
825/// their version in CLIENT_SETUP.
826///
827/// The endpoint is dropped when the dial returns — quinn keeps the connection's
828/// driver alive independently.
829pub async fn dial_quic_to(
830 target: &QuicTarget,
831 options: &QuicDialOptions,
832) -> Result<(super::Transport, Option<Vec<u8>>), DialError> {
833 let server_addr = target.addr;
834
835 // Built before the socket, so a CA the caller cannot have meant fails
836 // without a packet leaving the machine. Through `client_config` and not
837 // inline, so that this dial and the WebTransport one agree about the
838 // initial cipher suite as well as about certificates.
839 let config = client_config(options, options.alpn.clone())?;
840
841 // Bind in the target's address family. A socket bound to `0.0.0.0` cannot
842 // send to a v6 peer, and quinn reports that as `invalid remote address` —
843 // which reads like the address was malformed when it was only unreachable
844 // from the socket we opened.
845 let bind: SocketAddr = match server_addr {
846 SocketAddr::V4(_) => (Ipv4Addr::UNSPECIFIED, 0).into(),
847 SocketAddr::V6(_) => (Ipv6Addr::UNSPECIFIED, 0).into(),
848 };
849 let mut endpoint = quinn::Endpoint::client(bind).map_err(|e| {
850 // Not `InvalidAddress` about the *target*: the target is fine and this
851 // machine could not open a local socket to reach it. `LocalSocket`
852 // keeps that distinction in the type rather than in the message.
853 DialError::LocalSocket(format!("could not bind a local {} socket: {e}", family(bind)))
854 })?;
855 endpoint.set_default_client_config(config);
856
857 let quic = endpoint
858 .connect(server_addr, &target.server_name)
859 .map_err(TransportError::from)?
860 .await
861 // Not the blanket `From`, which flattens to a message: this is the one
862 // place a peer's refusal is still typed, and a caller measuring *why* a
863 // relay refused needs the code rather than a sentence containing it.
864 .map_err(|e| TransportError::Handshake(handshake_failure(&e)))?;
865
866 let negotiated = negotiated_alpn(&quic);
867 Ok((super::Transport::Quic(QuicTransport::new(quic)), negotiated))
868}
869
870/// Resolve `addr` and dial the first address that answers.
871///
872/// Keeps the signature every draft module's `connect_quic` calls.
873///
874/// Why it resolves rather than parses: `addr.parse::<SocketAddr>()` accepts
875/// numeric literals and nothing else, so every hostname fails with `invalid
876/// address: invalid socket address syntax` before a packet is sent — a loopback
877/// interop suite passes while every public relay is unreachable. Resolving in
878/// the caller is not an answer either: the resolved IP becomes the SNI and the
879/// certificate check runs against it. `resolve` hands back the name and the
880/// addresses separately, which is what [`QuicTarget`] has two fields for.
881///
882/// One connection is what this returns, so one is what it looks for; a caller
883/// that needs to know an address failed, rather than that some address worked,
884/// wants [`dial_quic_to`] per address instead.
885pub async fn dial_quic(
886 addr: &str,
887 options: &QuicDialOptions,
888) -> Result<(super::Transport, Option<Vec<u8>>), DialError> {
889 let (server_name, addrs) = resolve(addr).await?;
890
891 let mut last: Option<(SocketAddr, DialError)> = None;
892 for candidate in addrs {
893 let target = QuicTarget { addr: candidate, server_name: server_name.clone() };
894 match dial_quic_to(&target, options).await {
895 Ok(connected) => return Ok(connected),
896 Err(e) => last = Some((candidate, e)),
897 }
898 }
899
900 match last {
901 Some((candidate, e)) => Err(e_at(candidate, e)),
902 // `resolve` refuses to return an empty list, so the loop always ran.
903 None => Err(DialError::InvalidAddress(format!("{addr} resolved to no addresses"))),
904 }
905}
906
907/// Name the address a flattened multi-address failure came from.
908///
909/// `dial_quic` collapses several attempts into one error, which is exactly the
910/// lossiness a probe must not have — and is fine here, because a caller of
911/// `dial_quic` asked for one connection and not for a measurement. Saying
912/// which address produced the surviving error is the least it can do.
913///
914/// # It must not relabel the phase while it does that
915///
916/// Every arm returns the variant it was given, and that is a correctness
917/// requirement rather than tidiness: [`DialError::phase`] reads the variant, so
918/// an arm that rewrote a peer's refusal into a `Connect` in order to prefix the
919/// address would make `is_local` answer **true** for a relay that answered a
920/// handshake, and would throw that handshake's codes away with it. Prefixing a
921/// peer's failure means going inside [`HandshakeFailure`] and prefixing the
922/// reason, which keeps its codes as well as its phase.
923///
924/// [`HandshakeFailure`]: super::HandshakeFailure
925fn e_at(addr: SocketAddr, e: DialError) -> DialError {
926 match e {
927 DialError::InvalidAddress(m) => DialError::InvalidAddress(format!("{addr}: {m}")),
928 DialError::LocalSocket(m) => DialError::LocalSocket(format!("{addr}: {m}")),
929 DialError::TlsConfig(m) => DialError::TlsConfig(format!("{addr}: {m}")),
930 DialError::Transport(TransportError::Handshake(failure)) => {
931 DialError::Transport(TransportError::Handshake(super::HandshakeFailure {
932 reason: format!("{addr}: {}", failure.reason),
933 ..failure
934 }))
935 }
936 DialError::Transport(inner) => {
937 DialError::Transport(TransportError::Connect(format!("{addr}: {inner}")))
938 }
939 }
940}
941
942/// Split `host:port` into the name to validate against and every address it
943/// resolves to, v4 first.
944///
945/// v4 first is a preference and not a correctness claim: a v6 attempt on a
946/// host without v6 connectivity burns the caller's entire timeout before v4
947/// is reached, and this path exists to return one working connection quickly.
948/// It is the wrong default for measuring a relay, which is why a probe should
949/// enumerate the list itself and dial each address through [`dial_quic_to`]
950/// rather than inherit this ordering.
951async fn resolve(addr: &str) -> Result<(String, Vec<SocketAddr>), DialError> {
952 // A literal is its own answer and its own server name. Loopback peers are
953 // dialled this way, and one holding an IP-SAN certificate has to keep
954 // validating against the IP — so this cannot be routed through the
955 // hostname path even though `lookup_host` would accept it.
956 if let Ok(sock) = addr.parse::<SocketAddr>() {
957 return Ok((sock.ip().to_string(), vec![sock]));
958 }
959
960 let host = host_of(addr)?;
961 let mut resolved: Vec<SocketAddr> = tokio::net::lookup_host(addr)
962 .await
963 .map_err(|e| DialError::InvalidAddress(format!("could not resolve {addr}: {e}")))?
964 .collect();
965
966 if resolved.is_empty() {
967 return Err(DialError::InvalidAddress(format!("{addr} resolved to no addresses")));
968 }
969 resolved.sort_by_key(|a| a.is_ipv6());
970 Ok((host, resolved))
971}
972
973/// The host part of `host:port`, with the port removed and brackets stripped.
974///
975/// `rsplit_once(':')` alone is wrong for `[::1]:443`: it would cut at the last
976/// colon inside the address and hand back `[::1]` including the bracket, which
977/// is not a name rustls will accept.
978fn host_of(addr: &str) -> Result<String, DialError> {
979 if let Some(rest) = addr.strip_prefix('[') {
980 return rest
981 .split_once(']')
982 .map(|(host, _)| host.to_string())
983 .ok_or_else(|| DialError::InvalidAddress(format!("unclosed '[' in {addr}")));
984 }
985 addr.rsplit_once(':')
986 .map(|(host, _)| host.to_string())
987 .filter(|host| !host.is_empty())
988 .ok_or_else(|| DialError::InvalidAddress(format!("{addr} is not host:port")))
989}
990
991/// `"IPv4"` or `"IPv6"`, for the one error message that needs to say which.
992fn family(addr: SocketAddr) -> &'static str {
993 if addr.is_ipv4() {
994 "IPv4"
995 } else {
996 "IPv6"
997 }
998}
999
1000/// The ALPN the server selected, if the handshake recorded one.
1001fn negotiated_alpn(conn: &quinn::Connection) -> Option<Vec<u8>> {
1002 conn.handshake_data()?.downcast::<quinn::crypto::rustls::HandshakeData>().ok()?.protocol
1003}
1004
1005/// Called with a certificate chain observed during a handshake, DER, leaf
1006/// first.
1007///
1008/// A hook and not a return value because a verifier that is about to reject a
1009/// certificate has no return path that carries one — it answers with an error,
1010/// and the bytes it was judging go out of scope. The interesting certificates
1011/// are exactly the rejected ones, so the observation has to happen where the
1012/// judging does.
1013///
1014/// Two things about where it runs. It is called from inside the TLS handshake,
1015/// on quinn's connection driver task rather than on the task that called the
1016/// dial, so a hook that blocks stalls the connection and one that panics
1017/// unwinds into the driver — keep it to moving bytes somewhere. And
1018/// [`dial_quic`] calls it once per resolved address it tries, with nothing in
1019/// the arguments to say which; a caller that needs to attribute a chain to an
1020/// address should dial each one with [`dial_quic_to`].
1021pub type CertificateHook = std::sync::Arc<dyn Fn(&[Vec<u8>]) + Send + Sync>;
1022
1023/// The [`CertificateHook`] most callers want: keep the chain, read it after.
1024///
1025/// Shipped rather than left to each caller because the storage behind this hook
1026/// is the same shared, lock-guarded vector every time, and because it survives
1027/// cancellation — a dial abandoned by `tokio::time::timeout` still leaves
1028/// whatever chain it had already seen in the log, where a chain returned
1029/// alongside the dial's result would be dropped with the future.
1030#[derive(Clone, Default)]
1031pub struct CertificateLog(std::sync::Arc<std::sync::Mutex<Vec<Vec<u8>>>>);
1032
1033impl CertificateLog {
1034 /// An empty log.
1035 pub fn new() -> Self {
1036 Self::default()
1037 }
1038
1039 /// The hook to hand to
1040 /// [`QuicDialOptions::observing`](QuicDialOptions::observing).
1041 pub fn hook(&self) -> CertificateHook {
1042 let slot = std::sync::Arc::clone(&self.0);
1043 std::sync::Arc::new(move |chain: &[Vec<u8>]| {
1044 if let Ok(mut seen) = slot.lock() {
1045 *seen = chain.to_vec();
1046 }
1047 })
1048 }
1049
1050 /// The last chain observed, empty if the handshake never got as far as one.
1051 ///
1052 /// Empty therefore means *no certificate was offered* — a refused ALPN, a
1053 /// dead port, a timeout — and never *the certificate was unreadable*.
1054 pub fn chain(&self) -> Vec<Vec<u8>> {
1055 self.0.lock().map(|seen| seen.clone()).unwrap_or_default()
1056 }
1057}
1058
1059impl std::fmt::Debug for CertificateLog {
1060 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1061 f.debug_tuple("CertificateLog").field(&self.chain().len()).finish()
1062 }
1063}
1064
1065/// A verifier that writes down the chain it was shown, then defers to another.
1066///
1067/// It exists because the interesting certificates are the rejected ones. A
1068/// relay with an expired certificate, a private CA, or a name that does not
1069/// match never completes a handshake, so nothing is left afterwards to read the
1070/// chain off — and "the handshake failed for a certificate reason" without the
1071/// certificate is exactly the report a relay operator cannot act on.
1072///
1073/// It observes unconditionally and judges not at all. The inner verifier's
1074/// verdict is returned untouched, so wrapping cannot turn a rejection into an
1075/// acceptance however the observation goes.
1076struct ObservingVerifier {
1077 inner: std::sync::Arc<dyn rustls::client::danger::ServerCertVerifier>,
1078 hook: CertificateHook,
1079}
1080
1081// Hand-written because `rustls::client::danger::ServerCertVerifier` requires
1082// `Debug` of the verifier, and `#[derive]` would push that requirement onto the
1083// hook — which would make `CertificateHook` a trait with a `Debug` supertrait
1084// rather than a closure, for no benefit to anyone but this line.
1085impl std::fmt::Debug for ObservingVerifier {
1086 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1087 f.debug_struct("ObservingVerifier").field("inner", &self.inner).finish_non_exhaustive()
1088 }
1089}
1090
1091impl rustls::client::danger::ServerCertVerifier for ObservingVerifier {
1092 fn verify_server_cert(
1093 &self,
1094 end_entity: &rustls::pki_types::CertificateDer<'_>,
1095 intermediates: &[rustls::pki_types::CertificateDer<'_>],
1096 server_name: &rustls::pki_types::ServerName<'_>,
1097 ocsp_response: &[u8],
1098 now: rustls::pki_types::UnixTime,
1099 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
1100 // Observe before judging, so a rejection still leaves the evidence
1101 // behind. Leaf first, matching what a finished connection reports.
1102 let mut chain = Vec::with_capacity(1 + intermediates.len());
1103 chain.push(end_entity.as_ref().to_vec());
1104 chain.extend(intermediates.iter().map(|der| der.as_ref().to_vec()));
1105 (self.hook)(&chain);
1106
1107 self.inner.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
1108 }
1109
1110 fn verify_tls12_signature(
1111 &self,
1112 message: &[u8],
1113 cert: &rustls::pki_types::CertificateDer<'_>,
1114 dss: &rustls::DigitallySignedStruct,
1115 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1116 self.inner.verify_tls12_signature(message, cert, dss)
1117 }
1118
1119 fn verify_tls13_signature(
1120 &self,
1121 message: &[u8],
1122 cert: &rustls::pki_types::CertificateDer<'_>,
1123 dss: &rustls::DigitallySignedStruct,
1124 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1125 self.inner.verify_tls13_signature(message, cert, dss)
1126 }
1127
1128 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1129 self.inner.supported_verify_schemes()
1130 }
1131}
1132
1133/// TLS certificate verifier that skips all verification. Testing only.
1134#[derive(Debug)]
1135struct SkipVerification {
1136 /// The provider whose signature schemes this verifier advertises.
1137 ///
1138 /// Held rather than hardcoded because
1139 /// [`supported_verify_schemes`](SkipVerification::supported_verify_schemes)
1140 /// is not a claim about what this verifier checks — it checks nothing — but
1141 /// about what the *ClientHello* offers. See that method for why the
1142 /// distinction has teeth.
1143 provider: std::sync::Arc<rustls::crypto::CryptoProvider>,
1144}
1145
1146impl SkipVerification {
1147 /// Use whichever provider this process installed, falling back to the one
1148 /// this crate compiles with.
1149 ///
1150 /// Taking the installed provider rather than naming `ring` unconditionally
1151 /// keeps the verifier's advertised schemes in step with the schemes the
1152 /// rest of the handshake was actually built from, however the embedding
1153 /// binary configured rustls.
1154 fn new() -> Self {
1155 Self { provider: default_provider() }
1156 }
1157}
1158
1159impl rustls::client::danger::ServerCertVerifier for SkipVerification {
1160 fn verify_server_cert(
1161 &self,
1162 _end_entity: &rustls::pki_types::CertificateDer<'_>,
1163 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
1164 _server_name: &rustls::pki_types::ServerName<'_>,
1165 _ocsp_response: &[u8],
1166 _now: rustls::pki_types::UnixTime,
1167 ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
1168 Ok(rustls::client::danger::ServerCertVerified::assertion())
1169 }
1170
1171 fn verify_tls12_signature(
1172 &self,
1173 _message: &[u8],
1174 _cert: &rustls::pki_types::CertificateDer<'_>,
1175 _dcs: &rustls::DigitallySignedStruct,
1176 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1177 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
1178 }
1179
1180 fn verify_tls13_signature(
1181 &self,
1182 _message: &[u8],
1183 _cert: &rustls::pki_types::CertificateDer<'_>,
1184 _dcs: &rustls::DigitallySignedStruct,
1185 ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
1186 Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
1187 }
1188
1189 /// Every scheme the active provider can verify.
1190 ///
1191 /// This looks like dead weight on a verifier that verifies nothing, and it
1192 /// is not: rustls sends this list as the ClientHello's
1193 /// `signature_algorithms` extension, so it decides which certificates a
1194 /// server is *willing to offer us* — before this verifier is consulted at
1195 /// all.
1196 ///
1197 /// Read off the provider rather than written out by hand, because a
1198 /// hand-written list omits whatever it forgets — `ECDSA_NISTP521_SHA512` is
1199 /// the easy one to miss. A relay with a P-521 leaf would then fail
1200 /// a verification-disabled connection because of what this client offered,
1201 /// not because of anything wrong with the relay — and for the conformance
1202 /// probe that consumes this crate, a failure it manufactured itself is the
1203 /// one result it must never record.
1204 ///
1205 /// The provider's own list is the floor, so the offer widens whenever the
1206 /// provider's does. It is not the ceiling, because it cannot be: `ring`
1207 /// does not implement P-521 at all, so deferring to it alone still leaves
1208 /// that certificate unreachable. That constraint does not apply *here* —
1209 /// this verifier accepts every certificate without looking at it, so a
1210 /// scheme it could not check is one it never needs to. Advertising a
1211 /// superset is exactly right for a verifier that verifies nothing, and
1212 /// would be wrong for any verifier that does.
1213 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
1214 let mut schemes = self.provider.signature_verification_algorithms.supported_schemes();
1215
1216 // Schemes a server might legitimately sign with that the provider
1217 // cannot verify. Additive and deduplicated, so a provider that grows
1218 // support for one of these does not end up offering it twice. A named
1219 // list rather than a literal, so adding a scheme is adding a row.
1220 const EXTRA: &[rustls::SignatureScheme] = &[rustls::SignatureScheme::ECDSA_NISTP521_SHA512];
1221 for extra in EXTRA {
1222 if !schemes.contains(extra) {
1223 schemes.push(*extra);
1224 }
1225 }
1226 schemes
1227 }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use rustls::client::danger::ServerCertVerifier;
1234
1235 /// The list was six schemes written out by hand, and `ECDSA_NISTP521_SHA512`
1236 /// was not among them. rustls sends it as the ClientHello's
1237 /// `signature_algorithms`, so the omission decided which certificates a
1238 /// server would offer — meaning a P-521 relay failed a
1239 /// verification-disabled dial because of this client rather than because of
1240 /// anything about the relay.
1241 #[test]
1242 fn skipping_verification_still_offers_every_scheme_the_provider_has() {
1243 let schemes = SkipVerification::new().supported_verify_schemes();
1244
1245 assert!(
1246 schemes.contains(&rustls::SignatureScheme::ECDSA_NISTP521_SHA512),
1247 "P-521 missing from the offer: {schemes:?}"
1248 );
1249 // The six that were hardcoded must all survive.
1250 for required in [
1251 rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
1252 rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
1253 rustls::SignatureScheme::ED25519,
1254 rustls::SignatureScheme::RSA_PSS_SHA256,
1255 rustls::SignatureScheme::RSA_PSS_SHA384,
1256 rustls::SignatureScheme::RSA_PSS_SHA512,
1257 ] {
1258 assert!(schemes.contains(&required), "{required:?} missing from {schemes:?}");
1259 }
1260 }
1261
1262 /// A TLS alert is a QUIC code with the top bit set, and both halves matter:
1263 /// the code is what the wire carried, the alert is what it means.
1264 #[test]
1265 fn a_crypto_code_yields_the_alert_it_encodes() {
1266 // 120 `no_application_protocol` — a relay refusing every draft offered.
1267 let refused = handshake_failure(&quinn::ConnectionError::TransportError(
1268 quinn::TransportErrorCode::crypto(120).into(),
1269 ));
1270 assert_eq!(refused.code, Some(0x178));
1271 assert_eq!(refused.tls_alert, Some(120));
1272
1273 // 45 `certificate_expired` — the live case across the seed fleet.
1274 let expired = handshake_failure(&quinn::ConnectionError::TransportError(
1275 quinn::TransportErrorCode::crypto(45).into(),
1276 ));
1277 assert_eq!(expired.code, Some(0x12d));
1278 assert_eq!(expired.tls_alert, Some(45));
1279 }
1280
1281 /// Codes outside `0x0100..=0x01ff` are not alerts and must not be reported
1282 /// as one. `0x178 & 0xff` is a valid alert; `0x10f & 0xff` would be too, and
1283 /// masking without checking the range is how an application close becomes a
1284 /// fictional alert in a conformance report.
1285 #[test]
1286 fn a_non_crypto_code_names_no_alert() {
1287 let closed = handshake_failure(&quinn::ConnectionError::ApplicationClosed(
1288 quinn::ApplicationClose {
1289 error_code: quinn::VarInt::from_u32(271),
1290 reason: (&[][..]).into(),
1291 },
1292 ));
1293 assert_eq!(closed.code, Some(271));
1294 assert_eq!(closed.code_space, Some(super::super::CodeSpace::Application));
1295 assert_eq!(closed.tls_alert, None, "271 is an application code, not alert 15");
1296
1297 assert_eq!(super::super::HandshakeFailure::alert_of(0x00ff), None);
1298 assert_eq!(super::super::HandshakeFailure::alert_of(0x0200), None);
1299 assert_eq!(super::super::HandshakeFailure::alert_of(0x0100), Some(0));
1300 assert_eq!(super::super::HandshakeFailure::alert_of(0x01ff), Some(255));
1301 }
1302
1303 /// A session a peer ended on purpose keeps the code it ended it with.
1304 ///
1305 /// quinn renders `ReadError::ConnectionLost` as the two words "connection
1306 /// lost", so without the mapping under test this reaches a caller as a
1307 /// sentence with no code, no reason phrase and nothing to distinguish a
1308 /// deliberate refusal from a dropped connection. `0x15` is
1309 /// `VERSION_NEGOTIATION_FAILED`.
1310 #[test]
1311 fn a_session_a_peer_closed_keeps_its_code_and_its_reason() {
1312 let read = TransportError::from(quinn::ReadError::ConnectionLost(
1313 quinn::ConnectionError::ApplicationClosed(quinn::ApplicationClose {
1314 error_code: quinn::VarInt::from_u32(0x15),
1315 reason: (&b"unsupported version"[..]).into(),
1316 }),
1317 ));
1318 let TransportError::SessionClosed { code, .. } = &read else {
1319 panic!("expected a session close, got {read}");
1320 };
1321 assert_eq!(*code, 0x15);
1322 // Spelled the way every other code this crate reports is spelled, so
1323 // that a reader parsing the message finds it where it expects to.
1324 let shown = read.to_string();
1325 assert!(shown.contains("(code 21)"), "{shown}");
1326 assert!(shown.contains("unsupported version"), "{shown}");
1327 }
1328
1329 /// A connection lost with nothing behind it invents no code.
1330 ///
1331 /// A timeout is not a refusal, and reporting one as a close with a code is
1332 /// the same class of error as reading a TLS alert out of an application
1333 /// code: a finding that never happened.
1334 #[test]
1335 fn a_timeout_is_not_a_close_and_names_no_code() {
1336 let lost = TransportError::from(quinn::ReadError::ConnectionLost(
1337 quinn::ConnectionError::TimedOut,
1338 ));
1339 assert!(
1340 matches!(lost, TransportError::Connection(_)),
1341 "a timeout carries no application code, got {lost}"
1342 );
1343 }
1344
1345 /// Each close quinn records with a code comes back in its own space, and
1346 /// one with no code comes back as none.
1347 #[test]
1348 fn a_transport_close_is_read_in_its_own_space() {
1349 use super::super::{CodeSpace, TransportClose};
1350
1351 let peer =
1352 TransportClose::of(&quinn::ConnectionError::ConnectionClosed(quinn::ConnectionClose {
1353 error_code: quinn::TransportErrorCode::crypto(120),
1354 frame_type: None,
1355 reason: (&b"no application protocol"[..]).into(),
1356 }))
1357 .expect("a transport close carries a code");
1358 assert_eq!((peer.code, peer.space, peer.by_peer), (0x178, CodeSpace::Transport, true));
1359 assert_eq!(peer.reason, b"no application protocol");
1360
1361 let app = TransportClose::of(&quinn::ConnectionError::ApplicationClosed(
1362 quinn::ApplicationClose {
1363 error_code: quinn::VarInt::from_u32(0x3),
1364 reason: (&b"protocol violation"[..]).into(),
1365 },
1366 ))
1367 .expect("an application close carries a code");
1368 assert_eq!((app.code, app.space, app.by_peer), (0x3, CodeSpace::Application, true));
1369
1370 let local = TransportClose::of(&quinn::ConnectionError::TransportError(
1371 quinn::TransportErrorCode::crypto(45).into(),
1372 ))
1373 .expect("a local transport error carries a code");
1374 assert_eq!((local.code, local.space, local.by_peer), (0x12d, CodeSpace::Transport, false));
1375
1376 assert_eq!(TransportClose::of(&quinn::ConnectionError::TimedOut), None);
1377 assert_eq!(TransportClose::of(&quinn::ConnectionError::LocallyClosed), None);
1378 }
1379
1380 fn measuring(suites: Vec<u16>) -> QuicDialOptions {
1381 QuicDialOptions::new(vec![b"h3".to_vec()]).insecure(true).offering_cipher_suites(suites)
1382 }
1383
1384 /// The claim `cipher_suites` rests on, and the one that is not obvious.
1385 ///
1386 /// QUIC's Initial packets must use AES-128-GCM, so a config that does not
1387 /// offer it would normally be unbuildable — which would make "does this
1388 /// peer accept *only* ChaCha20" an unaskable question. `with_initial`
1389 /// separates the two, and this asserts it actually works rather than
1390 /// trusting the doc comment.
1391 #[test]
1392 fn a_suite_offer_excluding_aes128_still_builds() {
1393 for suite in [0x1303, 0x1302] {
1394 client_config(&measuring(vec![suite]), vec![b"h3".to_vec()])
1395 .unwrap_or_else(|e| panic!("offering only {suite:#06x} should build: {e}"));
1396 }
1397 }
1398
1399 /// Every suite this crate names must be one the provider actually has,
1400 /// or an enumeration built from the list reports false negatives.
1401 #[test]
1402 fn every_named_suite_is_offerable_on_its_own() {
1403 for suite in TLS13_CIPHER_SUITES {
1404 let name = show_cipher_suite(suite).expect("a named suite has a name");
1405 client_config(&measuring(vec![suite]), vec![b"h3".to_vec()])
1406 .unwrap_or_else(|e| panic!("{name} ({suite:#06x}) is not offerable: {e}"));
1407 }
1408 }
1409
1410 /// A suite the provider lacks must be an error, never a quiet omission:
1411 /// offering fewer suites than asked turns a refusal into a false negative.
1412 #[test]
1413 fn an_unavailable_suite_is_refused_rather_than_dropped() {
1414 // 0x1304 is TLS_AES_128_CCM_SHA256 — real, and not in `ring`.
1415 let err = client_config(&measuring(vec![0x1301, 0x1304]), vec![b"h3".to_vec()])
1416 .expect_err("an unimplemented suite must not be silently dropped");
1417 assert!(format!("{err}").contains("1304"), "the error should name the suite: {err}");
1418
1419 let empty = client_config(&measuring(Vec::new()), vec![b"h3".to_vec()])
1420 .expect_err("an empty offer cannot be answered by anyone");
1421 assert!(format!("{empty}").contains("empty"), "{empty}");
1422 }
1423
1424 /// The default path is unrestricted, and stays the path everything but a
1425 /// measurement takes.
1426 #[test]
1427 fn no_restriction_offers_the_whole_provider() {
1428 let options = QuicDialOptions::new(vec![b"h3".to_vec()]).insecure(true);
1429 assert!(options.cipher_suites.is_none());
1430 let config = rustls_client_config(&options, vec![b"h3".to_vec()]).expect("build");
1431 assert!(
1432 config.crypto_provider().cipher_suites.len() >= TLS13_CIPHER_SUITES.len(),
1433 "the unrestricted offer should carry at least the TLS 1.3 suites"
1434 );
1435 }
1436
1437 /// Every variant names a phase, and only one of them says a peer was there.
1438 ///
1439 /// The table is written out rather than derived so that a variant added to
1440 /// [`DialError`] arrives here as a missing row rather than as a silent
1441 /// answer — `phase` matches exhaustively, so the compiler catches the
1442 /// *variant*, and this catches the *claim* about which side it belongs to.
1443 #[test]
1444 fn every_dial_failure_names_its_phase_and_which_side_it_is() {
1445 let cases = [
1446 (DialError::InvalidAddress("x".into()), DialPhase::Address, true),
1447 (DialError::LocalSocket("x".into()), DialPhase::LocalSocket, true),
1448 (DialError::TlsConfig("x".into()), DialPhase::TlsConfig, true),
1449 (DialError::Transport(TransportError::Connect("x".into())), DialPhase::Connect, true),
1450 (
1451 DialError::Transport(TransportError::Handshake(
1452 super::super::HandshakeFailure::bare("x".into()),
1453 )),
1454 DialPhase::Handshake,
1455 false,
1456 ),
1457 ];
1458 for (err, phase, local) in cases {
1459 assert_eq!(err.phase(), phase, "{err}");
1460 assert_eq!(err.is_local(), local, "{err}");
1461 }
1462 }
1463
1464 /// A socket this machine would not open is not an invalid address.
1465 ///
1466 /// They are separate variants, so a consumer that needs the difference
1467 /// reads it off the type rather than by finding `could not bind` inside the
1468 /// prose. The message still carries those words for a human reader; it is
1469 /// not where the answer lives.
1470 #[test]
1471 fn a_failed_bind_is_this_machines_and_not_the_targets() {
1472 let bind = DialError::LocalSocket("could not bind a local IPv6 socket: oh no".into());
1473 assert_eq!(bind.phase(), DialPhase::LocalSocket);
1474 assert!(bind.is_local());
1475 assert!(bind.to_string().contains("could not bind a local IPv6 socket"), "{bind}");
1476
1477 // And the target being unusable is still its own answer.
1478 let target = DialError::InvalidAddress("relay.example:443 is not host:port".into());
1479 assert_eq!(target.phase(), DialPhase::Address);
1480 }
1481
1482 /// Flattening several addresses into one error must not relabel the phase.
1483 ///
1484 /// `e_at` prefixes the address onto the error it returns, and has to do
1485 /// that without rewriting a `Transport` failure into a `Connect`: the
1486 /// variant decides the phase, so flattening would throw the handshake's
1487 /// codes away and make a relay's refusal answer `is_local() == true`. A
1488 /// failure this machine decided and a failure a peer sent would then be the
1489 /// same value.
1490 #[test]
1491 fn collapsing_several_addresses_keeps_a_peers_refusal_a_peers() {
1492 let addr: SocketAddr = "203.0.113.7:443".parse().unwrap();
1493 let refused = DialError::Transport(TransportError::Handshake(
1494 super::super::HandshakeFailure::transport(0x178, "peer refused the ALPN".into()),
1495 ));
1496
1497 let named = e_at(addr, refused);
1498 assert_eq!(named.phase(), DialPhase::Handshake);
1499 assert!(!named.is_local(), "a peer answered this one");
1500
1501 let DialError::Transport(TransportError::Handshake(failure)) = &named else {
1502 panic!("the typed handshake failure did not survive: {named}");
1503 };
1504 assert_eq!(failure.code, Some(0x178), "the code the refusal carried");
1505 assert_eq!(failure.tls_alert, Some(120), "0x178 is no_application_protocol");
1506 assert!(failure.reason.contains("203.0.113.7:443"), "{}", failure.reason);
1507 assert!(failure.reason.contains("peer refused the ALPN"), "{}", failure.reason);
1508
1509 // The local phases keep their own variants through the same call.
1510 assert_eq!(
1511 e_at(addr, DialError::LocalSocket("no v6".into())).phase(),
1512 DialPhase::LocalSocket
1513 );
1514 assert_eq!(e_at(addr, DialError::TlsConfig("nope".into())).phase(), DialPhase::TlsConfig);
1515 }
1516
1517 /// A timeout is a real outcome with no code in it, and inventing one would
1518 /// be worse than reporting none.
1519 #[test]
1520 fn a_failure_with_no_code_reports_none() {
1521 let timed_out = handshake_failure(&quinn::ConnectionError::TimedOut);
1522 assert_eq!(timed_out.code, None);
1523 assert_eq!(timed_out.tls_alert, None);
1524 assert!(!timed_out.reason.is_empty(), "the prose is all this failure has");
1525 }
1526}