Skip to content

Commit a33e1ff

Browse files
authored
[release/9.0-staging] [WinHTTP] Certificate caching on WinHttpHandler to eliminate extra call to Custom Certificate Validation (#114678)
* [WinHTTP] Certificate caching on WinHttpHandler to eliminate extra call to Custom Certificate Validation
1 parent af20fd0 commit a33e1ff

File tree

7 files changed

+353
-5
lines changed

7 files changed

+353
-5
lines changed

src/libraries/Common/src/Interop/Windows/WinHttp/Interop.winhttp_types.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,16 @@ public struct WINHTTP_ASYNC_RESULT
336336
public uint dwError;
337337
}
338338

339+
[StructLayout(LayoutKind.Sequential)]
340+
public unsafe struct WINHTTP_CONNECTION_INFO
341+
{
342+
// This field is actually 4 bytes, but we use nuint to avoid alignment issues for x64.
343+
// If we want to read this field in the future, we need to change type and make sure
344+
// alignment is correct for necessary archs.
345+
public nuint cbSize;
346+
public fixed byte LocalAddress[128];
347+
public fixed byte RemoteAddress[128];
348+
}
339349

340350
[StructLayout(LayoutKind.Sequential)]
341351
public struct tcp_keepalive

src/libraries/System.Net.Http.WinHttpHandler/src/System.Net.Http.WinHttpHandler.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ System.Net.Http.WinHttpHandler</PackageDescription>
8080
Link="Common\System\Runtime\ExceptionServices\ExceptionStackTrace.cs" />
8181
<Compile Include="$(CommonPath)\System\Threading\Tasks\RendezvousAwaitable.cs"
8282
Link="Common\System\Threading\Tasks\RendezvousAwaitable.cs" />
83+
<Compile Include="System\Net\Http\CachedCertificateValue.cs" />
8384
<Compile Include="System\Net\Http\NetEventSource.WinHttpHandler.cs" />
8485
<Compile Include="System\Net\Http\NoWriteNoSeekStreamContent.cs" />
8586
<Compile Include="System\Net\Http\WinHttpAuthHelper.cs" />
@@ -117,6 +118,7 @@ System.Net.Http.WinHttpHandler</PackageDescription>
117118
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
118119
<PackageReference Include="System.Buffers" Version="$(SystemBuffersVersion)" />
119120
<PackageReference Include="System.Memory" Version="$(SystemMemoryVersion)" />
121+
<PackageReference Include="Microsoft.Bcl.HashCode" Version="$(MicrosoftBclHashCodeVersion)" />
120122
<Reference Include="System.Net.Http" />
121123
</ItemGroup>
122124

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics;
7+
using System.Text;
8+
using System.Threading;
9+
10+
namespace System.Net.Http
11+
{
12+
internal sealed class CachedCertificateValue(byte[] rawCertificateData, long lastUsedTime)
13+
{
14+
private long _lastUsedTime = lastUsedTime;
15+
public byte[] RawCertificateData { get; } = rawCertificateData;
16+
public long LastUsedTime
17+
{
18+
get => Volatile.Read(ref _lastUsedTime);
19+
set => Volatile.Write(ref _lastUsedTime, value);
20+
}
21+
}
22+
23+
internal readonly struct CachedCertificateKey : IEquatable<CachedCertificateKey>
24+
{
25+
public CachedCertificateKey(IPAddress address, HttpRequestMessage message)
26+
{
27+
Debug.Assert(message.RequestUri != null);
28+
Address = address;
29+
Host = message.Headers.Host ?? message.RequestUri.Host;
30+
}
31+
public IPAddress Address { get; }
32+
public string Host { get; }
33+
34+
public bool Equals(CachedCertificateKey other) =>
35+
Address.Equals(other.Address) &&
36+
Host == other.Host;
37+
38+
public override bool Equals(object? obj)
39+
{
40+
throw new Exception("Unreachable");
41+
}
42+
43+
public override int GetHashCode() => HashCode.Combine(Address, Host);
44+
}
45+
}

