Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WinHTTP] Certificate caching on WinHttpHandler to eliminate extra call to Custom Certificate Validation #111791

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

liveans
Copy link
Member

@liveans liveans commented Jan 24, 2025

It's a quick proof of concept.

Copy link
Contributor

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

@liveans
Copy link
Member Author

liveans commented Jan 24, 2025

/azp run runtime-libraries-coreclr outerloop-windows

Copy link

Azure Pipelines successfully started running 1 pipeline(s).

@@ -20,6 +23,8 @@ internal static class WinHttpRequestCallback
public static Interop.WinHttp.WINHTTP_STATUS_CALLBACK StaticCallbackDelegate =
new Interop.WinHttp.WINHTTP_STATUS_CALLBACK(WinHttpCallback);

private static ConcurrentDictionary<IPAddress, string> s_cachedCertificates = new();
Copy link
Member

Choose a reason for hiding this comment

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

This should be specific to each WinHttpHandler instance as cert validation callbacks may be different (either an instance collection, or the handler being included as the key).

We should also be clearing old entries from this to avoid a memory leak.

Copy link
Member

Choose a reason for hiding this comment

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

NIT: Also consider to invalidate instance level cache if validation callback is changed. I know it is not recommended to change handler after it has been wired to HttpClient but if it is possible we might consider to handle it.

}
}

if (ipAddress is not null && s_cachedCertificates.TryGetValue(ipAddress, out string? thumbprint) && thumbprint == serverCertificate.Thumbprint)
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to obtain the thumbprint from the certHandle without allocating the cert?
If so, this could be moved up to avoid some more overhead.
Otherwise, this is also fine assuming that building the chain is the bulk of wasted compute.

Copy link
Member

Choose a reason for hiding this comment

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

and thumbprint use obsolete SHA-1. We should avoid it IMHO for new code. We may try to hash the whole cert content e.g. raw bytes.

Copy link
Member

Choose a reason for hiding this comment

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

I suppose we shouldn't need hashing here at all. We have the certs, we can just always do a SequenceEqual on the raw bytes.

ref infoSize))
{
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128);
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0);
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan);

Copy link
Member

Choose a reason for hiding this comment

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

Oh I guess this is due to Framework? :/

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes

Copy link
Member

@MihaZupan MihaZupan Jan 28, 2025

Choose a reason for hiding this comment

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

In that case we can at least do .Slice(0, 2).ToArray() instead to avoid allocating the full 128 bytes

Copy link
Member

Choose a reason for hiding this comment

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

Can it be just Unsafe.ReadUnaligned<short>(ref MemoryMarshal.GetReference(remoteAddressSpan))? (Assuming we don't need to also check endianness here.)

Copy link
Member

Choose a reason for hiding this comment

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

Why not to use simple bit manipulation: RemoteAddressSpan[0] + (RemoteAddressSpan[0]<<8) normally I would recommend BinaryPrimitives.ReadInt16LittleEndian but I not sure we can afford package dependency here for framework.

Copy link
Member

Choose a reason for hiding this comment

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

The important thing is to avoid unnecessary allocations, it doesn't really matter how. (The code I quoted is BinaryPrimitives.ReadInt16LittleEndian's implementation.)

(IntPtr)pConnectionInfo,
ref infoSize))
{
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128);
Copy link
Member

Choose a reason for hiding this comment

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

Local variables should be lower case: remoteAddressSpan

Comment on lines +321 to +322
AddressFamily.InterNetwork => new IPAddress(BinaryPrimitives.ReadUInt32LittleEndian(RemoteAddressSpan.Slice(4))),
AddressFamily.InterNetworkV6 => new IPAddress(RemoteAddressSpan.Slice(8, 16).ToArray()),
Copy link
Member

@antonfirsov antonfirsov Jan 28, 2025

Choose a reason for hiding this comment

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

Since this is running per-request, can we try to avoid the IPAddress allocations (eg. by using a custom struct as a dictionary key), or if we think they are insignificant, run some benchmarks to prove it?

ref infoSize))
{
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128);
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0);
Copy link
Member

Choose a reason for hiding this comment

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

We should avoid the ToArray() allocation. Shouldn't we deal with endiannes here too and use BinaryPrimitives?

