From 767a89d83d1bb77d6e47cb4882eeeb37120075e8 Mon Sep 17 00:00:00 2001 From: Josiah Hunsinger Date: Wed, 13 May 2026 13:46:32 -0400 Subject: [PATCH] Expose TLS handshake bytes from `Incoming` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Incoming::handshake_bytes()`, which decrypts the Initial packet using the publicly-derivable Initial keys, walks the QUIC frames, and returns the concatenated `CRYPTO` frame contents in offset order. Per RFC 9001 §4.1.3, TLS handshake messages travel inside `CRYPTO` frames with no intervening TLS record layer, so the returned bytes start directly with the TLS handshake message header (`0x01` for ClientHello, followed by a 3-byte length and the body). Callers can feed this to a TLS parser to inspect extensions such as SNI or ALPN before deciding whether to `accept()`, `retry()`, `refuse()`, or `ignore()` the connection. The motivating use case is port-knocking style authentication: a server that wants to silently drop probes from any client that doesn't carry a pre-shared token (e.g. an HMAC in a custom ALPN entry) needs to inspect the ClientHello before any response goes on the wire. The existing `Incoming` API already provides `ignore()` for the silent-drop side; this adds the inspection side so the decision can be informed. The high-level `quinn::Incoming` wrapper forwards the new method, and a new `HandshakeBytesError` enum is re-exported from `quinn-proto`. --- quinn-proto/src/endpoint.rs | 69 +++++++++++++++++++++++++++++++++++++ quinn-proto/src/lib.rs | 3 +- quinn/src/incoming.rs | 13 ++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/quinn-proto/src/endpoint.rs b/quinn-proto/src/endpoint.rs index 6f5d94b295..b40e21f8f5 100644 --- a/quinn-proto/src/endpoint.rs +++ b/quinn-proto/src/endpoint.rs @@ -1198,6 +1198,75 @@ impl Incoming { pub fn orig_dst_cid(&self) -> ConnectionId { self.token.orig_dst_cid } + + /// The TLS handshake bytes carried by the Initial packet, suitable for + /// inspecting the ClientHello before deciding whether to + /// [`accept`](Endpoint::accept) or [`ignore`](Endpoint::ignore) this + /// connection attempt. + /// + /// This decrypts the Initial packet's payload using the (publicly + /// derivable) Initial keys, walks the QUIC frames, and concatenates the + /// data carried by `CRYPTO` frames in offset order. Per RFC 9001 §4.1.3, + /// QUIC carries TLS handshake messages directly inside `CRYPTO` frames + /// with no intervening TLS record layer, so the returned bytes begin with + /// the TLS handshake message header (`0x01` = ClientHello, then a 3-byte + /// length, then the body). + /// + /// Returns an error if the packet fails AEAD authentication, if its + /// frames cannot be parsed, or if the `CRYPTO` frames are non-contiguous + /// from offset 0. Non-`CRYPTO` frames (e.g. `PADDING`, `PING`) are + /// ignored. + pub fn handshake_bytes(&self) -> Result, HandshakeBytesError> { + let packet_number = self.packet.header.number.expand(0); + let mut payload = self.packet.payload.clone(); + self.crypto + .packet + .remote + .decrypt(packet_number, &self.packet.header_data, &mut payload) + .map_err(|_| HandshakeBytesError::Decrypt)?; + + let mut chunks: Vec<(u64, Bytes)> = Vec::new(); + let iter = + frame::Iter::new(payload.freeze()).map_err(|_| HandshakeBytesError::MalformedFrames)?; + for frame in iter { + // PADDING, PING, ACK, CONNECTION_CLOSE are legal in Initials — + // skip them. Anything else would be a protocol violation, but we + // leave that judgement to the post-accept handshake path. + if let frame::Frame::Crypto(c) = + frame.map_err(|_| HandshakeBytesError::MalformedFrames)? + { + chunks.push((c.offset, c.data)); + } + } + + chunks.sort_by_key(|(off, _)| *off); + let mut out = Vec::new(); + let mut expected = 0u64; + for (off, data) in chunks { + if off != expected { + return Err(HandshakeBytesError::NonContiguous); + } + expected += data.len() as u64; + out.extend_from_slice(&data); + } + + Ok(out) + } +} + +/// Error returned by [`Incoming::handshake_bytes`]. +#[derive(Debug, Error)] +pub enum HandshakeBytesError { + /// The Initial packet failed AEAD authentication. + #[error("initial packet failed authentication")] + Decrypt, + /// The Initial packet's frames could not be parsed. + #[error("malformed frames in initial packet")] + MalformedFrames, + /// `CRYPTO` frames in the Initial packet did not form a contiguous run + /// starting at offset 0. + #[error("CRYPTO frames in initial packet are not contiguous from offset 0")] + NonContiguous, } impl fmt::Debug for Incoming { diff --git a/quinn-proto/src/lib.rs b/quinn-proto/src/lib.rs index 5982b69033..d127fdbd6c 100644 --- a/quinn-proto/src/lib.rs +++ b/quinn-proto/src/lib.rs @@ -71,7 +71,8 @@ pub use crate::frame::{ApplicationClose, ConnectionClose, Datagram, FrameType}; mod endpoint; pub use crate::endpoint::{ - AcceptError, ConnectError, ConnectionHandle, DatagramEvent, Endpoint, Incoming, RetryError, + AcceptError, ConnectError, ConnectionHandle, DatagramEvent, Endpoint, HandshakeBytesError, + Incoming, RetryError, }; mod packet; diff --git a/quinn/src/incoming.rs b/quinn/src/incoming.rs index 47471bdfbf..86e9251512 100644 --- a/quinn/src/incoming.rs +++ b/quinn/src/incoming.rs @@ -6,7 +6,7 @@ use std::{ task::{Context, Poll}, }; -use proto::{ConnectionError, ConnectionId, ServerConfig}; +use proto::{ConnectionError, ConnectionId, HandshakeBytesError, ServerConfig}; use thiserror::Error; use crate::{ @@ -98,6 +98,17 @@ impl Incoming { pub fn orig_dst_cid(&self) -> ConnectionId { self.0.as_ref().unwrap().inner.orig_dst_cid() } + + /// The TLS handshake bytes carried by the Initial packet. + /// + /// See [`proto::Incoming::handshake_bytes`] for the full description. + /// The returned bytes begin with the TLS handshake message header (`0x01` + /// for `ClientHello`) and can be passed to a TLS parser to inspect + /// extensions such as SNI or ALPN before deciding whether to + /// [`accept`](Self::accept) or [`ignore`](Self::ignore) the connection. + pub fn handshake_bytes(&self) -> Result, HandshakeBytesError> { + self.0.as_ref().unwrap().inner.handshake_bytes() + } } impl Drop for Incoming {