大家知道 TCP 建立连接需要 3 次握手,这需要 1.5-RTT,如果再加上 TLS 的握手时间,总共需要 3-RTT,耗时将近 200-300 ms。随着互联网的高速发展,用户对于性能体验要求越来越高,TCP 连接握手带来的长时延显然是不可接受的。QUIC 因此提出一个新的建立连接机制,把传输和加密握手合并成一个,以最小化延迟(1-RTT)建立连接。后续的连接(repeat connections)还可以通过上一次连接时缓存的信息(TLS 1.3 Diffie-Hellman 公钥、传输参数、NEW_TOKEN 帧生成的令牌,等)直接在一个经过认证且加密的通道传输数据(0-RTT),这极大减小了建立连接的时延。

跟坚哥学QUIC系列:加密和传输握手-LMLPHP

下面我们将基于IETF QUIC 协议草案详细了解下 QUIC 是如何做到能快速建立连接(1-RTT/0-RTT)又能保证安全性。

QUIC 加密握手提供以下属性:

  • 认证密钥交换,其中
    • 服务端总是经过身份验证
    • 客户端可以选择性进行身份验证
    • 每个连接都会产生不同并且不相关的密钥
    • 密钥材料(keying material)可用于 0-RTT 和 1-RTT 数据包的保护
  • 两个端点(both endpoints)传输参数的认证值,以及服务端传输参数的保密保护
  • 应用协议的认证协商(TLS 使用 ALPN

下面显示了一个简化的握手以及相关数据包和帧的交换过程。用 “*” 表示在握手过程中可以进行应用数据交换。一旦握手完成,端点就可以交换应用数据。

Client                                               Server

Initial (CRYPTO)
0-RTT (*)              ---------->
                                           Initial (CRYPTO)
                                         Handshake (CRYPTO)
                       <----------                1-RTT (*)
Handshake (CRYPTO)
1-RTT (*)              ---------->
                       <---------- 1-RTT (HANDSHAKE_DONE,*)

1-RTT (*)              <=========>                1-RTT (*)

注意:

  • CRYPTO 帧可以在不同的数据包编号空间(packet number spaces)中发送。CRYPTO 帧使用偏移量(offsets)来确保加密握手数据的有序传递的在每个包编号空间(packet number spaces)都是从零开始。
  • 端点需要显式地协商应用协议(TLS 使用 ALPN)。这避免了对所使用的协议存在分歧的情况。

在QUIC中,数据包编号分为3个空间:

  • 初始(Initial)空间:所有初始包都在这个空间中。
  • 握手(Handshake)空间:所有握手包都在此空间中。
  • 应用数据(Application data)空间:所有 0-RTT 和 1-RTT 加密的数据包都在这个空间中。

握手流程示例

QUIC 在握手前会先进行地址验证(Address Validation),确保请求包里面的源地址不是伪造的。

一旦地址验证交换完成,就可以使用加密握手来获取加密密钥。加密握手通过初始(Initial)和握手(Handshake)包进行传输。

下图展示了 1-RTT 握手的示例。每行显示一个 QUIC 包(packet),首先显示包类型(type)和包编号(number),然后是帧(frames)。例如,第一个包是 Initial 类型,包编号为 0,并且包含一个携带 ClientHello(缩写:CH) 的 CRYPTO 帧。

多个 QUIC 数据包(即便是不同的类型)也可以合并成一个单独的 UDP 数据报(datagram)。因此,下图所示的 1-RTT 握手可以由 4 个UDP数据报(datagrams)组成。如果受协议固有的限制(如拥塞控制(congestion control)和反放大(anti-amplification))也可以使用更多的数据报。

Client                                                  Server

Initial[0]: CRYPTO[CH] ->

                                 Initial[0]: CRYPTO[SH] ACK[0]
                       Handshake[0]: CRYPTO[EE, CERT, CV, FIN]
                                 <- 1-RTT[0]: STREAM[1, "..."]

Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[0]: STREAM[0, "..."], ACK[0] ->

                                          Handshake[1]: ACK[0]
         <- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[0]

下图展示了一个 0-RTT 握手的连接示例:

Client                                                  Server

Initial[0]: CRYPTO[CH]
0-RTT[0]: STREAM[0, "..."] ->

                                 Initial[0]: CRYPTO[SH] ACK[0]
                                  Handshake[0] CRYPTO[EE, FIN]
                          <- 1-RTT[0]: STREAM[1, "..."] ACK[0]

Initial[1]: ACK[0]
Handshake[0]: CRYPTO[FIN], ACK[0]
1-RTT[1]: STREAM[0, "..."] ACK[0] ->

                                          Handshake[1]: ACK[0]
         <- 1-RTT[1]: HANDSHAKE_DONE, STREAM[3, "..."], ACK[1]

注意:服务端在 1-RTT 包中确认了(ACK)客户端发送 的 0-RTT 数据包,而客户端在相同的包编号空间(packet number space)中发送 1-RTT 数据包。

协商连接ID

QUIC 的数据包(packets)长报头(long header)包含两个连接ID:目标连接ID(Destination Connection ID)由数据包的接收者选择并用于提供一致的路由,源连接ID(Source Connection ID)用于对端(peer)响应时使用的目标连接ID(Destination Connection ID)。

在握手过程中,带有长报头的包用于建立两端(both endpoints)使用的连接ID。在处理第一个初始数据包(Initial packet)之后,每个端点使用其接收到的源连接ID(Source Connection ID)字段的值设置为后续数据包中的目标连接ID(Destination Connection ID)字段。

当客户端发送了一个初始包(Initial packet),而该客户端之前没有从服务端接收过初始数据包(Initial packet)或重试包(Retry packet),则客户端将用一个不可预测的值(长度至少为8字节)填充到目标连接ID字段。在从服务端接收到数据包之前,客户端必须对该连接中的所有数据包使用相同的目标连接ID值。

当第一次从服务端接收到初始(Initial)或重试(Retry)数据包时,客户端使用服务端提供的源连接ID作为后续数据包(包括任何 0-RTT 数据包)的目标连接ID。这意味着在建立连接的过程中,客户端可能需要两次更改它的目标连接ID字段:一次用于响应重试(Retry),一次用于响应来自服务端的初始数据包(Initial)。一旦客户端从服务端接收到有效的初始数据包,客户端必须丢弃它后续接收到的具有不同源连接ID的数据包。

服务端必须根据第一个接收到的初始数据包(Initial packet)的源连接ID,设置为用于发送数据包的目标连接ID。后续只有当接收到 NEW_CONNECTION_ID 帧时,才允许对目标连接ID进行更改。如果后续初始数据包包含不同的源连接ID,则必须将其丢弃。这样可以避免由于无状态(stateless)处理具有不同源连接ID的多个初始数据包而导致的不可预测结果。

端点可以在连接的生命周期内更改发送的目标连接ID,特别是在响应连接迁移(connection migration)时。

验证连接ID

QUIC 通过在传输参数(transport parameters)中包含相应的值来验证每个端点在握手过程中所选择的连接ID。这可以确保用于握手的所有连接ID也通过加密握手进行身份验证。

每个端点都包含源连接ID字段的值,该字段来自它发送的第一个初始(Initial)数据包的 initial_source_connection_id 传输参数中。服务端在 original_destination_connection_id 传输参数中包含它从客户端接收到的第一个初始(Initial)数据包的目标连接ID字段。如果服务端发送了一个重试(Retry)数据包,这是指在发送重试(Retry)数据包之前接收到的第一个初始(Initial)数据包。如果发送重试(Retry)数据包,服务端还会在 retry_source_connection_id 传输参数中包含来自重试数据包的源连接ID字段。

对端(peer)为这些传输参数提供的值必须与端点发送的初始(Initial)数据包的目标(Destination)或源(Source)连接ID字段中的值匹配。在传输参数中包含连接ID值并对其进行验证,可确保攻击者不会在握手过程中通过注入带有攻击者选择的连接ID的数据包来影响连接ID的选择。

需要将端点没有传输参数 initial_source_connection_id 或服务端中没有传输参数 original_destination_connection_id 视为类型为TRANSPORT_PARAMETER_ERROR的连接错误。

端点必须将以下情况视为TRANSPORT_PARAMETER_ERROR  PROTOCOL_VIOLATION类型的连接错误:

  • 接收到服务端的重试(Retry)数据包后,缺少传输参数retry_source_connection_id
  • 当未收到重试(Retry)数据包时,存在retry_source_connection_id传输参数
  • 从对端(peer)接收的传输参数的值与在初始包(Initial packets)对应的目标(Destination)或源(Source)连接ID字段中的值不匹配。

下图显示了完整握手中使用的连接ID(DCID = Destination Connection ID,SCID = Source Connection ID)。显示了初始(Initial)包的交换,以及随后交换的 1-RTT 数据包,其中包括在握手过程中建立的连接ID。

Client                                                  Server

Initial: DCID=S1, SCID=C1 ->
                                  <- Initial: DCID=C1, SCID=S3
                             ...
1-RTT: DCID=S3 ->
                                             <- 1-RTT: DCID=C1

下图显示了一个包含重试(Retry)包的握手。

Client                                                  Server

Initial: DCID=S1, SCID=C1 ->
                                    <- Retry: DCID=C1, SCID=S2
Initial: DCID=S2, SCID=C1 ->
                                  <- Initial: DCID=C1, SCID=S3
                             ...
1-RTT: DCID=S3 ->
                                             <- 1-RTT: DCID=C1

在这两种情况下,客户端将传输参数initial_source_connection_id的值设置为 C1。

当握手不包括重试(Retry)时,服务端将original_destination_connection_id设置为 S1,将initial_source_connection_id设置为 S3。在这种情况下,服务端不包括retry_source_connection_id传输参数。客户端接收到服务端的初始(Initial)包后,验证 original_destination_connection_id 是否与之前所设置的 DCID(目标连接ID)一致,然后将 DCID 更新为 S3。

当握手包括重试(Retry)时,服务端将original_destination_connection_id设置为 S1,retry_source_connection_id设置为 S2,initial_source_connection_id设置为 S3。客户端对应的分别将 DCID 更新为 S3 和 S3。

传输参数(Transport Parameters)

在建立连接期间,两个端点都对其传输参数进行了身份验证声明。端点需要遵守每个参数定义的限制,每个参数的描述包括它的处理规则。

传输参数是由每个端点单方面作出的声明。每个端点可以选择传输参数的值,而不依赖于其对端(peer)选择的值。

QUIC 在加密握手中包含编码后(encoded)的传输参数。一旦握手完成,对端(peer)声明的传输参数就可用了。每个端点验证其对端(peer)提供的值。

端点必须将以下情况视为TRANSPORT_PARAMETER_ERROR类型的连接错误:

  • 接收到的含有无效值的传输参数
  • 接收到重复的传输参数

应用层协议协商(Application Layer Protocol Negotiation, ALPN)允许客户端在建立连接期间提供多个应用协议。客户端在握手过程中的传输参数包含了它的所支持的所有应用协议。应用协议可以为传输参数推荐值,例如初始流量控制限制(initial flow control limits)。然而,对传输参数的值设置约束的应用协议可能使客户端无法在这些约束冲突时提供多个应用协议。

0-RTT 的传输参数值

使用 0-RTT 取决于客户端和服务端使用的协议参数是否是之前的连接协商过的。要启用 0-RTT,端点将存储服务端传输参数的值,并将其应用于在后续连接中发送到该对端(peer)的任何 0-RTT 数据包。这个信息与应用协议或加密握手所需的信息一起存储。

存储的传输参数将应用于新连接,直到握手完成并且客户端开始发送 1-RTT 数据包。一旦握手完成,客户端将使用握手中建立的传输参数。并非所有传输参数都会被存储,因为有些参数不适用于后续的连接,或者它们对 0-RTT 的使用没有影响。

新传输参数的定义必须指定存储 0-RTT 的传输参数是强制的(mandatory)、可选的(optional)、或者是禁止的(prohibited)。客户端不需要存储它无法处理的传输参数。

客户端不能存储以下参数的值:

  • ack_delay_exponent
  • max_ack_delay
  • initial_source_connection_id
  • original_destination_connection_id
  • preferred_address
  • retry_source_connection_id
  • stateless_reset_token

这些不能存储的参数值,客户端必须在 0-RTT 握手中使用服务端的新值,如果服务端没有提供新值,则使用默认值。

尝试发送 0-RTT 数据的客户端必须记住服务端使用的所有其他能存储的传输参数,并且服务端能够处理这些参数。服务端可以记住这些传输参数,或者在票证(ticket)中存储的参数值的完整性保护(integrity-protected)副本,并在接受 0-RTT 数据时恢复信息。服务端使用传输参数来确定是否接受 0-RTT 数据。

如果服务端接受 0-RTT 数据,则服务端不得减少任何限制或更改任何可能被客户端与其 0-RTT 数据冲突的值。特别是,接受 0-RTT 数据的服务端不得将以下参数设置为小于之前记住(remembered)的参数值。

  • active_connection_id_limit
  • initial_max_data
  • initial_max_stream_data_bidi_local
  • initial_max_stream_data_bidi_remote
  • initial_max_stream_data_uni
  • initial_max_streams_bidi
  • initial_max_streams_uni

忽略或设置某些传输参数的零(zero)值可能会导致启用 0-RTT 数据,但不可用。对于 0-RTT,允许发送应用数据的传输参数的子集应设置为非零(non-zero)值。这包括:

  • initial_max_data
  • initial_max_streams_bidi 和 initial_max_stream_data_bidi_remote, 或者initial_max_streams_uni 和 initial_max_stream_data_uni.

服务端可以存储和恢复之前发送的 max_idle_timeoutmax_udp_payload_sizedisable_active_migration的参数值。如果选择较小的值,则拒绝 0-RTT。在接受 0-RTT 数据的同时降低这些参数的值可能会降低连接的性能。具体地说,与直接拒绝 0-RTT 数据相比,降低max_udp_payload_size可能会导致丢包,从而导致性能更差。

如果传输参数的还原值不被支持,服务端必须拒绝 0-RTT 数据。

当以 0-RTT 数据包发送帧(frames)时,客户端必须只使用之前记住(remembered)的传输参数。重要的是,它不能使用从服务端获取到的新传输参数更新参数值,也不能从接收到的 1-RTT 数据包中的更新值。握手传输参数的更新值仅适用于 1-RTT 数据包。例如,记住(remembered)的传输参数的流量控制限制(flow control limits)适用于所有 0-RTT 包,即使这些值通过握手或在 1-RTT 包中被增加了。服务端可能会将 0-RTT 中更新传输参数视为PROTOCOL_VIOLATION类型的连接错误。

新传输参数

新的传输参数可用于协商新的协议行为。端点必须忽略它不支持的传输参数。因此,缺少传输参数将禁用使用该参数协商的任何可选(optional)协议功能。

客户端不理解的传输参数可以丢弃并在后续连接上尝试 0-RTT。但是,如果客户端添加了对丢弃的传输参数的支持,那么在尝试 0-RTT 时,可能会违反传输参数所建立的约束。新的传输参数可以通过设置最保守(most conservative)的默认值来避免这个问题。

加密消息缓冲

QUIC 实现(Implementations)需要维护无序接收的加密数据的缓冲区(buffer)。由于CRYPTO帧没有流量控制(flow control),端点可能会强制其对端(peer)缓冲无限量(unbounded)的数据。

QUIC 实现(Implementations)必须支持缓冲在无序 CRYPTO帧中接收的至少 4096 字节的数据。端点可以选择允许在握手期间缓冲更多的数据。握手过程中的更大限制可能允许交换更大的密钥(keys)或凭据(credentials)。端点的缓冲区大小不需要在连接的生命周期内保持不变。

无法在握手期间缓冲CRYPTO帧可能导致连接失败。如果在握手过程中超过了端点的缓冲区,则可以临时扩展其缓冲区以完成握手。如果端点不扩展其缓冲区,则必须使用CRYPTO_BUFFER_EXCEEDED错误代码关闭连接。

一旦握手完成,如果一个端点无法缓冲CRYPTO帧中的所有数据,它可能会丢弃该CRYPTO帧和将来接收到的所有CRYPTO帧,或者可能使用CRYPTO_BUFFER_EXCEEDED错误代码关闭连接。必须确认(ACK)包含丢弃的CRYPTO帧的数据包,因为即使丢弃了CRYPTO帧,该数据包也已被接收和处理。

参考链接:

https://medium.com/@chester.yw.chu/http-3-%E5%82%B3%E8%BC%B8%E5%8D%94%E8%AD%B0-quic-%E7%B0%A1%E4%BB%8B-5f8806d6c8cde

https://quicwg.org/base-drafts/draft-ietf-quic-transport.html#name-cryptographic-and-transportport.html#name-cryptographic-and-transport

Even faster connection establishment with QUIC 0-RTT resumption


QUIC 的全称是 Quick UDP Internet Connections protocol,由 Google 设计提出,目前由 IETF 工作组推动进展,其设计的目标是替代 TCP 成为 HTTP/3 的数据传输层协议。熹乐科技在物联网(IoT)和边缘计算(Edge Computing)场景也一直在打造底层基于 QUIC 通讯协议的边缘计算微服务框架YoMo,长时间关注 QUIC 协议的发展,本系列文章总结了学习 QUIC 协议时的知识点。

在线社区:discord/quic

维护者:YoMo

04-18 06:52