QUIC协议 - 3 - QUIC连接管理
TCP的安全问题
TCP依赖seq和ACK来实现可靠,流式以及全双工的传输模式,而实际过程中却需要通过三次握手来同步双端的 seq,如果我们提前约定好通信双方初始seq,其实是可以避免三次握手的,那么为什么没有这么做呢?答案是安全问题。
TCP的数据是没有经过任何安全保护的,无论是其header还是payload,对于一个攻击者而言,他可以在世界的任何角落,伪造一个合法TCP报文。
一个典型的例子就是攻击者可以伪造一个reset报文强制关闭一条TCP链接,而攻击成功的关键则是TCP字段里的 seq及ACK字段,只要报文中这两项位于接收者的滑动窗口内,该报文就是合法的,而TCP握手采用随机seq的方式(不完全随机,而是随着时间流逝而线性增长,到了2^32尽头再回滚)来提升攻击者猜测seq的难度,以增加安全性。
为此,TCP也不得不进行三次握手来同步各自的seq。当然,这样的方式对于off-path的攻击者有一定效果,对于 on-path的攻击者是完全无效的,一个on-path的攻击者仍然可以随意 reset链接,甚至伪造报文,篡改用户的数据。

TLS(传输安全层)解决了TCP的payload安全问题。TLS为用户提供了一种机制来保证中间人无法读取,篡改的TCP 的payload数据,TLS同时还提供了一套安全的身份认证体系,来防止攻击者冒充Web服务提供者。然而TCP的 header这一层仍然是不在保护范围内的,对于一个on/off-path攻击者,仍然具备理论上随时关闭TCP链接的能力。
由于TLS(用户)和TCP(内核)的分层设计,一个安全数据通道的建立实际上仍是一个相对繁琐的流程。以一次基于 TLS1.3 协议的数据安全通道新建流程为例:

在一个client正式开始发送应用层数据之前,需要3个RTT的交互,这算是一个非常大的开销。
从流程上来看,TCP握手和TLS的握手比较相似,有融合在一起的可能,但实际上需要做大量的重新设计,改动和迭代是一个痛苦且难以落地的过程。
TCP的设计问题
- ACK的二义性问题
- 队头阻塞问题
- 连接迁移问题
- 复杂的建连和断连
- 难用的TCP keepalive
- …
QUIC的连接建立
和TCP一样,QUIC的首要目标也是提供一个可靠、有序的流式传输协议。不仅如此,QUIC还要保证原生的数据安全以及传输的高效。
可以说,QUIC就是在以一种更简洁高效的机制去对标TCP+TLS。本质上是将TLS 1.3的加密握手过程承载于QUIC自己的数据包和帧结构之上。
数据包类型
- Initial Packet: 用于发起连接和传输初始的加密握手数据。使用长报头。
- Handshake Packet: 用于传输握手中非初始阶段的加密数据。使用长报头。
- 0-RTT Packet: 用于在 0-RTT 握手中传输早期应用数据。使用长报头。
- 1-RTT Packet: 握手成功后,用于传输应用数据。使用短报头,开销最小。
- Retry Packet: 服务器用于验证客户端地址合法性的一种特殊包。使用长报头。
加密级别
QUIC 在握手过程中使用多个级别的加密,每个级别都有自己独立的密钥和包序号空间
- Initial: 使用基于连接ID的初始密钥,仅用于保护握手初期的ClientHello和ServerHello,安全性较低,主要目的是混淆流量。
- Handshake: 使用从TLS握手早期密钥交换中派生出的密钥,用于保护握手后半段(服务器证书、身份验证等)。
- 0-RTT: 使用上一次连接中获取的PSK (Pre-Shared Key)加密,用于发送早期数据。
- 1-RTT: 使用从TLS握手最终派生出的密钥,用于保护连接中所有后续的应用数据。这是最安全的级别。
CRYPTO帧是QUIC的万能容器,所有TLS握手消息都被封装在CRYPTO帧中,然后在上述不同类型的数据包中发送。
1-RTT握手
这是最典型的情况,客户端之前没有与该服务器建立过连接。目标是在一个往返时延 (1-RTT) 内完成握手并开始发送应用数据。
Client                                                  Server
======                                                  ======
Initial Packet (PN 0)
(CRYPTO[ClientHello])
------------------------------------------------------->
                                          Initial Packet (PN 0)
                                          (CRYPTO[ServerHello], ACK)
                                        <----------------------------
                                          Handshake Packet (PN 0)
                                          (CRYPTO[...TLS Handshake...])
                                        <----------------------------
