Skip to content

Cache SslStream certificate hashes#130615

Draft
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-sslstream-tls-perf-hunt
Draft

Cache SslStream certificate hashes#130615
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-sslstream-tls-perf-hunt

Conversation

@artl93

@artl93 artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member

This draft avoids recomputing the SHA-512 digest used by SslStream credential-cache probes when an SslStreamCertificateContext is reused.

Change

SslStreamCertificateContext lazily publishes an immutable { certificate handle, hash } entry. Reuse requires the current native certificate handle to match, and publication occurs only when the handle is unchanged before and after hashing. Disposal/reset therefore invalidates the entry and preserves the existing exception behavior. The cached array remains internal and read-only. No public API or certificate-validation behavior changes.

Both server and reference-identical client certificate-context paths use the cached digest; other client certificate selections keep the existing direct-hash behavior.

Validation

Current head: 3dddc1ea7570c298a3400d395c6ae7b46b2d6589 (merged with current main).

  • System.Net.Security build: 0 warnings, 0 errors
  • Focused certificate hash/disposal/concurrent reuse matrix: 10/10 passed
  • Full functional tests: 4,982 total, 0 failed, 20 platform skips
  • Unit tests: 132 total, 0 failed, 3 platform skips
  • Review threads for ALPN eligibility/full boolean coverage and platform-specific disposal exceptions are fixed and resolved
  • Specialist lifetime/invalidation review found no blocking issue

The prior CI failures were unrelated System.Text.Json failures (RuntimeFeature.IsDynamicCodeCompiled on net481 and a Mono interpreter System.Text.Json abort), not System.Net.Security failures. Current-head CI is pending.

Performance status

A single final-head Release BenchmarkDotNet request is running on -amd -osx_arm64. It includes:

  1. DirectCertificateHashControl — an unchanged SHA-512 control expected to remain flat between base and PR.
  2. ReusedServerCredentialProbe — a real primed AcquireServerCredentials probe that exercises certificate selection, digest acquisition, credential-cache lookup, and the platform PAL credential call.

Historical local Debug/Apple component evidence showed the repeated server probe dropping from ~1,278 ns and 88 B to ~119 ns and approximately zero allocation, but those are not Release/shipping numbers and are not the readiness basis. Whole-handshake measurements were noisy and established no TLS-handshake, HTTPS, throughput, or session-resumption improvement.

This PR remains draft until the Release BDN results and clean current-head CI are available. If the probe cannot be measured consistently across the requested platforms, the performance claim will be withdrawn rather than generalized.

Note

This draft PR description was generated with GitHub Copilot.

Avoid recomputing and reallocating the SHA-512 certificate hash for each TLS credential cache lookup when a certificate context is reused.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e9b4cc84-3f86-481e-9c92-03023269817c
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/ncl, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an internal SHA-512 certificate-hash cache on SslStreamCertificateContext and uses it during credential-cache probes in SslStream to avoid repeated X509Certificate.GetCertHash(HashAlgorithmName.SHA512) work when the same reusable context is used across connections.

Changes:

  • Add SslStreamCertificateContext.GetCertificateHash() which caches/publishes {cert-handle, hash} using Volatile for safe cross-thread reuse.
  • Update SslStream.Protocol credential acquisition paths to prefer the cached hash when the selected certificate is the context’s target certificate.
  • Add functional tests for concurrent authentication using a reused certificate context and for hash caching behavior (via reflection).
Show a summary per file
File Description
src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamCredentialCacheTest.cs Adds a concurrent multi-connection theory that reuses SslStreamCertificateContext (optionally with ALPN and mutual TLS).
src/libraries/System.Net.Security/tests/FunctionalTests/SslStreamCertificateContextTests.cs Adds a reflection-based test for GetCertificateHash() reuse and disposed-certificate behavior.
src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.cs Implements cached certificate hash storage on the reusable context.
src/libraries/System.Net.Security/src/System/Net/Security/SslStream.Protocol.cs Uses the cached hash for credential-cache keying instead of re-hashing each probe.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Art Leonard added 2 commits July 19, 2026 10:54
Cover the complete client-certificate/ALPN matrix where supported and accept the platform-specific disposed-certificate exception shapes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e9b4cc84-3f86-481e-9c92-03023269817c
Copilot AI review requested due to automatic review settings July 20, 2026 01:21
@artl93

artl93 commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -amd -osx_arm64

using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(SslCredentialProbeBenchmark).Assembly).Run(args);

[MemoryDiagnoser]
public class SslCredentialProbeBenchmark
{
    private delegate bool AcquireServerCredentialsDelegate(ref byte[]? thumbPrint);

    private X509Certificate2 _certificate = null!;
    private SslStreamCertificateContext _certificateContext = null!;
    private MemoryStream _innerStream = null!;
    private SslStream _sslStream = null!;
    private AcquireServerCredentialsDelegate _acquireServerCredentials = null!;

