.NET 7更新之网络,是你期待的吗?

Amy Peng

最新的.NET 7现已发布,我们想介绍一下其在网络领域所做的一些有趣的更改和添加。这篇博文讨论了 .NET 7 在HTTP 空间新 QUIC API网络安全WebSockets方面的变化。

HTTP

改进了对连接尝试失败的处理

在 .NET 6 之前的版本中,如果连接池中没有立即可用的连接,(处理程序上的设置允许的情况下,例如HTTP /1.1中的MaxConnectionsPerServer,或 HTTP/2中的EnableMultipleHttp2Connections)新的 HTTP 请求始终会发起新的连接尝试并等待响应。这样做的缺点是,建立该连接需要一段时间,而在这段时间里如果另一个连接已经可用,该请求仍将继续等待它生成的连接,从而影响延迟。在 .NET 6.0中我们改变了这一进程,无论是新建立的连接还是与此同时准备好处理请求的另一个连接,第一个可用的连接会处理请求。这样仍会一个新连接被建立(受限制),如果发起的请求未使用这一连接,则它会被合并以供后续请求使用。

不幸的是,.NET 6.0 中的这一功能对某些用户来说是有问题的:失败的连接尝试也会使位于请求队列顶部的请求失败,这可能会在某些情况下导致意外的请求失败。此外,如果由于某些原因(例如由于服务器行为不当或网络问题)池中有一个永远未被使用的连接,与之关联的新传入的请求也将延迟并可能超时。

