From b3d070fe663bd309a70a7254f8c66d1ca0a5c0f3 Mon Sep 17 00:00:00 2001 From: Mark Rousskov Date: Tue, 10 Mar 2026 18:09:45 +0000 Subject: [PATCH] feat(s2n-quic-dc): Expose peer certificate chain (if available) --- dc/s2n-quic-dc/src/stream.rs | 2 +- dc/s2n-quic-dc/src/stream/application.rs | 9 ++++++ dc/s2n-quic-dc/src/stream/recv/application.rs | 4 +++ dc/s2n-quic-dc/src/stream/send/application.rs | 4 +++ dc/s2n-quic-dc/src/stream/tls.rs | 18 ++++++++++-- dc/s2n-quic-dc/src/stream/tls/cert_chain.rs | 29 +++++++++++++++++++ dc/s2n-quic-dc/src/stream/tls/test.rs | 10 +++++++ 7 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 dc/s2n-quic-dc/src/stream/tls/cert_chain.rs diff --git a/dc/s2n-quic-dc/src/stream.rs b/dc/s2n-quic-dc/src/stream.rs index 9686c51d03..9bc124c8ca 100644 --- a/dc/s2n-quic-dc/src/stream.rs +++ b/dc/s2n-quic-dc/src/stream.rs @@ -27,7 +27,7 @@ pub mod shared; pub mod socket; pub(crate) mod tls; -pub use tls::{Conn as TlsConnection, ConnectionBuilder as TlsConnectionBuilder}; +pub use tls::{CertificateChain, Conn as TlsConnection, ConnectionBuilder as TlsConnectionBuilder}; #[cfg(any(test, feature = "testing"))] pub mod testing; diff --git a/dc/s2n-quic-dc/src/stream/application.rs b/dc/s2n-quic-dc/src/stream/application.rs index 2d3d3791d5..33a3ec2c4f 100644 --- a/dc/s2n-quic-dc/src/stream/application.rs +++ b/dc/s2n-quic-dc/src/stream/application.rs @@ -161,6 +161,15 @@ where self.read.path_secret_id() } + /// Returns the validated peer certificate chain, if available. + /// + /// Currently this is only available for TLS streams, but in the future it may be opt-in + /// exposed for dcQUIC streams (at the cost of memory usage). + #[inline] + pub fn peer_cert_chain(&self) -> Option<&crate::stream::tls::CertificateChain> { + self.read.peer_cert_chain() + } + #[inline] pub fn protocol(&self) -> socket::Protocol { self.read.protocol() diff --git a/dc/s2n-quic-dc/src/stream/recv/application.rs b/dc/s2n-quic-dc/src/stream/recv/application.rs index 0ba4d01260..c195c2f954 100644 --- a/dc/s2n-quic-dc/src/stream/recv/application.rs +++ b/dc/s2n-quic-dc/src/stream/recv/application.rs @@ -217,6 +217,10 @@ where Err(_) => None, } } + + pub fn peer_cert_chain(&self) -> Option<&crate::stream::tls::CertificateChain> { + self.0.shared.s2n_connection.as_ref()?.peer_cert_chain() + } } impl Inner diff --git a/dc/s2n-quic-dc/src/stream/send/application.rs b/dc/s2n-quic-dc/src/stream/send/application.rs index 3381563a34..3c0d0a28b8 100644 --- a/dc/s2n-quic-dc/src/stream/send/application.rs +++ b/dc/s2n-quic-dc/src/stream/send/application.rs @@ -194,6 +194,10 @@ where Err(_) => None, } } + + pub fn peer_cert_chain(&self) -> Option<&crate::stream::tls::CertificateChain> { + self.0.shared.s2n_connection.as_ref()?.peer_cert_chain() + } } impl Inner diff --git a/dc/s2n-quic-dc/src/stream/tls.rs b/dc/s2n-quic-dc/src/stream/tls.rs index 6c3395b408..1dcd4c1acf 100644 --- a/dc/s2n-quic-dc/src/stream/tls.rs +++ b/dc/s2n-quic-dc/src/stream/tls.rs @@ -42,9 +42,14 @@ use crate::{ }, }; +mod cert_chain; + +pub use cert_chain::CertificateChain; + pub struct S2nTlsConnection { socket: Arc>, connection: Mutex<(Conn, ReadState)>, + cert_chain: Option, } struct ReadState { @@ -86,6 +91,7 @@ impl S2nTlsConnection { buffer: bytes::BytesMut::with_capacity(8192), }, )), + cert_chain: None, }) } @@ -94,7 +100,7 @@ impl S2nTlsConnection { mut initial_read_buffer: Option, ) -> io::Result<()> { std::future::poll_fn(|cx| -> Poll> { - let connection = &mut self.connection.get_mut().unwrap().0; + let s2n_connection = &mut self.connection.get_mut().unwrap().0; let context = NegotiateContext { socket: &self.socket, @@ -103,7 +109,7 @@ impl S2nTlsConnection { }; let mut connection = CallbackResetGuard { - conn: (**connection).as_mut(), + conn: (**s2n_connection).as_mut(), reset_write: true, reset_read: true, }; @@ -133,6 +139,10 @@ impl S2nTlsConnection { } } + self.cert_chain = Some(CertificateChain::new( + (**s2n_connection).as_mut().peer_cert_chain()?, + )?); + Poll::Ready(Ok(())) } Poll::Ready(Err(e)) => Poll::Ready(Err(e.into())), @@ -286,6 +296,10 @@ impl S2nTlsConnection { Ok(()) } + + pub(crate) fn peer_cert_chain(&self) -> Option<&CertificateChain> { + self.cert_chain.as_ref() + } } /// `NegotiateContext` for poll_negotiate. diff --git a/dc/s2n-quic-dc/src/stream/tls/cert_chain.rs b/dc/s2n-quic-dc/src/stream/tls/cert_chain.rs new file mode 100644 index 0000000000..4df0dc31dd --- /dev/null +++ b/dc/s2n-quic-dc/src/stream/tls/cert_chain.rs @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Wraps the `s2n_tls::cert_chain` module to avoid placing those s2n-tls types in the public API. +//! +//! Unlike the s2n-tls configuration which is shared across connections, once a connection is +//! established we'd expect to drop the s2n-tls Connection struct eventually. So we'll need our own +//! container for any information that we wish to retain after the connection ends. + +#[derive(Clone)] +pub struct CertificateChain { + der_certs: Vec>, +} + +impl CertificateChain { + pub(crate) fn new( + chain: s2n_tls::cert_chain::CertificateChain<'_>, + ) -> Result { + let mut der_certs = Vec::with_capacity(chain.len()); + for cert in chain.iter() { + der_certs.push(cert?.der()?.to_vec()); + } + Ok(Self { der_certs }) + } + + pub fn iter_der(&self) -> impl Iterator + '_ { + self.der_certs.iter().map(|v| &v[..]) + } +} diff --git a/dc/s2n-quic-dc/src/stream/tls/test.rs b/dc/s2n-quic-dc/src/stream/tls/test.rs index fcd55c0ff6..9f5cf6567f 100644 --- a/dc/s2n-quic-dc/src/stream/tls/test.rs +++ b/dc/s2n-quic-dc/src/stream/tls/test.rs @@ -161,6 +161,10 @@ async fn check_server(message: &[u8]) { let acceptor_addr = server.acceptor_addr().unwrap(); tokio::spawn(async move { while let Ok((mut stream, _)) = server.accept().await { + if let Some(chain) = stream.peer_cert_chain() { + assert_eq!(chain.iter_der().count(), 1); + } + tracing::info!("server accepted stream!"); let mut buffer = vec![]; stream.read_to_end(&mut buffer).await.unwrap(); @@ -182,6 +186,9 @@ async fn check_server(message: &[u8]) { ) .await .unwrap(); + + assert_eq!(stream.peer_cert_chain().unwrap().iter_der().count(), 1); + let (mut reader, mut writer) = stream.into_split(); writer.write_all_from(&mut &message[..]).await.unwrap(); @@ -204,6 +211,9 @@ async fn check_server(message: &[u8]) { ) .await .unwrap(); + + assert!(stream.peer_cert_chain().is_none()); + let (mut reader, mut writer) = stream.into_split(); writer.write_all_from(&mut &message[..]).await.unwrap();