From fa03f99a71b15622b04852a80e1bb946d1732752 Mon Sep 17 00:00:00 2001 From: emmettlu Date: Wed, 2 Sep 2026 18:00:11 +0800 Subject: [PATCH 1/3] Fix Windows Schannel TLS and make it independent of OpenSSL Drain leftover handshake records, handle TLS 1.3 SEC_I_RENEGOTIATE, send close_notify, and acquire credentials with SCH_CREDENTIALS. Add a Schannel server acceptor so client tests no longer need OpenSSL, expose the ntex schannel feature on ClientBuilder and http::schannel, and keep Schannel usable without openssl or ws. --- ntex-tls/CHANGES.md | 9 + ntex-tls/Cargo.toml | 4 +- ntex-tls/src/schannel/accept.rs | 70 +++++ ntex-tls/src/schannel/cert.rs | 232 +++++++++++++++ ntex-tls/src/schannel/connect.rs | 92 +++--- ntex-tls/src/schannel/mod.rs | 451 +++++++++++++++++++++-------- ntex/CHANGES.md | 7 + ntex/Cargo.toml | 4 +- ntex/src/client/builder.rs | 24 +- ntex/src/http/mod.rs | 27 +- ntex/src/http/test.rs | 71 ++++- ntex/src/lib.rs | 9 + ntex/src/web/error_default.rs | 5 +- ntex/src/web/test.rs | 34 ++- ntex/src/ws/client.rs | 9 + ntex/tests/connect.rs | 35 +-- ntex/tests/http_client_schannel.rs | 46 ++- 17 files changed, 900 insertions(+), 229 deletions(-) create mode 100644 ntex-tls/src/schannel/accept.rs create mode 100644 ntex-tls/src/schannel/cert.rs diff --git a/ntex-tls/CHANGES.md b/ntex-tls/CHANGES.md index c8edc9cac..2f94af378 100644 --- a/ntex-tls/CHANGES.md +++ b/ntex-tls/CHANGES.md @@ -1,5 +1,14 @@ # Changes +## [unreleased] + +* Fix Schannel client: drain leftover handshake records, handle TLS 1.3 + post-handshake messages (`SEC_I_RENEGOTIATE`), send `close_notify` on + shutdown, and acquire credentials with `SCH_CREDENTIALS` + +* Add Schannel server acceptor (`TlsAcceptor`, `ServerConfig::from_pem`) + so Schannel no longer depends on OpenSSL for tests or TLS servers + ## [4.0.0-beta.0] - 2026-08-24 * Migrate to ntex-service 5 diff --git a/ntex-tls/Cargo.toml b/ntex-tls/Cargo.toml index 437aaec3a..fdc957ac5 100644 --- a/ntex-tls/Cargo.toml +++ b/ntex-tls/Cargo.toml @@ -2,7 +2,7 @@ name = "ntex-tls" version = "4.0.0-beta.0" authors = ["ntex contributors "] -description = "An implementation of SSL streams for ntex backed by OpenSSL" +description = "TLS streams for ntex (OpenSSL, rustls, and Windows Schannel)" keywords = ["network", "framework", "async", "futures"] homepage = "https://ntex.rs" repository = "https://github.com/ntex-rs/ntex.git" @@ -58,7 +58,7 @@ windows-sys = { workspace = true, optional = true, features = [ ] } [dev-dependencies] -ntex = { workspace = true, features = ["openssl", "rustls"] } +ntex = { version = "4.0.0-beta.2", default-features = false } env_logger = { workspace = true } rustls-pemfile = { workspace = true } webpki-roots = { workspace = true } diff --git a/ntex-tls/src/schannel/accept.rs b/ntex-tls/src/schannel/accept.rs new file mode 100644 index 000000000..fe04ce796 --- /dev/null +++ b/ntex-tls/src/schannel/accept.rs @@ -0,0 +1,70 @@ +use std::io; + +use ntex_io::{Filter, Io, Layer}; +use ntex_service::{Ctx, Service, cfg::Cfg, cfg::Configuration}; +use ntex_util::{services::Counter, time}; + +use super::{SchannelFilter, ServerConfig, accept as accept_io}; +use crate::{MAX_SSL_ACCEPT_COUNTER, TlsConfig}; + +/// Support TLS server connections via Windows Schannel. +#[derive(Clone, Debug)] +pub struct TlsAcceptor { + config: ServerConfig, + conns: Counter, +} + +impl TlsAcceptor { + /// Create a Schannel acceptor service. + #[must_use] + pub fn new(config: ServerConfig) -> Self { + MAX_SSL_ACCEPT_COUNTER.with(|conns| TlsAcceptor { + config, + conns: conns.clone(), + }) + } +} + +impl From for TlsAcceptor { + fn from(config: ServerConfig) -> Self { + Self::new(config) + } +} + +impl Service> for TlsAcceptor { + type Res = Io>; + type Error = io::Error; + + async fn ready(&self, _: Ctx<'_, Self, St>) -> Result<(), Self::Error> { + if !self.conns.is_available() { + self.conns.available().await; + } + Ok(()) + } + + async fn call(&self, io: Io, _: Ctx<'_, Self, St>) -> Result { + let _guard = self.conns.get(); + let cfg: Cfg = io.cfg().ctx().get(); + time::timeout(cfg.handshake_timeout(), accept_io(io, self.config.clone())) + .await + .map_err(|()| io::Error::new(io::ErrorKind::TimedOut, "TLS Handshake timeout")) + .and_then(|item| item) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[ntex::test] + async fn test_schannel_accept() { + let config = ServerConfig::from_pem( + include_str!("../../examples/cert.pem"), + include_str!("../../examples/key.pem"), + ) + .unwrap(); + let srv = TlsAcceptor::new(config.clone()); + assert!(format!("{srv:?}").contains("TlsAcceptor")); + assert!(!config.cert_der().is_empty()); + } +} diff --git a/ntex-tls/src/schannel/cert.rs b/ntex-tls/src/schannel/cert.rs new file mode 100644 index 000000000..409442e14 --- /dev/null +++ b/ntex-tls/src/schannel/cert.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::{io, ptr, slice}; + +use windows_sys::Win32::Foundation::SEC_E_OK; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPTBUFFER_VERSION, BCryptBuffer, BCryptBufferDesc, CERT_CONTEXT, CERT_KEY_PROV_INFO_PROP_ID, + CRYPT_KEY_PROV_INFO, CRYPT_STRING_BASE64HEADER, CertCreateCertificateContext, + CertDuplicateCertificateContext, CertFreeCertificateContext, CertSetCertificateContextProperty, + CryptStringToBinaryA, MS_KEY_STORAGE_PROVIDER, NCRYPT_KEY_HANDLE, NCRYPT_OVERWRITE_KEY_FLAG, + NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG, + NCRYPTBUFFER_PKCS_KEY_NAME, NCryptDeleteKey, NCryptFreeObject, NCryptImportKey, + NCryptOpenStorageProvider, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING, +}; + +static KEY_ID: AtomicU64 = AtomicU64::new(1); + +struct PersistedKey(NCRYPT_KEY_HANDLE); + +impl Drop for PersistedKey { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { + NCryptDeleteKey(self.0, 0); + } + } + } +} + +/// Windows Schannel server configuration. +/// +/// Holds a certificate with an associated private key used to accept TLS +/// connections. +pub struct ServerConfig { + cert: *mut CERT_CONTEXT, + key: Arc, +} + +unsafe impl Send for ServerConfig {} +unsafe impl Sync for ServerConfig {} + +impl Clone for ServerConfig { + fn clone(&self) -> Self { + Self { + cert: unsafe { CertDuplicateCertificateContext(self.cert) }, + key: self.key.clone(), + } + } +} + +impl Drop for ServerConfig { + fn drop(&mut self) { + if !self.cert.is_null() { + unsafe { + CertFreeCertificateContext(self.cert); + } + } + } +} + +impl std::fmt::Debug for ServerConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServerConfig").finish() + } +} + +impl ServerConfig { + /// Load a PEM-encoded certificate and PKCS#8 private key. + pub fn from_pem(cert_pem: &str, key_pem: &str) -> io::Result { + let cert_der = decode_pem(cert_pem)?; + let key_der = decode_pem(key_pem)?; + + let cert = unsafe { + CertCreateCertificateContext( + X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, + cert_der.as_ptr(), + u32::try_from(cert_der.len()) + .map_err(|_| io::Error::other("TLS certificate is too large"))?, + ) + }; + if cert.is_null() { + return Err(io::Error::last_os_error()); + } + + match attach_private_key(cert, &key_der) { + Ok(key) => Ok(Self { + cert, + key: Arc::new(key), + }), + Err(err) => { + unsafe { + CertFreeCertificateContext(cert); + } + Err(err) + } + } + } + + /// Certificate in DER encoding. + #[must_use] + pub fn cert_der(&self) -> Vec { + unsafe { + let cert = &*self.cert; + slice::from_raw_parts(cert.pbCertEncoded, cert.cbCertEncoded as usize).to_vec() + } + } + + pub(super) fn cert(&self) -> *mut CERT_CONTEXT { + self.cert + } +} + +fn decode_pem(pem: &str) -> io::Result> { + let bytes = pem.as_bytes(); + let mut len = 0u32; + let ok = unsafe { + CryptStringToBinaryA( + bytes.as_ptr(), + u32::try_from(bytes.len()).map_err(|_| io::Error::other("PEM is too large"))?, + CRYPT_STRING_BASE64HEADER, + ptr::null_mut(), + &raw mut len, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + + let mut buf = vec![0u8; len as usize]; + let ok = unsafe { + CryptStringToBinaryA( + bytes.as_ptr(), + u32::try_from(bytes.len()).map_err(|_| io::Error::other("PEM is too large"))?, + CRYPT_STRING_BASE64HEADER, + buf.as_mut_ptr(), + &raw mut len, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + buf.truncate(len as usize); + Ok(buf) +} + +fn attach_private_key(cert: *mut CERT_CONTEXT, key_der: &[u8]) -> io::Result { + let id = KEY_ID.fetch_add(1, Ordering::Relaxed); + let mut name: Vec = format!("ntex-schannel-{}-{}", std::process::id(), id) + .encode_utf16() + .chain(Some(0)) + .collect(); + + let mut provider: NCRYPT_PROV_HANDLE = 0; + let status = + unsafe { NCryptOpenStorageProvider(&raw mut provider, MS_KEY_STORAGE_PROVIDER, 0) }; + if status != SEC_E_OK { + return Err(io::Error::from_raw_os_error(status)); + } + + let mut name_buf = BCryptBuffer { + cbBuffer: u32::try_from(name.len() * 2).expect("key name fits u32"), + BufferType: NCRYPTBUFFER_PKCS_KEY_NAME, + pvBuffer: name.as_mut_ptr().cast(), + }; + let params = BCryptBufferDesc { + ulVersion: BCRYPTBUFFER_VERSION, + cBuffers: 1, + pBuffers: &raw mut name_buf, + }; + + let mut key: NCRYPT_KEY_HANDLE = 0; + let status = unsafe { + NCryptImportKey( + provider, + 0, + NCRYPT_PKCS8_PRIVATE_KEY_BLOB, + &raw const params, + &raw mut key, + key_der.as_ptr(), + u32::try_from(key_der.len()).map_err(|_| io::Error::other("TLS key is too large"))?, + NCRYPT_OVERWRITE_KEY_FLAG | NCRYPT_SILENT_FLAG, + ) + }; + unsafe { + NCryptFreeObject(provider); + } + if status != SEC_E_OK { + return Err(io::Error::from_raw_os_error(status)); + } + + let mut prov_name: Vec = { + let mut s = Vec::new(); + let mut p = MS_KEY_STORAGE_PROVIDER; + unsafe { + while *p != 0 { + s.push(*p); + p = p.add(1); + } + } + s.push(0); + s + }; + let prov_info = CRYPT_KEY_PROV_INFO { + pwszContainerName: name.as_mut_ptr(), + pwszProvName: prov_name.as_mut_ptr(), + dwProvType: 0, + dwFlags: 0, + cProvParam: 0, + rgProvParam: ptr::null_mut(), + dwKeySpec: 0, + }; + let ok = unsafe { + CertSetCertificateContextProperty( + cert, + CERT_KEY_PROV_INFO_PROP_ID, + 0, + (&raw const prov_info).cast(), + ) + }; + if ok == 0 { + unsafe { + NCryptDeleteKey(key, 0); + } + return Err(io::Error::last_os_error()); + } + + Ok(PersistedKey(key)) +} diff --git a/ntex-tls/src/schannel/connect.rs b/ntex-tls/src/schannel/connect.rs index bab9f0a36..008947ed1 100644 --- a/ntex-tls/src/schannel/connect.rs +++ b/ntex-tls/src/schannel/connect.rs @@ -1,7 +1,7 @@ use std::io; use ntex_error::Error; -use ntex_io::{Io, Layer}; +use ntex_io::{Filter, Io, Layer}; use ntex_net::connect::{Address, Connect, ConnectError, Connector}; use ntex_service::{Ctx, IntoService, Service, cfg::SharedCfg}; use ntex_util::time::timeout_checked; @@ -50,52 +50,66 @@ impl TlsConnector> { } } -impl Service> for TlsConnector +impl TlsConnector { + /// Establish a TLS connection on top of an existing I/O stream. + pub async fn connect( + &self, + io: Io, + host: &str, + cfg: &SharedCfg, + ) -> Result>, Error> { + let cfg = cfg.get::(); + log::trace!("{}: TLS Handshake start for: {host:?} {io:?}", cfg.tag()); + + async { + match timeout_checked( + cfg.handshake_timeout(), + connect_io(io, host, self.config.clone()), + ) + .await + { + Ok(Ok(io)) => { + log::trace!("{}: TLS Handshake success: {host:?}", cfg.tag()); + Ok(io) + } + Ok(Err(e)) => { + log::trace!("{}: TLS Handshake error: {e:?}", cfg.tag()); + Err(ConnectError::from(e).into()) + } + Err(()) => { + log::trace!("{}: TLS Handshake timeout", cfg.tag()); + Err(ConnectError::from(io::Error::new( + io::ErrorKind::TimedOut, + "TLS Handshake timeout", + )) + .into()) + } + } + } + .await + .map_err(|e: Error<_>| e.set_service(cfg.service())) + } +} + +impl Service> for TlsConnector where - S: Service, Res = Io, Error = Error>, + S: Service, Res = Io, Error = Error>, { - type Res = Io>; + type Res = Io>; type Error = Error; - ntex_service::forward_ready!(SharedCfg, svc); - ntex_service::forward_shutdown!(SharedCfg, svc); - async fn call( &self, - message: Connect, + req: Connect, ctx: Ctx<'_, Self, SharedCfg>, ) -> Result { - let cfg = ctx.st().get::(); - let host = message.host().split(':').next().unwrap().to_string(); - - let io = ctx.call(&self.svc, message).await?; - let tag = io.tag(); - log::trace!("{tag}: TLS Handshake start for: {host:?}"); - - match timeout_checked( - cfg.handshake_timeout(), - connect_io(io, &host, self.config.clone()), - ) - .await - { - Ok(Ok(io)) => { - log::trace!("{tag}: TLS Handshake success: {host:?}"); - Ok(io) - } - Ok(Err(e)) => { - log::trace!("{tag}: TLS Handshake error: {e:?}"); - Err(ConnectError::from(e).into()) - } - Err(()) => { - log::trace!("{tag}: TLS Handshake timeout"); - Err(ConnectError::from(io::Error::new( - io::ErrorKind::TimedOut, - "TLS Handshake timeout", - )) - .into()) - } - } + let host = req.host().split(':').next().unwrap().to_string(); + let io = ctx.call(&self.svc, req).await?; + self.connect(io, &host, ctx.st()).await } + + ntex_service::forward_ready!(SharedCfg, svc); + ntex_service::forward_shutdown!(SharedCfg, svc); } #[cfg(test)] @@ -112,7 +126,7 @@ mod tests { let svc: TlsConnector> = TlsConnector::new(); assert!(format!("{svc:?}").contains("TlsConnector")); - let srv = Pipeline::new(svc); + let srv = Pipeline::with(SharedCfg::default(), svc); assert!(srv.ready().await.is_ok()); let result = srv .call(Connect::new("").set_addr(Some(server.addr()))) diff --git a/ntex-tls/src/schannel/mod.rs b/ntex-tls/src/schannel/mod.rs index 62be9ffa9..224157703 100644 --- a/ntex-tls/src/schannel/mod.rs +++ b/ntex-tls/src/schannel/mod.rs @@ -9,27 +9,49 @@ use windows_sys::Win32::Foundation::{ SEC_I_RENEGOTIATE, }; use windows_sys::Win32::Security::Authentication::Identity::{ - AcquireCredentialsHandleW, DecryptMessage, DeleteSecurityContext, EncryptMessage, - FreeContextBuffer, FreeCredentialsHandle, ISC_REQ_ALLOCATE_MEMORY, ISC_REQ_CONFIDENTIALITY, - ISC_REQ_EXTENDED_ERROR, ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, ISC_REQ_STREAM, - InitializeSecurityContextW, QueryContextAttributesW, SCH_CRED_AUTO_CRED_VALIDATION, - SCH_CRED_MANUAL_CRED_VALIDATION, SCH_CRED_NO_SERVERNAME_CHECK, SCH_USE_STRONG_CRYPTO, - SCHANNEL_CRED, SCHANNEL_CRED_VERSION, SECBUFFER_APPLICATION_PROTOCOLS, SECBUFFER_DATA, - SECBUFFER_EMPTY, SECBUFFER_EXTRA, SECBUFFER_STREAM_HEADER, SECBUFFER_STREAM_TRAILER, - SECBUFFER_TOKEN, SECBUFFER_VERSION, SECPKG_ATTR_APPLICATION_PROTOCOL, - SECPKG_ATTR_REMOTE_CERT_CONTEXT, SECPKG_ATTR_STREAM_SIZES, SECPKG_CRED_OUTBOUND, - SECURITY_NATIVE_DREP, SecApplicationProtocolNegotiationExt_ALPN, - SecApplicationProtocolNegotiationStatus_Success, SecBuffer, SecBufferDesc, - SecPkgContext_ApplicationProtocol, SecPkgContext_StreamSizes, UNISP_NAME_W, + ASC_REQ_ALLOCATE_MEMORY, ASC_REQ_CONFIDENTIALITY, ASC_REQ_EXTENDED_ERROR, + ASC_REQ_REPLAY_DETECT, ASC_REQ_SEQUENCE_DETECT, ASC_REQ_STREAM, AcceptSecurityContext, + AcquireCredentialsHandleW, ApplyControlToken, DecryptMessage, DeleteSecurityContext, + EncryptMessage, FreeContextBuffer, FreeCredentialsHandle, ISC_REQ_ALLOCATE_MEMORY, + ISC_REQ_CONFIDENTIALITY, ISC_REQ_EXTENDED_ERROR, ISC_REQ_MANUAL_CRED_VALIDATION, + ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, ISC_REQ_STREAM, InitializeSecurityContextW, + QueryContextAttributesW, SCH_CRED_AUTO_CRED_VALIDATION, SCH_CRED_MANUAL_CRED_VALIDATION, + SCH_CRED_NO_DEFAULT_CREDS, SCH_CRED_NO_SERVERNAME_CHECK, SCH_CREDENTIALS, + SCH_CREDENTIALS_VERSION, SCH_USE_STRONG_CRYPTO, SCHANNEL_SHUTDOWN, + SECBUFFER_APPLICATION_PROTOCOLS, SECBUFFER_DATA, SECBUFFER_EMPTY, SECBUFFER_EXTRA, + SECBUFFER_STREAM_HEADER, SECBUFFER_STREAM_TRAILER, SECBUFFER_TOKEN, SECBUFFER_VERSION, + SECPKG_ATTR_APPLICATION_PROTOCOL, SECPKG_ATTR_REMOTE_CERT_CONTEXT, SECPKG_ATTR_STREAM_SIZES, + SECPKG_CRED_INBOUND, SECPKG_CRED_OUTBOUND, SECURITY_NATIVE_DREP, + SecApplicationProtocolNegotiationExt_ALPN, SecApplicationProtocolNegotiationStatus_Success, + SecBuffer, SecBufferDesc, SecPkgContext_ApplicationProtocol, SecPkgContext_StreamSizes, + UNISP_NAME_W, }; use windows_sys::Win32::Security::Credentials::SecHandle; use windows_sys::Win32::Security::Cryptography::{ CERT_CONTEXT, CertDuplicateCertificateContext, CertFreeCertificateContext, }; +mod accept; +mod cert; mod connect; +pub use self::accept::TlsAcceptor; +pub use self::cert::ServerConfig; pub use self::connect::TlsConnector; +const ISC_REQ_FLAGS: u32 = ISC_REQ_SEQUENCE_DETECT + | ISC_REQ_REPLAY_DETECT + | ISC_REQ_CONFIDENTIALITY + | ISC_REQ_ALLOCATE_MEMORY + | ISC_REQ_EXTENDED_ERROR + | ISC_REQ_STREAM; + +const ASC_REQ_FLAGS: u32 = ASC_REQ_SEQUENCE_DETECT + | ASC_REQ_REPLAY_DETECT + | ASC_REQ_CONFIDENTIALITY + | ASC_REQ_ALLOCATE_MEMORY + | ASC_REQ_EXTENDED_ERROR + | ASC_REQ_STREAM; + /// Windows Schannel client configuration. #[derive(Clone, Debug)] pub struct ClientConfig { @@ -79,14 +101,39 @@ enum State { Streaming, } +#[derive(Clone, Copy)] +enum Side { + Client { verify: bool }, + Server, +} + struct Context { cred: SecHandle, ctxt: SecHandle, have_ctxt: bool, + side: Side, + /// Continue `InitializeSecurityContext` even when no new bytes are buffered. + /// + /// Set after `DecryptMessage` returns `SEC_I_RENEGOTIATE` (TLS 1.3 + /// `NewSessionTicket` / `KeyUpdate`, or a TLS 1.2 renegotiation request). + renegotiating: bool, target: Vec, sizes: Option, } +impl Side { + fn is_server(self) -> bool { + matches!(self, Self::Server) + } + + fn verify(self) -> bool { + match self { + Self::Client { verify } => verify, + Self::Server => false, + } + } +} + impl std::fmt::Debug for Context { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Context") @@ -101,13 +148,22 @@ enum HandshakeState { NeedRead, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecryptStatus { + Incomplete, + Closed, + Renegotiate, + Progress, +} + impl Context { fn new(domain: &str, config: &ClientConfig) -> io::Result { let mut cred = unsafe { mem::zeroed::() }; let mut expiry = 0i64; - let mut schannel_cred = unsafe { mem::zeroed::() }; - schannel_cred.dwVersion = SCHANNEL_CRED_VERSION; - schannel_cred.dwFlags = SCH_USE_STRONG_CRYPTO; + // SCH_CREDENTIALS is required for TLS 1.3; SCHANNEL_CRED is deprecated. + let mut schannel_cred = unsafe { mem::zeroed::() }; + schannel_cred.dwVersion = SCH_CREDENTIALS_VERSION; + schannel_cred.dwFlags = SCH_USE_STRONG_CRYPTO | SCH_CRED_NO_DEFAULT_CREDS; if config.verify { schannel_cred.dwFlags |= SCH_CRED_AUTO_CRED_VALIDATION; } else { @@ -135,11 +191,82 @@ impl Context { cred, ctxt: unsafe { mem::zeroed::() }, have_ctxt: false, + side: Side::Client { + verify: config.verify, + }, + renegotiating: false, target: domain.encode_utf16().chain(Some(0)).collect(), sizes: None, }) } + fn new_server(config: &ServerConfig) -> io::Result { + let mut cred = unsafe { mem::zeroed::() }; + let mut expiry = 0i64; + let mut cert = config.cert(); + let mut schannel_cred = unsafe { mem::zeroed::() }; + schannel_cred.dwVersion = SCH_CREDENTIALS_VERSION; + schannel_cred.dwFlags = SCH_USE_STRONG_CRYPTO; + schannel_cred.cCreds = 1; + schannel_cred.paCred = &raw mut cert; + + let status = unsafe { + AcquireCredentialsHandleW( + ptr::null(), + UNISP_NAME_W, + SECPKG_CRED_INBOUND, + ptr::null(), + (&raw mut schannel_cred).cast(), + None, + ptr::null(), + &raw mut cred, + &raw mut expiry, + ) + }; + if status != SEC_E_OK { + return Err(sspi_error("AcquireCredentialsHandleW", status)); + } + + Ok(Self { + cred, + ctxt: unsafe { mem::zeroed::() }, + have_ctxt: false, + side: Side::Server, + renegotiating: false, + target: Vec::new(), + sizes: None, + }) + } + + fn handshake( + &mut self, + mut input: Option<&mut BytesMut>, + output: &mut ntex_bytes::BytePages, + ) -> io::Result { + loop { + let in_len = input.as_ref().map_or(0, |src| src.len()); + if in_len == 0 && !self.renegotiating && (self.have_ctxt || self.side.is_server()) { + return Ok(HandshakeState::NeedRead); + } + + let state = self.handshake_step(input.as_deref_mut(), output)?; + let remaining = input.as_ref().map_or(0, |src| src.len()); + match state { + HandshakeState::Done => { + self.renegotiating = false; + return Ok(HandshakeState::Done); + } + HandshakeState::NeedRead if remaining > 0 && remaining < in_len => { + // Leftover handshake records were already in this buffer. + } + HandshakeState::NeedRead => { + self.renegotiating = false; + return Ok(HandshakeState::NeedRead); + } + } + } + } + #[allow(clippy::too_many_lines)] fn handshake_step( &mut self, @@ -158,42 +285,46 @@ impl Context { }; let mut alpn = alpn_buffer(); - let mut in_bufs = [ - SecBuffer { - cbBuffer: 0, - BufferType: SECBUFFER_EMPTY, - pvBuffer: ptr::null_mut(), - }, - SecBuffer { - cbBuffer: 0, - BufferType: SECBUFFER_EMPTY, - pvBuffer: ptr::null_mut(), - }, - SecBuffer { + let mut input_len = 0usize; + let mut in_bufs = Vec::new(); + if let Some(src) = input.as_ref() { + input_len = src.len(); + if input_len != 0 { + in_bufs.push(SecBuffer { + cbBuffer: u32::try_from(input_len) + .map_err(|_| io::Error::other("TLS input buffer is too large"))?, + BufferType: SECBUFFER_TOKEN, + pvBuffer: src.as_ptr().cast_mut().cast(), + }); + in_bufs.push(SecBuffer { + cbBuffer: 0, + BufferType: SECBUFFER_EMPTY, + pvBuffer: ptr::null_mut(), + }); + } + } + if !self.have_ctxt { + in_bufs.push(SecBuffer { cbBuffer: u32::try_from(alpn.len()) .map_err(|_| io::Error::other("TLS ALPN buffer is too large"))?, BufferType: SECBUFFER_APPLICATION_PROTOCOLS, pvBuffer: alpn.as_mut_ptr().cast(), - }, - ]; - let in_desc = SecBufferDesc { + }); + } + if in_bufs.is_empty() { + in_bufs.push(SecBuffer { + cbBuffer: 0, + BufferType: SECBUFFER_EMPTY, + pvBuffer: ptr::null_mut(), + }); + } + + let mut in_desc = SecBufferDesc { ulVersion: SECBUFFER_VERSION, - cBuffers: u32::try_from(in_bufs.len()).expect("static SecBuffer count fits u32"), + cBuffers: u32::try_from(in_bufs.len()).expect("SecBuffer count fits u32"), pBuffers: in_bufs.as_mut_ptr(), }; - let mut input_len = 0usize; - if let Some(src) = input.as_ref() { - input_len = src.len(); - if input_len != 0 { - in_bufs[0].BufferType = SECBUFFER_TOKEN; - in_bufs[0].cbBuffer = u32::try_from(input_len) - .map_err(|_| io::Error::other("TLS input buffer is too large"))?; - in_bufs[0].pvBuffer = src.as_ptr().cast_mut().cast(); - } - } - let input_desc = &raw const in_desc; - let mut attrs = 0u32; let mut expiry = 0i64; let ctxt = if self.have_ctxt { @@ -201,26 +332,41 @@ impl Context { } else { ptr::null() }; - let status = unsafe { - InitializeSecurityContextW( - &raw const self.cred, - ctxt, - self.target.as_ptr(), - ISC_REQ_SEQUENCE_DETECT - | ISC_REQ_REPLAY_DETECT - | ISC_REQ_CONFIDENTIALITY - | ISC_REQ_ALLOCATE_MEMORY - | ISC_REQ_EXTENDED_ERROR - | ISC_REQ_STREAM, - 0, - SECURITY_NATIVE_DREP, - input_desc, - 0, - &raw mut self.ctxt, - &raw mut out_desc, - &raw mut attrs, - &raw mut expiry, - ) + let status = if self.side.is_server() { + unsafe { + AcceptSecurityContext( + &raw const self.cred, + ctxt, + &raw const in_desc, + ASC_REQ_FLAGS, + SECURITY_NATIVE_DREP, + &raw mut self.ctxt, + &raw mut out_desc, + &raw mut attrs, + &raw mut expiry, + ) + } + } else { + let mut flags = ISC_REQ_FLAGS; + if !self.side.verify() { + flags |= ISC_REQ_MANUAL_CRED_VALIDATION; + } + unsafe { + InitializeSecurityContextW( + &raw const self.cred, + ctxt, + self.target.as_ptr(), + flags, + 0, + SECURITY_NATIVE_DREP, + &raw mut in_desc, + 0, + &raw mut self.ctxt, + &raw mut out_desc, + &raw mut attrs, + &raw mut expiry, + ) + } }; self.have_ctxt = true; @@ -240,18 +386,16 @@ impl Context { return Ok(HandshakeState::NeedRead); } if status != SEC_E_OK && status != SEC_I_CONTINUE_NEEDED { - return Err(sspi_error("InitializeSecurityContextW", status)); + let ctx = if self.side.is_server() { + "AcceptSecurityContext" + } else { + "InitializeSecurityContextW" + }; + return Err(sspi_error(ctx, status)); } if let Some(src) = input { - let extra = in_bufs - .iter() - .find(|buf| buf.BufferType == SECBUFFER_EXTRA) - .map_or(0, |buf| buf.cbBuffer as usize); - let consumed = input_len.saturating_sub(extra); - if consumed != 0 { - src.advance_to(consumed); - } + consume_extra(src, input_len, extra_len(&in_bufs)); } if status == SEC_E_OK { @@ -338,9 +482,9 @@ impl Context { Ok(len) } - fn decrypt(&mut self, src: &mut BytesMut, dst: &mut BytesMut) -> io::Result { + fn decrypt(&mut self, src: &mut BytesMut, dst: &mut BytesMut) -> io::Result { if src.is_empty() { - return Ok(false); + return Ok(DecryptStatus::Incomplete); } let input_len = src.len(); @@ -378,36 +522,49 @@ impl Context { match status { SEC_E_OK => {} - SEC_E_INCOMPLETE_MESSAGE => return Ok(false), + SEC_E_INCOMPLETE_MESSAGE => return Ok(DecryptStatus::Incomplete), SEC_I_CONTEXT_EXPIRED => { - src.clear(); - return Ok(false); + consume_extra(src, input_len, extra_len(&bufs)); + return Ok(DecryptStatus::Closed); } SEC_I_RENEGOTIATE => { - return Err(io::Error::other("TLS renegotiation is not supported")); + copy_data(&bufs, dst); + consume_extra(src, input_len, extra_len(&bufs)); + self.renegotiating = true; + return Ok(DecryptStatus::Renegotiate); } _ => return Err(sspi_error("DecryptMessage", status)), } - let mut produced = false; - if bufs[1].BufferType == SECBUFFER_DATA && bufs[1].cbBuffer != 0 { - let data = unsafe { - slice::from_raw_parts(bufs[1].pvBuffer.cast::(), bufs[1].cbBuffer as usize) - }; - dst.put_slice(data); - produced = true; + copy_data(&bufs, dst); + consume_extra(src, input_len, extra_len(&bufs)); + Ok(DecryptStatus::Progress) + } + + fn send_close_notify(&mut self, output: &mut ntex_bytes::BytePages) -> io::Result<()> { + if !self.have_ctxt { + return Ok(()); } - let extra = if bufs[3].BufferType == SECBUFFER_EXTRA { - bufs[3].cbBuffer as usize - } else { - 0 + let mut token = SCHANNEL_SHUTDOWN; + let mut buf = SecBuffer { + cbBuffer: u32::try_from(mem::size_of_val(&token)) + .expect("SCHANNEL_SHUTDOWN size fits u32"), + BufferType: SECBUFFER_TOKEN, + pvBuffer: (&raw mut token).cast(), }; - let consumed = input_len.saturating_sub(extra); - if consumed != 0 { - src.advance_to(consumed); + let desc = SecBufferDesc { + ulVersion: SECBUFFER_VERSION, + cBuffers: 1, + pBuffers: &raw mut buf, + }; + let status = unsafe { ApplyControlToken(&raw const self.ctxt, &raw const desc) }; + if status != SEC_E_OK { + return Err(sspi_error("ApplyControlToken", status)); } - Ok(produced || consumed != 0) + + let _ = self.handshake_step(None, output)?; + Ok(()) } fn peer_cert(&self) -> Option> { @@ -498,36 +655,51 @@ impl FilterLayer for SchannelFilter { } } - fn shutdown(&self, _: &FilterBuf<'_>) -> io::Result> { + fn shutdown(&self, buf: &FilterBuf<'_>) -> io::Result> { + let mut inner = self.inner.borrow_mut(); + buf.with_write_buffers(|_, dst| inner.ctx.send_close_notify(dst))?; Ok(Poll::Ready(())) } fn process_read_buf(&self, rb: &FilterBuf<'_>) -> io::Result<()> { - let mut inner = self.inner.borrow_mut(); - if inner.state == State::Handshaking { - let state = rb.with_write_buffers(|_, dst| { - rb.with_read_src(|src| inner.ctx.handshake_step(src.as_mut(), dst)) - })?; - if state == HandshakeState::NeedRead { - return Ok(()); + loop { + let mut inner = self.inner.borrow_mut(); + if inner.state == State::Handshaking { + let state = rb.with_write_buffers(|_, dst| { + rb.with_read_src(|src| inner.ctx.handshake(src.as_mut(), dst)) + })?; + if state == HandshakeState::NeedRead { + return Ok(()); + } + inner.state = State::Streaming; } - inner.state = State::Streaming; - } - rb.with_read_buffers(|r_src, r_dst| { - if let Some(src) = r_src { - loop { - let progressed = inner.ctx.decrypt(src, r_dst)?; - if !progressed { - break; - } - if src.is_empty() { - break; + let renegotiate = rb.with_read_buffers(|r_src, r_dst| -> io::Result { + if let Some(src) = r_src { + loop { + match inner.ctx.decrypt(src, r_dst)? { + DecryptStatus::Incomplete => break, + DecryptStatus::Closed => { + rb.io().close(); + break; + } + DecryptStatus::Renegotiate => { + return Ok(true); + } + DecryptStatus::Progress if src.is_empty() => break, + DecryptStatus::Progress => {} + } } } + Ok(false) + })?; + + if renegotiate { + inner.state = State::Handshaking; + continue; } - Ok(()) - }) + return Ok(()); + } } fn process_write_buf(&self, wb: &FilterBuf<'_>) -> io::Result<()> { @@ -553,7 +725,7 @@ impl SchannelFilter { fn start_handshake(&self, buf: &FilterBuf<'_>) -> io::Result { let mut inner = self.inner.borrow_mut(); buf.with_write_buffers(|_, dst| { - let state = inner.ctx.handshake_step(None, dst)?; + let state = inner.ctx.handshake(None, dst)?; if state == HandshakeState::Done { inner.state = State::Streaming; } @@ -598,6 +770,55 @@ pub async fn connect( } } +pub async fn accept( + io: Io, + config: ServerConfig, +) -> io::Result>> { + let filter = SchannelFilter { + inner: RefCell::new(Schannel { + ctx: Context::new_server(&config)?, + state: State::Handshaking, + }), + }; + let io = io.add_filter(filter); + + loop { + io.read_notify() + .await? + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "disconnected"))?; + io.flush(false).await?; + + if !io.filter().is_handshaking() { + return Ok(io); + } + } +} + +fn extra_len(bufs: &[SecBuffer]) -> usize { + bufs.iter() + .find(|buf| buf.BufferType == SECBUFFER_EXTRA) + .map_or(0, |buf| buf.cbBuffer as usize) +} + +fn copy_data(bufs: &[SecBuffer], dst: &mut BytesMut) { + if let Some(buf) = bufs + .iter() + .find(|buf| buf.BufferType == SECBUFFER_DATA && buf.cbBuffer != 0) + && !buf.pvBuffer.is_null() + { + let data = + unsafe { slice::from_raw_parts(buf.pvBuffer.cast::(), buf.cbBuffer as usize) }; + dst.put_slice(data); + } +} + +fn consume_extra(src: &mut BytesMut, input_len: usize, extra: usize) { + let consumed = input_len.saturating_sub(extra); + if consumed != 0 { + src.advance_to(consumed); + } +} + fn alpn_buffer() -> Vec { // Layout for SECBUFFER_APPLICATION_PROTOCOLS: // u32 ProtocolListsSize, then one or more protocol lists. diff --git a/ntex/CHANGES.md b/ntex/CHANGES.md index 5056fb138..4f0d4d77f 100644 --- a/ntex/CHANGES.md +++ b/ntex/CHANGES.md @@ -1,5 +1,12 @@ # Changes +## [unreleased] + +* Add `schannel` crate feature and wire Windows Schannel into `ClientBuilder` + and `http::schannel` without requiring OpenSSL or `ws` + +* Allow building without the `ws` feature + ## [4.0.0-beta.2] - 2026-08-29 * Refactor web error rendering diff --git a/ntex/Cargo.toml b/ntex/Cargo.toml index 2165e7a0a..f5398d072 100644 --- a/ntex/Cargo.toml +++ b/ntex/Cargo.toml @@ -39,6 +39,9 @@ openssl = ["tls_openssl", "ntex-tls/openssl"] # rustls support rustls = ["tls_rustls", "webpki-roots", "ntex-tls/rustls"] +# Windows Schannel support +schannel = ["ntex-tls/schannel"] + # enable compressison support compress = ["flate2"] @@ -132,7 +135,6 @@ rand = { workspace = true } time = { workspace = true } oneshot = { workspace = true } futures-util = { workspace = true } -tls_openssl = { workspace = true } tls_rustls = { workspace = true, features = ["ring", "std"] } rustls-pemfile = { workspace = true } webpki-roots = { workspace = true } diff --git a/ntex/src/client/builder.rs b/ntex/src/client/builder.rs index a0103aaca..70f542503 100644 --- a/ntex/src/client/builder.rs +++ b/ntex/src/client/builder.rs @@ -74,7 +74,20 @@ impl ClientBuilder { config.alpn_protocols = protos; builder.rustls(config) } - #[cfg(not(any(feature = "openssl", feature = "rustls")))] + #[cfg(all( + windows, + not(feature = "openssl"), + not(feature = "rustls"), + feature = "schannel" + ))] + { + builder.schannel(crate::connect::schannel::ClientConfig::new()) + } + #[cfg(not(any( + feature = "openssl", + feature = "rustls", + all(windows, feature = "schannel") + )))] { builder } @@ -100,6 +113,15 @@ impl ClientBuilder { self.secure_connector(TlsConnector::new(config)) } + #[must_use] + #[cfg(all(windows, feature = "schannel"))] + /// Use Windows Schannel connector for secured connections. + pub fn schannel(self, config: crate::connect::schannel::ClientConfig) -> Self { + use crate::connect::schannel::TlsConnector; + + self.secure_connector(TlsConnector::with_config(config)) + } + #[must_use] /// Use custom connector to open un-secured connections. pub fn connector(mut self, f: impl IntoService>) -> Self diff --git a/ntex/src/http/mod.rs b/ntex/src/http/mod.rs index 182fc01e4..68729c6d2 100644 --- a/ntex/src/http/mod.rs +++ b/ntex/src/http/mod.rs @@ -46,7 +46,13 @@ pub struct HeaderItem { #[cfg(feature = "openssl")] use crate::server::openssl::{SslAcceptor, SslFilter}; -#[cfg(any(feature = "openssl", feature = "rustls"))] +#[cfg(all(windows, feature = "schannel"))] +use crate::server::schannel::{SchannelFilter, TlsAcceptor as SchannelAcceptor}; +#[cfg(any( + feature = "openssl", + feature = "rustls", + all(windows, feature = "schannel") +))] use crate::{IntoService, Service, io::Filter, io::Io, io::Layer, server::TlsError}; #[cfg(feature = "openssl")] @@ -65,7 +71,7 @@ where } #[cfg(feature = "rustls")] -use crate::server::rustls::{TlsAcceptor, TlsServerFilter}; +use crate::server::rustls::{TlsAcceptor as RustlsAcceptor, TlsServerFilter}; #[cfg(feature = "rustls")] /// Create rustls based service. @@ -84,7 +90,22 @@ where config.alpn_protocols = protos.iter().map(|s| s.to_string().into()).collect(); } - TlsAcceptor::new(std::sync::Arc::new(config)) + RustlsAcceptor::new(std::sync::Arc::new(config)) + .map_err(TlsError::Tls) + .and_then(service.into_service().map_err(TlsError::Service)) +} + +#[cfg(all(windows, feature = "schannel"))] +/// Create Schannel based service +pub fn schannel( + config: crate::connect::schannel::ServerConfig, + service: impl IntoService>>, +) -> impl Service, Res = S::Res, Error = TlsError> +where + F: Filter, + S: Service>>, +{ + SchannelAcceptor::new(config) .map_err(TlsError::Tls) .and_then(service.into_service().map_err(TlsError::Service)) } diff --git a/ntex/src/http/test.rs b/ntex/src/http/test.rs index 2083ecee3..a6bde4de5 100644 --- a/ntex/src/http/test.rs +++ b/ntex/src/http/test.rs @@ -338,13 +338,14 @@ impl TestServer { ntex_h2::ServiceConfig::new() .set_max_header_list_size(256 * 1024) .set_max_header_continuation_frames(96), - ) - .add( - WsClientConfig::new() - .set_address(addr) - .set_timeout(Seconds(30)), - ) - .build(); + ); + #[cfg(feature = "ws")] + let cfg = cfg.add( + WsClientConfig::new() + .set_address(addr) + .set_timeout(Seconds(30)), + ); + let cfg = cfg.build(); let client = Self::create_client(cfg.clone()); @@ -361,20 +362,21 @@ impl TestServer { #[must_use] /// Set client timeout pub fn set_client_timeout(mut self, timeout: Seconds, connect_timeout: Millis) -> Self { - self.cfg = SharedCfg::new("TEST-CLIENT") + let cfg = SharedCfg::new("TEST-CLIENT") .add(IoConfig::new().set_connect_timeout(connect_timeout)) .add(TlsConfig::new().set_handshake_timeout(timeout)) .add( ntex_h2::ServiceConfig::new() .set_max_header_list_size(256 * 1024) .set_max_header_continuation_frames(96), - ) - .add( - WsClientConfig::new() - .set_address(self.addr) - .set_timeout(Seconds(30)), - ) - .build(); + ); + #[cfg(feature = "ws")] + let cfg = cfg.add( + WsClientConfig::new() + .set_address(self.addr) + .set_timeout(Seconds(30)), + ); + self.cfg = cfg.build(); self.client = Self::create_client(self.cfg.clone()); self } @@ -494,6 +496,45 @@ impl TestServer { .await } + #[cfg(all( + windows, + feature = "schannel", + feature = "ws", + not(feature = "openssl") + ))] + /// Connect to a websocket server + pub async fn wss( + &self, + ) -> Result< + WsConnection>, + Error, + > { + self.wss_at("/").await + } + + #[cfg(all( + windows, + feature = "schannel", + feature = "ws", + not(feature = "openssl") + ))] + /// Connect to secure websocket server at a given path + pub async fn wss_at( + &self, + path: &str, + ) -> Result< + WsConnection>, + Error, + > { + WsClient::new(self.url(path), &self.cfg) + .unwrap() + .schannel( + crate::connect::schannel::ClientConfig::new().danger_accept_invalid_certs(true), + ) + .connect() + .await + } + /// Stop http server pub async fn stop(self, graceful: bool) { self.server.stop(graceful).await; diff --git a/ntex/src/lib.rs b/ntex/src/lib.rs index 07ad8bb29..b7019a2be 100644 --- a/ntex/src/lib.rs +++ b/ntex/src/lib.rs @@ -4,6 +4,7 @@ //! //! * `openssl` - enables ssl support via `openssl` crate //! * `rustls` - enables ssl support via `rustls` crate +//! * `schannel` - enables ssl support via Windows Schannel //! * `compress` - enables compression support in http and web modules //! * `cookie` - enables cookie support in http and web modules #![deny(clippy::pedantic)] @@ -63,6 +64,11 @@ pub mod connect { pub mod rustls { pub use ntex_tls::rustls::*; } + + #[cfg(all(windows, feature = "schannel"))] + pub mod schannel { + pub use ntex_tls::schannel::*; + } } pub mod router { @@ -91,6 +97,9 @@ pub mod server { #[cfg(feature = "rustls")] pub use ntex_tls::rustls; + #[cfg(all(windows, feature = "schannel"))] + pub use ntex_tls::schannel; + pub use ntex_tls::{TlsConfig, TlsError}; } diff --git a/ntex/src/web/error_default.rs b/ntex/src/web/error_default.rs index 0ec4a2a4d..237a1a907 100644 --- a/ntex/src/web/error_default.rs +++ b/ntex/src/web/error_default.rs @@ -6,7 +6,9 @@ use serde_json::error::Error as JsonError; use serde_urlencoded::ser::Error as FormError; use crate::client; -use crate::http::{self, StatusCode, header}; +#[cfg(feature = "ws")] +use crate::http::header; +use crate::http::{self, StatusCode}; use crate::util::timeout::TimeoutError; #[cfg(feature = "ws")] use crate::ws::error::HandshakeError; @@ -268,6 +270,7 @@ impl WebResponseError for HandshakeError { } } +#[cfg(feature = "ws")] impl From for WebError { fn from(err: HandshakeError) -> Self { Self::new(err) diff --git a/ntex/src/web/test.rs b/ntex/src/web/test.rs index 63d5f0845..db18803ee 100644 --- a/ntex/src/web/test.rs +++ b/ntex/src/web/test.rs @@ -708,7 +708,7 @@ where thread::sleep(Millis(25).into()); let cfg = cfg.client_cfg.clone().unwrap_or_else(|| { - SharedCfg::new("TEST-CLIENT") + let builder = SharedCfg::new("TEST-CLIENT") .add(IoConfig::new().set_connect_timeout(Millis(90_000))) .add(ntex_tls::TlsConfig::new().set_handshake_timeout(Seconds(5))) .add( @@ -716,13 +716,14 @@ where .set_max_header_list_size(256 * 1024) .set_max_header_continuation_frames(96), ) - .add(ClientConfig::new().set_lifetime(Seconds::ZERO)) - .add( - WsClientConfig::new() - .set_address(addr) - .set_timeout(Seconds(60)), - ) - .build() + .add(ClientConfig::new().set_lifetime(Seconds::ZERO)); + #[cfg(feature = "ws")] + let builder = builder.add( + WsClientConfig::new() + .set_address(addr) + .set_timeout(Seconds(60)), + ); + builder.build() }); let client = { @@ -893,6 +894,7 @@ impl TestServerConfig { /// Test server controller pub struct TestServer { id: Uuid, + #[cfg_attr(not(feature = "ws"), allow(dead_code))] cfg: SharedCfg, addr: net::SocketAddr, client: Client, @@ -992,9 +994,21 @@ impl TestServer { .await .map(WsConnection::seal) } - #[cfg(not(feature = "openssl"))] + #[cfg(all(not(feature = "openssl"), windows, feature = "schannel"))] + { + WsClient::new(self.url(path), &self.cfg) + .unwrap() + .schannel( + crate::connect::schannel::ClientConfig::new() + .danger_accept_invalid_certs(true), + ) + .connect() + .await + .map(WsConnection::seal) + } + #[cfg(not(any(feature = "openssl", all(windows, feature = "schannel"))))] { - panic!("openssl feature is required") + panic!("TLS feature is required") } } else { WsClient::new(self.url(path), &self.cfg) diff --git a/ntex/src/ws/client.rs b/ntex/src/ws/client.rs index eb2993477..fb0ef1438 100644 --- a/ntex/src/ws/client.rs +++ b/ntex/src/ws/client.rs @@ -107,6 +107,15 @@ impl WsClient { ) -> WsClient> { self.connector(TlsConnector::from(config)) } + + #[cfg(all(windows, feature = "schannel"))] + /// Use Windows Schannel connector. + pub fn schannel( + self, + config: crate::connect::schannel::ClientConfig, + ) -> WsClient> { + self.connector(crate::connect::schannel::TlsConnector::with_config(config)) + } } impl WsClient diff --git a/ntex/tests/connect.rs b/ntex/tests/connect.rs index 2ac552631..a82d8de09 100644 --- a/ntex/tests/connect.rs +++ b/ntex/tests/connect.rs @@ -1,9 +1,13 @@ -use std::{io, rc::Rc}; +use std::io; +#[cfg(feature = "openssl")] +use std::rc::Rc; use ntex::io::{Io, types::PeerAddr}; -use ntex::service::{Pipeline, Service, cfg::SharedCfg, svc}; +#[cfg(feature = "openssl")] +use ntex::server::build_test_server; +use ntex::service::{Pipeline, cfg::SharedCfg, svc}; use ntex::{codec::BytesCodec, connect::Connect}; -use ntex::{server::build_test_server, server::test_server, time, util::Bytes}; +use ntex::{server::test_server, time, util::Bytes}; #[cfg(feature = "rustls")] mod rustls_utils; @@ -132,15 +136,16 @@ async fn test_openssl_read_before_error() { assert!(io.recv(&BytesCodec).await.unwrap().is_none()); } -#[cfg(all(windows, feature = "openssl"))] +#[cfg(all(windows, feature = "schannel"))] #[ntex::test] async fn test_schannel_string() { - use ntex::{io::types::HttpProtocol, server::openssl}; - use ntex_tls::schannel::{ClientConfig, PeerCert, TlsConnector}; - use tls_openssl::x509::X509; + use ntex::io::types::HttpProtocol; + use ntex_tls::schannel::{ClientConfig, PeerCert, ServerConfig, TlsAcceptor, TlsConnector}; - let srv = test_server(async || { - svc(openssl::SslAcceptor::new(ssl_acceptor())).and_then(async move |io: Io<_>| { + let server = ServerConfig::from_pem(include_str!("cert.pem"), include_str!("key.pem")).unwrap(); + let cert = server.cert_der(); + let srv = test_server(async move || { + svc(TlsAcceptor::new(server.clone())).and_then(async move |io: Io<_>| { let item = io.recv(&BytesCodec).await.unwrap().unwrap(); io.send(item, &BytesCodec).await.unwrap(); Ok::<_, io::Error>(()) @@ -157,15 +162,11 @@ async fn test_schannel_string() { let addr = format!("localhost:{}", srv.addr().port()); let io = conn.call(addr.into()).await.unwrap(); assert_eq!(io.query::().get().unwrap(), srv.addr().into()); - assert_eq!( + assert!(matches!( io.query::().get().unwrap(), - HttpProtocol::Http1 - ); - let cert = X509::from_pem(include_bytes!("cert.pem")).unwrap(); - assert_eq!( - io.query::().as_ref().unwrap().0, - cert.to_der().unwrap() - ); + HttpProtocol::Http1 | HttpProtocol::Http2 + )); + assert_eq!(io.query::().as_ref().unwrap().0, cert); io.send(Bytes::from_static(b"test"), &BytesCodec) .await .unwrap(); diff --git a/ntex/tests/http_client_schannel.rs b/ntex/tests/http_client_schannel.rs index 34c255788..96333a2ee 100644 --- a/ntex/tests/http_client_schannel.rs +++ b/ntex/tests/http_client_schannel.rs @@ -1,33 +1,16 @@ #![recursion_limit = "256"] -#![cfg(all(windows, feature = "openssl"))] +#![cfg(all(windows, feature = "schannel"))] use std::sync::{Arc, atomic::AtomicUsize, atomic::Ordering}; use ntex::client::Client; -use ntex::http::{HttpService, Uri, Version, openssl, test::server as test_server}; +use ntex::http::{HttpService, Version, schannel, test::server as test_server}; use ntex::service::{cfg::SharedCfg, svc}; use ntex::web::{self, App, HttpResponse}; -use ntex_tls::schannel::{ClientConfig, TlsConnector}; -use tls_openssl::ssl::{AlpnError, SslAcceptor, SslFiletype, SslMethod}; +use ntex_tls::schannel::{ClientConfig, ServerConfig, TlsConnector}; -fn ssl_acceptor() -> SslAcceptor { - let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); - builder - .set_private_key_file("./tests/key.pem", SslFiletype::PEM) - .unwrap(); - builder - .set_certificate_chain_file("./tests/cert.pem") - .unwrap(); - builder.set_alpn_select_callback(|_, protos| { - const H2: &[u8] = b"\x02h2"; - if protos.windows(3).any(|window| window == H2) { - Ok(b"h2") - } else { - Err(AlpnError::NOACK) - } - }); - builder.set_alpn_protos(b"\x02h2").unwrap(); - builder.build() +fn server_config() -> ServerConfig { + ServerConfig::from_pem(include_str!("cert.pem"), include_str!("key.pem")).unwrap() } #[ntex::test] @@ -41,15 +24,15 @@ async fn test_connection_reuse_h2() { num2.fetch_add(1, Ordering::Relaxed); Ok(io) }) - .and_then(openssl( - ssl_acceptor(), + .and_then(schannel( + server_config(), HttpService::h2( App::new().service(web::resource("/").route(web::to(async || HttpResponse::Ok()))), ), )) }); - let tls = TlsConnector::>::with_config( + let tls = TlsConnector::>::with_config( ClientConfig::new().danger_accept_invalid_certs(true), ); let client = Client::builder() @@ -65,3 +48,16 @@ async fn test_connection_reuse_h2() { assert_eq!(num.load(Ordering::Relaxed), 1); } + +#[ntex::test] +async fn test_schannel_public_https() { + let tls = TlsConnector::>::new(); + let client = Client::builder() + .secure_connector(tls) + .build(SharedCfg::default()); + + let response = client.get("https://example.com/").send().await.unwrap(); + assert!(response.status().is_success()); + let body = response.body().await.unwrap(); + assert!(!body.is_empty()); +} From f7cdbf02d4c4a80ef35dc8050a3f6856c8dd3b66 Mon Sep 17 00:00:00 2001 From: emmettlu Date: Wed, 2 Sep 2026 18:36:23 +0800 Subject: [PATCH 2/3] Fix Schannel handshake hang on large certificate chains Incomplete TLS records were left in ntex's read source, which paused further socket reads. Hosts such as cn.bing.com then hit the 5s handshake timeout. Keep unread ciphertext in Context.enc_buf, drain the ntex source on every process_read_buf, and add a Bing HTTPS client test. --- ntex-tls/src/schannel/mod.rs | 167 +++++++++++++++-------------- ntex/tests/http_client_schannel.rs | 19 ++++ 2 files changed, 107 insertions(+), 79 deletions(-) diff --git a/ntex-tls/src/schannel/mod.rs b/ntex-tls/src/schannel/mod.rs index 224157703..c95920db5 100644 --- a/ntex-tls/src/schannel/mod.rs +++ b/ntex-tls/src/schannel/mod.rs @@ -14,17 +14,16 @@ use windows_sys::Win32::Security::Authentication::Identity::{ AcquireCredentialsHandleW, ApplyControlToken, DecryptMessage, DeleteSecurityContext, EncryptMessage, FreeContextBuffer, FreeCredentialsHandle, ISC_REQ_ALLOCATE_MEMORY, ISC_REQ_CONFIDENTIALITY, ISC_REQ_EXTENDED_ERROR, ISC_REQ_MANUAL_CRED_VALIDATION, - ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, ISC_REQ_STREAM, InitializeSecurityContextW, - QueryContextAttributesW, SCH_CRED_AUTO_CRED_VALIDATION, SCH_CRED_MANUAL_CRED_VALIDATION, - SCH_CRED_NO_DEFAULT_CREDS, SCH_CRED_NO_SERVERNAME_CHECK, SCH_CREDENTIALS, - SCH_CREDENTIALS_VERSION, SCH_USE_STRONG_CRYPTO, SCHANNEL_SHUTDOWN, + ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, ISC_REQ_STREAM, ISC_REQ_USE_SUPPLIED_CREDS, + InitializeSecurityContextW, QueryContextAttributesW, SCH_CRED_AUTO_CRED_VALIDATION, + SCH_CRED_MANUAL_CRED_VALIDATION, SCH_CRED_NO_DEFAULT_CREDS, SCH_CRED_NO_SERVERNAME_CHECK, + SCH_CREDENTIALS, SCH_CREDENTIALS_VERSION, SCH_USE_STRONG_CRYPTO, SCHANNEL_SHUTDOWN, SECBUFFER_APPLICATION_PROTOCOLS, SECBUFFER_DATA, SECBUFFER_EMPTY, SECBUFFER_EXTRA, SECBUFFER_STREAM_HEADER, SECBUFFER_STREAM_TRAILER, SECBUFFER_TOKEN, SECBUFFER_VERSION, SECPKG_ATTR_APPLICATION_PROTOCOL, SECPKG_ATTR_REMOTE_CERT_CONTEXT, SECPKG_ATTR_STREAM_SIZES, - SECPKG_CRED_INBOUND, SECPKG_CRED_OUTBOUND, SECURITY_NATIVE_DREP, - SecApplicationProtocolNegotiationExt_ALPN, SecApplicationProtocolNegotiationStatus_Success, - SecBuffer, SecBufferDesc, SecPkgContext_ApplicationProtocol, SecPkgContext_StreamSizes, - UNISP_NAME_W, + SECPKG_CRED_INBOUND, SECPKG_CRED_OUTBOUND, SecApplicationProtocolNegotiationExt_ALPN, + SecApplicationProtocolNegotiationStatus_Success, SecBuffer, SecBufferDesc, + SecPkgContext_ApplicationProtocol, SecPkgContext_StreamSizes, UNISP_NAME_W, }; use windows_sys::Win32::Security::Credentials::SecHandle; use windows_sys::Win32::Security::Cryptography::{ @@ -43,7 +42,8 @@ const ISC_REQ_FLAGS: u32 = ISC_REQ_SEQUENCE_DETECT | ISC_REQ_CONFIDENTIALITY | ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_EXTENDED_ERROR - | ISC_REQ_STREAM; + | ISC_REQ_STREAM + | ISC_REQ_USE_SUPPLIED_CREDS; const ASC_REQ_FLAGS: u32 = ASC_REQ_SEQUENCE_DETECT | ASC_REQ_REPLAY_DETECT @@ -119,6 +119,12 @@ struct Context { renegotiating: bool, target: Vec, sizes: Option, + /// Encrypted bytes not yet consumed by Schannel. + /// + /// Incomplete TLS records stay here so the ntex read buffer can be drained + /// and the socket can keep reading (certificate chains often exceed one + /// TCP segment). + enc_buf: BytesMut, } impl Side { @@ -197,6 +203,7 @@ impl Context { renegotiating: false, target: domain.encode_utf16().chain(Some(0)).collect(), sizes: None, + enc_buf: BytesMut::new(), }) } @@ -235,30 +242,34 @@ impl Context { renegotiating: false, target: Vec::new(), sizes: None, + enc_buf: BytesMut::new(), }) } - fn handshake( - &mut self, - mut input: Option<&mut BytesMut>, - output: &mut ntex_bytes::BytePages, - ) -> io::Result { + fn pull_encrypted(&mut self, src: Option<&mut BytesMut>) { + if let Some(src) = src + && !src.is_empty() + { + self.enc_buf.extend_from_slice(src); + src.clear(); + } + } + + fn handshake(&mut self, output: &mut ntex_bytes::BytePages) -> io::Result { loop { - let in_len = input.as_ref().map_or(0, |src| src.len()); + let in_len = self.enc_buf.len(); if in_len == 0 && !self.renegotiating && (self.have_ctxt || self.side.is_server()) { return Ok(HandshakeState::NeedRead); } - let state = self.handshake_step(input.as_deref_mut(), output)?; - let remaining = input.as_ref().map_or(0, |src| src.len()); + let state = self.handshake_step(output)?; + let remaining = self.enc_buf.len(); match state { HandshakeState::Done => { self.renegotiating = false; return Ok(HandshakeState::Done); } - HandshakeState::NeedRead if remaining > 0 && remaining < in_len => { - // Leftover handshake records were already in this buffer. - } + HandshakeState::NeedRead if remaining > 0 && remaining < in_len => {} HandshakeState::NeedRead => { self.renegotiating = false; return Ok(HandshakeState::NeedRead); @@ -268,11 +279,7 @@ impl Context { } #[allow(clippy::too_many_lines)] - fn handshake_step( - &mut self, - input: Option<&mut BytesMut>, - output: &mut ntex_bytes::BytePages, - ) -> io::Result { + fn handshake_step(&mut self, output: &mut ntex_bytes::BytePages) -> io::Result { let mut out_buf = SecBuffer { cbBuffer: 0, BufferType: SECBUFFER_TOKEN, @@ -285,23 +292,21 @@ impl Context { }; let mut alpn = alpn_buffer(); - let mut input_len = 0usize; + let input_len = self.enc_buf.len(); + let enc_ptr = self.enc_buf.as_mut_ptr(); let mut in_bufs = Vec::new(); - if let Some(src) = input.as_ref() { - input_len = src.len(); - if input_len != 0 { - in_bufs.push(SecBuffer { - cbBuffer: u32::try_from(input_len) - .map_err(|_| io::Error::other("TLS input buffer is too large"))?, - BufferType: SECBUFFER_TOKEN, - pvBuffer: src.as_ptr().cast_mut().cast(), - }); - in_bufs.push(SecBuffer { - cbBuffer: 0, - BufferType: SECBUFFER_EMPTY, - pvBuffer: ptr::null_mut(), - }); - } + if input_len != 0 { + in_bufs.push(SecBuffer { + cbBuffer: u32::try_from(input_len) + .map_err(|_| io::Error::other("TLS input buffer is too large"))?, + BufferType: SECBUFFER_TOKEN, + pvBuffer: enc_ptr.cast(), + }); + in_bufs.push(SecBuffer { + cbBuffer: 0, + BufferType: SECBUFFER_EMPTY, + pvBuffer: ptr::null_mut(), + }); } if !self.have_ctxt { in_bufs.push(SecBuffer { @@ -327,20 +332,20 @@ impl Context { let mut attrs = 0u32; let mut expiry = 0i64; - let ctxt = if self.have_ctxt { - &raw const self.ctxt + let (ph_context, ph_new_context) = if self.have_ctxt { + (&raw const self.ctxt, ptr::null_mut()) } else { - ptr::null() + (ptr::null(), &raw mut self.ctxt) }; let status = if self.side.is_server() { unsafe { AcceptSecurityContext( &raw const self.cred, - ctxt, + ph_context, &raw const in_desc, ASC_REQ_FLAGS, - SECURITY_NATIVE_DREP, - &raw mut self.ctxt, + 0, + ph_new_context, &raw mut out_desc, &raw mut attrs, &raw mut expiry, @@ -354,14 +359,14 @@ impl Context { unsafe { InitializeSecurityContextW( &raw const self.cred, - ctxt, + ph_context, self.target.as_ptr(), flags, 0, - SECURITY_NATIVE_DREP, + 0, &raw mut in_desc, 0, - &raw mut self.ctxt, + ph_new_context, &raw mut out_desc, &raw mut attrs, &raw mut expiry, @@ -370,8 +375,10 @@ impl Context { }; self.have_ctxt = true; + let mut out_len = 0u32; if !out_buf.pvBuffer.is_null() { if out_buf.cbBuffer != 0 { + out_len = out_buf.cbBuffer; let token = unsafe { slice::from_raw_parts(out_buf.pvBuffer.cast::(), out_buf.cbBuffer as usize) }; @@ -382,6 +389,12 @@ impl Context { } } + let extra = extra_len(&in_bufs); + log::trace!( + "schannel handshake status=0x{:08X} in={input_len} extra={extra} out={out_len} attrs={attrs}", + u32::from_ne_bytes(status.to_ne_bytes()) + ); + if status == SEC_E_INCOMPLETE_MESSAGE { return Ok(HandshakeState::NeedRead); } @@ -394,9 +407,7 @@ impl Context { return Err(sspi_error(ctx, status)); } - if let Some(src) = input { - consume_extra(src, input_len, extra_len(&in_bufs)); - } + consume_extra(&mut self.enc_buf, input_len, extra); if status == SEC_E_OK { self.query_stream_sizes()?; @@ -482,18 +493,18 @@ impl Context { Ok(len) } - fn decrypt(&mut self, src: &mut BytesMut, dst: &mut BytesMut) -> io::Result { - if src.is_empty() { + fn decrypt(&mut self, dst: &mut BytesMut) -> io::Result { + if self.enc_buf.is_empty() { return Ok(DecryptStatus::Incomplete); } - let input_len = src.len(); + let input_len = self.enc_buf.len(); let mut bufs = [ SecBuffer { cbBuffer: u32::try_from(input_len) .map_err(|_| io::Error::other("TLS input buffer is too large"))?, BufferType: SECBUFFER_DATA, - pvBuffer: src.as_mut_ptr().cast(), + pvBuffer: self.enc_buf.as_mut_ptr().cast(), }, SecBuffer { cbBuffer: 0, @@ -524,12 +535,12 @@ impl Context { SEC_E_OK => {} SEC_E_INCOMPLETE_MESSAGE => return Ok(DecryptStatus::Incomplete), SEC_I_CONTEXT_EXPIRED => { - consume_extra(src, input_len, extra_len(&bufs)); + consume_extra(&mut self.enc_buf, input_len, extra_len(&bufs)); return Ok(DecryptStatus::Closed); } SEC_I_RENEGOTIATE => { copy_data(&bufs, dst); - consume_extra(src, input_len, extra_len(&bufs)); + consume_extra(&mut self.enc_buf, input_len, extra_len(&bufs)); self.renegotiating = true; return Ok(DecryptStatus::Renegotiate); } @@ -537,7 +548,7 @@ impl Context { } copy_data(&bufs, dst); - consume_extra(src, input_len, extra_len(&bufs)); + consume_extra(&mut self.enc_buf, input_len, extra_len(&bufs)); Ok(DecryptStatus::Progress) } @@ -563,7 +574,7 @@ impl Context { return Err(sspi_error("ApplyControlToken", status)); } - let _ = self.handshake_step(None, output)?; + let _ = self.handshake_step(output)?; Ok(()) } @@ -664,31 +675,29 @@ impl FilterLayer for SchannelFilter { fn process_read_buf(&self, rb: &FilterBuf<'_>) -> io::Result<()> { loop { let mut inner = self.inner.borrow_mut(); + rb.with_read_src(|src| inner.ctx.pull_encrypted(src.as_mut())); + if inner.state == State::Handshaking { - let state = rb.with_write_buffers(|_, dst| { - rb.with_read_src(|src| inner.ctx.handshake(src.as_mut(), dst)) - })?; + let state = rb.with_write_buffers(|_, dst| inner.ctx.handshake(dst))?; if state == HandshakeState::NeedRead { return Ok(()); } inner.state = State::Streaming; } - let renegotiate = rb.with_read_buffers(|r_src, r_dst| -> io::Result { - if let Some(src) = r_src { - loop { - match inner.ctx.decrypt(src, r_dst)? { - DecryptStatus::Incomplete => break, - DecryptStatus::Closed => { - rb.io().close(); - break; - } - DecryptStatus::Renegotiate => { - return Ok(true); - } - DecryptStatus::Progress if src.is_empty() => break, - DecryptStatus::Progress => {} + let renegotiate = rb.with_read_buffers(|_, r_dst| -> io::Result { + loop { + match inner.ctx.decrypt(r_dst)? { + DecryptStatus::Incomplete => break, + DecryptStatus::Closed => { + rb.io().close(); + break; + } + DecryptStatus::Renegotiate => { + return Ok(true); } + DecryptStatus::Progress if inner.ctx.enc_buf.is_empty() => break, + DecryptStatus::Progress => {} } } Ok(false) @@ -725,7 +734,7 @@ impl SchannelFilter { fn start_handshake(&self, buf: &FilterBuf<'_>) -> io::Result { let mut inner = self.inner.borrow_mut(); buf.with_write_buffers(|_, dst| { - let state = inner.ctx.handshake(None, dst)?; + let state = inner.ctx.handshake(dst)?; if state == HandshakeState::Done { inner.state = State::Streaming; } diff --git a/ntex/tests/http_client_schannel.rs b/ntex/tests/http_client_schannel.rs index 96333a2ee..1d87c7f01 100644 --- a/ntex/tests/http_client_schannel.rs +++ b/ntex/tests/http_client_schannel.rs @@ -61,3 +61,22 @@ async fn test_schannel_public_https() { let body = response.body().await.unwrap(); assert!(!body.is_empty()); } + +#[ntex::test] +async fn test_schannel_bing_https() { + let tls = TlsConnector::>::new(); + let client = Client::builder().secure_connector(tls).build( + ntex::client::ClientConfig::new() + .disable_timeout() + .set_response_payload_limit(usize::MAX), + ); + + let response = client + .get("https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN") + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + let body = response.body().await.unwrap(); + assert!(!body.is_empty()); +} From abb2fe3ca0606593c13dd870b534aedbeb07e090 Mon Sep 17 00:00:00 2001 From: emmettlu Date: Wed, 2 Sep 2026 18:47:03 +0800 Subject: [PATCH 3/3] Fix rustls connect tests failing without Service in scope TlsAcceptor::map_err comes from ntex::service::Service. Dropping that import broke every CI job that compiles ntex tests with rustls. Restore ntex-tls's workspace ntex dev-dependency with openssl/rustls so examples keep compiling. --- ntex-tls/Cargo.toml | 2 +- ntex/tests/connect.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ntex-tls/Cargo.toml b/ntex-tls/Cargo.toml index fdc957ac5..a1387ecbb 100644 --- a/ntex-tls/Cargo.toml +++ b/ntex-tls/Cargo.toml @@ -58,7 +58,7 @@ windows-sys = { workspace = true, optional = true, features = [ ] } [dev-dependencies] -ntex = { version = "4.0.0-beta.2", default-features = false } +ntex = { workspace = true, features = ["openssl", "rustls"] } env_logger = { workspace = true } rustls-pemfile = { workspace = true } webpki-roots = { workspace = true } diff --git a/ntex/tests/connect.rs b/ntex/tests/connect.rs index a82d8de09..1f90ccdb3 100644 --- a/ntex/tests/connect.rs +++ b/ntex/tests/connect.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use ntex::io::{Io, types::PeerAddr}; #[cfg(feature = "openssl")] use ntex::server::build_test_server; -use ntex::service::{Pipeline, cfg::SharedCfg, svc}; +use ntex::service::{Pipeline, Service, cfg::SharedCfg, svc}; use ntex::{codec::BytesCodec, connect::Connect}; use ntex::{server::test_server, time, util::Bytes};