Handshake Packet (PN 0)
(CRYPTO[...TLS Finished...], ACK)
------------------------------------------------------->
1-RTT Packet (PN 0)
(STREAM[GET /])
------------------------------------------------------->
                                          Handshake Packet (PN 1)
                                          (HANDSHAKE_DONE)
                                        <----------------------------
                                          1-RTT Packet (PN 0)
                                          (STREAM[...Response...], ACK)
                                        <----------------------------
- 
    Client -> Server: 客户端发起连接 - 客户端生成一个随机的目标连接 ID (DCID)。
- 客户端构造一个 TLS ClientHello消息。
- 客户端将此 ClientHello消息放入一个CRYPTO帧中。
- 客户端将此 CRYPTO帧放入一个 Initial Packet 中。- 加密: 使用 Initial级别的密钥进行加密。
- 填充: 为了防止 DDoS 放大攻击,客户端会把这个初始包填充到至少 1200 字节。
 
- 加密: 使用 
- 客户端发送这个 Initial包给服务器。
 
- 
    Server -> Client: 服务器响应 - 服务器收到 Initial包后,解密并解析出 TLSClientHello。
- 服务器根据 ClientHello派生出Handshake级别的密钥。
- 服务器准备好它的 TLS 握手消息,通常包括 ServerHello,EncryptedExtensions,Certificate,CertificateVerify,Finished。
- 服务器会发送多个数据包作为响应,这些包可能会被打包在同一个UDP数据报中:
        - Initial Packet: 包含 CRYPTO帧,其中封装了 TLSServerHello。同时也用ACK帧来确认收到了客户端的Initial包。
- Handshake Packet: 包含 CRYPTO帧,其中封装了服务器剩余的 TLS 握手消息 (证书、验证等)。此包使用Handshake级别的密钥加密。
 
- Initial Packet: 包含 
 
- 服务器收到 
- 
    Client -> Server: 客户端完成握手并发送数据 
 +   客户端收到服务器的 `Initial` 和 `Handshake` 包。
 +   客户端使用 `Initial` 密钥解密 `Initial` 包,获取 `ServerHello`。
 +   根据 `ServerHello`,客户端现在也可以派生出 `Handshake` 密钥和 **`1-RTT` 应用程序密钥**。
 +   客户端使用 `Handshake` 密钥解密服务器的 `Handshake` 包,验证服务器的证书和身份。
 +   此时,客户端认为握手已基本完成,可以开始发送应用数据了!它会发送:
     1.  **Handshake Packet:** 包含 `CRYPTO` 帧,其中是客户端的 TLS `Finished` 消息。此包用于向服务器证明客户端已正确完成密钥计算。同时也 `ACK` 服务器的 `Handshake` 包。
     1.  **1-RTT Packet:** 包含一个 `STREAM` 帧,里面是**真正的应用数据**。此包使用最终的 `1-RTT` 密钥加密,并使用短报头。
 **此时,客户端可以开始发送应用数据。**
- 
    Server -> Client: 服务器确认握手完成 - 服务器收到客户端的 Handshake包,验证 TLSFinished消息后,确认握手在两端都已完成。
- 服务器收到客户端的 1-RTT包,解密并开始处理应用请求。
- 服务器发送一个 Handshake包,其中包含一个HANDSHAKE_DONE帧,这是一个明确的信号,告知客户端握手已全部完成。
- 随后,服务器通过 1-RTT包发送应用响应数据。
 此时,连接完全建立,双方都使用 1-RTT包(短报头)进行通信。
- 服务器收到客户端的 
0-RTT 握手
如果客户端之前成功连接过,并且服务器通过 TLS 的 NewSessionTicket 机制提供了一个 PSK,那么客户端可以尝试0-RTT 连接。
Client                                                  Server
======                                                  ======
Initial Packet
(CRYPTO[ClientHello with PSK])
+
0-RTT Packet
(STREAM[GET /])
------------------------------------------------------->
                                          (Same as 1-RTT response...)
                                          Initial Packet
                                          Handshake Packet
                                          1-RTT Packet (Response)
                                        <----------------------------
客户端发送:
- 客户端在发送第一个 Initial包(其中ClientHello包含了 PSK)的同时,会立即发送一个或多个 0-RTT Packet。
- 这些 0-RTT包使用从 PSK 派生出的0-RTT密钥进行加密,其中包含了实际的应用数据。
服务器处理:
- 服务器收到 Initial包,发现 PSK 有效,就会接受并尝试解密紧随其后的0-RTT数据。
- 重要安全警告: 0-RTT 数据容易受到重放攻击。服务器必须确保只接受那些不会产生副作用的请求,或者有其他机制来防止重放。
- 之后,服务器的响应流程与 1-RTT 握手基本相同,它仍然需要发送 Handshake包来完成完整的 TLS 握手。
0-RTT 的巨大优势在于,应用数据在第 0 个 RTT 就已经发送出去,实现了真正的零延迟连接。
地址验证 (Retry 机制)
为了防止攻击者伪造源 IP 地址,向受害者服务器发送 Initial 包,从而导致服务器向一个无辜的 IP 发送大量数据(DDoS 放大攻击),QUIC 设计了 Retry 机制。
Client                                                  Server
======                                                  ======
Initial Packet (small)
------------------------------------------------------->
                                          Retry Packet
                                          (Contains Token)
                                        <----------------------------
Initial Packet (larger)
(Contains Token from Retry)
------------------------------------------------------->
                                          (Proceeds as normal 1-RTT)
                                        <----------------------------
步骤:
- 客户端发送第一个 Initial包。
- 服务器收到后,如果怀疑客户端的地址或为了规避风险,它不会立即进行完整的握手。
- 服务器回复一个非常小的 Retry Packet。这个包里包含一个加密的令牌 (Token),该令牌与客户端的 IP 地址和服务器的一些状态相关联。
- 客户端收到 Retry包后,必须中止当前的握手尝试。
- 客户端重新发起一个新的 Initial包,但这次必须包含服务器发来的令牌。
- 服务器收到带有有效令牌的新 Initial包,它现在可以确认客户端确实拥有该源 IP 地址(因为它收到了Retry包)。
- 之后,连接建立过程按正常的 1-RTT 流程继续。
Retry 机制增加了一个 RTT 的延迟,但有效地验证了客户端地址的合法性。
QUIC的连接断开
QUIC主要有三种连接断开的方式:
- 主动关闭: 通过发送 CONNECTION_CLOSE帧来明确地、立即地终止连接。这是最常见的方式。
- 空闲超时: 当连接在一段时间内没有任何活动时,双方静默地、独立地断开连接。
- 无状态重置 : 一种强制的、单向的终止机制,用于处理一端丢失连接状态的特殊情况。
主动关闭
当应用层决定关闭连接,或者当协议栈检测到不可恢复的错误时,就会使用此机制。
一端(无论是客户端还是服务器)发送一个包含 CONNECTION_CLOSE 帧的数据包,来启动关闭流程。
      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |          0x1c or 0x1d (8)         |
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      Error Code (i)                         ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                   [Frame Type (i)]                          ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                    Reason Phrase Length (i)                 ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
     |                      Reason Phrase (*)                      ...
     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Frame Type (0x1c or 0x1d): 用于区分错误来源。