在 .NET 7.0 中,我们实施了以下更改来解决这些问题:

  • 失败的连接尝试只能使其相关的发起请求失败,而不会导致无关的请求失败。如果在连接失败时原始请求已得到处理,则连接失败将被忽略 ( dotnet/runtime#62935 )。
  • 如果一个请求发起了一个新连接,但随后被池中的另一个连接处理,则新的待使用的连接尝试将不管ConnectTimeout,在短时间后自动超时。通过此更改,延迟的连接将不会延迟不相关的请求 ( dotnet/runtime#71785 )。请注意,不被使用的连接尝试自动超时失败的这一进程只会在后台自己运行,用户不会看到此进程。观察它们的唯一方法是启用telemetry。

HttpHeaders 读取线程安全

这些HttpHeaders集合从来都不是线程安全的。访问header可能会强制延迟解析它的值,从而导致对底层数据结构的修改。

在 .NET 6 之前,同时读取集合在大多数情况下恰好是线程安全的。

从 .NET 6 开始,由于内部不再需要锁定,针对header解析执行的锁定较少。这一变化导致许多用户错误地同时访问header,例如,在 gRPC ( dotnet/runtime#55898 )、NewRelic ( newrelic/newrelic-dotnet-agent#803 ) 甚至 HttpClient 本身 ( dotnet/runtime #65379)。违反 .NET 6 中的线程安全可能会导致header值重复/格式错误或在枚举(enumeration)/header访问期间产生各种异常。

.NET 7 使header行为更加直观。该HttpHeaders集合现在符合Dictionary线程安全保证:

集合可以同时支持多个读者,只要它不被修改。极少数情况下,枚举(enumeration)与书写访问权限争用,则该集合必须在整个枚举期间被锁定。要允许多个线程访问集合以同时进行读写,您必须实现自己的同步。

这是通过以下更改实现的:

检测 HTTP/2 HTTP/3 协议错误

HTTP/2 和 HTTP/3 协议在RFC 7540 第 7 节RFC 9114 第 8.1节中定义了协议级别的错误代码,例如,HTTP/2 中的REFUSED_STREAM (0x7) 或HTTP/3 中的H3_EXCESSIVE_LOAD (0x0107) 。与 HTTP 状态代码不同,这是对大多数HttpClient用户来说不重要的低级错误信息,但它在高级 HTTP/2 或 HTTP/3 场景中有帮助,特别是 grpc-dotnet,其中区分协议错误对于实现客户端重试至关重要。

我们定义了一个新的异常HttpProtocolException来在其ErrorCode属性中保存协议级错误代码。

HttpClient直接调用时,HttpProtocolException可以是内部异常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)

}

使用HttpContent的响应流时,它被直接抛出:

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

在HttpClient之前的 .NET 版本已经完成了对 HTTP/3 的支持,所以我们主要集中精力在这个领域的System.Net.Quic底层。尽管如此,我们确实在 .NET 7 中引入了一些修复和更改。

最重要的变化是现在默认启用 HTTP/3 ( dotnet/runtime#73153 )。这并不意味着从现在开始所有 HTTP 请求都将首选 HTTP/3,但在某些情况下它们可能会升级到HTTP/3。为此,请求必须通过将HttpRequestMessage.VersionPolicy设置为RequestVersionOrHigher,从而能够版本升级。然后,如果服务器在Alt-Svc header中有 HTTP/3 授权,HttpClient将使用它进行进一步的请求,请参阅RFC 9114 第 3.1.1 节

列举几个其他有趣的变化:

QUIC

QUIC 是一种新的传输层协议。它最近已在RFC 9000中标准化。它使用 UDP 作为底层协议,并且它本质上是安全的,因为它要求使用 TLS 1.3,请参阅RFC 9001。与众所周知的传输协议(如 TCP 和 UDP)的另一个有趣区别是它在传输层上内置了流多路复用。这使其能够拥有多个并发的独立数据流,且这些数据流不会相互影响。

QUIC 本身没有为交换的数据定义任何语义,因为它是一种传输协议。它更适用于应用层协议,例如HTTP/3SMB over QUIC。它还可以用于任何自定义协议。

与 TLS 的 TCP 相比,该协议具有许多优势。例如,它不需要像顶部带有 TLS 的 TCP 那样多的往返行程,所以能够更快地建立连接。它能够避免队头阻塞问题,一个丢失的数据包不会阻塞所有其他流的数据。另一方面,使用 QUIC 也有缺点。由于它是一个新协议,它的采用仍在增长并且是有限的。除此之外,QUIC 流量甚至可能被某些网络组件阻止。

.NET 中的 QUIC

我们在System.Net.Quic库中介绍了 .NET 5 中的 QUIC 实现。然而,到目前为止,这个库是仅限内部的,并且只为自己的 HTTP/3 实现服务。随着 .NET 7 的发布,我们公开了该库并公开了它的 API。由于我们只有HttpClient和Kestrel 作为此版本 API 的使用者,因此我们决定将它们保留为预览功能。它使我们能够在确定最终形式之前在下一个版本中调整 API。

从实施的角度来看,System.Net.Quic取决于QUIC 协议的原生实现MsQuic。因此,System.Net.Quic平台支持和依赖项继承自 MsQuic,并记录在HTTP/3 平台依赖项文档中。简而言之,MsQuic 库作为 .NET for Windows 的一部分提供。对于 Linux,libmsquic必须通过适当的包管理器手动安装。对于其他平台,仍然可以手动构建 MsQuic,无论是针对 SChannel 还是 OpenSSL,并将其与System.Net.Quic一起使用。

API概述

System.Net.Quic 携带了能够使用QUIC协议的三个主要类:

但是在使用这些类之前,用户代码应该检查当前系统是否支持QUIC,因为系统可能缺失libmsquic 或者不支持TLS 1.3。为此, QuicListener和QuicConnection都公开了一个静态属性IsSupported:

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

请注意,目前这两个属性是同步的并将显示相同的值,但将来可能会改变。所以我们建议检查一下支持服务器场景的QuicListener.IsSupported和用于客户端的QuicListener.IsSupported

QuicListener

QuicListener 属于接受客户端的传入连接的服务器端类。 该侦听设备是通过静态方法QuicListener.ListenAsync构造和启动的。该方法接受QuicListenerOptions类的一个实例,其中包含启动侦听设备和接受传入连接所需的所有设置。之后,侦听设备着手通过AcceptConnectionAsync分发连接。此方法返回的连接始终是完全连接的,这意味着TLS交互已经完成,连接可以使用了。最后,要关闭侦听设备并释放所有资源,必须调用DisposeAsync 方法。

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();

更多关于这个类设计的细节可以在QuicListener API Proposal (dotnet/runtime#67560) 中找到。

QuicConnection

是用于服务器端和客户端QUIC连接的类。服务器端连接由侦听设备内部创建,并通过QuicListener.AcceptConnectionAsync分发连接。客户端连接必须被打开并连接到服务器。静态方法QuicConnection和侦听设备一起,建立并实例连接。它接受QuicClientConnectionOptions的实例,这是一个类似于QuicServerConnectionOptions的类。初次之前,此链接的工作方式与客户端和服务器之间没有区别。它可以打开向外和向内的流。它还提供与连接信息有关的属性,如LocalEndPoint、RemoteEndPoint或RemoteCertificate。

当连接的工作完成后,需要关闭和处置侦听设备。QUIC协议要求使用应用层代码立即关闭侦听设备,参见RFC 9000 Section 10.2。为此,可以调用带有应用层代码的CloseAsync ,如果没有,DisposeAsync将使用QuicConnectionOptions.DefaultCloseErrorCode中提供的代码。无论是哪种方式,都必须在连接工作结束时调用DisposeAsync,涌起完全释放所有相关资源。

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();

更多关于这个类设计的细节可以在QuicConnection API Proposal (dotnet/runtime#68902) 中找到。

QuicStream

QuicStream是QUIC协议中用于发送和接收数据的实际类型。它起源于普通的流Stream。可以和普通的流一样使用,但它也提供了一些特定于QUIC协议的特性。首先,QUIC流可以是单向的,也可以是双向的,参见RFC 9000 Section 2.1。双向流能够在两端发送和接收数据,而单向流只能从发起端输入数据,从接受端读取。每端都可以限制每种类型的并发流的数量,参见QuicConnectionOptions.MaxInboundBidirectionalStreams 与 QuicConnectionOptions.MaxInboundUnidirectionalStreams.

QUIC流的另一个特点是能够在流的工作过程中显式地关闭写入端,参见CompleteWrites or WriteAsync-system-boolean-system-threading-cancellationtoken))重载CompleteWrites参数。关闭写入端可以让接受端明确不会再有更多的数据输入了,但另一端仍然可以继续发送数据流(在双向流的情况下)。这在HTTP请求/响应交换等场景中非常有用,客户端发送请求并关闭写入端,服务器就知道这是请求发送的所有内容了。在此之后,服务器仍然能够发送响应,但客户端却不会发送更多的数据流了。而对于错误的情况,流的写入或读取端都可以进行中止,请参阅Abort。下表总结了每种流类型的每种方法的表现(注意客户端和服务器都可以开放和接受流):

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) bidirectionalSTOP_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) bidirectionalRESET_STREAM => peer read throws QuicException(QuicError.OperationAborted) unidirectional: no-op

在以上这些方法中,quickstream提供了两个专门的属性,用于在流的读写端关闭时获得通知:它们是readclosed和WritesClosed。两者都返回一个任务,该任务完成后相应的边被关闭。无论任务是成功还是中止,其中都将包含相应的字段。当用户代码需要知道流端的关闭情况而不需要调用ReadAsync或WriteAsync时,这些属性非常就派上作用了。

最后,当流的工作完成时,它需要使用DisposeAsync方法。这一方法将确保读取/写入端(取决于流类型)都关闭。如果流直到结束还没有被正确读取,dispose将发出一个等效的Abort(QuicAbortDirection.Read)命令。但是,如果流写入端还没有关闭,写入端会像使用CompleteWrites方法一样被关闭。之所以存在这种差异,是为了确保普通流的使用能够按照预期的方式进行,并进入相应的路径。请参考以下例子:

// 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);

客户端场景下QuicStream的使用示例:

// 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

服务端场景下QuicStream的使用示例:

// 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.

更多关于这个类设计的细节可以在the QuicStream API Proposal (dotnet/runtime#69675)中找到。

未来

由于System.Net.Quic 是新公开的,并且用法有限,因此我们将不胜感激有关API形状的任何错误报告或见解。由于 API 处于预览模式,我们仍然有机会根据收到的反馈针对 .NET 8 调整它们。可以在 dotnet/runtime 存储库中提交问题。

安全

Negotiate API

Windows Authentication 是一个包含多种技术的术语,用于企业中针对中央机构(通常是域控制器)对用户和应用程序进行身份验证。它支持诸如单点登录电子邮件服务或 Intranet 应用程序之类的场景。用于身份验证的基础技术是 Kerberos、NTLM 和包含的Negotiate协议,其中为特定身份验证方案选择最合适的技术。

在 .NET 7 之前,Windows 身份验证在高级 API 中公开。例如HttpClient (Negotiate 和 NTLM 身份验证方案),SmtpClient (GSSAPI 和 NTLM 身份验证方案),NegotiateStream、ASP.NET Core和SQL Server 客户端库。 虽然这涵盖了最终用户的大多数场景,但它对库作者来说是有限的。其他库,如Npgsql PostgreSQL client、 MailKit、 Apache Kudu client 需要诉诸各种技巧,为并非基于 HTTP 或其他可用的高级构建块构建的低级协议实施相同的身份验证方案。

.NET 7 引入了新的 API,提供低级构建块来执行上述协议的身份验证交换,请参阅dotnet/runtime#69920。与.NET 中的所有其他API 一样, 它在构建时考虑了跨平台互操作性。在 Linux、 macOS、iOS和其他类似平台上,它使用 GSSAPI 系统库。在 Windows上,它依赖于SSPI 库。对于系统实现不可用的平台,例如Android 和 tvOS,存在有限的仅客户端实现。

如何使用API

为了理解身份验证 API 的工作原理,让我们从一个示例开始,了解身份验证会话在 SMTP 等高级协议中的外观。该示例取自 Microsoft protocol documentation,该文档对其进行了更详细的解释。

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

身份验证从客户端生成质询令牌开始。然后服务器产生响应。客户端处理响应,并向服务器发送新的质询。这种质询/响应交换可以发生多次。当任何一方拒绝认证或双方都接受认证时,它结束。令牌的格式由 Windows 身份验证协议定义,而封装是高级协议规范的一部分。在此示例中,SMTP 协议预先添加334 代码以告知客户端服务器产生了身份验证响应,该235 代码表示​​身份验证成功。

大部分新 API 都以新NegotiateAuthentication 类为中心。它用于实例化客户端或服务器端身份验证的上下文。有多种选项可以指定建立经过身份验证的会话的要求,例如要求加密或确定要使用的特定协议 (Negotiate, Kerberos 或 NTLM) 指定参数后,身份验证将通过在客户端和服务器之间交换身份验证质询/响应来进行。GetOutgoingBlob 方法用于此目的。它可以处理字节跨度或base64 编码的字符串。

以下代码将同时执行客户端和服务器,对同一台机器上的当前用户进行部分身份验证:

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;
    }
}