src/libraries/System.Net.Http.WinHttpHandler/src/System/Net/Http/WinHttpHandler.cs

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Concurrent;
45
using System.Collections.Generic;
56
using System.Diagnostics;
7+
using System.Diagnostics.CodeAnalysis;
68
using System.IO;
79
using System.Net.Http.Headers;
810
using System.Net.Security;
@@ -41,11 +43,14 @@ public class WinHttpHandler : HttpMessageHandler
4143
internal static readonly Version HttpVersion20 = new Version(2, 0);
4244
internal static readonly Version HttpVersion30 = new Version(3, 0);
4345
internal static readonly Version HttpVersionUnknown = new Version(0, 0);
46+
internal static bool CertificateCachingAppContextSwitchEnabled { get; } = AppContext.TryGetSwitch("System.Net.Http.UseWinHttpCertificateCaching", out bool enabled) && enabled;
4447
private static readonly TimeSpan s_maxTimeout = TimeSpan.FromMilliseconds(int.MaxValue);
4548

4649
private static readonly StringWithQualityHeaderValue s_gzipHeaderValue = new StringWithQualityHeaderValue("gzip");
4750
private static readonly StringWithQualityHeaderValue s_deflateHeaderValue = new StringWithQualityHeaderValue("deflate");
4851
private static readonly Lazy<bool> s_supportsTls13 = new Lazy<bool>(CheckTls13Support);
52+
private static readonly TimeSpan s_cleanCachedCertificateTimeout = TimeSpan.FromMilliseconds((int?)AppDomain.CurrentDomain.GetData("System.Net.Http.WinHttpCertificateCachingCleanupTimerInterval") ?? 60_000);
53+
private static readonly long s_staleTimeout = (long)(s_cleanCachedCertificateTimeout.TotalSeconds * Stopwatch.Frequency);
4954

5055
[ThreadStatic]
5156
private static StringBuilder? t_requestHeadersBuilder;
@@ -93,9 +98,44 @@ private Func<
9398
private volatile bool _disposed;
9499
private SafeWinHttpHandle? _sessionHandle;
95100
private readonly WinHttpAuthHelper _authHelper = new WinHttpAuthHelper();
101+
private readonly Timer? _certificateCleanupTimer;
102+
private bool _isTimerRunning;
103+
private readonly ConcurrentDictionary<CachedCertificateKey, CachedCertificateValue> _cachedCertificates = new();
96104

97105
public WinHttpHandler()
98106
{
107+
if (CertificateCachingAppContextSwitchEnabled)
108+
{
109+
WeakReference<WinHttpHandler> thisRef = new(this);
110+
bool restoreFlow = false;
111+
try
112+
{
113+
if (!ExecutionContext.IsFlowSuppressed())
114+
{
115+
ExecutionContext.SuppressFlow();
116+
restoreFlow = true;
117+
}
118+
119+
_certificateCleanupTimer = new Timer(
120+
static s =>
121+
{
122+
if (((WeakReference<WinHttpHandler>)s!).TryGetTarget(out WinHttpHandler? thisRef))
123+
{
124+
thisRef.ClearStaleCertificates();
125+
}
126+
},
127+
thisRef,
128+
Timeout.Infinite,
129+
Timeout.Infinite);
130+
}
131+
finally
132+
{
133+
if (restoreFlow)
134+
{
135+
ExecutionContext.RestoreFlow();
136+
}
137+
}
138+
}
99139
}
100140

101141
#region Properties
@@ -543,9 +583,12 @@ protected override void Dispose(bool disposing)
543583
{
544584
_disposed = true;
545585

546-
if (disposing && _sessionHandle != null)
586+
if (disposing)
547587
{
548-
SafeWinHttpHandle.DisposeAndClearHandle(ref _sessionHandle);
588+
if (_sessionHandle is not null) {
589+
SafeWinHttpHandle.DisposeAndClearHandle(ref _sessionHandle);
590+
}
591+
_certificateCleanupTimer?.Dispose();
549592
}
550593
}
551594