Comment on lines +328 to +329
else
{
Copy link
Member

Choose a reason for hiding this comment

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

On what Windows versions is WINHTTP_OPTION_CONNECTION_INFO supported? Can we disable WinHttpQueryOption queries forever if it fails once?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

@liveans liveans Jan 29, 2025

Choose a reason for hiding this comment

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

I don't think there is a windows version which doesn't support this feature and we support as .NET, this should be pretty safe.

WINHTTP_OPTION_CONNECTION_INFO says that this Applies to: Windows XP with SP2 and later; Windows 2003 with SP1 and later.

So we're safe here.

Copy link
Member

Choose a reason for hiding this comment

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

Might worth to add a comment.

AddressFamily.InterNetworkV6 => new IPAddress(RemoteAddressSpan.Slice(8, 16).ToArray()),
_ => null
};
if (ipAddress != null && NetEventSource.Log.IsEnabled()) NetEventSource.Info(state, $"ipAddress: {ipAddress}");
Copy link
Member

@antonfirsov antonfirsov Jan 28, 2025

Choose a reason for hiding this comment

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

Do we really expect non-IP address families here? Or can we just do Debug.Assert() for it and avoid further checks?

@@ -56,6 +61,11 @@ private static void RequestCallback(
{
switch (internetStatus)
{
case Interop.WinHttp.WINHTTP_CALLBACK_STATUS_CONNECTED_TO_SERVER:
IPAddress connectedToIPAddress = IPAddress.Parse(Marshal.PtrToStringUni(statusInformation)!);
Copy link
Member

Choose a reason for hiding this comment

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

This may be problematic in general as there can be many virtual servers and certificates on single IP address.

Copy link
Member Author

Choose a reason for hiding this comment

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

If you're talking about the same ip-port pair, yes, but we can extend this solution to cover sni-port too, the only thing we can do at that moment is, once we have a connection to this ip address, we still need to clear all cache for this ip address, because we can't extract more info other than ip address in this callback.

Copy link
Member

Choose a reason for hiding this comment

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

Is a (host, port) pair really sufficient to identify connections? In SocketsHttpHandler our connection key includes much more info.

internal readonly struct HttpConnectionKey : IEquatable<HttpConnectionKey>
{
public readonly HttpConnectionKind Kind;
public readonly string? Host;
public readonly int Port;
public readonly string? SslHostName; // null if not SSL
public readonly Uri? ProxyUri;
public readonly string Identity;
public HttpConnectionKey(HttpConnectionKind kind, string? host, int port, string? sslHostName, Uri? proxyUri, string identity)

Copy link
Member

@rokonec rokonec left a comment

Choose a reason for hiding this comment

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

This is very nice for Prove of concept, but there are few things which needs to be considered before we decide on feasibility of this approach:

  • decide if unbounded cache can be an memory leak issue (I have hard time to imagine use where it would)
  • consider different cache key to handle virtual servers infra

ref infoSize))
{
ReadOnlySpan<byte> RemoteAddressSpan = new ReadOnlySpan<byte>(connectionInfo.RemoteAddress, 128);
AddressFamily addressFamily = (AddressFamily)BitConverter.ToInt16(RemoteAddressSpan.ToArray(), 0);
Copy link
Member

Choose a reason for hiding this comment

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

Why not to use simple bit manipulation: RemoteAddressSpan[0] + (RemoteAddressSpan[0]<<8) normally I would recommend BinaryPrimitives.ReadInt16LittleEndian but I not sure we can afford package dependency here for framework.

@@ -20,6 +23,8 @@ internal static class WinHttpRequestCallback
public static Interop.WinHttp.WINHTTP_STATUS_CALLBACK StaticCallbackDelegate =
new Interop.WinHttp.WINHTTP_STATUS_CALLBACK(WinHttpCallback);

private static ConcurrentDictionary<IPAddress, string> s_cachedCertificates = new();
Copy link
Member

Choose a reason for hiding this comment

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

NIT: Also consider to invalidate instance level cache if validation callback is changed. I know it is not recommended to change handler after it has been wired to HttpClient but if it is possible we might consider to handle it.

}
}

if (ipAddress is not null && s_cachedCertificates.TryGetValue(ipAddress, out string? thumbprint) && thumbprint == serverCertificate.Thumbprint)
Copy link
Member

Choose a reason for hiding this comment

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

If cache key stays as IPAddress and not some more specific unique connection hash/id, there is change that IP of virtual servers will cause huge degradation in perf. For example if give IP serve http://foo/ and http://bar/ client which will alternate between foo and bar it will always cross invalidate foo and bar caches. For example if in this setup every call will be like:

1) http://foo/ensure-connected
2) http://bar/resource/verb?op=x

It will always call validation callback.
I believe we shall seek for cache key which has better affinity to particular endpoint, for example hash(request_host+cert_thumbprint).

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.

5 participants