Cache SslStream certificate hashes#130615
Draft
artl93 wants to merge 3 commits into
Draft
Conversation
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
Contributor
|
Tagging subscribers to this area: @dotnet/ncl, @bartonjs, @vcsjones |
Contributor
There was a problem hiding this comment.
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}usingVolatilefor safe cross-thread reuse. - Update
SslStream.Protocolcredential 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
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
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. |
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This draft avoids recomputing the SHA-512 digest used by
SslStreamcredential-cache probes when anSslStreamCertificateContextis reused.Change
SslStreamCertificateContextlazily 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 currentmain).The prior CI failures were unrelated System.Text.Json failures (
RuntimeFeature.IsDynamicCodeCompiledon 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:DirectCertificateHashControl— an unchanged SHA-512 control expected to remain flat between base and PR.ReusedServerCredentialProbe— a real primedAcquireServerCredentialsprobe 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.