@@ -1644,7 +1687,8 @@ private void SetStatusCallback(
16441687
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS |
16451688
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_HANDLES |
16461689
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_REDIRECT |
1647-
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_SEND_REQUEST;
1690+
Interop.WinHttp.WINHTTP_CALLBACK_FLAG_SEND_REQUEST |
1691+
Interop.WinHttp.WINHTTP_CALLBACK_STATUS_CONNECTED_TO_SERVER;
16481692

16491693
IntPtr oldCallback = Interop.WinHttp.WinHttpSetStatusCallback(
16501694
requestHandle,
@@ -1730,5 +1774,90 @@ private RendezvousAwaitable<int> InternalReceiveResponseHeadersAsync(WinHttpRequ
17301774

17311775
return state.LifecycleAwaitable;
17321776
}
1777+
1778+
internal bool GetCertificateFromCache(CachedCertificateKey key, [NotNullWhen(true)] out byte[]? rawCertificateBytes)
1779+
{
1780+
if (_cachedCertificates.TryGetValue(key, out CachedCertificateValue? cachedValue))
1781+
{
1782+
cachedValue.LastUsedTime = Stopwatch.GetTimestamp();
1783+
rawCertificateBytes = cachedValue.RawCertificateData;
1784+
return true;
1785+
}
1786+
1787+
rawCertificateBytes = null;
1788+
return false;
1789+
}
1790+
1791+
internal void AddCertificateToCache(CachedCertificateKey key, byte[] rawCertificateData)
1792+
{
1793+
if (_cachedCertificates.TryAdd(key, new CachedCertificateValue(rawCertificateData, Stopwatch.GetTimestamp())))
1794+
{
1795+
EnsureCleanupTimerRunning();
1796+
}
1797+
}
1798+
1799+
internal bool TryRemoveCertificateFromCache(CachedCertificateKey key)
1800+
{
1801+
bool result = _cachedCertificates.TryRemove(key, out _);
1802+
if (result)
1803+
{
1804+
StopCleanupTimerIfEmpty();
1805+
}
1806+
return result;
1807+
}
1808+
1809+
private void ChangeCleanerTimer(TimeSpan timeout)
1810+
{
1811+
Debug.Assert(Monitor.IsEntered(_lockObject));
1812+
Debug.Assert(_certificateCleanupTimer != null);
1813+
if (_certificateCleanupTimer!.Change(timeout, Timeout.InfiniteTimeSpan))
1814+
{
1815+
_isTimerRunning = timeout != Timeout.InfiniteTimeSpan;
1816+
}
1817+
}
1818+
1819+
private void ClearStaleCertificates()
1820+
{
1821+
foreach (KeyValuePair<CachedCertificateKey, CachedCertificateValue> kvPair in _cachedCertificates)
1822+
{
1823+
if (IsStale(kvPair.Value.LastUsedTime))
1824+
{
1825+
_cachedCertificates.TryRemove(kvPair.Key, out _);
1826+
}
1827+
}
1828+
1829+
lock (_lockObject)
1830+
{
1831+
ChangeCleanerTimer(_cachedCertificates.IsEmpty ? Timeout.InfiniteTimeSpan : s_cleanCachedCertificateTimeout);
1832+
}
1833+
1834+
static bool IsStale(long lastUsedTime)
1835+
{
1836+
long now = Stopwatch.GetTimestamp();
1837+
return (now - lastUsedTime) > s_staleTimeout;
1838+
}
1839+
}
1840+
1841+
private void EnsureCleanupTimerRunning()
1842+
{
1843+
lock (_lockObject)
1844+
{
1845+
if (!_cachedCertificates.IsEmpty && !_isTimerRunning)
1846+
{
1847+
ChangeCleanerTimer(s_cleanCachedCertificateTimeout);
1848+
}
1849+
}
1850+
}
1851+
1852+
private void StopCleanupTimerIfEmpty()
1853+
{
1854+
lock (_lockObject)
1855+
{
1856+
if (_cachedCertificates.IsEmpty && _isTimerRunning)
1857+
{
1858+
ChangeCleanerTimer(Timeout.InfiniteTimeSpan);
1859+
}
1860+
}
1861+
}
17331862
}
17341863
}

0 commit comments

Comments
 (0)