    [GlobalSetup]
    public async Task Setup()
    {
        _certificate = CreateCertificate();
        _certificateContext = SslStreamCertificateContext.Create(_certificate, null, offline: true);
        await PrimeCredentialCacheAsync();

        _innerStream = new MemoryStream();
        _sslStream = new SslStream(_innerStream);
        var options = new SslServerAuthenticationOptions
        {
            ServerCertificateContext = _certificateContext,
            EnabledSslProtocols = SslProtocols.Tls12,
            AllowTlsResume = true,
            CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
        };

        object authenticationOptions = typeof(SslStream)
            .GetField("_sslAuthenticationOptions", BindingFlags.Instance | BindingFlags.NonPublic)!
            .GetValue(_sslStream)!;
        authenticationOptions.GetType()
            .GetMethod(
                "UpdateOptions",
                BindingFlags.Instance | BindingFlags.NonPublic,
                binder: null,
                [typeof(SslServerAuthenticationOptions)],
                modifiers: null)!
            .Invoke(authenticationOptions, [options]);

        _acquireServerCredentials =
            (AcquireServerCredentialsDelegate)typeof(SslStream)
                .GetMethod("AcquireServerCredentials", BindingFlags.Instance | BindingFlags.NonPublic)!
                .CreateDelegate(typeof(AcquireServerCredentialsDelegate), _sslStream);
    }

    [Benchmark(Baseline = true)]
    public byte[] DirectCertificateHashControl() =>
        _certificate.GetCertHash(HashAlgorithmName.SHA512);

    [Benchmark]
    public bool ReusedServerCredentialProbe()
    {
        byte[]? thumbPrint = null;
        return _acquireServerCredentials(ref thumbPrint);
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _sslStream.Dispose();
        _innerStream.Dispose();
        _certificate.Dispose();
    }

    private async Task PrimeCredentialCacheAsync()
    {
        using var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        Task<TcpClient> acceptTask = listener.AcceptTcpClientAsync();
        using var clientSocket = new TcpClient();
        await clientSocket.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
        using TcpClient serverSocket = await acceptTask;
        using var client = new SslStream(clientSocket.GetStream(), false, static (_, _, _, _) => true);
        using var server = new SslStream(serverSocket.GetStream(), false);

        await Task.WhenAll(
            client.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
            {
                TargetHost = "localhost",
                EnabledSslProtocols = SslProtocols.Tls12,
                AllowTlsResume = true,
                CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
            }),
            server.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
            {
                ServerCertificateContext = _certificateContext,
                EnabledSslProtocols = SslProtocols.Tls12,
                AllowTlsResume = true,
                CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
            }));
    }

    private static X509Certificate2 CreateCertificate()
    {
        using RSA rsa = RSA.Create(2048);
        var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
        request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
        request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
            new OidCollection { new("1.3.6.1.5.5.7.3.1") },
            false));
        var san = new SubjectAlternativeNameBuilder();
        san.AddDnsName("localhost");
        request.CertificateExtensions.Add(san.Build());
        return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));
    }
}

Note

This benchmark request was generated with GitHub Copilot.

@artl93 artl93 changed the title WIP: DO NOT REVIEW Cache SslStream certificate hashes Cache SslStream certificate hashes Jul 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 5

Comment on lines +719 to +720
byte[] guessedThumbPrint = _sslAuthenticationOptions.CertificateContext.GetCertificateHash();
bool sendTrustedList = _sslAuthenticationOptions.CertificateContext!.Trust?._sendTrustInHandshake ?? false;
Comment on lines +569 to +572
byte[]? guessedThumbPrint = selectedCert is null ? null :
ReferenceEquals(selectedCert, _sslAuthenticationOptions.CertificateContext?.TargetCertificate) ?
_sslAuthenticationOptions.CertificateContext.GetCertificateHash() :
selectedCert.GetCertHash(HashAlgorithmName.SHA512);
Comment on lines +192 to +208
internal byte[] GetCertificateHash()
{
IntPtr certificateHandle = TargetCertificate.Handle;
CertificateHashCache? cache = Volatile.Read(ref _targetCertificateHash);
if (cache is not null && certificateHandle == cache.CertificateHandle)
{
return cache.CertificateHash;
}

byte[] certificateHash = TargetCertificate.GetCertHash(HashAlgorithmName.SHA512);
if (certificateHandle == TargetCertificate.Handle)
{
Volatile.Write(ref _targetCertificateHash, new CertificateHashCache(certificateHandle, certificateHash));
}

return certificateHash;
}
Comment on lines +22 to +28
MethodInfo getCertificateHash = typeof(SslStreamCertificateContext).GetMethod(
"GetCertificateHash",
BindingFlags.Instance | BindingFlags.NonPublic);

Assert.NotNull(getCertificateHash);
byte[] firstHash = (byte[])getCertificateHash.Invoke(context, null);
byte[] secondHash = (byte[])getCertificateHash.Invoke(context, null);
Comment on lines +101 to +111
const int ConnectionCount = 16;
var start = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int readyCount = 0;
var tasks = new Task[ConnectionCount];

for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = ConnectAsync();
}

await TestConfiguration.WhenAllOrAnyFailedWithTimeout(tasks);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants