QUIC 学习笔记

本文参考了以下资料:

QUIC 协议详解 - 知乎 (zhihu.com)

IETF | Innovative New Technology for Sending Data Over the Internet Published as Open Standard

RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport (rfc-editor.org)

基础概念

Quick UDP Internet Connections

RFC9000

HTTP/3 将是第一个基于 QUIC 的应用层协议

While QUIC is a general transport protocol, the IETF will also soon release HTTP/3, the first application protocol designed for use over QUIC.

img

术语:

名词 解释
QUIC QUIC is a name, not an acronym.
Endpoint 端点 可以通过生成、接收、处理 QUIC 包参与 QUIC 连接的实体,分为客户端和服务端
Client 客户端 发起连接的 endpoint
Server 服务端 接受连接的 endpoint
QUIC Packet 包 QUIC 可将其封装至 UDP 数据报的单位,一个 UDP 数据报可以容纳多个 QUIC Packet
Frame 帧 结构化的协议信息单元,有多种类型可以携带不同的信息。包含在 Packet 中
Connection ID 用于在 endpoint 中标识一个 QUIC 连接
Stream 流 单向或双向的有序字节通道。一个连接中可以有多个 Stream

简介

流是 QUIC 提供的轻量级、有序的字节流抽象,流可以是单向的和双向的。流可以是持久的(long-linved)而且可以在整个连接中持续。所有关于流的操作(ending, canceling, and managing flow control)的设计目标都是最小化开销。

不同流之间可以同时传送数据,但是 QUIC 不保证不同流之间的数据的有序性。

流使用 stream ID 来标识(0~26212^{62}-1),任何情况下都不应该在一个连接中重用一个 stream ID

流的类型

stream ID 中的最低有效位(0x01)标明流的创建人,客户端发起的为 0,服务端发起的为 1,故客户端的 stream ID 永远是偶数,而服务端的永远是奇数。

最低的第二个有效位表示流是单向的还是双向的,双向为 0,单向为 1

将最低的四位看做一个 16 进制数,可以从这个数推断流的类型

Bits Stream Type
0x00 Client-Initiated, Bidirectional
0x01 Server-Initiated, Bidirectional
0x02 Client-Initiated, Unidirectional
0x03 Server-Initiated, Unidirectional

流的优先级

如果能够正确地在多个流中配置资源,那会大幅提高应用的性能。

但是 QUIC 不提供交换优先级信息的机制,它依赖于应用提供的优先级信息。QUIC 的实现应该(should)为应用提供指明流优先级的方式,并用这些信息来决定怎么为正在工作的流分配资源。

Stream Frame

Stream Frame 隐含地创建一个流并传送数据,可用于流操作(见下一小节)

关于各字段的具体含义见:RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport (rfc-editor.org)

1
2
3
4
5
6
7
STREAM Frame {
Type (i) = 0x08..0x0f,
Stream ID (i),
[Offset (i)],
[Length (i)],
Stream Data (..),
}

流操作

发送端:

  • 写数据(write data)

  • 终止流(end the stream,clean termination),以 FIN 位置 1 的 Stream Frame 结束

  • 强制终止流(reset the stream ,abrupt termination),以 REST_STREAM 帧结束,收到 RESET_FRAME 的流可以丢弃其已经收到的所有数据。若仅仅发送数据的端收到了 REST_STREAM 则必须终止链接并抛出错误 STREAM_STATE_ERROR(Section 19.4)

    1
    2
    3
    4
    5
    6
    RESET_STREAM Frame {
    Type (i) = 0x04,
    Stream ID (i),
    Application Protocol Error Code (i),
    Final Size (i),
    }

接收端:

  • 读数据(read_data)

  • 中断读取并请求停止发送数据,使用 STOP_ENDING 帧

    1
    2
    3
    4
    5
    STOP_SENDING Frame {
    Type (i) = 0x05,
    Stream ID (i),
    Application Protocol Error Code (i),
    }

流状态

发送端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    o
| Create Stream (Sending)
| Peer Creates Bidirectional Stream
v
+-------+
| Ready | Send RESET_STREAM
| |-----------------------.
+-------+ |
| |
| Send STREAM / |
| STREAM_DATA_BLOCKED |
v |
+-------+ |
| Send | Send RESET_STREAM |
| |---------------------->|
+-------+ |
| |
| Send STREAM + FIN |
v v
+-------+ +-------+
| Data | Send RESET_STREAM | Reset |
| Sent |------------------>| Sent |
+-------+ +-------+
| |
| Recv All ACKs | Recv ACK
v v
+-------+ +-------+
| Data | | Reset |
| Recvd | | Recvd |
+-------+ +-------+

Ready:新建的流且已准备后接收来自应用的信息,在这个状态下可缓存即将发送的数据

Send:发送第一个 Stream 帧或 STREAM_DATA_BLOCKED 帧后即进入 Send 态。在实现上可以直到流进入该状态后再分配 Stream ID,便于确定流的优先级

连接建立

对比 TCP:计算机网络基础:运输层 | microven’s blog

TCP 三次握手需要 2 个 RTT,TLS 1.3 握手需要 1 个 RTT,QUIC 基于 TLS 1.3 实现的,首次建立连接时需要 1 RTT,但之后连接时可以实现 0-RTT

0-RTT:客户端缓存了服务端在 TLS 1.3 中发送的 B(b*G%P),在再次连接时直接使用缓存,但又引发了前向安全问题(静态密钥泄露出去,是否会泄漏之前的通讯内容)

  1. Client Hello:客户端生成随机数 a,计算 A=aG%PA=a*G \% P ,随后将 A 和 G 发送给服务器
  2. 客户端使用缓存计算初试密钥 initKey=aB=abG%PinitKey = a*B = a*b*G\%P ,加密发送应用数据 1
  3. 服务器根据 ClientHello 信息计算 initKey=AbinitKey = A*b (A 是 Client Hello 发的,b 是服务器静态配置的,此 initKey 与之前客户端生成的 initKey 一样)
  4. Server Hello:服务器生成随机数 C,使用初始密钥加密 C 发送至客户端
  5. 客户端收到随机数 C 后计算会话密钥 acG%Pa*c*G\%P,发送应用数据 2
  6. 服务器同样计算会话密钥,获取应用数据 2

使用会话密钥通信能保证即便 b 泄露了,攻击者也无法解密之前发送的数据包

客户端缓存并不会泄露 b,因为客户端存的是 bG%Pb*G\%P ,难以计算出 b

TLS 笔记

参考资料:

握手是在通信电路建立之后,信息传输开始之前。 握手用于达成参数,如信息传输率,字母表,奇偶校验, 中断过程,和其他协议特性。

TLS 1.2 握手过程(2 RTT):

img

TLS 1.3 握手过程(1 RTT):

img

TLS 1.3 采用了 ECDH 的加密算法,其通过椭圆曲线的对数问题来交换客户端和服务器的随机数 a 和 b,并在 Client Hello 和 Server Hello 中发送 a*G%P 和 B*G%P,攻击者只能截获 a*G%P 或 b*G%P 且难以从中计算 a 和 b,而在客户端方面,随机数 a 是其自己生成的,服务器交给客户端 b*G,其可以计算 a*b*G%P,服务端也同理,再以此作为对称加密的加密密钥。

1 RTT图例

  • 带星号代表可以在握手过程中携带应用数据,但存在风险
  • 0-RTT 表示不经过握手就可以发送
  • 1-RTT 表示要经过一次握手才能发送
  • CPYPTO 表示是加密帧
1
2
3
4
5
6
7
8
9
10
11
12
Client                                               Server

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

1-RTT <=========> 1-RTT

可靠传输

可靠性:完整、有序

完整:选择重传。给每个数据包一个递增的包号(PKN),然后服务器通过 SACK 告知客户端已经收到的包号(一次可确认多个,如 SACK = (1, 3)),没有收到 SACK 的包则重传,重传的包的 PKN 仍然是递增的,而不是和之前的相同,故不能通过包号来组装数据

有序:偏移(offset),每个数据包设置一个偏移。

流量控制

和 TCP 一样使用滑动窗口机制,但其将滑动窗口分为了 Connection 和 Stream 两种级别

  • Stream 每个的窗口大小
  • Connection 整个连接的总窗口(一个连接由很多个流组成,即为每个流窗口的综合)

拥塞控制

拥塞控制类似于 TCP 的拥塞控制,但是 QUIC 是在用户空间实现的拥塞控制,可以灵活地设置拥塞控制算法

  • 和 TCP 一样发送窗口的大小由拥塞窗口和接收窗口的最小值决定,即 swnd = min(cwnd, rwnd)

  • 慢开始、拥塞避免、与 TCP 拥塞控制相同

  • 拥塞发生:

    • 若发生超时重传,则慢启动门限设为当前拥塞窗口的一半(ssthresh = cwnd / 2),且把拥塞窗口设为 1(cwnd = 1),重新执行慢开始用拥塞避免。
    • 若发生快重传(3 次相同ACK),则 ssthresh = cwnd = cwnd/2,进入快恢复
  • 快恢复:cwnd = ssthresh + 3(虽然资料 1 说是因为收到 3 个ACK所以加 3,但还是不理解为什么),进入拥塞避免

img

多路复用

HTTP/2 时已经实现了基于一个 TCP 连接发起多个 HTTP 请求,因为其采用了二进制帧格式的数据结构,一个请求对应一条流,通过帧中的 Stream ID 来判断数据属于哪条流。

存在队头阻塞问题

HTTP 2 协议基于 TCP 有序字节流实现,因此应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题。如下面的动态图片所示,服务器返回的绿色响应由 5 个 TCP 报文组成,而黄色响应由 4 个 TCP 报文组成,当第 2 个黄色报文丢失后,即使客户端接收到完整的 5 个绿色报文,但 TCP 层不会允许应用进程的 read 函数读取到最后 5 个报文,并发成了一纸空谈:

动图

QUIC 解决队头阻塞:为每条流分配一个独立的滑动窗口,但对于同一个流上的请求仍存在着队头阻塞问题

连接迁移

  1. QUIC 是基于无连接的 UDP 协议,为连接迁移创造了前提
  2. QUIC 使用 Connection ID 来分辨客户端而不像TCP 依赖于四元组,当源 IP 改变时,仍然可以分辨该数据包来自的客户端是同一个(目的 IP 没有改变,仍可以转发到目的主机,且目的主机认得这个数据包来自哪个客户端)

连接迁移只能发生在握手成功之后,QUIC 握手期间终端必须保持稳定的地址