From cdab7d676126358cc1c129cb2e2c613b511fde64 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Thu, 3 Sep 2026 17:09:27 +0200 Subject: [PATCH 01/18] Skip peer certificate revalidation on resumed TLS sessions by default On a resumed (abbreviated) TLS handshake the peer does not resend its certificate; its identity was established during the original full handshake. Common TLS stacks (OpenSSL, SChannel) do not re-run certificate verification on resumption. Match that behavior: by default SslStream now adopts the cached peer certificate without rebuilding the chain or invoking the user validation callback on a resumed session. Add the opt-in System.Net.Security.RevalidateCertificateOnTlsResume AppContext switch (env DOTNET_SYSTEM_NET_SECURITY_REVALIDATECERTIFICATEONTLSRESUME) to restore the previous revalidate-on-resume behavior. Wire up TlsResumed detection across platforms: - Windows (SChannel) / Linux (OpenSSL): un-guard the existing TlsResumed computation from DEBUG-only builds. - Apple SecureTransport: new AppleCryptoNative_SslGetSessionResumed export using SSLGetResumableSessionInfo, plumbed through Interop.Ssl and SslConnectionInfo.OSX. - Network Framework and Android expose no reliable resumption signal and keep the safe fallback of always revalidating (documented in code). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../Interop.Ssl.cs | 3 +++ .../Net/Security/LocalAppContextSwitches.cs | 12 +++++++++++ .../Net/Security/SslConnectionInfo.Android.cs | 6 ++++++ .../Net/Security/SslConnectionInfo.Linux.cs | 2 -- .../Net/Security/SslConnectionInfo.OSX.cs | 15 +++++++++++++ .../Net/Security/SslConnectionInfo.Windows.cs | 2 -- .../System/Net/Security/SslConnectionInfo.cs | 6 ++++-- .../System/Net/Security/SslStream.Protocol.cs | 21 +++++++++++++++++++ .../pal_ssl.c | 20 ++++++++++++++++++ .../pal_ssl.h | 11 ++++++++++ 10 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs index d63a79c660fe3a..755434c3a39361 100644 --- a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs @@ -165,6 +165,9 @@ internal static unsafe partial int SslSetIoCallbacks( [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslGetProtocolVersion")] internal static partial int SslGetProtocolVersion(SafeSslHandle sslHandle, out SslProtocols protocol); + [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslGetSessionResumed")] + internal static partial int SslGetSessionResumed(SafeSslHandle sslHandle, out int sessionResumed); + [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslSetEnabledCipherSuites")] internal static unsafe partial int SslSetEnabledCipherSuites(SafeSslHandle sslHandle, uint* cipherSuites, int numCipherSuites); diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs index 96a06e41fb0b4b..182db24d946c91 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs @@ -20,6 +20,18 @@ internal static bool DisableTlsResume get => GetCachedSwitchValue("System.Net.Security.DisableTlsResume", "DOTNET_SYSTEM_NET_SECURITY_DISABLETLSRESUME", ref s_disableTlsResume); } + // By default the peer certificate is not re-validated on a resumed (abbreviated) TLS + // handshake, matching the behavior of common TLS stacks (e.g. OpenSSL, SChannel) that + // do not re-run certificate verification when a session is resumed. Enabling this + // switch restores the previous behavior of re-validating the peer certificate (running + // the chain build and the user validation callback) on every successful resumption. + private static int s_revalidateCertificateOnTlsResume; + internal static bool RevalidateCertificateOnTlsResume + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => GetCachedSwitchValue("System.Net.Security.RevalidateCertificateOnTlsResume", "DOTNET_SYSTEM_NET_SECURITY_REVALIDATECERTIFICATEONTLSRESUME", ref s_revalidateCertificateOnTlsResume); + } + private static int s_captureClientHello; internal static bool CaptureClientHello { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Android.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Android.cs index 5445860ff5eec3..934b8c38afcfe8 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Android.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Android.cs @@ -31,6 +31,12 @@ public void UpdateSslConnectionInfo(SafeSslHandle sslContext) // Enum value names should match the cipher suite name, so we just parse the string cipherSuite = Interop.AndroidCrypto.SSLStreamGetCipherSuite(sslContext); MapCipherSuite(Enum.Parse(cipherSuite)); + + // The Java/Conscrypt SSLEngine API does not expose a reliable signal for whether the + // TLS session was resumed (SSLSession has no isReused()). The only heuristic would be + // caching and comparing SSLSession.getId() across the handshake, which is fragile. + // Leave TlsResumed as false so that the peer certificate is always revalidated on this + // backend, matching the safe fallback. } } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Linux.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Linux.cs index a22a85292ab5d6..f5ffac5b471fee 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Linux.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Linux.cs @@ -28,9 +28,7 @@ public void UpdateSslConnectionInfo(SafeSslHandle sslContext) { ApplicationProtocol = alpn.ToArray(); } -#if DEBUG TlsResumed = Interop.Ssl.SslSessionReused(sslContext); -#endif MapCipherSuite(SslGetCurrentCipherSuite(sslContext)); } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs index 7ad816d4f3c9a8..257268b9f0ceca 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs @@ -57,6 +57,12 @@ private unsafe void UpdateSslConnectionInfoNetworkFramework(SafeDeleteNwContext Protocol = (int)protocol; TlsCipherSuite = cipherSuite; MapCipherSuite(cipherSuite); + + // Network Framework does not expose a public API to determine whether the TLS + // session was resumed (sec_protocol_metadata only reports the negotiated protocol, + // cipher suite and ALPN; sec_protocol_metadata_get_early_data_accepted covers TLS 1.3 + // 0-RTT only, not general resumption). Leave TlsResumed as false so that the peer + // certificate is always revalidated on this backend, matching the safe fallback. } private void UpdateSslConnectionInfoAppleCrypto(SafeDeleteSslContext context) @@ -77,6 +83,15 @@ private void UpdateSslConnectionInfoAppleCrypto(SafeDeleteSslContext context) Protocol = (int)protocol; TlsCipherSuite = cipherSuite; + + // SecureTransport reliably reports whether the session was resumed via an + // abbreviated handshake. Treat any failure as "not resumed" so that we fall back + // to the safe behavior of revalidating the peer certificate. + if (Interop.AppleCrypto.SslGetSessionResumed(sslContext, out int sessionResumed) == 0) + { + TlsResumed = sessionResumed != 0; + } + if (context.IsServer) { if (context.SelectedApplicationProtocol.Protocol.Length > 0) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs index 0558485b6c4aa1..8970d9b8316b29 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs @@ -79,7 +79,6 @@ public void UpdateSslConnectionInfo(SafeDeleteContext securityContext) ApplicationProtocol = GetNegotiatedApplicationProtocol(securityContext); } -#if DEBUG SecPkgContext_SessionInfo info = default; TlsResumed = SSPIWrapper.QueryBlittableContextAttributes( GlobalSSPI.SSPISecureChannel, @@ -87,7 +86,6 @@ public void UpdateSslConnectionInfo(SafeDeleteContext securityContext) Interop.SspiCli.ContextAttribute.SECPKG_ATTR_SESSION_INFO, ref info) && ((SecPkgContext_SessionInfo.Flags)info.dwFlags).HasFlag(SecPkgContext_SessionInfo.Flags.SSL_SESSION_RECONNECT); -#endif } } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.cs index 0407cb71011fea..e516297d0a024d 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.cs @@ -21,8 +21,10 @@ internal partial struct SslConnectionInfo public int KeyExchKeySize { get; private set; } public byte[]? ApplicationProtocol { get; internal set; } -#if DEBUG + + // Indicates whether the current TLS session was resumed (abbreviated handshake) + // rather than negotiated via a full handshake. Used to decide whether the peer + // certificate needs to be (re)validated on this connection. public bool TlsResumed { get; private set; } -#endif } } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index addf0f6d921e14..6da73be41a5e44 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -1232,6 +1232,27 @@ internal static bool VerifyRemoteCertificateCore( return true; } + if (certificate != null && + connectionInfo.TlsResumed && + !LocalAppContextSwitches.RevalidateCertificateOnTlsResume) + { + // The TLS session was resumed via an abbreviated handshake. The peer did not + // send its certificate again; its identity was established and validated during + // the original full handshake that produced the session ticket / session id. + // Common TLS stacks (e.g. OpenSSL, SChannel) do not re-run certificate + // verification on resumption, so by default neither do we: adopt the cached peer + // certificate for the RemoteCertificate property but skip rebuilding the chain + // and invoking the user validation callback. Set the + // System.Net.Security.RevalidateCertificateOnTlsResume switch to opt back into + // re-validating the peer certificate on every resumption. + remoteCertificateSlot = certificate; + if (NetEventSource.Log.IsEnabled()) + { + NetEventSource.Info(sender, "Skipping remote certificate validation on resumed TLS session."); + } + return true; + } + // don't assign to remoteCertificateSlot yet, this prevents weird exceptions if SslStream is disposed in parallel with X509Chain building if (certificate == null) diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c index 88898efc9b7b64..53024cc2340296 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c @@ -561,6 +561,26 @@ int32_t AppleCryptoNative_SslGetCipherSuite(SSLContextRef sslContext, uint16_t* return status; } +int32_t AppleCryptoNative_SslGetSessionResumed(SSLContextRef sslContext, int32_t* pSessionResumed) +{ + if (pSessionResumed != NULL) + *pSessionResumed = 0; + + if (sslContext == NULL || pSessionResumed == NULL) + return errSecParam; + + Boolean sessionWasResumed = false; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + OSStatus status = SSLGetResumableSessionInfo(sslContext, &sessionWasResumed, NULL, NULL); +#pragma clang diagnostic pop + + if (status == noErr) + *pSessionResumed = sessionWasResumed ? 1 : 0; + + return status; +} + int32_t AppleCryptoNative_SslSetEnabledCipherSuites(SSLContextRef sslContext, const uint32_t* cipherSuites, int32_t numCipherSuites) { // Max numCipherSuites is 2^16 (all possible cipher suites) diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h index 6413674d664124..8780dcfd0cbce5 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h @@ -299,6 +299,17 @@ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 */ PALEXPORT int32_t AppleCryptoNative_SslGetCipherSuite(SSLContextRef sslContext, uint16_t* pCipherSuiteOut); +/* +Determine whether the current TLS session was resumed via an abbreviated handshake +(session ticket or session id reuse) rather than negotiated with a full handshake. + +Returns the output of SSLGetResumableSessionInfo. + +Output: +pSessionResumed: Receives 1 if the session was resumed, 0 otherwise. Set to 0 on error. +*/ +PALEXPORT int32_t AppleCryptoNative_SslGetSessionResumed(SSLContextRef sslContext, int32_t* pSessionResumed); + /* Sets enabled cipher suites for the current session. From 38787c49cac1bf7a9d7eb312e72e64b49a6480bc Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 09:24:18 +0200 Subject: [PATCH 02/18] Gate resume revalidation skip to initial handshake; drop unavailable Apple API Address review feedback and fix Apple CI build: - Only skip peer certificate revalidation on the *initial* resumed handshake. During renegotiation / TLS 1.3 post-handshake auth the peer can present a new certificate, which must always be validated. Thread an isInitialHandshake flag (SslStream passes !_isRenego; the TlsSession external-validation path passes false) into VerifyRemoteCertificateCore and gate the resumption shortcut on it. - Revert the SecureTransport TlsResumed wiring: SSLGetResumableSessionInfo has been removed from current Apple SDKs and fails to compile ("call to undeclared function") across all Apple targets. Apple now leaves TlsResumed unset, matching the Network Framework and Android safe fallback of always revalidating on resumption. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../Interop.Ssl.cs | 3 --- .../Net/Security/SslConnectionInfo.OSX.cs | 13 +++++------ .../System/Net/Security/SslStream.Protocol.cs | 22 +++++++++++++------ .../src/System/Net/Security/TlsSession.cs | 3 +++ .../pal_ssl.c | 20 ----------------- .../pal_ssl.h | 11 ---------- 6 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs index 755434c3a39361..d63a79c660fe3a 100644 --- a/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs +++ b/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Ssl.cs @@ -165,9 +165,6 @@ internal static unsafe partial int SslSetIoCallbacks( [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslGetProtocolVersion")] internal static partial int SslGetProtocolVersion(SafeSslHandle sslHandle, out SslProtocols protocol); - [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslGetSessionResumed")] - internal static partial int SslGetSessionResumed(SafeSslHandle sslHandle, out int sessionResumed); - [LibraryImport(Interop.Libraries.AppleCryptoNative, EntryPoint = "AppleCryptoNative_SslSetEnabledCipherSuites")] internal static unsafe partial int SslSetEnabledCipherSuites(SafeSslHandle sslHandle, uint* cipherSuites, int numCipherSuites); diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs index 257268b9f0ceca..924304f9dddf00 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.OSX.cs @@ -84,14 +84,11 @@ private void UpdateSslConnectionInfoAppleCrypto(SafeDeleteSslContext context) Protocol = (int)protocol; TlsCipherSuite = cipherSuite; - // SecureTransport reliably reports whether the session was resumed via an - // abbreviated handshake. Treat any failure as "not resumed" so that we fall back - // to the safe behavior of revalidating the peer certificate. - if (Interop.AppleCrypto.SslGetSessionResumed(sslContext, out int sessionResumed) == 0) - { - TlsResumed = sessionResumed != 0; - } - + // SecureTransport does not expose an API to determine whether the session was + // resumed that is still present in current Apple SDKs (SSLGetResumableSessionInfo has + // been removed), so TlsResumed is left as false here. As with the Network Framework + // and Android backends this means the peer certificate is always revalidated on + // resumption on this platform, matching the safe fallback. if (context.IsServer) { if (context.SelectedApplicationProtocol.Protocol.Length > 0) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 6da73be41a5e44..e003f172264e5d 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -1185,6 +1185,7 @@ internal bool VerifyRemoteCertificate( { return VerifyRemoteCertificateCore( this, + !_isRenego, _sslAuthenticationOptions, _securityContext, ref _remoteCertificate, @@ -1199,6 +1200,7 @@ internal bool VerifyRemoteCertificate( internal static bool VerifyRemoteCertificateCore( object sender, + bool isInitialHandshake, SslAuthenticationOptions sslAuthenticationOptions, #if TARGET_APPLE SafeDeleteContext? securityContext, @@ -1233,18 +1235,24 @@ internal static bool VerifyRemoteCertificateCore( } if (certificate != null && + isInitialHandshake && connectionInfo.TlsResumed && !LocalAppContextSwitches.RevalidateCertificateOnTlsResume) { - // The TLS session was resumed via an abbreviated handshake. The peer did not - // send its certificate again; its identity was established and validated during - // the original full handshake that produced the session ticket / session id. - // Common TLS stacks (e.g. OpenSSL, SChannel) do not re-run certificate - // verification on resumption, so by default neither do we: adopt the cached peer - // certificate for the RemoteCertificate property but skip rebuilding the chain - // and invoking the user validation callback. Set the + // The initial TLS handshake was a resumption via an abbreviated handshake. The + // peer did not send its certificate again; its identity was established and + // validated during the original full handshake that produced the session ticket + // / session id. Common TLS stacks (e.g. OpenSSL, SChannel) do not re-run + // certificate verification on resumption, so by default neither do we: adopt the + // cached peer certificate for the RemoteCertificate property but skip rebuilding + // the chain and invoking the user validation callback. Set the // System.Net.Security.RevalidateCertificateOnTlsResume switch to opt back into // re-validating the peer certificate on every resumption. + // + // This shortcut is gated on the initial handshake: during renegotiation or + // TLS 1.3 post-handshake authentication the peer can present a new certificate, + // which must always be validated (the identical-certificate case above is handled + // separately). remoteCertificateSlot = certificate; if (NetEventSource.Log.IsEnabled()) { diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index ad7eb4a5079fbc..dba8ef032fe936 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -359,6 +359,9 @@ public SslPolicyErrors AcceptWithDefaultValidation() // populated with the same instance. ok = SslStream.VerifyRemoteCertificateCore( this, + // The external certificate is being (re)validated after the handshake, so the + // resumption shortcut in VerifyRemoteCertificateCore must not apply here. + isInitialHandshake: false, _options, _securityContext, ref _remoteCertificate, diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c index 53024cc2340296..88898efc9b7b64 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.c @@ -561,26 +561,6 @@ int32_t AppleCryptoNative_SslGetCipherSuite(SSLContextRef sslContext, uint16_t* return status; } -int32_t AppleCryptoNative_SslGetSessionResumed(SSLContextRef sslContext, int32_t* pSessionResumed) -{ - if (pSessionResumed != NULL) - *pSessionResumed = 0; - - if (sslContext == NULL || pSessionResumed == NULL) - return errSecParam; - - Boolean sessionWasResumed = false; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - OSStatus status = SSLGetResumableSessionInfo(sslContext, &sessionWasResumed, NULL, NULL); -#pragma clang diagnostic pop - - if (status == noErr) - *pSessionResumed = sessionWasResumed ? 1 : 0; - - return status; -} - int32_t AppleCryptoNative_SslSetEnabledCipherSuites(SSLContextRef sslContext, const uint32_t* cipherSuites, int32_t numCipherSuites) { // Max numCipherSuites is 2^16 (all possible cipher suites) diff --git a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h index 8780dcfd0cbce5..6413674d664124 100644 --- a/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h +++ b/src/native/libs/System.Security.Cryptography.Native.Apple/pal_ssl.h @@ -299,17 +299,6 @@ TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 */ PALEXPORT int32_t AppleCryptoNative_SslGetCipherSuite(SSLContextRef sslContext, uint16_t* pCipherSuiteOut); -/* -Determine whether the current TLS session was resumed via an abbreviated handshake -(session ticket or session id reuse) rather than negotiated with a full handshake. - -Returns the output of SSLGetResumableSessionInfo. - -Output: -pSessionResumed: Receives 1 if the session was resumed, 0 otherwise. Set to 0 on error. -*/ -PALEXPORT int32_t AppleCryptoNative_SslGetSessionResumed(SSLContextRef sslContext, int32_t* pSessionResumed); - /* Sets enabled cipher suites for the current session. From 40d2bea1a020511ae6063ec38c58282149e0f599 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 10:02:32 +0200 Subject: [PATCH 03/18] Dispose peer intermediates on resumed handshake and add revalidate-switch tests On a resumed handshake the certificate validation callback is skipped by default, so the outer VerifyRemoteCertificate no longer had anything adopt the peer-sent intermediate certificates that GetRemoteCertificate appends to the chain's ExtraStore. Thread a certificateValidationSkippedOnResume flag out of VerifyRemoteCertificateCore so those intermediates are disposed even when a RemoteCertificateValidationCallback is configured, avoiding leaked X509Certificate2 handles across repeated resumptions. Add a RemoteExecutor-based test that verifies the client validation callback is not invoked on a resumed handshake by default and that setting RevalidateCertificateOnTlsResume restores callback invocation on resumption. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../System/Net/Security/SslStream.Protocol.cs | 30 ++++++- .../src/System/Net/Security/TlsSession.cs | 1 + .../SslStreamAllowTlsResumeTests.cs | 88 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index e003f172264e5d..9cc944f5e1084d 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -1138,12 +1138,26 @@ internal bool VerifyRemoteCertificate(SslCertificateTrust? trust, ref ProtocolTo int preexistingExtraCertsCount = _sslAuthenticationOptions.CertificateChainPolicy?.ExtraStore?.Count ?? 0; X509Chain? chain = null; + bool certificateValidationSkippedOnResume = false; try { X509Certificate2? certificate = CertificateValidationPal.GetRemoteCertificate(_securityContext, ref chain, _sslAuthenticationOptions.CertificateChainPolicy); - return VerifyRemoteCertificate(certificate, chain, trust, ref alertToken, ref sslPolicyErrors, out chainStatus); + return VerifyRemoteCertificateCore( + this, + !_isRenego, + _sslAuthenticationOptions, + _securityContext, + ref _remoteCertificate, + ref _connectionInfo, + certificate, + chain, + trust, + ref alertToken, + ref sslPolicyErrors, + out chainStatus, + out certificateValidationSkippedOnResume); } finally { @@ -1155,7 +1169,11 @@ internal bool VerifyRemoteCertificate(SslCertificateTrust? trust, ref ProtocolTo // Only cleanup certificates if no user callback was provided. // When a callback is provided, users might add their own certificates to ExtraStore // or keep references to certificates from ChainElements. - if (_sslAuthenticationOptions.CertValidationDelegate == null) + // On a resumed handshake we skip the callback entirely (see the resumption shortcut + // in VerifyRemoteCertificateCore), so nothing else adopts the peer-sent intermediates + // GetRemoteCertificate appended; dispose them here even when a callback is configured + // to avoid leaking X509Certificate2 handles across repeated resumptions. + if (_sslAuthenticationOptions.CertValidationDelegate == null || certificateValidationSkippedOnResume) { // Dispose only the certificates that were added by GetRemoteCertificate for (int i = preexistingExtraCertsCount; i < chain.ChainPolicy.ExtraStore.Count; i++) @@ -1195,7 +1213,8 @@ internal bool VerifyRemoteCertificate( trust, ref alertToken, ref sslPolicyErrors, - out chainStatus); + out chainStatus, + out _); } internal static bool VerifyRemoteCertificateCore( @@ -1214,9 +1233,11 @@ internal static bool VerifyRemoteCertificateCore( SslCertificateTrust? trust, ref ProtocolToken alertToken, ref SslPolicyErrors sslPolicyErrors, - out X509ChainStatusFlags chainStatus) + out X509ChainStatusFlags chainStatus, + out bool certificateValidationSkippedOnResume) { chainStatus = X509ChainStatusFlags.NoError; + certificateValidationSkippedOnResume = false; bool success = false; @@ -1254,6 +1275,7 @@ internal static bool VerifyRemoteCertificateCore( // which must always be validated (the identical-certificate case above is handled // separately). remoteCertificateSlot = certificate; + certificateValidationSkippedOnResume = true; if (NetEventSource.Log.IsEnabled()) { NetEventSource.Info(sender, "Skipping remote certificate validation on resumed TLS session."); diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs index dba8ef032fe936..806f1e42f47b70 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/TlsSession.cs @@ -371,6 +371,7 @@ public SslPolicyErrors AcceptWithDefaultValidation() trust: null, ref alertToken, ref sslPolicyErrors, + out _, out _); } finally diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 140a486a178bca..a7b29c887a60ba 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Diagnostics; using System.Reflection; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using System.Linq; +using Microsoft.DotNet.RemoteExecutor; using Xunit; using Microsoft.DotNet.XUnitExtensions; @@ -408,6 +410,92 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server await RunConnectionAsync(serverOptions, clientOptions, false); } } + + // By default the peer certificate is not re-validated on a resumed handshake, so the + // RemoteCertificateValidationCallback is not invoked. Setting the + // System.Net.Security.RevalidateCertificateOnTlsResume switch restores the previous + // behavior of running the callback on every resumption. Toggle the switch via its + // environment variable in a child process so the cached switch value is picked up. + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux)] + [InlineData(false)] + [InlineData(true)] + public async Task ResumedHandshake_RevalidateSwitch_ControlsClientCallback(bool revalidateOnResume) + { + var psi = new ProcessStartInfo(); + psi.Environment["DOTNET_SYSTEM_NET_SECURITY_REVALIDATECERTIFICATEONTLSRESUME"] = revalidateOnResume ? "1" : "0"; + + await RemoteExecutor.Invoke(async revalidateStr => + { + bool revalidate = bool.Parse(revalidateStr); + + FieldInfo connectionInfoField = typeof(SslStream).GetField("_connectionInfo", BindingFlags.Instance | BindingFlags.NonPublic); + PropertyInfo tlsResumedProperty = typeof(SslStream).Assembly + .GetType("System.Net.Security.SslConnectionInfo") + .GetProperty("TlsResumed"); + + bool IsResumed(SslStream ssl) => (bool)tlsResumedProperty.GetValue(connectionInfoField.GetValue(ssl)); + + int clientCallbackCount = 0; + var serverOptions = new SslServerAuthenticationOptions + { + EnabledSslProtocols = SslProtocols.Tls12, + ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), + }; + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = Guid.NewGuid().ToString("N"), + EnabledSslProtocols = SslProtocols.Tls12, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + { + clientCallbackCount++; + return true; + }, + }; + + async Task ConnectAsync() + { + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + { + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + + bool resumed = IsResumed(client); + + await client.ShutdownAsync(); + await server.ShutdownAsync(); + return resumed; + } + } + + // Prime the session cache with a full handshake; the callback always runs here. + Assert.False(await ConnectAsync()); + Assert.True(clientCallbackCount > 0, "Client validation callback was not invoked on the initial full handshake"); + + // Establish a resumed session and measure whether the client callback runs on it. + bool measuredResume = false; + for (int i = 0; i < 5 && !measuredResume; i++) + { + clientCallbackCount = 0; + measuredResume = await ConnectAsync(); + } + + Assert.True(measuredResume, "TLS session did not resume"); + + if (revalidate) + { + Assert.True(clientCallbackCount > 0, "Client validation callback should run on resumption when RevalidateCertificateOnTlsResume is set"); + } + else + { + Assert.True(clientCallbackCount == 0, "Client validation callback should not run on resumption by default"); + } + }, revalidateOnResume.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + } } } #endif From 9ecdac2f170c5f6933498b9746283d9c2caafdaf Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 10:21:16 +0200 Subject: [PATCH 04/18] Cover server-side callback and skip when resumption unavailable in revalidate test Parametrize the revalidate-switch resume test over client-side (server cert) and server-side (client cert) validation so the shared skip default is exercised in both directions. When the environment cannot establish session resumption, signal a skip to the parent process via a marker file instead of hard-failing, matching the SkipTestException pattern used elsewhere in this file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../SslStreamAllowTlsResumeTests.cs | 168 ++++++++++++------ 1 file changed, 111 insertions(+), 57 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index a7b29c887a60ba..734cf9f02468bd 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Reflection; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; @@ -414,87 +415,140 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server // By default the peer certificate is not re-validated on a resumed handshake, so the // RemoteCertificateValidationCallback is not invoked. Setting the // System.Net.Security.RevalidateCertificateOnTlsResume switch restores the previous - // behavior of running the callback on every resumption. Toggle the switch via its - // environment variable in a child process so the cached switch value is picked up. + // behavior of running the callback on every resumption. This applies to both the client + // (validating the server certificate) and the server (validating the client certificate). + // Toggle the switch via its environment variable in a child process so the cached switch + // value is picked up. [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux)] - [InlineData(false)] - [InlineData(true)] - public async Task ResumedHandshake_RevalidateSwitch_ControlsClientCallback(bool revalidateOnResume) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ResumedHandshake_RevalidateSwitch_ControlsCallback(bool revalidateOnResume, bool testServer) { + // The child process signals via this marker file that the environment could not + // establish session resumption, so the parent can treat the run as skipped rather + // than a failure (matching the SkipTestException pattern used elsewhere in this file). + string skipMarker = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var psi = new ProcessStartInfo(); psi.Environment["DOTNET_SYSTEM_NET_SECURITY_REVALIDATECERTIFICATEONTLSRESUME"] = revalidateOnResume ? "1" : "0"; + psi.Environment["SSLSTREAM_RESUME_SKIP_MARKER"] = skipMarker; - await RemoteExecutor.Invoke(async revalidateStr => + try { - bool revalidate = bool.Parse(revalidateStr); + await RemoteExecutor.Invoke(async (revalidateStr, testServerStr) => + { + bool revalidate = bool.Parse(revalidateStr); + bool onServer = bool.Parse(testServerStr); - FieldInfo connectionInfoField = typeof(SslStream).GetField("_connectionInfo", BindingFlags.Instance | BindingFlags.NonPublic); - PropertyInfo tlsResumedProperty = typeof(SslStream).Assembly - .GetType("System.Net.Security.SslConnectionInfo") - .GetProperty("TlsResumed"); + FieldInfo connectionInfoField = typeof(SslStream).GetField("_connectionInfo", BindingFlags.Instance | BindingFlags.NonPublic); + PropertyInfo tlsResumedProperty = typeof(SslStream).Assembly + .GetType("System.Net.Security.SslConnectionInfo") + .GetProperty("TlsResumed"); - bool IsResumed(SslStream ssl) => (bool)tlsResumedProperty.GetValue(connectionInfoField.GetValue(ssl)); + bool IsResumed(SslStream ssl) => (bool)tlsResumedProperty.GetValue(connectionInfoField.GetValue(ssl)); - int clientCallbackCount = 0; - var serverOptions = new SslServerAuthenticationOptions - { - EnabledSslProtocols = SslProtocols.Tls12, - ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), - }; - var clientOptions = new SslClientAuthenticationOptions - { - TargetHost = Guid.NewGuid().ToString("N"), - EnabledSslProtocols = SslProtocols.Tls12, - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + int clientCallbackCount = 0; + int serverCallbackCount = 0; + + var serverOptions = new SslServerAuthenticationOptions { - clientCallbackCount++; - return true; - }, - }; + EnabledSslProtocols = SslProtocols.Tls12, + ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), + ClientCertificateRequired = onServer, + RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + { + serverCallbackCount++; + return true; + }, + }; + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = Guid.NewGuid().ToString("N"), + EnabledSslProtocols = SslProtocols.Tls12, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + { + clientCallbackCount++; + return true; + }, + }; + + if (onServer) + { + clientOptions.ClientCertificates = new X509CertificateCollection() { Configuration.Certificates.GetClientCertificate() }; + } - async Task ConnectAsync() - { - (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); - using (client) - using (server) + // The callback under test is the one that validates the peer certificate on the + // side identified by 'onServer'. + int MeasuredCount() => onServer ? serverCallbackCount : clientCallbackCount; + void ResetMeasuredCount() + { + clientCallbackCount = 0; + serverCallbackCount = 0; + } + + async Task ConnectAsync() { - await TestConfiguration.WhenAllOrAnyFailedWithTimeout( - client.AuthenticateAsClientAsync(clientOptions), - server.AuthenticateAsServerAsync(serverOptions)); + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + { + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); - bool resumed = IsResumed(client); + bool resumed = IsResumed(client); - await client.ShutdownAsync(); - await server.ShutdownAsync(); - return resumed; + await client.ShutdownAsync(); + await server.ShutdownAsync(); + return resumed; + } } - } - // Prime the session cache with a full handshake; the callback always runs here. - Assert.False(await ConnectAsync()); - Assert.True(clientCallbackCount > 0, "Client validation callback was not invoked on the initial full handshake"); + // Prime the session cache with a full handshake; the callback always runs here. + Assert.False(await ConnectAsync()); + Assert.True(MeasuredCount() > 0, "Validation callback was not invoked on the initial full handshake"); - // Establish a resumed session and measure whether the client callback runs on it. - bool measuredResume = false; - for (int i = 0; i < 5 && !measuredResume; i++) - { - clientCallbackCount = 0; - measuredResume = await ConnectAsync(); - } + // Establish a resumed session and measure whether the callback runs on it. + bool measuredResume = false; + for (int i = 0; i < 5 && !measuredResume; i++) + { + ResetMeasuredCount(); + measuredResume = await ConnectAsync(); + } - Assert.True(measuredResume, "TLS session did not resume"); + if (!measuredResume) + { + // The environment could not establish resumption; signal a skip to the parent. + File.WriteAllText(Environment.GetEnvironmentVariable("SSLSTREAM_RESUME_SKIP_MARKER"), "skip"); + return; + } - if (revalidate) + if (revalidate) + { + Assert.True(MeasuredCount() > 0, "Validation callback should run on resumption when RevalidateCertificateOnTlsResume is set"); + } + else + { + Assert.True(MeasuredCount() == 0, "Validation callback should not run on resumption by default"); + } + }, revalidateOnResume.ToString(), testServer.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + if (File.Exists(skipMarker)) { - Assert.True(clientCallbackCount > 0, "Client validation callback should run on resumption when RevalidateCertificateOnTlsResume is set"); + throw new SkipTestException("Unable to establish TLS session resumption"); } - else + } + finally + { + if (File.Exists(skipMarker)) { - Assert.True(clientCallbackCount == 0, "Client validation callback should not run on resumption by default"); + File.Delete(skipMarker); } - }, revalidateOnResume.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + } } } } From f4d4f64e4133a11fecc2b41cac14a061ae40bbe1 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 10:33:24 +0200 Subject: [PATCH 05/18] Assert reflection lookups resolve in resume revalidate test Add explicit assertions after the field/type/property reflection lookups so a future rename of the internal members produces a clear diagnostic instead of a NullReferenceException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../FunctionalTests/SslStreamAllowTlsResumeTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 734cf9f02468bd..6fce03097a4a45 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -444,9 +444,13 @@ await RemoteExecutor.Invoke(async (revalidateStr, testServerStr) => bool onServer = bool.Parse(testServerStr); FieldInfo connectionInfoField = typeof(SslStream).GetField("_connectionInfo", BindingFlags.Instance | BindingFlags.NonPublic); - PropertyInfo tlsResumedProperty = typeof(SslStream).Assembly - .GetType("System.Net.Security.SslConnectionInfo") - .GetProperty("TlsResumed"); + Assert.True(connectionInfoField != null, "Could not find the SslStream._connectionInfo field via reflection"); + + Type connectionInfoType = typeof(SslStream).Assembly.GetType("System.Net.Security.SslConnectionInfo"); + Assert.True(connectionInfoType != null, "Could not find the System.Net.Security.SslConnectionInfo type via reflection"); + + PropertyInfo tlsResumedProperty = connectionInfoType.GetProperty("TlsResumed"); + Assert.True(tlsResumedProperty != null, "Could not find the SslConnectionInfo.TlsResumed property via reflection"); bool IsResumed(SslStream ssl) => (bool)tlsResumedProperty.GetValue(connectionInfoField.GetValue(ssl)); From 92776e8c43dda76a6d33730c96fe15c11f423a2e Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 10:40:58 +0200 Subject: [PATCH 06/18] Avoid Enum.HasFlag boxing when detecting TLS resumption on Windows TlsResumed is now evaluated on every handshake in Release builds, so replace the boxing Enum.HasFlag call with a direct bit-test against SSL_SESSION_RECONNECT to avoid the per-handshake allocation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../src/System/Net/Security/SslConnectionInfo.Windows.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs index 8970d9b8316b29..87d820d10642bf 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslConnectionInfo.Windows.cs @@ -85,7 +85,7 @@ public void UpdateSslConnectionInfo(SafeDeleteContext securityContext) securityContext, Interop.SspiCli.ContextAttribute.SECPKG_ATTR_SESSION_INFO, ref info) && - ((SecPkgContext_SessionInfo.Flags)info.dwFlags).HasFlag(SecPkgContext_SessionInfo.Flags.SSL_SESSION_RECONNECT); + (info.dwFlags & (uint)SecPkgContext_SessionInfo.Flags.SSL_SESSION_RECONNECT) != 0; } } } From b9c803815cfcbe9318da416aaec6ea78c1aa8846 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 10:45:10 +0200 Subject: [PATCH 07/18] Clarify resume-revalidation switch comment is platform-dependent Session resumption is only detected on Windows and Linux; on other platforms TlsResumed stays false and the peer certificate is always re-validated. Note this in the switch comment so the default behavior is not read as universal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../src/System/Net/Security/LocalAppContextSwitches.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs index 182db24d946c91..82477091d8cc58 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/LocalAppContextSwitches.cs @@ -22,7 +22,9 @@ internal static bool DisableTlsResume // By default the peer certificate is not re-validated on a resumed (abbreviated) TLS // handshake, matching the behavior of common TLS stacks (e.g. OpenSSL, SChannel) that - // do not re-run certificate verification when a session is resumed. Enabling this + // do not re-run certificate verification when a session is resumed. This optimization + // only applies on platforms where session resumption can be detected (currently Windows + // and Linux); elsewhere the peer certificate is always re-validated. Enabling this // switch restores the previous behavior of re-validating the peer certificate (running // the chain build and the user validation callback) on every successful resumption. private static int s_revalidateCertificateOnTlsResume; From 320e6e15aa3e65ed60e2cf390ac61213b3ab840c Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 11:00:28 +0200 Subject: [PATCH 08/18] Run resume tests in all configs and harden skip-marker path TlsResumed is no longer DEBUG-only, so drop the file-level #if DEBUG guard so the resumption/revalidation tests are also exercised in Release. Reserve a unique skip-marker path via Path.GetTempFileName() (deleted up-front) so the child creates it only to signal a skip, avoiding the GetRandomFileName collision case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../SslStreamAllowTlsResumeTests.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 6fce03097a4a45..fa4ec6264053ae 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -15,7 +15,6 @@ using System.Net.Test.Common; -#if DEBUG namespace System.Net.Security.Tests { using Configuration = System.Net.Test.Common.Configuration; @@ -33,7 +32,8 @@ public class SslStreamTlsResumeTests private bool CheckResumeFlag(SslStream ssl) { - // This works only on Debug build where SslStream has extra property so we can validate. + // SslConnectionInfo.TlsResumed is an internal property we read via reflection to + // validate whether the handshake resumed a previous session. object info = connectionInfo.GetValue(ssl); return (bool)tlsResumed.GetValue(info); } @@ -427,10 +427,13 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server [InlineData(true, true)] public async Task ResumedHandshake_RevalidateSwitch_ControlsCallback(bool revalidateOnResume, bool testServer) { - // The child process signals via this marker file that the environment could not - // establish session resumption, so the parent can treat the run as skipped rather - // than a failure (matching the SkipTestException pattern used elsewhere in this file). - string skipMarker = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + // Reserve a unique temp path for the skip marker and remove it up-front, so the + // marker exists only if the child process explicitly creates it to signal that the + // environment could not establish session resumption. In that case the parent treats + // the run as skipped rather than a failure (matching the SkipTestException pattern + // used elsewhere in this file). + string skipMarker = Path.GetTempFileName(); + File.Delete(skipMarker); var psi = new ProcessStartInfo(); psi.Environment["DOTNET_SYSTEM_NET_SECURITY_REVALIDATECERTIFICATEONTLSRESUME"] = revalidateOnResume ? "1" : "0"; @@ -556,4 +559,3 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( } } } -#endif From 8601ffece523a236fae4a3bb2aba7829d17212ef Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 11:14:22 +0200 Subject: [PATCH 09/18] Assert CheckResumeFlag reflection lookups resolve Add explicit assertions so the static field/property reflection lookups used by CheckResumeFlag produce a clear diagnostic instead of a NullReferenceException if the internals are renamed or removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index fa4ec6264053ae..98e98d1031ba45 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -32,6 +32,9 @@ public class SslStreamTlsResumeTests private bool CheckResumeFlag(SslStream ssl) { + Assert.True(connectionInfo != null, "Could not find the SslStream._connectionInfo field via reflection"); + Assert.True(tlsResumed != null, "Could not find the SslConnectionInfo.TlsResumed property via reflection"); + // SslConnectionInfo.TlsResumed is an internal property we read via reflection to // validate whether the handshake resumed a previous session. object info = connectionInfo.GetValue(ssl); From 2fd6625a18f63d27b899d03cab26953b8b4dde85 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 4 Sep 2026 11:26:37 +0200 Subject: [PATCH 10/18] Make tlsResumed reflection lookup null-safe in resume tests The static initializer chained GetProperty on the result of GetType, which would throw a TypeInitializationException before the asserts in CheckResumeFlag could run if the type name ever changed. Use ?. so the field becomes null and the existing Assert.True gives a clear diagnostic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 98e98d1031ba45..c58365c8395a41 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -28,7 +28,7 @@ public class SslStreamTlsResumeTests private static PropertyInfo tlsResumed = typeof(SslStream).Assembly.GetType("System.Net.Security.SslConnectionInfo") - .GetProperty("TlsResumed"); + ?.GetProperty("TlsResumed"); private bool CheckResumeFlag(SslStream ssl) { From c4bc11f5646d7ebd4d293c197a518132e07b0ccb Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Sat, 5 Sep 2026 00:04:48 +0200 Subject: [PATCH 11/18] Parametrize resume revalidation test over supported TLS protocols and assert server resume flag Address review feedback on the resume revalidation test: - Enumerate supported protocols dynamically (TLS 1.2 + TLS 1.3) via SslProtocolSupport.EnumerateSupportedProtocols instead of hardcoding TLS 1.2, so the test keeps covering the negotiated versions as older ones are retired. - Assert that the server observes the same resumption state as the client (IsResumed(server) == IsResumed(client)). - PingPong after the handshake so the client consumes the post-handshake TLS 1.3 session ticket, which is required to actually exercise TLS 1.3 resumption. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../SslStreamAllowTlsResumeTests.cs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index c58365c8395a41..ed583f8e3925b2 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -415,6 +415,16 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server } } + public static IEnumerable RevalidateSwitchData() + { + foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, true)) + foreach (bool revalidateOnResume in new[] { true, false }) + foreach (bool testServer in new[] { true, false }) + { + yield return new object[] { protocol, revalidateOnResume, testServer }; + } + } + // By default the peer certificate is not re-validated on a resumed handshake, so the // RemoteCertificateValidationCallback is not invoked. Setting the // System.Net.Security.RevalidateCertificateOnTlsResume switch restores the previous @@ -424,11 +434,8 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server // value is picked up. [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux)] - [InlineData(false, false)] - [InlineData(true, false)] - [InlineData(false, true)] - [InlineData(true, true)] - public async Task ResumedHandshake_RevalidateSwitch_ControlsCallback(bool revalidateOnResume, bool testServer) + [MemberData(nameof(RevalidateSwitchData))] + public async Task ResumedHandshake_RevalidateSwitch_ControlsCallback(SslProtocols protocol, bool revalidateOnResume, bool testServer) { // Reserve a unique temp path for the skip marker and remove it up-front, so the // marker exists only if the child process explicitly creates it to signal that the @@ -444,8 +451,9 @@ public async Task ResumedHandshake_RevalidateSwitch_ControlsCallback(bool revali try { - await RemoteExecutor.Invoke(async (revalidateStr, testServerStr) => + await RemoteExecutor.Invoke(async (protocolStr, revalidateStr, testServerStr) => { + SslProtocols protocol = Enum.Parse(protocolStr); bool revalidate = bool.Parse(revalidateStr); bool onServer = bool.Parse(testServerStr); @@ -465,7 +473,7 @@ await RemoteExecutor.Invoke(async (revalidateStr, testServerStr) => var serverOptions = new SslServerAuthenticationOptions { - EnabledSslProtocols = SslProtocols.Tls12, + EnabledSslProtocols = protocol, ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), ClientCertificateRequired = onServer, RemoteCertificateValidationCallback = (sender, cert, chain, errors) => @@ -477,7 +485,7 @@ await RemoteExecutor.Invoke(async (revalidateStr, testServerStr) => var clientOptions = new SslClientAuthenticationOptions { TargetHost = Guid.NewGuid().ToString("N"), - EnabledSslProtocols = SslProtocols.Tls12, + EnabledSslProtocols = protocol, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, RemoteCertificateValidationCallback = (sender, cert, chain, errors) => { @@ -511,6 +519,12 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( server.AuthenticateAsServerAsync(serverOptions)); bool resumed = IsResumed(client); + Assert.Equal(resumed, IsResumed(server)); + + // Exchange application data so the client consumes any post-handshake + // session ticket (TLS 1.3 delivers tickets as application data after the + // handshake), which primes the next connection for resumption. + await TestHelper.PingPong(client, server); await client.ShutdownAsync(); await server.ShutdownAsync(); @@ -545,7 +559,7 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( { Assert.True(MeasuredCount() == 0, "Validation callback should not run on resumption by default"); } - }, revalidateOnResume.ToString(), testServer.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + }, protocol.ToString(), revalidateOnResume.ToString(), testServer.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); if (File.Exists(skipMarker)) { From 8c82aa639267a12733cce57d723445bbdc05500d Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Fri, 18 Sep 2026 12:11:18 +0200 Subject: [PATCH 12/18] Fix HttpConnectionPool Partitioning tests --- .../tests/FunctionalTests/SocketsHttpHandlerTest.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 66088cb47fa4b3..a1c5c1d4bcc286 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -5483,7 +5483,10 @@ await LoopbackServerFactory.CreateClientAndServerAsync( private static void ConfigureSniCallback(HttpClientHandler handler, List sniValues) { - GetUnderlyingSocketsHttpHandler(handler).SslOptions.RemoteCertificateValidationCallback = (sender, _, _, _) => + SslClientAuthenticationOptions sslOptions = GetUnderlyingSocketsHttpHandler(handler).SslOptions; + // Disable TLS session resumption so that cert validation callback fires for every handshake. + sslOptions.AllowTlsResume = false; + sslOptions.RemoteCertificateValidationCallback = (sender, _, _, _) => { string sni = sender switch { From 0ab9cf94c07d6588cadecbaf120653009a1505d0 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Tue, 22 Sep 2026 15:03:26 +0200 Subject: [PATCH 13/18] Strengthen TLS resume callback validation Assert exact client and server certificate validation callback counts across full and resumed handshakes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../SslStreamAllowTlsResumeTests.cs | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index ed583f8e3925b2..4aaf4df006e4ab 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -476,11 +476,6 @@ await RemoteExecutor.Invoke(async (protocolStr, revalidateStr, testServerStr) => EnabledSslProtocols = protocol, ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), ClientCertificateRequired = onServer, - RemoteCertificateValidationCallback = (sender, cert, chain, errors) => - { - serverCallbackCount++; - return true; - }, }; var clientOptions = new SslClientAuthenticationOptions { @@ -496,13 +491,15 @@ await RemoteExecutor.Invoke(async (protocolStr, revalidateStr, testServerStr) => if (onServer) { + serverOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) => + { + serverCallbackCount++; + return true; + }; clientOptions.ClientCertificates = new X509CertificateCollection() { Configuration.Certificates.GetClientCertificate() }; } - // The callback under test is the one that validates the peer certificate on the - // side identified by 'onServer'. - int MeasuredCount() => onServer ? serverCallbackCount : clientCallbackCount; - void ResetMeasuredCount() + void ResetCallbackCounts() { clientCallbackCount = 0; serverCallbackCount = 0; @@ -534,13 +531,14 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( // Prime the session cache with a full handshake; the callback always runs here. Assert.False(await ConnectAsync()); - Assert.True(MeasuredCount() > 0, "Validation callback was not invoked on the initial full handshake"); + Assert.Equal(1, clientCallbackCount); + Assert.Equal(onServer ? 1 : 0, serverCallbackCount); - // Establish a resumed session and measure whether the callback runs on it. + // Establish a resumed session and measure whether either callback runs on it. bool measuredResume = false; for (int i = 0; i < 5 && !measuredResume; i++) { - ResetMeasuredCount(); + ResetCallbackCounts(); measuredResume = await ConnectAsync(); } @@ -551,14 +549,10 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( return; } - if (revalidate) - { - Assert.True(MeasuredCount() > 0, "Validation callback should run on resumption when RevalidateCertificateOnTlsResume is set"); - } - else - { - Assert.True(MeasuredCount() == 0, "Validation callback should not run on resumption by default"); - } + int expectedClientCallbackCount = revalidate ? 1 : 0; + int expectedServerCallbackCount = revalidate && onServer ? 1 : 0; + Assert.Equal(expectedClientCallbackCount, clientCallbackCount); + Assert.Equal(expectedServerCallbackCount, serverCallbackCount); }, protocol.ToString(), revalidateOnResume.ToString(), testServer.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); if (File.Exists(skipMarker)) From 17dc14b05975d89c5fcccef00f6e9f605a9f0537 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 23 Sep 2026 10:56:50 +0200 Subject: [PATCH 14/18] Fix TLS callback tests with session resumption Disable resumption in tests that require certificate validation callbacks to run on every connection, and remove an unrelated client certificate from the changed-host resumption test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../tests/FunctionalTests/SocketsHttpHandlerTest.cs | 2 ++ .../System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs | 1 + .../tests/FunctionalTests/CertificateValidationClientServer.cs | 1 + .../tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs | 1 - 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index a1c5c1d4bcc286..21b42aa2c15656 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -807,6 +807,7 @@ await LoopbackServer.CreateClientAndServerAsync(async uri => using SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true); handler.Proxy = new UseSpecifiedUriWebProxy(uri, new NetworkCredential("abc", "password")); + handler.SslOptions.AllowTlsResume = false; handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, error) => { validationCalled = true; @@ -6444,6 +6445,7 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => using HttpClient client = CreateHttpClient(handler); GetUnderlyingSocketsHttpHandler(handler).SslOptions = new SslClientAuthenticationOptions() { + AllowTlsResume = false, RemoteCertificateValidationCallback = delegate { return false; }, }; using HttpRequestMessage message = new(HttpMethod.Get, uri) diff --git a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs index 262a01e06f7919..bc4084f45e62b4 100644 --- a/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs +++ b/src/libraries/System.Net.Mail/tests/Functional/SmtpClientTlsTest.cs @@ -144,6 +144,7 @@ public async Task DisableSslServerSupport_NoTls() [Fact] public async Task AuthenticationException_Propagates() { + Server.SslOptions.AllowTlsResume = false; _serverCertValidationCallback = (cert, chain, errors) => { return false; // force auth errors diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs index 9fd6d37bf289e9..6bd2a28a69f5ee 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/CertificateValidationClientServer.cs @@ -318,6 +318,7 @@ public async Task RemoteCertificateValidationCallback_ExtraStoreCertificates_Not SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions { TargetHost = "localhost", + AllowTlsResume = false, RemoteCertificateValidationCallback = (sender, cert, chain, errors) => { connectionCount++; diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 4aaf4df006e4ab..ce2b8537fc4fe7 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -312,7 +312,6 @@ public Task DifferentHost_NoResume(SslProtocols sslProtocol) EnabledSslProtocols = sslProtocol, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true, - ClientCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetClientCertificate(), null, false) }; return TestNoResumeAfterChange(serverOptions, clientOptions, From 646637bec3d81111771e408e29b58a658ae2e56f Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Tue, 29 Sep 2026 12:38:07 +0200 Subject: [PATCH 15/18] Validate certificates during peer TLS reauthentication Mark peer-initiated renegotiation and post-handshake authentication explicitly so resumed-session certificate validation is never skipped during reauthentication. Cover TLS 1.2 and TLS 1.3 state transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../src/System/Net/Security/SslStream.IO.cs | 2 + .../SslStreamAllowTlsResumeTests.cs | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs index bc79976d479d5b..6cee3c90d3efc0 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs @@ -159,12 +159,14 @@ private async Task ProcessAuthenticationWithTelemetryAsync(bool isAsync, Cancell private async Task ReplyOnReAuthenticationAsync(byte[]? buffer, CancellationToken cancellationToken) where TIOAdapter : IReadWriteAdapter { + _isRenego = true; try { await ForceAuthenticationAsync(receiveFirst: false, buffer, cancellationToken).ConfigureAwait(false); } finally { + _isRenego = false; _handshakeWaiter!.SetResult(true); _handshakeWaiter = null; } diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index ce2b8537fc4fe7..4fb901cdb9e27d 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -414,6 +414,76 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server } } + [ConditionalTheory(typeof(TestConfiguration), nameof(TestConfiguration.SupportsRenegotiation))] + [MemberData(nameof(PeerRenegotiationProtocolsData))] + public async Task PeerRenegotiation_SetsRenegotiationState(SslProtocols protocol) + { + using X509Certificate2 clientCertificate = Configuration.Certificates.GetClientCertificate(); + + FieldInfo isRenegoField = typeof(SslStream).GetField("_isRenego", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(isRenegoField); + FieldInfo authenticationOptionsField = typeof(SslStream).GetField("_sslAuthenticationOptions", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(authenticationOptionsField); + + var serverOptions = new SslServerAuthenticationOptions + { + EnabledSslProtocols = protocol, + AllowRenegotiation = true, + ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), + RemoteCertificateValidationCallback = (_, _, _, _) => true, + }; + + var clientOptions = new SslClientAuthenticationOptions + { + TargetHost = Guid.NewGuid().ToString("N"), + EnabledSslProtocols = protocol, + AllowRenegotiation = true, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + RemoteCertificateValidationCallback = (_, _, _, _) => true, + }; + + (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); + using (client) + using (server) + { + await TestConfiguration.WhenAllOrAnyFailedWithTimeout( + client.AuthenticateAsClientAsync(clientOptions), + server.AuthenticateAsServerAsync(serverOptions)); + await TestHelper.PingPong(client, server); + + bool wasRenegotiatingDuringSelection = false; + object authenticationOptions = authenticationOptionsField.GetValue(client); + Type authenticationOptionsType = typeof(SslStream).Assembly.GetType("System.Net.Security.SslAuthenticationOptions"); + Assert.NotNull(authenticationOptionsType); + PropertyInfo certSelectionDelegateProperty = authenticationOptionsType.GetProperty( + "CertSelectionDelegate", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + Assert.NotNull(certSelectionDelegateProperty); + certSelectionDelegateProperty.SetValue(authenticationOptions, (LocalCertificateSelectionCallback)((sender, _, _, _, _) => + { + wasRenegotiatingDuringSelection = (bool)isRenegoField.GetValue(sender); + return clientCertificate; + })); + + byte[] buffer = new byte[1]; + ValueTask clientRead = client.ReadAsync(buffer); + await server.NegotiateClientCertificateAsync(); + await server.WriteAsync(new byte[1]); + Assert.Equal(1, await clientRead); + + Assert.True(wasRenegotiatingDuringSelection); + Assert.False((bool)isRenegoField.GetValue(client)); + } + } + + public static IEnumerable PeerRenegotiationProtocolsData() + { + foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, false)) + { + yield return new object[] { protocol }; + } + } + public static IEnumerable RevalidateSwitchData() { foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, true)) From 4a887150ee9b8872ff622d3fe4fb2ca3b6da1044 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Tue, 29 Sep 2026 15:48:22 +0200 Subject: [PATCH 16/18] Fix peer TLS reauthentication state tracking Track peer reauthentication separately from TLS framing renegotiation state and apply it to both managed and OpenSSL certificate validation paths. Strengthen coverage for TLS 1.2 validation and TLS 1.3 state cleanup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../src/System/Net/Security/SslStream.IO.cs | 5 +-- .../System/Net/Security/SslStream.Protocol.cs | 4 +-- .../SslStreamAllowTlsResumeTests.cs | 32 ++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs index 6cee3c90d3efc0..cbc74d39551807 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.IO.cs @@ -19,6 +19,7 @@ public partial class SslStream internal new Stream InnerStream => base.InnerStream; private NestedState _nestedAuth; private bool _isRenego; + private bool _isReAuthentication; private TlsFrameHelper.TlsFrameInfo _lastFrame; @@ -159,14 +160,14 @@ private async Task ProcessAuthenticationWithTelemetryAsync(bool isAsync, Cancell private async Task ReplyOnReAuthenticationAsync(byte[]? buffer, CancellationToken cancellationToken) where TIOAdapter : IReadWriteAdapter { - _isRenego = true; + _isReAuthentication = true; try { await ForceAuthenticationAsync(receiveFirst: false, buffer, cancellationToken).ConfigureAwait(false); } finally { - _isRenego = false; + _isReAuthentication = false; _handshakeWaiter!.SetResult(true); _handshakeWaiter = null; } diff --git a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs index 9cc944f5e1084d..917598be7474cd 100644 --- a/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs +++ b/src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs @@ -1146,7 +1146,7 @@ internal bool VerifyRemoteCertificate(SslCertificateTrust? trust, ref ProtocolTo return VerifyRemoteCertificateCore( this, - !_isRenego, + !_isRenego && !_isReAuthentication, _sslAuthenticationOptions, _securityContext, ref _remoteCertificate, @@ -1203,7 +1203,7 @@ internal bool VerifyRemoteCertificate( { return VerifyRemoteCertificateCore( this, - !_isRenego, + !_isRenego && !_isReAuthentication, _sslAuthenticationOptions, _securityContext, ref _remoteCertificate, diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 4fb901cdb9e27d..9f063030e67cb3 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -416,14 +416,16 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server [ConditionalTheory(typeof(TestConfiguration), nameof(TestConfiguration.SupportsRenegotiation))] [MemberData(nameof(PeerRenegotiationProtocolsData))] - public async Task PeerRenegotiation_SetsRenegotiationState(SslProtocols protocol) + public async Task PeerRenegotiation_SetsReauthenticationState(SslProtocols protocol) { using X509Certificate2 clientCertificate = Configuration.Certificates.GetClientCertificate(); - FieldInfo isRenegoField = typeof(SslStream).GetField("_isRenego", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(isRenegoField); + FieldInfo isReAuthenticationField = typeof(SslStream).GetField("_isReAuthentication", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(isReAuthenticationField); FieldInfo authenticationOptionsField = typeof(SslStream).GetField("_sslAuthenticationOptions", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(authenticationOptionsField); + FieldInfo remoteCertificateField = typeof(SslStream).GetField("_remoteCertificate", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(remoteCertificateField); var serverOptions = new SslServerAuthenticationOptions { @@ -433,13 +435,18 @@ public async Task PeerRenegotiation_SetsRenegotiationState(SslProtocols protocol RemoteCertificateValidationCallback = (_, _, _, _) => true, }; + int clientValidationCallbackCount = 0; var clientOptions = new SslClientAuthenticationOptions { TargetHost = Guid.NewGuid().ToString("N"), EnabledSslProtocols = protocol, AllowRenegotiation = true, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - RemoteCertificateValidationCallback = (_, _, _, _) => true, + RemoteCertificateValidationCallback = (_, _, _, _) => + { + clientValidationCallbackCount++; + return true; + }, }; (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); @@ -450,8 +457,9 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( client.AuthenticateAsClientAsync(clientOptions), server.AuthenticateAsServerAsync(serverOptions)); await TestHelper.PingPong(client, server); + Assert.Equal(1, clientValidationCallbackCount); - bool wasRenegotiatingDuringSelection = false; + bool wasReauthenticatingDuringSelection = false; object authenticationOptions = authenticationOptionsField.GetValue(client); Type authenticationOptionsType = typeof(SslStream).Assembly.GetType("System.Net.Security.SslAuthenticationOptions"); Assert.NotNull(authenticationOptionsType); @@ -461,7 +469,14 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( Assert.NotNull(certSelectionDelegateProperty); certSelectionDelegateProperty.SetValue(authenticationOptions, (LocalCertificateSelectionCallback)((sender, _, _, _, _) => { - wasRenegotiatingDuringSelection = (bool)isRenegoField.GetValue(sender); + wasReauthenticatingDuringSelection = (bool)isReAuthenticationField.GetValue(sender); + + object clientConnectionInfo = connectionInfo.GetValue(sender); + tlsResumed.SetValue(clientConnectionInfo, true); + connectionInfo.SetValue(sender, clientConnectionInfo); + + using X509Certificate2 remoteCertificate = (X509Certificate2)remoteCertificateField.GetValue(sender); + remoteCertificateField.SetValue(sender, null); return clientCertificate; })); @@ -471,8 +486,9 @@ await TestConfiguration.WhenAllOrAnyFailedWithTimeout( await server.WriteAsync(new byte[1]); Assert.Equal(1, await clientRead); - Assert.True(wasRenegotiatingDuringSelection); - Assert.False((bool)isRenegoField.GetValue(client)); + Assert.True(wasReauthenticatingDuringSelection); + Assert.False((bool)isReAuthenticationField.GetValue(client)); + Assert.Equal(protocol == SslProtocols.Tls12 ? 2 : 1, clientValidationCallbackCount); } } From 43329c4bb52d0c56a0c7784bc41dfdcc03f74d09 Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 30 Sep 2026 09:11:00 +0200 Subject: [PATCH 17/18] Delete reflection-based test --- .../SslStreamAllowTlsResumeTests.cs | 86 ------------------- 1 file changed, 86 deletions(-) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index 9f063030e67cb3..ce2b8537fc4fe7 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -414,92 +414,6 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server } } - [ConditionalTheory(typeof(TestConfiguration), nameof(TestConfiguration.SupportsRenegotiation))] - [MemberData(nameof(PeerRenegotiationProtocolsData))] - public async Task PeerRenegotiation_SetsReauthenticationState(SslProtocols protocol) - { - using X509Certificate2 clientCertificate = Configuration.Certificates.GetClientCertificate(); - - FieldInfo isReAuthenticationField = typeof(SslStream).GetField("_isReAuthentication", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(isReAuthenticationField); - FieldInfo authenticationOptionsField = typeof(SslStream).GetField("_sslAuthenticationOptions", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(authenticationOptionsField); - FieldInfo remoteCertificateField = typeof(SslStream).GetField("_remoteCertificate", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(remoteCertificateField); - - var serverOptions = new SslServerAuthenticationOptions - { - EnabledSslProtocols = protocol, - AllowRenegotiation = true, - ServerCertificateContext = SslStreamCertificateContext.Create(Configuration.Certificates.GetServerCertificate(), null, false), - RemoteCertificateValidationCallback = (_, _, _, _) => true, - }; - - int clientValidationCallbackCount = 0; - var clientOptions = new SslClientAuthenticationOptions - { - TargetHost = Guid.NewGuid().ToString("N"), - EnabledSslProtocols = protocol, - AllowRenegotiation = true, - CertificateRevocationCheckMode = X509RevocationMode.NoCheck, - RemoteCertificateValidationCallback = (_, _, _, _) => - { - clientValidationCallbackCount++; - return true; - }, - }; - - (SslStream client, SslStream server) = TestHelper.GetConnectedSslStreams(); - using (client) - using (server) - { - await TestConfiguration.WhenAllOrAnyFailedWithTimeout( - client.AuthenticateAsClientAsync(clientOptions), - server.AuthenticateAsServerAsync(serverOptions)); - await TestHelper.PingPong(client, server); - Assert.Equal(1, clientValidationCallbackCount); - - bool wasReauthenticatingDuringSelection = false; - object authenticationOptions = authenticationOptionsField.GetValue(client); - Type authenticationOptionsType = typeof(SslStream).Assembly.GetType("System.Net.Security.SslAuthenticationOptions"); - Assert.NotNull(authenticationOptionsType); - PropertyInfo certSelectionDelegateProperty = authenticationOptionsType.GetProperty( - "CertSelectionDelegate", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - Assert.NotNull(certSelectionDelegateProperty); - certSelectionDelegateProperty.SetValue(authenticationOptions, (LocalCertificateSelectionCallback)((sender, _, _, _, _) => - { - wasReauthenticatingDuringSelection = (bool)isReAuthenticationField.GetValue(sender); - - object clientConnectionInfo = connectionInfo.GetValue(sender); - tlsResumed.SetValue(clientConnectionInfo, true); - connectionInfo.SetValue(sender, clientConnectionInfo); - - using X509Certificate2 remoteCertificate = (X509Certificate2)remoteCertificateField.GetValue(sender); - remoteCertificateField.SetValue(sender, null); - return clientCertificate; - })); - - byte[] buffer = new byte[1]; - ValueTask clientRead = client.ReadAsync(buffer); - await server.NegotiateClientCertificateAsync(); - await server.WriteAsync(new byte[1]); - Assert.Equal(1, await clientRead); - - Assert.True(wasReauthenticatingDuringSelection); - Assert.False((bool)isReAuthenticationField.GetValue(client)); - Assert.Equal(protocol == SslProtocols.Tls12 ? 2 : 1, clientValidationCallbackCount); - } - } - - public static IEnumerable PeerRenegotiationProtocolsData() - { - foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, false)) - { - yield return new object[] { protocol }; - } - } - public static IEnumerable RevalidateSwitchData() { foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, true)) From 7fc9786f82e357fc474c7a81fb0919ccf8a2b20c Mon Sep 17 00:00:00 2001 From: Radek Zikmund Date: Wed, 30 Sep 2026 12:45:55 +0200 Subject: [PATCH 18/18] Limit legacy TLS resume tests to debug builds Keep the new callback behavior tests enabled in all configurations while restoring the existing resumption suite's prior debug-only scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5fc01286-cba3-4fbb-b19f-c323b7b4d964 --- .../tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs index ce2b8537fc4fe7..ff0dcf1c673f36 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamAllowTlsResumeTests.cs @@ -19,6 +19,7 @@ namespace System.Net.Security.Tests { using Configuration = System.Net.Test.Common.Configuration; +#if DEBUG [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux)] public class SslStreamTlsResumeTests { @@ -413,7 +414,12 @@ private async Task TestNoResumeAfterChange(SslServerAuthenticationOptions server await RunConnectionAsync(serverOptions, clientOptions, false); } } + } +#endif + [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux)] + public class SslStreamTlsResumeCallbackTests + { public static IEnumerable RevalidateSwitchData() { foreach (SslProtocols protocol in SslProtocolSupport.EnumerateSupportedProtocols(SslProtocols.Tls12 | SslProtocols.Tls13, true))