From 4611a56964c7e9aa10b61101e236a887bed0686f Mon Sep 17 00:00:00 2001 From: Peder Bergan Date: Tue, 3 Nov 2026 12:17:16 +0100 Subject: [PATCH] feat(rustls): expose negotiated key exchange group to callbacks --- pingora-core/src/protocols/tls/rustls/mod.rs | 43 +++++- .../src/protocols/tls/rustls/server.rs | 136 ++++++++++++++++++ .../src/protocols/tls/rustls/stream.rs | 4 + pingora-rustls/src/lib.rs | 4 +- 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/pingora-core/src/protocols/tls/rustls/mod.rs b/pingora-core/src/protocols/tls/rustls/mod.rs index 93e026c34..d6872eb38 100644 --- a/pingora-core/src/protocols/tls/rustls/mod.rs +++ b/pingora-core/src/protocols/tls/rustls/mod.rs @@ -19,13 +19,13 @@ mod stream; pub use stream::*; use crate::utils::tls::WrappedX509; -use pingora_rustls::CertificateDer; +use pingora_rustls::{CertificateDer, NamedGroup}; pub type CaType = [WrappedX509]; /// TLS connection state exposed to post-handshake callbacks. /// -/// Provides access to peer certificates and negotiated cipher suite +/// Provides access to peer certificates and negotiated TLS parameters /// after a TLS handshake completes. This is the rustls equivalent of /// the OpenSSL `SslRef` that is used as `TlsRef` in the boringssl/openssl path. #[derive(Debug)] @@ -34,6 +34,7 @@ pub struct TlsRef { pub(super) cipher: Option<&'static str>, pub(super) version: Option<&'static str>, pub(super) server_name: Option, + pub(super) key_exchange_group: Option, } impl TlsRef { @@ -56,6 +57,44 @@ impl TlsRef { self.cipher } + /// Returns the negotiated key exchange group, if available. + /// + /// Returns `None` when no key exchange occurred, including resumed TLS 1.2 + /// handshakes. This reports the group for this handshake, not an earlier + /// handshake that established a resumed session. + /// + /// A downstream [`TlsAccept`](crate::listeners::TlsAccept) callback can + /// retain this value in an application-defined digest extension: + /// + /// ``` + /// use async_trait::async_trait; + /// use pingora_core::listeners::TlsAccept; + /// use pingora_core::protocols::tls::{SslDigest, TlsRef}; + /// use pingora_rustls::NamedGroup; + /// use std::{any::Any, sync::Arc}; + /// + /// struct Group(Option); + /// struct CaptureGroup; + /// + /// #[async_trait] + /// impl TlsAccept for CaptureGroup { + /// async fn handshake_complete_callback( + /// &self, + /// tls: &TlsRef, + /// ) -> Option> { + /// Some(Arc::new(Group(tls.negotiated_key_exchange_group()))) + /// } + /// } + /// + /// fn group_id(digest: &SslDigest) -> Option { + /// // Numeric identifiers also preserve groups without a known name. + /// digest.extension.get::()?.0.map(u16::from) + /// } + /// ``` + pub fn negotiated_key_exchange_group(&self) -> Option { + self.key_exchange_group + } + /// Returns the negotiated TLS protocol version (e.g. "TLSv1.3"), if available. pub fn version(&self) -> Option<&'static str> { self.version diff --git a/pingora-core/src/protocols/tls/rustls/server.rs b/pingora-core/src/protocols/tls/rustls/server.rs index 1a30cccf8..c60574eff 100644 --- a/pingora-core/src/protocols/tls/rustls/server.rs +++ b/pingora-core/src/protocols/tls/rustls/server.rs @@ -203,4 +203,140 @@ mod tests { let cipher = digest.extension.get::().unwrap(); assert!(!cipher.0.is_empty()); } + + #[derive(Debug)] + struct NegotiatedTls { + group: Option, + cipher: String, + } + + struct GroupCallback; + + #[async_trait] + impl TlsAccept for GroupCallback { + async fn handshake_complete_callback( + &self, + tls: &TlsRef, + ) -> Option> { + Some(Arc::new(NegotiatedTls { + group: tls.negotiated_key_exchange_group(), + cipher: tls.current_cipher_name()?.to_owned(), + })) + } + } + + fn group_acceptor() -> crate::listeners::tls::Acceptor { + let cert = format!("{}/tests/keys/server.crt", env!("CARGO_MANIFEST_DIR")); + let key = format!("{}/tests/keys/key.pem", env!("CARGO_MANIFEST_DIR")); + let mut settings = TlsSettings::with_callbacks(Box::new(GroupCallback)).unwrap(); + settings.set_certificate_chain_file(&cert).unwrap(); + settings.set_private_key_file(&key).unwrap(); + settings.build() + } + + fn group_connector( + group: &'static dyn rustls::crypto::SupportedKxGroup, + version: &'static rustls::SupportedProtocolVersion, + ) -> pingora_rustls::TlsConnector { + let mut provider = rustls::crypto::ring::default_provider(); + provider.kx_groups = vec![group]; + let config = ClientConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[version]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + pingora_rustls::TlsConnector::from(Arc::new(config)) + } + + async fn group_handshake( + acceptor: &crate::listeners::tls::Acceptor, + connector: &pingora_rustls::TlsConnector, + ) -> ( + super::TlsStream, + super::TlsStream, + ) { + let (client, server) = tokio::io::duplex(4096); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let (client, server) = tokio::join!( + crate::protocols::tls::rustls::client::handshake( + connector, + "openrusty.org", + client + ), + acceptor.tls_handshake(server), + ); + (client.unwrap(), server.unwrap()) + }) + .await + .expect("TLS handshake timed out") + } + + #[tokio::test] + async fn test_negotiated_group_digest_extension() { + use crate::protocols::Ssl; + use rustls::crypto::ring::kx_group::{SECP256R1, X25519}; + + let acceptor = group_acceptor(); + let mut digests = Vec::new(); + for version in [&rustls::version::TLS12, &rustls::version::TLS13] { + for group in [X25519, SECP256R1] { + let connector = group_connector(group, version); + let (client, server) = group_handshake(&acceptor, &connector).await; + assert_eq!( + client.get_ssl().unwrap().negotiated_key_exchange_group(), + Some(group.name()) + ); + let digest = server.ssl_digest().unwrap(); + let metadata = digest.extension.get::().unwrap(); + assert_eq!(metadata.group, Some(group.name())); + assert_eq!(metadata.cipher, digest.cipher); + assert_eq!(client.ssl_digest().unwrap().version, digest.version); + digests.push((digest, group.name())); + } + } + // Retained digests must keep their own connection's metadata. + for (digest, group) in digests { + assert_eq!( + digest.extension.get::().unwrap().group, + Some(group) + ); + } + } + + #[tokio::test] + async fn test_negotiated_group_tls12_resumption() { + use crate::protocols::Ssl; + + let acceptor = group_acceptor(); + let connector = group_connector( + rustls::crypto::ring::kx_group::X25519, + &rustls::version::TLS12, + ); + let (client, server) = group_handshake(&acceptor, &connector).await; + let original = server.ssl_digest().unwrap(); + assert_eq!( + original.extension.get::().unwrap().group, + Some(rustls::NamedGroup::X25519) + ); + drop((client, server)); + + let (client, server) = group_handshake(&acceptor, &connector).await; + assert_eq!( + client.stream.as_ref().unwrap().get_ref().1.handshake_kind(), + Some(rustls::HandshakeKind::Resumed) + ); + assert_eq!( + client.get_ssl().unwrap().negotiated_key_exchange_group(), + None + ); + let digest = server.ssl_digest().unwrap(); + let metadata = digest.extension.get::().unwrap(); + assert_eq!(metadata.group, None); + assert_eq!(metadata.cipher, digest.cipher); + assert_eq!( + original.extension.get::().unwrap().group, + Some(rustls::NamedGroup::X25519) + ); + } } diff --git a/pingora-core/src/protocols/tls/rustls/stream.rs b/pingora-core/src/protocols/tls/rustls/stream.rs index 36efe53f8..db203ab8f 100644 --- a/pingora-core/src/protocols/tls/rustls/stream.rs +++ b/pingora-core/src/protocols/tls/rustls/stream.rs @@ -333,6 +333,9 @@ impl InnerStream { .negotiated_cipher_suite() .and_then(|suite| suite.suite().as_str()); let version = session.protocol_version().and_then(|v| v.as_str()); + let key_exchange_group = session + .negotiated_key_exchange_group() + .map(|group| group.name()); let server_name = match stream { RusTlsStream::Server(s) => s.get_ref().1.server_name().map(|n| n.to_string()), RusTlsStream::Client(_) => None, @@ -342,6 +345,7 @@ impl InnerStream { cipher, version, server_name, + key_exchange_group, }) } } diff --git a/pingora-rustls/src/lib.rs b/pingora-rustls/src/lib.rs index 2df4bd12f..df286965d 100644 --- a/pingora-rustls/src/lib.rs +++ b/pingora-rustls/src/lib.rs @@ -32,8 +32,8 @@ pub use rustls::server::{ pub use rustls::sign; pub use rustls::{ client::WebPkiServerVerifier, crypto::CryptoProvider, version, CertificateError, ClientConfig, - DigitallySignedStruct, Error as RusTlsError, KeyLogFile, RootCertStore, ServerConfig, - SignatureScheme, Stream, + DigitallySignedStruct, Error as RusTlsError, KeyLogFile, NamedGroup, RootCertStore, + ServerConfig, SignatureScheme, Stream, }; /// Install the default `ring` CryptoProvider for rustls.