- 0x1c: 表示 QUIC 传输层错误。
- 0x1d: 表示应用层错误)。
Error Code (变长整数): 一个数字代码,用于程序化地识别错误原因。例如 NO_ERROR (0x0) 表示优雅关闭。
Frame Type (变长整数, 可选): 如果是传输层错误,此字段可以指明是哪种类型的帧触发了错误。
Reason Phrase (UTF-8 字符串): 一段人类可读的文本,用于调试和日志记录,解释关闭的原因。
流程:
1. 发起关闭:
- 当一端( A)决定关闭连接时,它会发送一个包含 CONNECTION_CLOSE帧的数据包。
- 发送后,A 立即进入 closing状态。在此状态下,A 不能再发送任何其他数据,只能等待来自对端(B)的数据包。
2. 接收关闭:
- 当 B 收到包含 CONNECTION_CLOSE帧的数据包后,它立即进入draining状态。
- 在 draining状态下,B 也不能再发送任何新数据。它的唯一任务是响应 A 可能重传的数据包。如果 B 持续收到来自 A 的CONNECTION_CLOSE数据(说明 A 可能没收到 B 的 ACK),B 会不断地重新发送自己最后的CONNECTION_CLOSE确认。
3. 连接终结:
- 对于 A (发起方): 在进入 closing状态后,A 会启动一个计时器(通常是 3 倍的 PTO - Probe Timeout)。它会等待这段时间,以接收可能在途中的来自 B 的数据包。计时器结束后,或者收到确认其关闭帧的 ACK 后,A 将删除所有连接状态,连接彻底终结。
- 对于 B (接收方): 在进入 draining状态后,B 也在等待。如果它在一段时间内没有再收到来自 A 的任何数据,它也认为连接已确认关闭,将删除所有连接状态,连接彻底终结。
空闲超时
这是一种静默的关闭方式,用于自动清理那些长时间没有活动的连接,防止资源耗尽。
- 超时协商: 在连接建立的握手阶段,双方通过 TLS 握手中的 transport_parameters扩展来交换各自的max_idle_timeout值。最终,连接实际使用的超时时间是双方提议值中的较小者。如果一方没有提供该值,则默认为无限大。
- 计时器重置: 在连接的生命周期中,每一端都维护一个空闲计时器。
    - 当发送或接收到任何被对端确认 的数据包时,计时器就会被重置。
- 这意味着只要有有效的网络活动,连接就不会超时。
 
- 超时触发: 如果计时器在没有任何活动的情况下走到了协商的超时时间,该端就会单方面地、静默地认为连接已关闭。它会直接删除所有与该连接相关的状态(密钥、流状态等),不会发送任何通知报文。
无状态重置
无状态重置用于处理一端(通常是服务器)因重启或故障而丢失了连接状态的极端情况。
假设服务器S和客户端C正在通信。突然服务器S重启了。重启后,S完全丢失了与C的连接状态(包括密钥、连接ID等)。但C对此一无所知,它继续向S发送之前连接的 1-RTT 数据包。S收到这些包后,因为它没有对应的密钥,所以无法解密,也不知道该如何处理。这时就需要无状态重置。
- 令牌交换: 在连接握手时,每一端都会生成一个16字节的、不可预测的无状态重置令牌,并通过 NEW_CONNECTION_ID帧发送给对端。每一端都需要存储对端发来的令牌**。
- 重置触发: 当一端收到一个它无法处理的有效QUIC数据包时,它就会触发无状态重置。
- 发送重置包:
    - 触发端会发送一个特殊的UDP数据报。这个数据报不是一个格式完整的QUIC包。
- 它的内容大部分是随机字节,但其最后16个字节必须是它之前从对端收到的那个无状态重置令牌。
- 为了防止被轻易识别,这个UDP包的长度被设计成一个随机值。
 
- 接收重置:
    - 当另一端收到这个UDP数据报时,它会检查数据报的最后16个字节。
- 如果这16个字节与它自己当初生成并发给对端的令牌相匹配,它就确认这是一个无状态重置。
- 它会立即、无条件地终止连接,并清理所有相关状态。