From 6e4209b7469410c16e5fe576f6e2169e51c1a573 Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 23 Sep 2026 21:51:51 -0700 Subject: [PATCH 1/3] Preserve peer certificate chain in TlsSession Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Net/Security/SslStream.Protocol.cs | 15 +- .../src/System/Net/Security/TlsSession.cs | 79 +++++++--- .../tests/FunctionalTests/TlsSessionTests.cs | 141 ++++++++++++++++++ 3 files changed, 212 insertions(+), 23 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 addf0f6d921e14..27adba2975e646 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 @@ -1194,7 +1194,8 @@ internal bool VerifyRemoteCertificate( trust, ref alertToken, ref sslPolicyErrors, - out chainStatus); + out chainStatus, + peerCertificateChain: null); } internal static bool VerifyRemoteCertificateCore( @@ -1212,7 +1213,8 @@ internal static bool VerifyRemoteCertificateCore( SslCertificateTrust? trust, ref ProtocolToken alertToken, ref SslPolicyErrors sslPolicyErrors, - out X509ChainStatusFlags chainStatus) + out X509ChainStatusFlags chainStatus, + X509Certificate2Collection? peerCertificateChain) { chainStatus = X509ChainStatusFlags.NoError; @@ -1248,7 +1250,9 @@ internal static bool VerifyRemoteCertificateCore( if (sslAuthenticationOptions.CertificateChainPolicy != null) { - chain.ChainPolicy = sslAuthenticationOptions.CertificateChainPolicy; + chain.ChainPolicy = peerCertificateChain is null + ? sslAuthenticationOptions.CertificateChainPolicy + : sslAuthenticationOptions.CertificateChainPolicy.Clone(); } else { @@ -1274,6 +1278,11 @@ internal static bool VerifyRemoteCertificateCore( } } + if (peerCertificateChain is { Count: > 0 }) + { + chain.ChainPolicy.ExtraStore.AddRange(peerCertificateChain); + } + // set ApplicationPolicy unless already provided. if (chain.ChainPolicy.ApplicationPolicy.Count == 0) { 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 efc182fb6b3c53..0b9b133e4f24c9 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 @@ -360,14 +360,9 @@ public SslPolicyErrors AcceptWithDefaultValidation() SR.Format(SR.net_tlssession_validation_not_pending, nameof(AcceptWithDefaultValidation))); } - // Build a fresh X509Chain locally and seed it with the peer-sent intermediates. - // The chain instance is never exposed to TlsSession callers; once validation is - // recorded it is disposed in SetRemoteCertificateValidationResult below. + // Build a fresh X509Chain locally. VerifyRemoteCertificateCore applies the configured + // chain policy before adding the peer-sent intermediates captured by this session. using X509Chain chain = new X509Chain(); - if (_externalRemoteCertificates is { Count: > 0 } intermediates) - { - chain.ChainPolicy.ExtraStore.AddRange(intermediates); - } ProtocolToken alertToken = default; SslPolicyErrors sslPolicyErrors = SslPolicyErrors.None; @@ -389,7 +384,8 @@ public SslPolicyErrors AcceptWithDefaultValidation() trust: null, ref alertToken, ref sslPolicyErrors, - out _); + out _, + _externalRemoteCertificates); } finally { @@ -1856,25 +1852,68 @@ private void OnHandshakeCompleted() // when AcceptWithDefaultValidation runs. private void CaptureRemoteCertificateForExternalValidation() { + int preexistingExtraCertsCount = _options.CertificateChainPolicy?.ExtraStore.Count ?? 0; X509Chain? chain = null; - _externalPendingCert = CertificateValidationPal.GetRemoteCertificate( - _securityContext, ref chain, _options.CertificateChainPolicy); + X509Certificate2Collection? intermediates = null; - // Snapshot the peer-sent intermediates into a flat collection and dispose the - // platform-built chain immediately. The chain instance never escapes the PAL - // boundary into TlsSession state or its public surface. - if (chain is not null) + try { - if (chain.ChainElements.Count > 1) + _externalPendingCert = CertificateValidationPal.GetRemoteCertificate( + _securityContext, ref chain, _options.CertificateChainPolicy); + + if (chain is not null) { - X509Certificate2Collection intermediates = new X509Certificate2Collection(); - for (int i = 1; i < chain.ChainElements.Count; i++) + X509Certificate2Collection extraStore = chain.ChainPolicy.ExtraStore; + while (extraStore.Count > preexistingExtraCertsCount) { - intermediates.Add(new X509Certificate2(chain.ChainElements[i].Certificate)); + X509Certificate2 certificate = extraStore[preexistingExtraCertsCount]; + extraStore.RemoveAt(preexistingExtraCertsCount); + + bool transferred = false; + try + { + if (_externalPendingCert is null || + !certificate.RawDataMemory.Span.SequenceEqual(_externalPendingCert.RawDataMemory.Span)) + { + (intermediates ??= new X509Certificate2Collection()).Add(certificate); + transferred = true; + } + } + finally + { + if (!transferred) + { + certificate.Dispose(); + } + } } - _externalRemoteCertificates = intermediates; } - chain.Dispose(); + + _externalRemoteCertificates = intermediates; + intermediates = null; + } + finally + { + if (intermediates is not null) + { + foreach (X509Certificate2 certificate in intermediates) + { + certificate.Dispose(); + } + } + + if (chain is not null) + { + X509Certificate2Collection extraStore = chain.ChainPolicy.ExtraStore; + while (extraStore.Count > preexistingExtraCertsCount) + { + X509Certificate2 certificate = extraStore[preexistingExtraCertsCount]; + extraStore.RemoveAt(preexistingExtraCertsCount); + certificate.Dispose(); + } + + chain.Dispose(); + } } _externalValidationPending = true; diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index db6fc5e1eef6f6..15d4ab21aa4e4d 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -462,6 +462,140 @@ public async Task ServerSession_MutualAuth_InitialHandshake_InvokesValidator() } } + [Fact] + public async Task ServerSession_ExternalValidation_PreservesClientCertificateChain() + { + const string serverName = "localhost"; + using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); + using X509Certificate2 preexistingExtraCertificate = TestCertificates.GetClientCertificate(); + using TestCertificates.PkiHolder clientPki = TestCertificates.GenerateCertificates( + serverName, + serverCertificate: false); + X509Certificate2 expectedIntermediate = clientPki.IssuerChain[0]; + + X509ChainPolicy chainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + }; + chainPolicy.ExtraStore.Add(preexistingExtraCertificate); + + int validatorCalls = 0; + bool callbackSawPreexistingCertificate = false; + bool callbackSawPeerIntermediate = false; + bool sessionSawPeerIntermediate = false; + bool sessionSawPeerLeaf = false; + bool sessionSawPreexistingCertificate = false; + bool secondValidationSawFirstPeerIntermediate = false; + using TlsContext ctx = TlsContext.CreateServer(new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateRequired = true, + CertificateChainPolicy = chainPolicy, + RemoteCertificateValidationCallback = (_, _, chain, _) => + { + validatorCalls++; + Assert.NotNull(chain); + if (validatorCalls == 1) + { + callbackSawPreexistingCertificate = chain.ChainPolicy.ExtraStore.Find( + X509FindType.FindByThumbprint, + preexistingExtraCertificate.Thumbprint, + validOnly: false).Count > 0; + callbackSawPeerIntermediate = chain.ChainPolicy.ExtraStore.Find( + X509FindType.FindByThumbprint, + expectedIntermediate.Thumbprint, + validOnly: false).Count > 0; + } + else + { + secondValidationSawFirstPeerIntermediate = chain.ChainPolicy.ExtraStore.Find( + X509FindType.FindByThumbprint, + expectedIntermediate.Thumbprint, + validOnly: false).Count > 0; + } + return true; + }, + }); + + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate)) + { + using TlsBufferSession session = NewBufferSession(ctx); + + Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = serverName, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateContext = clientPki.CreateSslStreamCertificateContext(), + RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, + }); + + Task serverHandshake = DriveHandshakeWithExternalValidationAsync( + session, + serverStream, + onSuspend: () => + { + X509Certificate2Collection? remoteCertificates = session.GetRemoteCertificates(); + if (remoteCertificates is not null) + { + sessionSawPeerIntermediate = remoteCertificates.Find( + X509FindType.FindByThumbprint, + expectedIntermediate.Thumbprint, + validOnly: false).Count > 0; + sessionSawPeerLeaf = remoteCertificates.Find( + X509FindType.FindByThumbprint, + clientPki.EndEntity.Thumbprint, + validOnly: false).Count > 0; + sessionSawPreexistingCertificate = remoteCertificates.Find( + X509FindType.FindByThumbprint, + preexistingExtraCertificate.Thumbprint, + validOnly: false).Count > 0; + } + session.AcceptWithDefaultValidation(); + }); + + await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(1, validatorCalls); + Assert.True(callbackSawPreexistingCertificate); + Assert.True(callbackSawPeerIntermediate); + Assert.True(sessionSawPeerIntermediate); + Assert.False(sessionSawPeerLeaf); + Assert.False(sessionSawPreexistingCertificate); + Assert.True(session.IsHandshakeComplete); + Assert.True(clientSsl.IsAuthenticated); + } + + (clientStream, serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate)) + using (TlsBufferSession session = NewBufferSession(ctx)) + { + Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = serverName, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificates = new X509CertificateCollection { preexistingExtraCertificate }, + RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, + }); + Task serverHandshake = DriveHandshakeWithExternalValidationAsync( + session, + serverStream, + onSuspend: () => session.AcceptWithDefaultValidation()); + + await Task.WhenAll(clientHandshake, serverHandshake).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(2, validatorCalls); + Assert.False(secondValidationSawFirstPeerIntermediate); + Assert.True(session.IsHandshakeComplete); + Assert.True(clientSsl.IsAuthenticated); + } + } + // Cross-platform baseline: SslStream on BOTH sides, server rejects client cert. // - TLS 1.2 with OpenSSL: server validates client cert before sending ServerFinished, so the // client's AuthenticateAsClientAsync must throw AuthenticationException. @@ -1527,6 +1661,13 @@ await DriveHandshakeWithExternalValidationAsync( session, clientStream, onSuspend: () => { + X509Certificate2Collection? remoteCertificates = session.GetRemoteCertificates(); + Assert.NotNull(remoteCertificates); + Assert.NotEmpty(remoteCertificates); + Assert.Empty(remoteCertificates.Find( + X509FindType.FindByThumbprint, + pkiHolder.EndEntity.Thumbprint, + validOnly: false)); observedErrors = session.AcceptWithDefaultValidation(); }); }); From aab9a1e0f7ce6bd3831d523e1a99023f79b9bf6b Mon Sep 17 00:00:00 2001 From: wfurt Date: Wed, 23 Sep 2026 22:10:38 -0700 Subject: [PATCH 2/3] Isolate certificate policies across TLS sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Net/Security/TlsSession.cs | 5 +- .../tests/FunctionalTests/TlsSessionTests.cs | 98 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) 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 0b9b133e4f24c9..1054d7dcd2c985 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 @@ -1852,14 +1852,15 @@ private void OnHandshakeCompleted() // when AcceptWithDefaultValidation runs. private void CaptureRemoteCertificateForExternalValidation() { - int preexistingExtraCertsCount = _options.CertificateChainPolicy?.ExtraStore.Count ?? 0; + X509ChainPolicy? chainPolicy = _options.CertificateChainPolicy?.Clone(); + int preexistingExtraCertsCount = chainPolicy?.ExtraStore.Count ?? 0; X509Chain? chain = null; X509Certificate2Collection? intermediates = null; try { _externalPendingCert = CertificateValidationPal.GetRemoteCertificate( - _securityContext, ref chain, _options.CertificateChainPolicy); + _securityContext, ref chain, chainPolicy); if (chain is not null) { diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index 15d4ab21aa4e4d..f89809f5305fc7 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -596,6 +596,104 @@ public async Task ServerSession_ExternalValidation_PreservesClientCertificateCha } } + [Fact] + public async Task ServerSession_ExternalValidation_ConcurrentSessionsPreserveOwnCertificateChain() + { + const int SessionCount = 4; + const string serverName = "localhost"; + using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); + using TlsContext ctx = TlsContext.CreateServer(new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateRequired = true, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + }, + }); + + TestCertificates.PkiHolder?[] clientPkis = new TestCertificates.PkiHolder?[SessionCount]; + try + { + Task[] handshakes = new Task[SessionCount]; + for (int i = 0; i < SessionCount; i++) + { + clientPkis[i] = TestCertificates.GenerateCertificates( + serverName, + testName: $"{nameof(ServerSession_ExternalValidation_ConcurrentSessionsPreserveOwnCertificateChain)}_{i}", + serverCertificate: false); + } + + for (int i = 0; i < SessionCount; i++) + { + handshakes[i] = RunHandshakeAsync(clientPkis[i]!); + } + + await Task.WhenAll(handshakes).WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + foreach (TestCertificates.PkiHolder? clientPki in clientPkis) + { + clientPki?.Dispose(); + } + } + + async Task RunHandshakeAsync(TestCertificates.PkiHolder clientPki) + { + X509Certificate2 expectedIntermediate = clientPki.IssuerChain[0]; + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate)) + using (TlsBufferSession session = NewBufferSession(ctx)) + { + Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = serverName, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateContext = clientPki.CreateSslStreamCertificateContext(), + RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, + }); + Task serverHandshake = DriveHandshakeWithExternalValidationAsync( + session, + serverStream, + onSuspend: () => + { + X509Certificate2Collection? remoteCertificates = session.GetRemoteCertificates(); + Assert.NotNull(remoteCertificates); + Assert.NotEmpty(remoteCertificates.Find( + X509FindType.FindByThumbprint, + expectedIntermediate.Thumbprint, + validOnly: false)); + Assert.Empty(remoteCertificates.Find( + X509FindType.FindByThumbprint, + clientPki.EndEntity.Thumbprint, + validOnly: false)); + + for (int i = 0; i < clientPkis.Length; i++) + { + TestCertificates.PkiHolder? otherPki = clientPkis[i]; + if (otherPki is not null && !ReferenceEquals(otherPki, clientPki)) + { + Assert.Empty(remoteCertificates.Find( + X509FindType.FindByThumbprint, + otherPki.IssuerChain[0].Thumbprint, + validOnly: false)); + } + } + + session.SetRemoteCertificateValidationResult(SslPolicyErrors.None); + }); + + await Task.WhenAll(clientHandshake, serverHandshake); + Assert.True(session.IsHandshakeComplete); + Assert.True(clientSsl.IsAuthenticated); + } + } + } + // Cross-platform baseline: SslStream on BOTH sides, server rejects client cert. // - TLS 1.2 with OpenSSL: server validates client cert before sending ServerFinished, so the // client's AuthenticateAsClientAsync must throw AuthenticationException. From 65bf3cc1225322428b5f2edd2abeede4ddc2dbf5 Mon Sep 17 00:00:00 2001 From: wfurt Date: Thu, 24 Sep 2026 09:39:03 -0700 Subject: [PATCH 3/3] Isolate leaf-only TLS validation policies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Net/Security/SslStream.Protocol.cs | 12 +-- .../src/System/Net/Security/TlsSession.cs | 3 +- .../tests/FunctionalTests/TlsSessionTests.cs | 75 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 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 27adba2975e646..e86bd5ad2bc7e0 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 @@ -1195,7 +1195,8 @@ internal bool VerifyRemoteCertificate( ref alertToken, ref sslPolicyErrors, out chainStatus, - peerCertificateChain: null); + peerCertificateChain: null, + cloneCertificateChainPolicy: false); } internal static bool VerifyRemoteCertificateCore( @@ -1214,7 +1215,8 @@ internal static bool VerifyRemoteCertificateCore( ref ProtocolToken alertToken, ref SslPolicyErrors sslPolicyErrors, out X509ChainStatusFlags chainStatus, - X509Certificate2Collection? peerCertificateChain) + X509Certificate2Collection? peerCertificateChain, + bool cloneCertificateChainPolicy) { chainStatus = X509ChainStatusFlags.NoError; @@ -1250,9 +1252,9 @@ internal static bool VerifyRemoteCertificateCore( if (sslAuthenticationOptions.CertificateChainPolicy != null) { - chain.ChainPolicy = peerCertificateChain is null - ? sslAuthenticationOptions.CertificateChainPolicy - : sslAuthenticationOptions.CertificateChainPolicy.Clone(); + chain.ChainPolicy = cloneCertificateChainPolicy + ? sslAuthenticationOptions.CertificateChainPolicy.Clone() + : sslAuthenticationOptions.CertificateChainPolicy; } else { 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 1054d7dcd2c985..f8461b814529e6 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 @@ -385,7 +385,8 @@ public SslPolicyErrors AcceptWithDefaultValidation() ref alertToken, ref sslPolicyErrors, out _, - _externalRemoteCertificates); + _externalRemoteCertificates, + cloneCertificateChainPolicy: true); } finally { diff --git a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs index f89809f5305fc7..bf979b0f3d7b6a 100644 --- a/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs +++ b/src/libraries/System.Net.Security/tests/FunctionalTests/TlsSessionTests.cs @@ -694,6 +694,81 @@ async Task RunHandshakeAsync(TestCertificates.PkiHolder clientPki) } } + [Fact] + public async Task ServerSession_ExternalValidation_ConcurrentLeafOnlySessionsDoNotShareChainPolicy() + { + const int SessionCount = 4; + using X509Certificate2 serverCert = TestCertificates.GetServerCertificate(); + using X509Certificate2 clientCert = TestCertificates.GetClientCertificate(); + + object callbackLock = new object(); + bool sharedPolicyObserved = false; + int validatorCalls = 0; + using TlsContext ctx = TlsContext.CreateServer(new SslServerAuthenticationOptions + { + ServerCertificate = serverCert, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificateRequired = true, + CertificateChainPolicy = new X509ChainPolicy + { + RevocationMode = X509RevocationMode.NoCheck, + }, + RemoteCertificateValidationCallback = (_, _, chain, _) => + { + Assert.NotNull(chain); + lock (callbackLock) + { + sharedPolicyObserved |= chain.ChainPolicy.CertificatePolicy.Count != 0; + chain.ChainPolicy.CertificatePolicy.Add(new System.Security.Cryptography.Oid("1.3.6.1.4.1.311.99999.1")); + } + Interlocked.Increment(ref validatorCalls); + return true; + }, + }); + + Task[] handshakes = new Task[SessionCount]; + for (int i = 0; i < SessionCount; i++) + { + handshakes[i] = RunHandshakeAsync(); + } + + await Task.WhenAll(handshakes).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(SessionCount, validatorCalls); + Assert.False(sharedPolicyObserved); + + async Task RunHandshakeAsync() + { + string serverName = serverCert.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + (Stream clientStream, Stream serverStream) = TestHelper.GetConnectedStreams(); + using (clientStream) + using (serverStream) + using (SslStream clientSsl = new SslStream(clientStream, leaveInnerStreamOpen: false, TestHelper.AllowAnyServerCertificate)) + using (TlsBufferSession session = NewBufferSession(ctx)) + { + Task clientHandshake = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = serverName, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ClientCertificates = new X509CertificateCollection { clientCert }, + RemoteCertificateValidationCallback = TestHelper.AllowAnyServerCertificate, + }); + Task serverHandshake = DriveHandshakeWithExternalValidationAsync( + session, + serverStream, + onSuspend: () => + { + Assert.Null(session.GetRemoteCertificates()); + session.AcceptWithDefaultValidation(); + }); + + await Task.WhenAll(clientHandshake, serverHandshake); + Assert.True(session.IsHandshakeComplete); + Assert.True(clientSsl.IsAuthenticated); + } + } + } + // Cross-platform baseline: SslStream on BOTH sides, server rejects client cert. // - TLS 1.2 with OpenSSL: server validates client cert before sending ServerFinished, so the // client's AuthenticateAsClientAsync must throw AuthenticationException.