从服务器发起请求开始追踪,细说数据包在 QUIC 协议中经历的每一步。大量实例代码展示,简明易懂了解 QUIC。
前言
本文介绍了在 QUIC 服务器在收到 QUIC 客户端发起的第一个 UDP 请求— Initial 数据包的分析、处理和解密过程,涉及Initial数据包的格式,数据包头部保护的去除, Packet Number 的计算,负载数据的解密,client hello 的解析,等等。本文的 C 实现采用 OpenSSL,并基于 IETFQUIC Draft-27。
术语
**PacketNumber :**数据包序号
**Initial Packet:**初始数据包
**Variable-length Integer Encode:**可变长度整型编码
**HMAC:**Hash-based messageauthencation code,基于 Hash 的验证信息码
**HKDF: **HMAC-based Extract-and-Expand KeyDerivation Function,基于 HMAC 的提取扩展密钥衍生函数
AEAD: authenticated encryption withassociated data, 带有关联数据的认证加密
ECB: Electronic codebook,电子密码本
GCM: Galois/Counter Mode,伽罗瓦/计数器模式
IV: InitialVector, 初始化向量
基本概念介绍
Initial 数据包的结构
Initial 包是长头部结构的数据包,结构如图 3.1 所示,在 CRYPTO 帧后面需要跟上 PADDING 帧,这是 QUIC 协议预防 UDP 攻击的手段之一。一般情况下,CRYPTO 帧太短了(确实也有比较长“一锅炖不下”的情况,可参阅 QTS-TLS 4.3节),服务端为了响应 CRYPTO, 必须发送数据长度大得多的握手包(Handshake Packet),这样就会造成所谓的反射攻击。
QUIC 使用三种方法来抑制此类攻击:
-
含有 ClientHello 的数据包必须使用 PADDING 帧,达到协议要求的最小数据长度 1200 字节;
-
当服务端响应未经验证原地址的请求,第一次(firstflight)发送数据时,不允许发送超过三个 UDP 数据报的数据;
-
确认握手包是带验证的,盲攻击者无法伪造。
typedef struct {
uint8_t flag;
uint32_t version;
uint8_t dcid_length;
uint8_t *dcid;
uint8_t scid_length;
uint8_t *scid;
uint64_t token_length;
uint8_t *token;
uint64_t packet_length;
uint8_t *payload;} quic_long_header_packet_t;
Packet Number 三种上下文空间
Packet Number 为整型变量,其值在 0 到 2^62-1 之间,它也用于生成数据包加密所需的 nonce。通讯双方维护各自的 Packet Number 体系, 并且分为三个独立的上下文空间:
-
Initial 空间:所有的 Initial 数据包的 Packet Number 均在这个上下文空间里;
-
Handshake 空间:所有的握手数据包;
-
应用数据空间:所有的 0-RTT 和 1-RTT 包。
所谓的 Packet Number 空间,指得是一种上下文关系,在这个上下文关系里,数据包被处理,被确认。换言之,初始数据包只能使用初始数据包专用的密钥,也只能确认初始数据包。类似的, 握手包只能使用握手包专用的密钥,也只能确认握手数据包。从 Initial 阶段进入 Handshake 阶段后, Initial 阶段使用的密钥就可以被丢弃了,Packet Number 也重新从 0 开始编号。
0-RTT 和 1-RTT 共享同一个 Packet Number 空间,这样做是为了更容易实现这两类数据包的丢包处理算法。
在同一连接同一个 Packet Number 空间里,你不能复用包号,包号必须是单调递增的,当然,具体实现的时候草案并不强制要求每次都递增1, 你可以递增 20,30。当 Packet Number 达到 2^62 -1 时,发送方必须关闭该连接。
通讯过程 Packet Number 的处理还有许多细节,比如重复抑制问题,这部分可以参考 QUIC-TLS 部分以及 RFC4303 的 3.4.3 节,这里就不深入展开讨论。
HKDF:基于 HMAC 的密钥衍生函数
密钥衍生函数(KDF)是加密系统最为基本核心的组件,它将初始密钥作为输入,生成一个或多个足够健壮的加密密钥。
HKDF 的提出一方面是为了给其他协议和应用程序提供基本的功能块,同时也为了解决各种不同机制的密钥衍生函数实现的激增问题。它采用“先提取再扩展(extract-and-expand)”的设计方式,逻辑上,一般采用两个步骤来完成密钥衍生。第一步,将输入的字符转换成固定长度的伪随机密钥。第二步,将其扩展成若干个伪随机密钥。一般人们把通过 Diffie-Hellman 交换的共享密文转换为指定长度的密钥,用于加密,完整性检查以及验证。具体原理可参考 RFC5869。
可变长度整型编码
QUIC 协议中大量使用可变长度整型编码,用首字节的高 2 位来表示数据的长度,编码规则如下:
举个例子:
0b00000011 01011110,0x035e => 2Bit=00,代表长度为 1,可用位数 6, 所以,Value = 3
0b01011001 01011110,0x595e => 2Bit=01,代表长度为 2,可用位数 14,所以,Value = 6494
代码如下:
uint64_t Buffer_pull_uint_var(upai_buffer_t *buf, ssize_t *size)
{
CK_RD_BOUNDS(buf, 1)
uint64_t value;
switch (*(buf->pos) >> 6) {
case 0:
value = *(buf->pos++) & 0x3F;
if (size != NULL) *size = 1;
break;
case 1:
CK_RD_BOUNDS(buf, 2)
value = (uint16_t)(*(buf->pos) & 0x3F) << 8 |
(uint16_t)(*(buf->pos + 1));
buf->pos += 2;
if (size != NULL) *size = 2;
break;
case 2:
CK_RD_BOUNDS(buf, 4)
value = (uint32_t)(*(buf->pos) & 0x3F) << 24 |
(uint32_t)(*(buf->pos + 1)) << 16 |
(uint32_t)(*(buf->pos + 2)) <&