With the recent release of .NET 7, we’d like to introduce some interesting changes and additions done in the networking space. This blog post talks about .NET 7 changes in HTTP space, new QUIC APIs, networking security and WebSockets.
HTTP
Improved handling of connection attempt failures
In versions prior to .NET 6, in case there is no connection immediately available in the connection pool, a new HTTP request will always spin up a new connection attempt and wait for it (if the settings on the handler permit it, e.g. MaxConnectionsPerServer
for HTTP/1.1, or EnableMultipleHttp2Connections
for HTTP/2). The downside to this is, if it takes a while to establish that connection and another connection becomes available in the meantime, that request would continue waiting for the connection it spawned, hurting latency. In .NET 6.0 we changed this to process requests on whichever connection that becomes available first, whether that’s a newly established one or one that became ready to handle the request in the meantime. A new connection is still created (subject to limits), and if it wasn’t used by the initiating request, it is then pooled for subsequent requests to possibly use.
Unfortunately, the .NET 6.0 implementation turned out to be problematic for some users: a failing connection attempt also fails the request on the top of the request queue, which may lead to unexpected request failures in certain scenarios. Moreover, if there is a pending connection in the pool that will never become usable, e.g. because of a misbehaving server or a network issue, new incoming requests associated with it will also stall and may time out.
In .NET 7.0 we implemented the following changes to address these issues:
- A failing connection attempt can only fail it’s initiating request, and never an unrelated one. If the original request has been handled by the time a connection fails, the connection failure is ignored (dotnet/runtime#62935).
- If a request initiates a new connection, but then becomes handled by another connection from the pool, the new pending connection attempt will time out silently after a short period, regardless of
ConnectTimeout
. With this change, stalled connections will not stall unrelated requests (dotnet/runtime#71785). Note that the failures of such “disowned” pending connection attempts will happen in the background and never surface to the user, the only way to observe them is to enable telemetry.
HttpHeaders read thread safety
The HttpHeaders
collections were never thread-safe. Accessing a header may force lazy parsing of its value, resulting in modifications to the underlying data structures.
Before .NET 6, reading from the collection concurrently happened to be thread-safe in most cases.
Starting with .NET 6, less locking was performed around header parsing as it was no longer needed internally. Due to this change, multiple examples of users erroneously accessing the headers concurrently surfaced, for example, in gRPC (dotnet/runtime#55898), NewRelic (newrelic/newrelic-dotnet-agent#803), or even HttpClient itself (dotnet/runtime#65379). Violating thread safety in .NET 6 may result in the header values being duplicated/malformed or various exceptions being thrown during enumeration/header accesses.
.NET 7 makes the header behavior more intuitive. The HttpHeaders
collection now matches the thread-safety guarantees of a Dictionary
:
The collection can support multiple readers concurrently, as long as it is not modified. In the rare case where an enumeration contends with write accesses, the collection must be locked during the entire enumeration. To allow the collection to be accessed by multiple threads for reading and writing, you must implement your own synchronization.
This was achieved by the following changes:
- A “validating read” of an invalid value does not result in the removal of the invalid value – dotnet/runtime#67833 (thanks @heathbm).
- Concurrent reads are thread-safe – dotnet/runtime#68115.
Detect HTTP/2 and HTTP/3 protocol errors
The HTTP/2 and HTTP/3 protocols define protocol-level error codes in RFC 7540 Section 7 and RFC 9114 Section 8.1, for example, REFUSED_STREAM (0x7)
in HTTP/2 or H3_EXCESSIVE_LOAD (0x0107)
in HTTP/3. Unlike HTTP-status codes, this is low-level error information that is unimportant for most HttpClient
users, but it helps in advanced HTTP/2 or HTTP/3 scenarios, notably grpc-dotnet, where distinguishing protocol errors is vital to implement client retries.
We defined a new exception HttpProtocolException
to hold the protocol-level error code in it’s ErrorCode
property.
When calling HttpClient
directly, HttpRequestException
can be an inner exception of HttpRequestException
:
try
{
using var response = await httpClient.GetStringAsync(url);
}
catch (HttpRequestException ex) when (ex.InnerException is HttpProtocolException pex)
{
Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
When working with HttpContent
‘s response stream, it is being thrown directly:
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
using var responseStream = await response.Content.ReadAsStreamAsync();
try
{
await responseStream.ReadAsync(buffer);
}
catch (HttpProtocolException pex)
{
Console.WriteLine("HTTP error code: " + pex.ErrorCode)
}
HTTP/3
HTTP/3 support in HttpClient
was already feature complete in the previous .NET release, so we mostly concentrated our efforts in this space on the underlying System.Net.Quic
. Despite that, we did introduce few fixes and changes in .NET 7.
The most important change is that HTTP/3 is now enabled by default (dotnet/runtime#73153). It doesn’t mean that all HTTP requests will prefer HTTP/3 from now on, but in certain cases they might upgrade to it. For it to happen, the request must opt into version upgrade via HttpRequestMessage.VersionPolicy
set to RequestVersionOrHigher
. Then, if the server announces HTTP/3 authority in Alt-Svc
header, HttpClient
will use it for further requests, see RFC 9114 Section 3.1.1.
To name a few other interesting changes:
- HTTP telemetry was extended to cover HTTP/3 – dotnet/runtime#40896.
- Exception details were improved in case QUIC connection cannot be established – dotnet/runtime#70949.
- Proper usage of Host header for Server Name Identification (SNI) was fixed – dotnet/runtime#57169.
QUIC
QUIC is a new, transport layer protocol. It has been recently standardized in RFC 9000. It uses UDP as an underlying protocol and it’s inherently secure as it mandates TLS 1.3 usage, see RFC 9001. Another interesting difference from well-known transport protocols such as TCP and UDP is that it has stream multiplexing built-in on the transport layer. This allows having multiple, concurrent, independent data streams that do not affect each other.
QUIC itself doesn’t define any semantics for the exchanged data as it’s a transport protocol. It’s rather used in application layer protocols, for example in HTTP/3 or in SMB over QUIC. It can also be used for any custom defined protocol.
The protocol offers many advantages over TCP with TLS. For instance, faster connection establishment as it doesn’t require as many round trips as TCP with TLS on top. Or avoidance of head-of-line blocking problem where one lost packet doesn’t block data of all the other streams. On the other hand, there are disadvantages that come with using QUIC. As it is a new protocol, its adoption is still growing and is limited. Apart from that, QUIC traffic might be even blocked by some networking components.
QUIC in .NET
We introduced QUIC implementation in .NET 5 in System.Net.Quic
library. However, up until now the library was strictly internal and served only for own implementation of HTTP/3. With the release of .NET 7, we’re making the library public and we’re exposing its APIs. Since we had only HttpClient
and Kestrel as consumers of the APIs for this release, we decided to keep them as preview feature. It gives us ability to tweak the API in the next release before we settle on the final shape.
From the implementation perspective, System.Net.Quic
depends on MsQuic, native implementation of QUIC protocol. As a result, System.Net.Quic
platform support and dependencies are inherited from MsQuic and documented in HTTP/3 Platform dependencies. In short, MsQuic library is shipped as part of .NET for Windows. For Linux, libmsquic
must be manually installed via an appropriate package manager. For the other platforms, it is still possible to build MsQuic manually, whether against SChannel or OpenSSL, and use it with System.Net.Quic
.
API overview
System.Net.Quic
brings three major classes that enable usage of QUIC protocol:
QuicListener
– server side class for accepting incoming connections.QuicConnection
– QUIC connection, corresponding to RFC 9000 Section 5.QuicStream
– QUIC stream, corresponding to RFC 9000 Section 2.
But before any usage of these classes, user code should check whether QUIC is currently supported, as libmsquic
might be missing, or TLS 1.3 might not be supported. For that, both QuicListener
and QuicConnection
expose a static property IsSupported
:
if (QuicListener.IsSupported)
{
// Use QuicListener
}
else
{
// Fallback/Error
}
if (QuicConnection.IsSupported)
{
// Use QuicConnection
}
else
{
// Fallback/Error
}
Note that, at the moment both these properties are in sync and will report the same value, but that might change in the future. So we recommend to check QuicListener.IsSupported
for server-scenarios and QuicConnection.IsSupported
for the client ones.
QuicListener
QuicListener
represents a server side class that accepts incoming connections from the clients. The listener is constructed and started with a static method QuicListener.ListenAsync
. The method accepts an instance of QuicListenerOptions
class with all the settings necessary to start the listener and accept incoming connections. After that, listener is ready to hand out connections via AcceptConnectionAsync
. Connections returned by this method are always fully connected, meaning that the TLS handshake is finished and the connection is ready to be used. Finally, to stop listening and release all resources, DisposeAsync
must be called.
The sample usage of QuicListener
:
using System.Net.Quic;
// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
return;
}
// We want the same configuration for each incoming connection, so we prepare the connection options upfront and reuse them.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions()
{
// Used to abort stream if it's not properly closed by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.
// Used to close the connection if it's not done by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.
// Same options as for server side SslStream.
ServerAuthenticationOptions = new SslServerAuthenticationOptions
{
// List of supported application protocols, must be the same or subset of QuicListenerOptions.ApplicationProtocols.
ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" },
// Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
ServerCertificate = serverCertificate
}
};
// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions()
{
// Listening endpoint, port 0 means any port.
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
// List of all supported application protocols by this listener.
ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" },
// Callback to provide options for the incoming connections, it gets called once per each connection.
ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});
// Accept and process the connections.
while (isRunning)
{
// Accept will propagate any exceptions that occurred during the connection establishment,
// including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
var connection = await listener.AcceptConnectionAsync();
// Process the connection...
}
// When finished, dispose the listener.
await listener.DisposeAsync();
More details about how this class was designed can be found in the QuicListener
API Proposal (dotnet/runtime#67560).
QuicConnection
QuicConnection
is a class used for both server and client side QUIC connections. Server side connections are created internally by the listener and handed out via QuicListener.AcceptConnectionAsync
. Client side connections must be opened and connected to the server. As with the listener, there’s a static method QuicConnection.ConnectAsync
that instantiates and connects the connection. It accepts an instance of QuicClientConnectionOptions
, an analogous class to QuicServerConnectionOptions
. After that, the work with the connection doesn’t differ between client and server. It can open outgoing streams and accept incoming ones. It also provides properties with information about the connection, like LocalEndPoint
, RemoteEndPoint
, or RemoteCertificate
.
When the work with the connection is done, it needs to be closed and disposed. QUIC protocol mandates using an application layer code for immediate closure, see RFC 9000 Section 10.2. For that, CloseAsync
with application layer code can be called or if not, DisposeAsync
will use the code provided in QuicConnectionOptions.DefaultCloseErrorCode
. Either way, DisposeAsync
must be called at the end of the work with the connection to fully release all the associated resources.
The sample usage of QuicConnection
:
using System.Net.Quic;
// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
return;
}
// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions()
{
// End point of the server to connect to.
RemoteEndPoint = listener.LocalEndPoint,
// Used to abort stream if it's not properly closed by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.
// Used to close the connection if it's not done by the user.
// See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.
// Optionally set limits for inbound streams.
MaxInboundUnidirectionalStreams = 10,
MaxInboundBidirectionalStreams = 100,
// Same options as for client side SslStream.
ClientAuthenticationOptions = new SslClientAuthenticationOptions()
{
// List of supported application protocols.
ApplicationProtocols = new List<SslApplicationProtocol>() { "protocol-name" }
}
};
// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);
Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");
// Open a bidirectional (can both read and write) outbound stream.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
// Work with the outgoing stream ...
// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
// Accept an inbound stream.
var incomingStream = await connection.AcceptInboundStreamAsync();
// Work with the incoming stream ...
}
// Close the connection with the custom code.
await connection.CloseAsync(0x0C);
// Dispose the connection.
await connection.DisposeAsync();
More details about how this class was designed can be found in the QuicConnection
API Proposal (dotnet/runtime#68902).
QuicStream
QuicStream
is the actual type that is used to send and receive data in QUIC protocol. It derives from ordinary Stream
and can be used as such, but it also offers several features that are specific to QUIC protocol. Firstly, a QUIC stream can either be unidirectional or bidirectional, see RFC 9000 Section 2.1. A bidirectional stream is able to send and receive data on both sides, whereas unidirectional stream can only write from the initiating side and read on the accepting one. Each peer can limit how many concurrent stream of each type is willing to accept, see QuicConnectionOptions.MaxInboundBidirectionalStreams
and QuicConnectionOptions.MaxInboundUnidirectionalStreams
.
Another particularity of QUIC stream is ability to explicitly close the writing side in the middle of work with the stream, see CompleteWrites
or WriteAsync
-system-boolean-system-threading-cancellationtoken)) overload with completeWrites
argument. Closing of the writing side lets the peer know that no more data will arrive, yet the peer still can continue sending (in case of a bidirectional stream). This is useful in scenarios like HTTP request/response exchange when the client sends the request and closes the writing side to let the server know that this is the end of the request content. Server is still able to send the response after that, but knows that no more data will arrive from the client. And for erroneous cases, either writing or reading side of the stream can be aborted, see Abort
. The behavior of the individual methods for each stream type is summarized in the following table (note that both client and server can open and accept streams):
Peer opening stream | Peer accepting stream | |
---|---|---|
CanRead |
bidirectional: true unidirectional: false |
true |
CanWrite |
true |
bidirectional: true unidirectional: false |
ReadAsync |
bidirectional: reads data unidirectional: InvalidOperationException |
reads data |
WriteAsync |
sends data => peer read returns the data | bidirectional: sends data => peer read returns the data unidirectional: InvalidOperationException |
CompleteWrites |
closes writing side => peer read returns 0 | bidirectional: closes writing side => peer read returns 0 unidirectional: no-op |
Abort(QuicAbortDirection.Read) |
bidirectional: STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted) unidirectional: no-op |
STOP_SENDING => peer write throws QuicException(QuicError.OperationAborted) |
Abort(QuicAbortDirection.Write) |
RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) |
bidirectional: RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) unidirectional: no-op |
On top of these methods, QuicStream
offers two specialized properties to get notified whenever either reading or writing side of the stream has been closed: ReadsClosed
and WritesClosed
. Both return a Task
that completes with its corresponding side getting closed, whether it be success or abort, in which case the Task
will contain appropriate exception. These properties are useful when the user code needs to know about stream side getting closed without issuing call to ReadAsync
or WriteAsync
.
Finally, when the work with the stream is done, it needs to be disposed with DisposeAsync
. The dispose will make sure that both reading and/or writing side – depending on the stream type – is closed. If stream hasn’t been properly read till the end, dispose will issue an equivalent of Abort(QuicAbortDirection.Read)
. However, if stream writing side hasn’t been closed, it will be gracefully closed as it would be with CompleteWrites
. The reason for this difference is to make sure that scenarios working with an ordinary Stream
behave as expected and lead to a successful path. Consider the following example:
// Work done with all different types of streams.
async Task WorkWithStream(Stream stream)
{
// This will dispose the stream at the end of the scope.
await using (stream)
{
// Simple echo, read data and send them back.
byte[] buffer = new byte[1024];
int count = 0;
// The loop stops when read returns 0 bytes as is common for all streams.
while ((count = await stream.ReadAsync(buffer)) > 0)
{
await stream.WriteAsync(buffer.AsMemory(0, count));
}
}
}
// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStream(quicStream);
The sample usage of QuicStream
in client scenario:
// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenStreamAsync(QuicStreamType.Bidirectional, cancellationToken);
// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);
// End the writing-side together with the last data.
await stream.WriteAsync(data, endStream: true, cancellationToken);
// Or separately.
stream.CompleteWrites();
// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
// Handle buffer data...
}
// DisposeAsync called by await using at the top.
And the sample usage of QuicStream
in server scenario:
// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptStreamAsync(cancellationToken);
if (stream.Type != QuicStreamType.Bidirectional)
{
Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
return;
}
// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
// Handle buffer data...
// Client completed the writes, the loop might be exited now without another ReadAsync.
if (stream.ReadsCompleted.IsCompleted)
{
break;
}
}
// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
try
{
await stream.WritesClosed;
}
catch (Exception ex)
{
// Handle peer aborting our writing side ...
}
}
// DisposeAsync called by await using at the top.
More details about how this class was designed can be found in the QuicStream
API Proposal (dotnet/runtime#69675).
Future
As System.Net.Quic
is newly made public and we have only limited usages, we’ll appreciate any bug reports or insights on the API shape. Thanks to APIs being in preview mode, we still have a chance to tweak them for .NET 8 based on the feedback we get. Issues can be filed in dotnet/runtime repository.
Security
Negotiate API
Windows Authentication is an encompassing term for multiple technologies used in enterprises to authenticate users and applications against a central authority, usually the domain controller. It enables scenarios like single sign-on to email services or intranet applications. The underlying technologies used for the authentication are Kerberos, NTLM, and the encompassing Negotiate protocol, where the most suitable technology is chosen for a specific authentication scenario.
Prior to .NET 7, Windows Authentication was exposed in the high-level APIs, like HttpClient
(Negotiate
and NTLM
authentication schemes), SmtpClient
(GSSAPI
and NTLM
authentication schemes), NegotiateStream
, ASP.NET Core, and the SQL Server client libraries. While that covers most scenarios for end users, it is limiting for library authors. Other libraries like the Npgsql PostgreSQL client, MailKit, Apache Kudu client and others needed to resort to various tricks to implement the same authentication schemes for low-level protocols that were not built on HTTP or other available high-level building blocks.
.NET 7 introduces new API providing low-level building blocks to perform the authentication exchange for the above mentioned protocols, see dotnet/runtime#69920. As with all the other APIs in .NET, it is built with cross-platform interoperability in mind. On Linux, macOS, iOS, and other similar platforms, it uses the GSSAPI system library. On Windows, it relies on the SSPI library. For platforms where system implementation is not available, such as Android and tvOS, a limited client-only implementation is present.
How to use the API
In order to understand how the authentication API works, let’s start with an example of how the authentication session looks in a high-level protocol like SMTP. The example is taken from Microsoft protocol documentation that explains it in greater detail.
S: 220 server.contoso.com Authenticated Receive Connector
C: EHLO client.contoso.com
S: 250-server-contoso.com Hello [203.0.113.1]
S: 250-AUTH GSSAPI NTLM
S: 250 OK
C: AUTH GSSAPI <token1>
S: 334 <token2>
C: <token3>
S: 235 2.7.0 Authentication successful
The authentication starts with the client producing a challenge token. Then the server produces a response. The client processes the response, and a new challenge is sent to the server. This challenge/response exchange can happen multiple times. It finishes when either party rejects the authentication or when both parties accept the authentication. The format of the tokens is defined by the Windows Authentication protocols, while the encapsulation is part of the high-level protocol specification. In this example, the SMTP protocol prepends the 334
code to tell the client that the server produced an authentication response, and the 235
code indicates successful authentication.
The bulk of the new API centers around a new NegotiateAuthentication
class. It is used to instantiate the context for client-side or server-side authentication. There are various options to specify requirements for establishing the authenticated session, like requiring encryption or determining that a specific protocol (Negotiate, Kerberos, or NTLM) is to be used. Once the parameters are specified, the authentication proceeds by exchanging the authentication challenges/responses between the client and the server. The GetOutgoingBlob
method is used for that. It can work either on byte spans or base64-encoded strings.
The following code will perform both, client and server, part of the authentication for the current user on the same machine:
using System.Net;
using System.Net.Security;
var serverAuthentication = new NegotiateAuthentication(new NegotiateAuthenticationServerOptions { });
var clientAuthentication = new NegotiateAuthentication(
new NegotiateAuthenticationClientOptions
{
Package = "Negotiate",
Credential = CredentialCache.DefaultNetworkCredentials,
TargetName = "HTTP/localhost",
RequiredProtectionLevel = ProtectionLevel.Sign
});
string? serverBlob = null;
while (!clientAuthentication.IsAuthenticated)
{
// Client produces the authentication challenge, or response to server's challenge
string? clientBlob = clientAuthentication.GetOutgoingBlob(serverBlob, out var clientStatusCode);
if (clientStatusCode == NegotiateAuthenticationStatusCode.ContinueNeeded)
{
// Send the client blob to the server; this would normally happen over a network
Console.WriteLine($"C: {clientBlob}");
serverBlob = serverAuthentication.GetOutgoingBlob(clientBlob, out var serverStatusCode);
if (serverStatusCode != NegotiateAuthenticationStatusCode.Completed &&
serverStatusCode != NegotiateAuthenticationStatusCode.ContinueNeeded)
{
Console.WriteLine($"Server authentication failed with status code {serverStatusCode}");
break;
}
Console.WriteLine($"S: {serverBlob}");
}
else
{
Console.WriteLine(
clientStatusCode == NegotiateAuthenticationStatusCode.Completed ?
"Successfully authenticated" :
$"Authentication failed with status code {clientStatusCode}");
break;
}
}
Once the authenticated session is established, the NegotiateAuthentication
instance can be used to sign/encrypt the outgoing messages and verify/decrypt the incoming messages. This is done through the Wrap
and Unwrap
methods.
This change was done as well as this part of the article was written by a community contributor @filipnavara. Thanks!
Options for certificate validation
When a client receives server’s certificate, or vice-versa if client certificate is requested, the certificate is validated via X509Chain
. The validation happens always, even if RemoteCertificateValidationCallback
is provided, and additional certificates might get downloaded during the validation. Several issues were raised as there was no way to control this behavior. Among them were asks to completely prevent certificate download, put a timeout on it, or to provide custom store to get the certificates from. To mitigate this whole group of issues, we decided to introduce a new property CertificateChainPolicy
on both SslClientAuthenticationOptions
and SslServerAuthenticationOptions
. The goal of this property is to override the default behavior of SslStream
when building the chain during AuthenticateAsClientAsync
/ AuthenticateAsServerAsync
operation. In normal circumstances, X509ChainPolicy
is constructed automatically in the background. But if this new property is specified, it will take precedence and be used instead, giving the user full control over the certificate validation process.
The usage of the chain policy might look like:
// Client side:
var policy = new X509ChainPolicy();
policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
policy.ExtraStore.Add(s_ourPrivateRoot);
policy.UrlRetrievalTimeout = TimeSpan.FromSeconds(3);
var options = new SslClientAuthenticationOptions();
options.TargetHost = "myServer";
options.CertificateChainPolicy = policy;
var sslStream = new SslStream(transportStream);
sslStream.AuthenticateAsClientAsync(options, cancellationToken);
// Server side:
var policy = new X509ChainPolicy();
policy.DisableCertificateDownloads = true;
var options = new SslServerAuthenticationOptions();
options.CertificateChainPolicy = policy;
var sslStream = new SslStream(transportStream);
sslStream.AuthenticateAsServerAsync(options, cancellationToken);
More info can be found in the API proposal (dotnet/runtime#71191).
Performance
Most of the networking perfomance improvements in .NET 7 are covered by Stephen’s article Performance Improvements in .NET 7 – Networking, but some of the security ones are worth mentioning again.
TLS Resume
Establishing new TLS connection is fairly expensive operation as it requires multiple steps and several round trips. In scenarios where connection to the same server is re-created very often, time consumed by the handshakes will add up. TLS offers feature to mitigate this called Session Resumption, see RFC 5246 Section 7.3 and RFC 8446 Section 2.2. In short, during the handshake, client can send an identification of previously established TLS session and if server agrees, the security context gets re-established based on the cached data from the previous connection. Even though the mechanics differ for different TLS versions, the end goal is the same, save a round-trip and some CPU time when re-establishing connection to a previously connected server. This feature is automatically provided by SChannel on Windows, but with OpenSSL on Linux it required several changes to enable this:
- Server side (stateless) – dotnet/runtime#57079 and dotnet/runtime#63030.
- Client side – dotnet/runtime#64369.
- Cache size control – dotnet/runtime#69065.
If caching the TLS context is not desired, it can be turned off process-wide with either environment variable “DOTNET_SYSTEM_NET_SECURITY_DISABLETLSRESUME” or via AppContext.SetSwitch
“System.Net.Security.TlsCacheSize”.
OCSP Stapling
Online Certificate Status Protocol (OCSP) Stapling is a mechanism for server to provide signed and timestamped proof (OCSP response) that the sent certificate has not been revoked, see RFC 6961. As a result, client doesn’t need to contact the OCSP server itself, reducing the number of requests necessary to establish the connection as well as the load exerted on the OCSP server. And as the OCSP response needs to be signed by the certificate authority (CA), it cannot be forged by the server providing the certificate. We didn’t take advantage of this TLS feature until this release, for more details see dotnet/runtime#33377.
Consistency across platforms
We are aware, that some of the functionality provided by .NET is available only on certain platforms. But each release we try to narrow the gap a bit more. In .NET 7, we made several changes in the networking security space to improve the disparity:
- Support for post-handshake authentication on Linux for TLS 1.3 – dotnet/runtime#64268
- Remote certificate is now set on Windows in
SslClientAuthenticationOptions.LocalCertificateSelectionCallback
– dotnet/runtime#65134 - Support for sending names of trusted CA in TLS handshake on OSX and Linux – dotnet/runtime#65195
WebSockets
WebSocket handshake response details
Prior to .NET 7, server’s response part of WebSocket’s opening handshake (HTTP response to Upgrade request) was hidden inside ClientWebSocket
implementation, and all handshake errors would surface as WebSocketException
without much details beside the exception message. However, the information about HTTP response headers and status code might be important in both failure and success scenarios.
In case of failure, HTTP status code can help to distinguish between retriable and non-retriable errors (e.g. server doesn’t support WebSockets at all, or it was just a transient network error). Headers might also contain additional information on how to handle the situation. The headers are useful even in case of a successful WebSocket handshake, e.g. they can contain token tied to a session, information related to subprotocol version, or that the server can go down soon.
.NET 7 adds CollectHttpResponseDetails
setting to ClientWebSocketOptions
that enables collecting upgrade response details in a ClientWebSocket
instance during ConnectAsync
call. You can later access the data using HttpStatusCode
and HttpResponseHeaders
properties of the ClientWebSocket
instance, even in case of ConnectAsync
throwing an exception. Note that in the exceptional case, the information might be unavailable, i.e. if the server never responded to the request.
Also note that in case of a successful connect and after consuming HttpResponseHeaders
data, you can reduce ClientWebSocket
‘s memory footprint by setting ClientWebSocket.HttpResponseHeaders
property to null
.
var ws = new ClientWebSocket();
ws.Options.CollectHttpResponseDetails = true;
try
{
await ws.ConnectAsync(uri, cancellationToken);
// success scenario
ProcessSuccess(ws.HttpResponseHeaders);
ws.HttpResponseHeaders = null; // clean up (if needed)
}
catch (WebSocketException)
{
// failure scenario
if (ws.HttpStatusCode != 0)
{
ProcessFailure(ws.HttpStatusCode, ws.HttpResponseHeaders);
}
}
Providing external HTTP client
In the default case, ClientWebSocket
uses a cached static HttpMessageInvoker
instance to execute HTTP Upgrade request. However, there are certain ClientWebSocketOptions
that prevent caching the invoker, such as Proxy
, ClientCertificates
or Cookies
. HttpMessageInvoker
instance with these parameters is not safe to be reused and needs be created each time ConnectAsync
is called. This results in many unnecessary allocations and makes reuse of HttpMessageInvoker
connection pool impossible.
.NET 7 allows you to pass an existing HttpMessageInvoker
(e.g. HttpClient
) instance to ConnectAsync
call, using the ConnectAsync(Uri, HttpMessageInvoker, CancellationToken)
overload. In that case, HTTP Upgrade request would be executed using the provided instance.
var httpClient = new HttpClient();
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, httpClient, cancellationToken);
Note that in case a custom HTTP invoker is passed, any of the following ClientWebSocketOptions
must not be set, and should be set up on the HTTP invoker instead:
ClientCertificates
Cookies
Credentials
Proxy
RemoteCertificateValidationCallback
UseDefaultCredentials
This is how you can set up all of these options on the HttpMessageInvoker
instance:
var handler = new HttpClientHandler();
handler.CookieContainer = cookies;
handler.UseCookies = cookies != null;
handler.ServerCertificateCustomValidationCallback = remoteCertificateValidationCallback;
handler.Credentials = useDefaultCredentials ?
CredentialCache.DefaultCredentials :
credentials;
if (proxy == null)
{
handler.UseProxy = false;
}
else
{
handler.Proxy = proxy;
}
if (clientCertificates?.Count > 0)
{
handler.ClientCertificates.AddRange(clientCertificates);
}
var invoker = new HttpMessageInvoker(handler);
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, invoker, cancellationToken);
WebSockets over HTTP/2
.NET 7 also adds an ability to use WebSocket protocol over HTTP/2, as described in RFC 8441. With that, WebSocket connection is established over a single stream on HTTP/2 connection. This allows for a single TCP connection to be shared between several WebSocket connections and HTTP requests at the same time, resulting in more efficient use of the network.
To enable WebSockets over HTTP/2, you can set ClientWebSocketOptions.HttpVersion
option to HttpVersion.Version20
. You can also enable upgrade/downgrade of HTTP version used by setting ClientWebSocketOptions.HttpVersionPolicy
property. These options will behave in the same way HttpRequestMessage.Version
and HttpRequestMessage.VersionPolicy
behave.
For example, the following code would probe for HTTP/2 WebSockets, and if a WebSocket connection cannot be established, it will fallback to HTTP/1.1:
var ws = new ClientWebSocket();
ws.Options.HttpVersion = HttpVersion.Version20;
ws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
await ws.ConnectAsync(uri, httpClient, cancellationToken);
The combination of HttpVersion.Version11
and HttpVersionPolicy.RequestVersionOrHigher
will result in the same behavior as above, while HttpVersionPolicy.RequestVersionExact
will disallow upgrade/downgrade of HTTP version.
By default, HttpVersion = HttpVersion.Version11
and HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
are set, meaning that only HTTP/1.1 will be used.
The ability to multiplex WebSocket connections and HTTP requests over a single HTTP/2 connection is a crucial part of this feature. For it to work as expected, you need to pass and reuse the same HttpMessageInvoker
instance (e.g. HttpClient
) from your code when calling ConnectAsync
, i.e. use ConnectAsync(Uri, HttpMessageInvoker, CancellationToken)
overload. This will reuse the connection pool within the HttpMessageInvoker
instance for the multiplexing.
Final notes
We try to pick the most interesting and impactful changes in the networking space. The article doesn’t contain all the changes we did, but they can be found in dotnet/runtime repository. As usual, performance improvements are mostly omitted as they get their spot in Stephens’s article Performance Improvements in .NET 7. We’d also like to hear from you, so if you encounter an issue or have any feedback, you can file it in our GitHub. Finally, you can find similar blog posts for previous releases here: .NET 6 Networking Improvements, .NET 5 Networking Improvements.
The post .NET 7 Networking Improvements appeared first on .NET Blog.
source https://devblogs.microsoft.com/dotnet/dotnet-7-networking-improvements/
Comments
Post a Comment