Repository navigation
Description
Description
TlsSession.SetRemoteCertificateValidationResult builds its rejection exception from the net_ssl_io_cert_validation resource without SR.Format, so the exception message ends in a literal {0} instead of the SslPolicyErrors value the caller passed.
if (_isHandshakeComplete)
{
_externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation); _externalValidationFault = new AuthenticationException(SR.net_ssl_io_cert_validation);The resource has a placeholder:
<data name="net_ssl_io_cert_validation" xml:space="preserve">
<value>The remote certificate is invalid according to the validation procedure: {0}</value>SslStream formats the same resource with its SslPolicyErrors:
return ExceptionDispatchInfo.SetCurrentStackTrace(new AuthenticationException(SR.Format(SR.net_ssl_io_cert_validation, sslPolicyErrors), null));Reproduction Steps
A server TlsBufferSession with ClientCertificateRequired = true and no RemoteCertificateValidationCallback rejects the client certificate with SetRemoteCertificateValidationResult(SslPolicyErrors.RemoteCertificateChainErrors). The client is an SslStream over loopback TCP. The program prints the message of the AuthenticationException the session throws, once for TLS 1.2 and once for TLS 1.3.
repro.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);SYSLIB5007</NoWarn>
</PropertyGroup>
</Project>Program.cs:
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
foreach (SslProtocols protocol in new[] { SslProtocols.Tls12, SslProtocols.Tls13 })
{
Console.WriteLine($"{protocol}: {await RejectClientCertificateAsync(protocol)}");
}
static async Task<string> RejectClientCertificateAsync(SslProtocols protocol)
{
using X509Certificate2 serverCert = CreateCertificate("CN=localhost");
using X509Certificate2 clientCert = CreateCertificate("CN=client");
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
using var clientTcp = new TcpClient();
await clientTcp.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
using TcpClient serverTcp = await listener.AcceptTcpClientAsync();
NetworkStream transport = serverTcp.GetStream();
using var clientSsl = new SslStream(clientTcp.GetStream(), false, (_, _, _, _) => true);
_ = clientSsl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = "localhost",
EnabledSslProtocols = protocol,
ClientCertificates = new X509CertificateCollection { clientCert },
});
// No RemoteCertificateValidationCallback: the caller validates the client certificate.
using TlsContext context = TlsContext.CreateServer(new SslServerAuthenticationOptions
{
ServerCertificate = serverCert,
EnabledSslProtocols = protocol,
ClientCertificateRequired = true,
});
using var session = new TlsBufferSession();
session.SetContext(context);
byte[] input = new byte[32 * 1024];
byte[] output = new byte[32 * 1024];
int inputLength = 0;
try
{
while (!session.IsHandshakeComplete)
{
TlsOperationStatus status = session.Handshake(input.AsSpan(0, inputLength), output, out int consumed, out int written);
input.AsSpan(consumed, inputLength - consumed).CopyTo(input);
inputLength -= consumed;
await transport.WriteAsync(output.AsMemory(0, written));
while (session.HasPendingOutput)
{
session.DrainPendingOutput(output, out written);
await transport.WriteAsync(output.AsMemory(0, written));
}
if (status == TlsOperationStatus.NeedsCertificateValidation)
{
session.SetRemoteCertificateValidationResult(SslPolicyErrors.RemoteCertificateChainErrors);
}
else if (status == TlsOperationStatus.NeedMoreData)
{
int read = await transport.ReadAsync(input.AsMemory(inputLength));
if (read == 0)
{
return "peer closed the connection";
}
inputLength += read;
}
}
session.Write("hello"u8, output, out _, out _);
return "no exception";
}
catch (AuthenticationException ex)
{
return $"{ex.GetType().Name}: {ex.Message}";
}
}
static X509Certificate2 CreateCertificate(string subject)
{
using RSA key = RSA.Create(2048);
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null);
}Build and run:
dotnet build -c Release -o out
dotnet out/repro.dll
Expected behavior
The message names the errors the caller passed in, as SslStream's message does:
Tls12: AuthenticationException: The remote certificate is invalid according to the validation procedure: RemoteCertificateChainErrors
Tls13: AuthenticationException: The remote certificate is invalid according to the validation procedure: RemoteCertificateChainErrors
Actual behavior
On .NET 11.0.0-rc.1.26420.103:
Tls12: AuthenticationException: The remote certificate is invalid according to the validation procedure: {0}
Tls13: AuthenticationException: The remote certificate is invalid according to the validation procedure: {0}
Regression?
No. Both lines came in with the commit that added TlsContext and TlsSession (#130366), and main at d148189 still has them unchanged.
Known Workarounds
The caller has the SslPolicyErrors value it passed to SetRemoteCertificateValidationResult and can log that value itself.
Configuration
.NET 11.0.0-rc.1.26420.103, Ubuntu 24.04.4 LTS, x64, OpenSSL 3.0.13. A local build of main at 6f1d933 prints the same {0} lines.
The repro was run on that Linux machine only. The code is not OpenSSL-specific: TlsSession.cs is compiled for every platform target except Android, Windows included:
System.Net.Security.csproj:80-81
<Compile Include="System\Net\Security\TlsSession.cs"
Condition="'$(TargetPlatformIdentifier)' != '' and '$(UseAndroidCrypto)' != 'true'" />Other information
The fix is SR.Format(SR.net_ssl_io_cert_validation, errors) at both sites. In the repro on Linux (OpenSSL 3.0.13), both TLS 1.2 and TLS 1.3 reach the first site (the _isHandshakeComplete branch); the second site (the _resumeAfterCertValidation branch) was not reached.
On Windows 11 x64 the regression test in the fix's PR fails on the old code too: for TLS 1.2 and TLS 1.3, the server-side message does not contain RemoteCertificateChainErrors.
I have a fix with a test ready and will open a PR for it shortly. Could this be assigned to me?
Note
AI-generated, written at my direction and reviewed by me before posting.
Source read at dotnet/runtime 6f1d933. The repro ran on Ubuntu 24.04.4 LTS x64 with OpenSSL 3.0.13, on .NET 11.0.0-rc.1.26420.103 and on a ./build.sh clr+libs -rc Release build of 6f1d933, with dotnet build -c Release -o out and dotnet out/repro.dll (the local build through its testhost dotnet with DOTNET_ROLL_FORWARD=LatestMajor).
The Windows result is from that test, run on Windows 11 Pro for Workstations x64 (10.0.26300), against a build.cmd clr+libs -rc checked build of dotnet/runtime d148189 (same TlsSession.cs as 6f1d933), run with dotnet.cmd build /t:Test in src/libraries/System.Net.Security/tests/FunctionalTests.
Activity
dotnet-policy-service commentedon Oct 7, 2026
Tagging subscribers to this area: @dotnet/ncl, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.