Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cdab7d6
Skip peer certificate revalidation on resumed TLS sessions by default
rzikm Sep 3, 2026
38787c4
Gate resume revalidation skip to initial handshake; drop unavailable …
rzikm Sep 4, 2026
40d2bea
Dispose peer intermediates on resumed handshake and add revalidate-sw…
rzikm Sep 4, 2026
9ecdac2
Cover server-side callback and skip when resumption unavailable in re…
rzikm Sep 4, 2026
f4d4f64
Assert reflection lookups resolve in resume revalidate test
rzikm Sep 4, 2026
92776e8
Avoid Enum.HasFlag boxing when detecting TLS resumption on Windows
rzikm Sep 4, 2026
b9c8038
Clarify resume-revalidation switch comment is platform-dependent
rzikm Sep 4, 2026
320e6e1
Run resume tests in all configs and harden skip-marker path
rzikm Sep 4, 2026
8601ffe
Assert CheckResumeFlag reflection lookups resolve
rzikm Sep 4, 2026
2fd6625
Make tlsResumed reflection lookup null-safe in resume tests
rzikm Sep 4, 2026
c4bc11f
Parametrize resume revalidation test over supported TLS protocols and…
rzikm Sep 4, 2026
8c82aa6
Fix HttpConnectionPool Partitioning tests
rzikm Sep 18, 2026
0ab9cf9
Strengthen TLS resume callback validation
rzikm Sep 22, 2026
17dc14b
Fix TLS callback tests with session resumption
rzikm Sep 23, 2026
646637b
Validate certificates during peer TLS reauthentication
rzikm Sep 29, 2026
4a88715
Fix peer TLS reauthentication state tracking
rzikm Sep 29, 2026
43329c4
Delete reflection-based test
rzikm Sep 30, 2026
7fc9786
Limit legacy TLS resume tests to debug builds
rzikm Sep 30, 2026
6ee73e9
Merge remote-tracking branch 'origin/main' into rzikm/sslstream-skip-…
rzikm Sep 30, 2026
8cc1ffd
Merge latest main into TLS resume changes
rzikm Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -5483,7 +5484,10 @@ await LoopbackServerFactory.CreateClientAndServerAsync(

private static void ConfigureSniCallback(HttpClientHandler handler, List<string> 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
{
Expand Down Expand Up @@ -6441,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ 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. 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;
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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TlsCipherSuite>(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.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ public void UpdateSslConnectionInfo(SafeSslHandle sslContext)
{
ApplicationProtocol = alpn.ToArray();
}
#if DEBUG
TlsResumed = Interop.Ssl.SslSessionReused(sslContext);
#endif
MapCipherSuite(SslGetCurrentCipherSuite(sslContext));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -77,6 +83,12 @@ private void UpdateSslConnectionInfoAppleCrypto(SafeDeleteSslContext context)

Protocol = (int)protocol;
TlsCipherSuite = cipherSuite;

// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,13 @@ public void UpdateSslConnectionInfo(SafeDeleteContext securityContext)
ApplicationProtocol = GetNegotiatedApplicationProtocol(securityContext);
}

#if DEBUG
SecPkgContext_SessionInfo info = default;
TlsResumed = SSPIWrapper.QueryBlittableContextAttributes(
GlobalSSPI.SSPISecureChannel,
securityContext,
Interop.SspiCli.ContextAttribute.SECPKG_ATTR_SESSION_INFO,
ref info) &&
((SecPkgContext_SessionInfo.Flags)info.dwFlags).HasFlag(SecPkgContext_SessionInfo.Flags.SSL_SESSION_RECONNECT);
#endif
(info.dwFlags & (uint)SecPkgContext_SessionInfo.Flags.SSL_SESSION_RECONNECT) != 0;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -159,12 +160,14 @@ private async Task ProcessAuthenticationWithTelemetryAsync(bool isAsync, Cancell
private async Task ReplyOnReAuthenticationAsync<TIOAdapter>(byte[]? buffer, CancellationToken cancellationToken)
where TIOAdapter : IReadWriteAdapter
{
_isReAuthentication = true;
try
{
await ForceAuthenticationAsync<TIOAdapter>(receiveFirst: false, buffer, cancellationToken).ConfigureAwait(false);
}
finally
{
_isReAuthentication = false;
_handshakeWaiter!.SetResult(true);
_handshakeWaiter = null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1138,12 +1138,28 @@ 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 && !_isReAuthentication,
_sslAuthenticationOptions,
_securityContext,
ref _remoteCertificate,
ref _connectionInfo,
certificate,
chain,
trust,
ref alertToken,
ref sslPolicyErrors,
out chainStatus,
out certificateValidationSkippedOnResume,
peerCertificateChain: null,
cloneCertificateChainPolicy: false);
}
finally
{
Expand All @@ -1155,7 +1171,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++)
Expand Down Expand Up @@ -1185,6 +1205,7 @@ internal bool VerifyRemoteCertificate(
{
return VerifyRemoteCertificateCore(
this,
!_isRenego && !_isReAuthentication,
_sslAuthenticationOptions,
_securityContext,
ref _remoteCertificate,
Expand All @@ -1195,12 +1216,14 @@ internal bool VerifyRemoteCertificate(
ref alertToken,
ref sslPolicyErrors,
out chainStatus,
out _,
peerCertificateChain: null,
cloneCertificateChainPolicy: false);
}

internal static bool VerifyRemoteCertificateCore(
object sender,
bool isInitialHandshake,
SslAuthenticationOptions sslAuthenticationOptions,
#if TARGET_APPLE
SafeDeleteContext? securityContext,
Expand All @@ -1215,10 +1238,12 @@ internal static bool VerifyRemoteCertificateCore(
ref ProtocolToken alertToken,
ref SslPolicyErrors sslPolicyErrors,
out X509ChainStatusFlags chainStatus,
out bool certificateValidationSkippedOnResume,
X509Certificate2Collection? peerCertificateChain,
bool cloneCertificateChainPolicy)
{
chainStatus = X509ChainStatusFlags.NoError;
certificateValidationSkippedOnResume = false;

bool success = false;

Expand All @@ -1236,6 +1261,34 @@ internal static bool VerifyRemoteCertificateCore(
return true;
}

if (certificate != null &&
isInitialHandshake &&
connectionInfo.TlsResumed &&
!LocalAppContextSwitches.RevalidateCertificateOnTlsResume)
{
Comment thread
rzikm marked this conversation as resolved.
Comment thread
rzikm marked this conversation as resolved.
Comment thread
rzikm marked this conversation as resolved.
// 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
Comment thread
rzikm marked this conversation as resolved.
// 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;
certificateValidationSkippedOnResume = true;
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,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,
Expand All @@ -385,6 +388,7 @@ public SslPolicyErrors AcceptWithDefaultValidation()
ref alertToken,
ref sslPolicyErrors,
out _,
out _,
_externalRemoteCertificates,
cloneCertificateChainPolicy: true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ public async Task RemoteCertificateValidationCallback_ExtraStoreCertificates_Not
SslClientAuthenticationOptions clientOptions = new SslClientAuthenticationOptions
{
TargetHost = "localhost",
AllowTlsResume = false,
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
connectionCount++;
Expand Down
Loading
Loading