一旦建立了经过身份验证的会话,NegotiateAuthentication 实例就可以用于对传出消息进行签名/加密并验证/解密传入消息。这是通过 Wrap 和 Unwrap 方法完成的。

完成此更改以及文章的这一部分是由社区贡献者@filipnavara撰写的。谢谢!

证书验证选项

当客户端收到服务器的证书时,反之亦然,如果请求客户端证书,证书将通过X509Chain。验证始终进行,即使RemoteCertificateValidationCallback 提供了验证,并且在验证期间可能会下载其他证书。由于无法控制此行为,因此提出了几个问题。其中包括完全阻止证书下载、设置超时或提供自定义存储以从中获取证书的要求。为了缓解这整组问题,我们决定在SslClientAuthenticationOptions  和SslServerAuthenticationOptions上引入一个新属性CertificateChainPolicy。此属性的目标是在AuthenticateAsClientAsync / AuthenticateAsServerAsync操作期间生成链时覆盖SslStream 的默认行为。在正常情况下,X509ChainPolicy 在后台自动构建。但是,如果指定了这个新属性,它将优先并被使用,从而使用户能够完全控制证书验证过程。

链策略的用法可能如下所示:

// 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);

更多信息可以在 API 提案 (dotnet/runtime#71191) 中找到。

表现

Stephen的文章Performance Improvements in .NET 7 – Networking涵盖了 .NET 7 中的大部分网络性能改进,但一些安全性改进值得再次提及。

TLS 简介

建立新的TLS 连接是相当昂贵的操作,因为它需要多个步骤和多次往返。在经常重新创建与同一服务器的连接的情况下,握手所消耗的时间将加起来。TLS 提供的被称为会话恢复的功能可以缓解这种情况,请参阅RFC 5246 Section 7.3 和 RFC 8446 Section 2.2。 简而言之,在握手期间,客户端可以发送先前建立的TLS 会话的标识,如果服务器同意,则根据先前连接的缓存数据重新建立安全上下文。尽管不同 TLS 版本的机制不同,但最终目标是相同的,即在重新建立与先前连接的服务器的连接时节省往返时间和一些CPU 时间。此功能由 Windows上的SChannel 自动提供,但在Linux上使用OpenSSL 需要进行几处更改才能启用此功能:

如果不需要缓存TLS 上下文,可以使用环境变量“DOTNET_SYSTEM_NET_SECURITY_DISABLETLSRESUME” 或通过 AppContext.SetSwitch “System.Net.Security.TlsCacheSize” 在进程范围内关闭它。

OCSP Stapling

Online Certificate Status Protocol (OCSP) Stapling 是服务器提供已签名和时间戳证明(OCSP 响应)的机制,证明发送的证书尚未被吊销,参见RFC 6961。 因此,客户端无需联系 OCSP 服务器本身,从而减少了建立连接所需的请求数量以及施加在 OCSP 服务器上的负载。由于 OCSP 响应需要由证书颁发机构 (CA) 签名,因此提供证书的服务器无法伪造它。 在此版本之前,我们没有利用此 TLS 功能,有关详细信息请参阅 dotnet/runtime#33377.

跨平台的一致性

我们知道,.NET 提供的某些功能仅在某些平台上可用。但是每个版本我们都试图进一步缩小差距。在 .NET 7 中,我们对网络安全空间进行了一些更改,以改善差异:

WebSockets

WebSocket 握手响应详细信息

在 .NET 7 之前,WebSocket 的开场握手(对升级请求的 HTTP 响应)的服务器响应部分隐藏在 ClientWebSocket 实现中, 并且所有握手错误都将显示为 WebSocketException,在异常信息旁边没有太多详细信息。 但是,有关 HTTP 响应标头和状态代码的信息在失败和成功方案中都可能很重要。 如果发生故障,HTTP 状态代码可以帮助区分可重试和不可重试的错误(例如,服务器根本不支持 WebSockets,或者只是暂时性网络错误)。标头还可能包含有关如何处理这种情况的其他信息。即使在 WebSocket 握手成功的情况下,标头也很有用,例如,它们可以包含与会话绑定的令牌、与子协议版本相关的信息,或者服务器可能很快关闭的信息。

.NET 7 将 CollectHttpResponseDetails 设置添加到  ClientWebSocketOptions中,该设置允许在 ConnectAsync 调用期间收集 ClientWebSocket 实例中的升级响应详细信息。你稍后可以使用 ClientWebSocket 实例的 HttpStatusCodeHttpResponseHeaders 属性访问数据,即使在 ConnectAsync 引发异常的情况下也是如此。请注意,在特殊情况下,信息可能不可用,即如果服务器从未响应请求。

另请注意,如果连接成功,并且在使用 HttpResponseHeaders 数据后,可以通过将 ClientWebSocket.HttpResponseHeaders 属性设置为 null 来减少 ClientWebSocket 的内存占用量。

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);
    }
}

提供外部 HTTP Client

在默认情况下,ClientWebSocket 使用缓存的静态 HttpMessageInvoker 实例来执行 HTTP 升级请求。 但是,有一些 ClientWebSocketOptions 会阻止缓存调用程序,例如 Proxy、ClientCertificates 或 Cookie。具有这些参数的 HttpMessageInvoker 实例不安全,每次调用 ConnectAsync 时都需要创建。这会导致许多不必要的分配,并使 HttpMessageInvoker 连接池无法重用。

.NET 7 允许您使用 ConnectAsync(Uri, HttpMessageInvoker, CancellationToken) 重载将现有的  HttpMessageInvoker (例如 HttpClient) 实例传递给 ConnectAsync 调用。 在这种情况下,将使用提供的实例执行 HTTP 升级请求。

var httpClient = new HttpClient();
var ws = new ClientWebSocket();
await ws.ConnectAsync(uri, httpClient, cancellationToken);

请注意,如果传递了自定义 HTTP 调用程序,则不得设置以下任何 ClientWebSocketOptions,而应在 HTTP 调用程序上设置:

以下是在 HttpMessageInvoker 实例上设置所有这些选项的方法:

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 还增加了通过 HTTP/2 使用 WebSocket 协议的功能,如RFC 8441中所述。 这样,WebSocket连接是通过HTTP / 2连接上的单个流建立的。这允许同时在多个 WebSocket 连接和 HTTP 请求之间共享单个 TCP 连接,从而更有效地使用网络。

要通过 HTTP/2 启用 WebSockets,您可以将 ClientWebSocketOptions.HttpVersion 选项设置为HttpVersion.Version20。 您还可以通过设置  ClientWebSocketOptions.HttpVersionPolicy  属性来启用所使用的 HTTP 版本的升级/降级。这些选项的行为方式与 HttpRequestMessage.Version 和 HttpRequestMessage.VersionPolicy 的行为方式相同。

例如,以下代码将探测 HTTP/2 WebSocket,如果无法建立 WebSocket 连接,它将回退到 HTTP/1.1:

var ws = new ClientWebSocket();
ws.Options.HttpVersion = HttpVersion.Version20;
ws.Options.HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
await ws.ConnectAsync(uri, httpClient, cancellationToken);

HttpVersion.Version11 和 HttpVersionPolicy.RequestVersionOrHigher 的组合将导致与上述相同的行为,而 HttpVersionPolicy.RequestVersionExact 将不允许升级/降级 HTTP 版本。

默认情况下,设置了 HttpVersion = HttpVersion.Version11 和 HttpVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,这意味着只会使用 HTTP/1.1。

通过单个 HTTP/2 连接多路复用 WebSocket 连接和 HTTP 请求的能力是此功能的关键部分。 为了使它按预期工作,您需要在调用 ConnectAsync 时从代码中传递并重用相同的 HttpMessageInvoker 实例(例如 HttpClient),即使用 ConnectAsync(Uri, HttpMessageInvoker, CancellationToken) 重载。 这将重用 HttpMessageInvoker 实例中的连接池进行多路复用。

结语

我们尝试在网络空间中挑选最有趣和最有影响力的变化。 本文不包含我们所做的所有更改,但可以在 dotnet/runtime 存储库中找到它们。 像往常一样,性能改进大多被省略,因为它们在 Stephens 的文章 Performance Improvements in .NET 7中占有一席之地。 我们也希望收到您的来信,因此,如果您遇到问题或有任何反馈,可以在我们的 GitHub 中提交。 最后,您可以在此处找到以前版本的类似博客文章: .NET 6 Networking Improvements.NET 5 Networking Improvements.

感谢您成为.NET中的一员。