QUIC报文分为两大类:一类是长首部报文,用于建立QUIC连接和建立连接前发送应用数据;一类是短首部报文,用于在QUIC连接建立后发送应用数据和QUIC协议内容。具体报文类型见表2-1,典型的报文使用如图2-3所示。
表2-1 QUIC报文类型
QUIC报文封装在UDP数据报内部,一个UDP数据报可以包含一个或几个长首部QUIC报文,如图2-4所示;或者包含几个长首部QUIC报文和一个短首部QUIC报文,短首部QUIC报文因为没有显式指定长度,必须放在最后,如图2-5所示;或者只包含一个短首部QUIC报文,如图2-6所示。
将多个QUIC报文放入一个UDP数据报主要是为了合并不同加密级别的报文,这样可以提高负载率,更重要的是可以避免报文丢失或乱序导致的无法解密。这在连接建立期间尤其有用,比如重连时客户端就可以将初始报文和0-RTT报文放在同一个UDP数据报中发送,在0-RTT数据较少的情况下,服务器就不会因为初始报文丢失而无法解密0-RTT报文。在连接建立期间,服务器将初始报文和握手报文合并,也可以避免初始报文丢弃导致客户端无法解密握手报文。
图2-3 QUIC典型的报文使用
图2-4 UDP数据报内包含数个QUIC长首部报文
图2-5 UDP数据报内包含数个QUIC长首部报文和一个QUIC短首部报文
图2-6 UDP数据报内包含一个QUIC短首部报文
长首部报文用于QUIC连接建立,直到1-RTT密钥协商成功才可以开始使用短首部报文。长首部格式如图2-7所示。
图2-7 长首部格式
图2-7中的字段含义如下。
Flag(8位): 包含QUIC报文的各种标记位。在所有版本中,第1位都是首部格式位,其余7位由版本定义。
首部格式位(1位): 用于区分QUIC报文是长首部还是短首部。此位为1表示长首部,为0表示短首部。
版本号(32位): 表示当前使用的QUIC版本。版本号为0表示此报文是协商报文。
目的连接标识长度(8位): 目的连接标识长度字段用于指定目的连接标识字段的字节长度。版本1中连接长度限制在160位以内,所以此字段的范围为0~20,其他版本目前没有确定范围。当为0时,目的连接标识字段长度为0,不使用目的连接标识。
目的连接标识: 目的连接标识字段是接收方选择的连接标识,接收者可以根据此字段找到对应的连接。客户端初始报文的目的连接标识字段是客户端选择的随机值,初始报文中的目的连接标识还用来保护初始报文。
源连接标识长度(8位): 源连接标识长度字段用来标识SCID字段的长度,版本1中源连接标识长度值的范围为0~20,其他版本没有确定范围。当为0时,源连接ID字段长度为0,即不存在。
源连接标识: 发送方选择的连接标识,用于通知接收方发送报文中填入的目的连接标识。
长首部报文中,第一字节的低7位是版本特定的,在QUICv1中定义如下。
固定位(1位): 连接建立期间值固定为1(版本协商报文除外),为0则表示不是QUICv1的有效报文。这一位用于表示此报文是QUIC报文,以便与其他协议区分(RFC7983)。连接建立后可以根据协商使用非固定值(RFC9287)。此位目前是版本1特定的,其他版本还没有定义。
报文类型位(2位): 用于指定长首部报文的具体类型。值00表示初始报文;值01表示0-RTT报文;值10表示握手报文;值11表示重试报文。报文类型目前也是版本1特定的,其他版本还没有定义。
报文类型特定位(4位): 这四位值的含义由具体报文类型而定。
在长首部报文中,首部格式位、版本号、目的连接标识长度、目的连接标识、源连接标识长度、源连接标识是与版本与无关的,但是目的连接标识长度和源连接标识长度的取值范围并不是与版本无关的,后面的版本指定的范围可能更大。QUICv1长首部报文格式如图2-8所示,报文类型特定的格式取决于具体的报文类型。
图2-8 QUICv1长首部报文格式
1.初始报文
客户端使用初始报文来发起连接,服务器使用初始报文和握手报文回应客户端的连接请求。初始报文使用CRYPTO帧携带了TLS初始加密握手消息(对于客户端是ClientHello消息,对于服务器是ServerHello消息),其中包含了这个QUIC连接的传输参数,还在QUIC首部中携带了初始源连接标识、初始目的连接标识、QUIC版本,还可以包含一个服务器之前提供的令牌。初始报文除包含一个或几个CRYRTO帧外,还可以包含ACK帧,也可以包含PING帧、PADDING帧和CONNECTION_CLOSE帧。初始报文格式如图2-9所示。
图2-9 初始报文格式
初始报文中,第一个字节中前两位是长首部固定的数值:第一位为1表示长首部,第二位QUICv1中固定为1。第三位和第四位是报文类型位,初始报文值为00。第一字节后四位是由初始报文定义的,前两位保留,后两位是报文编号长度。长首部固定字段,包含版本号、目的连接标识长度、目的连接标识、源连接标识长度、源连接标识。各字段具体含义如下。
报文编号长度(2位): 用于得到报文编号字段的字节长度,该值加1是报文编号字段的真实长度。
令牌长度: 变长整型值(编码方式见2.9节),指定了令牌字段以字节为单位的长度。如果不存在令牌则为0。服务器不允许发送含有令牌的初始报文,所以此字段必须为0。
令牌: 客户端填入由重试报文或NEW_TOKEN帧提供的令牌,服务器无此字段。
报文编号(8~32位): 该初始报文在初始报文编号空间内的编号编码后的值。
负载: 由CRYPTO帧、ACK帧等组成的QUIC报文负载。
客户端在收到服务器回复的初始报文时,改变后续报文的目的连接标识为服务器选择的值;客户端在收到服务器回复的重试报文时,改变后续报文的目的连接标识为重试报文的源连接标识。
一般情况下,初始报文使用客户端选择的初始目的连接标识衍生出的密钥保护,但在客户端收到重试报文时,需要改变密钥为重试报文的源连接标识衍生的密钥。
客户端发送的包含初始报文的UDP数据报必须填充至1200字节,这可以跟0-RTT报文或其他报文合并,也可以使用PADDING帧填充。同样,服务器发送的包含引发确认的初始报文的数据报也必须填充至1200字节。这样可以确保两个方向都满足QUIC的最小PMTU要求。
2.0-RTT报文
0-RTT报文用于承载QUIC连接建立之前想要发送的数据,一般用于恢复连接后立即发送数据。此外也可以从配置或者其他连接得到PSK和必要连接信息,用于尽快发送数据的场景。0-RTT报文容易被重放,由应用决定是否使用以及怎么使用0-RTT报文。0-RTT报文格式见图2-10。
0-RTT报文中,第一个字节中前两位是长首部固定位:第一位为1表示长首部,第二位固定为1表示QUIC报文,第三位和第四位是报文类型位,值为01表示0-RTT报文。第一字节后四位是由0-RTT报文定义的(这四位跟初始报文一样),前两位保留,后两位是报文编号长度。长首部固定字段,包含版本号、目的连接标识长度、目的连接标识、源连接标识长度、源连接标识。
各字段具体含义如下:
报文编号长度(2位): 用于得到报文编号字段的字节长度,该值加1即为报文编号字段的字节长度。
报文长度: 变长整型值,指定了报文编号和负载的总长度,可以通过此字段判断这个QUIC报文的末尾,从而判断下一个QUIC报文的起始处。
负载: 由STREAM帧组成的QUIC报文负载。
0-RTT报文只能包含STREAM帧,不能包含ACK帧,服务器使用1-RTT报文中的ACK帧确认客户端的0-RTT报文,所以两者使用同一个报文编号空间。根据TLS 1.3和QUIC的规定,0-RTT报文只能由客户端发出。
图2-10 0-RTT报文格式
3.握手报文
握手报文用来携带服务器和客户端的TLS加密握手消息和确认,载荷通常是CRYPTO帧和ACK帧,但也可以包含PING帧、PADDING帧、CONNECTION_CLOSE帧。握手报文的具体格式如图2-11所示。
图2-11 握手报文格式
握手报文的格式跟上文中0-RTT报文格式基本一致,除了第一个字节中的第三位和第四位的报文类型位是10,表示此报文是握手报文。
服务器开始发送握手报文是收到客户端初始报文的结果,因此,客户端收到来自服务器的握手报文后,不再发送初始报文,转而发送握手报文;服务器发送握手报文后,不再接收客户端的初始报文,也不再发送初始报文。
握手报文的使用握手报文编号空间,因此客户端和服务器的握手报文的报文编号重新从0开始,单向增长。
4.重试报文
重试报文是服务器用来验证客户端地址的报文,可以防止源地址欺骗,具体格式如图2-12所示。
服务器收到客户端的初始报文后,可以使用重试报文通知客户端按照要求重新发送初始报文。服务器在重试报文中携带重试令牌给客户端,并使用服务器选择的连接标识作为重试报文的源连接标识;客户端需要使用服务器指定的连接标识作为目的连接标识,携带服务器指定的重试令牌,构建新的初始报文,重新发送给服务器。
图2-12 重试报文格式
重试报文中,第一个字节中前四位根据长首部报文的规则已经固定了数值:第一位为1表示长首部,第二位QUIC报文中固定为1,第三位和第四位是报文类型位,值为11表示重试报文。第一个字节的后四位保留。长首部固定字段,包含版本号、目的连接标识长度、目的连接标识、源连接标识长度、源连接标识。
各字段具体含义如下。
重试令牌: 服务器提供给客户端的不透明令牌,用来验证客户端的地址。
重试完整性标签(128位): 用来保护重试报文完整性的标签,防止报文被篡改。
重试报文中并没有重试令牌长度字段。这是因为重试报文不与其他报文合并,占据单独一个UDP数据报,可以根据UDP数据报长度得到重试令牌长度。零长度的令牌是非法的。
重试报文不需要显式确认,也就不需要报文编号。一方面重试报文的确认是以客户端重发的初始报文隐式进行的;另一方面重试报文不能检测到丢失然后重发,这样容易引起放大攻击。如果重试报文丢失了,过程应该是客户端在一段时间内未收到初始报文的回应,重发初始报文,服务器收到后再触发一次重试报文发送。
对于收到的一个UDP数据报,服务器至多回复一个重试报文,以防被用于放大攻击;相应地,客户端对于一次发送不能处理超过一个重试报文,以防止被攻击。
重试报文本身的要求比较简单,除非实现重试卸载(见6.3节)。然而,对于客户端用来回复重试报文的初始报文,其连接标识、令牌、负载中的加密握手消息等都有比较严格的要求:客户端回复的初始报文的源连接标识必须使用之前发送的原始初始报文的源连接标识,不能改变;目的连接标识则必须使用重试报文中的源连接标识,即服务器选择的连接标识,这个连接标识也用来生成后续初始报文的密钥。
客户端在后续所有的初始报文中都应该填入收到的重试报文中的令牌。虽然大部分情况下一个初始报文就可以,但TLS的ClientHello消息较大、TLS重试、令牌长度过长都会导致发出多个初始报文。
客户端回复重试报文的初始报文中的加密握手消息(即CRYPTO帧内容)必须跟原始初始报文一样。
服务器可以将原始0-RTT报文缓存,等待验证完成后再处理,但这样容易被攻击。所以客户端可以在收到重试报文重新尝试发送0-RTT报文,这时0-RTT报文的目的连接标识应该是重试报文的源连接标识。
客户端收到重试报文后,应该继续使用之前的初始数据编号空间和0-RTT数据编号空间。尤其是0-RTT报文中可能不是重传的之前0-RTT数据,如果使用相同的报文编号,那么就使用了相同的密钥保护不同的报文,这样会导致保护失效。
5.版本协商报文
当服务器收到包含自己不支持的版本号的初始报文时,就会发送版本协商报文。客户端收到版本协商报文后需要在其中选择一个自己支持的版本号,重新以新版本号发送初始报文。
版本协商报文是版本无关的长首部报文,所以格式不是严格遵循某个QUIC版本(如QUICv1),而是靠长首部报文(首部格式位值为1)和版本号字段为0来判断的。没有使用长首部报文类型来区分是因为该字段是版本特定的,有可能不符合之后版本的规定。版本协商报文的具体格式如图2-13所示。
版本协商报文的各个字段含义如下:
首部格式位(第1位): 固定为1,表示长首部报文。
未使用位(第2至8位): 由服务器设置为任意值。其中第2位对应QUICv1长首部报文中的固定位,当与其他协议一起使用时,可以设置为1用来识别是否是QUIC报文。
版本号(32位): 版本号需要设置为全0,表示此报文是版本协商报文。
图2-13 版本协商报文格式
源连接标识长度(8位): 含义跟上文中其他长首部报文一致,但取值范围有所不同。QUICv1要求源连接标识长度是0~160位,对应字节长度0~20字节。但其他版本并不一定有此限制,所以版本协商报文中没有限制取值范围。
源连接标识(0~2040位): 源连接标识取自触发版本协商的报文的目的连接标识(一般是初始报文,也可能是0-RTT报文),可以向客户端证明自己确实收到了初始报文,这保证了版本协商报文不是来自没有观察到初始报文的攻击者。
目的连接标识长度(8位): 含义跟上文中其他长首部报文一致,但取值范围不同,取值范围与源连接标识长度字段一致,见上文。
目的连接标识(0~2040位): 目的连接标识取自触发版本协商的报文的源连接标识,客户端可以通过这个字段来识别具体的连接。
支持的版本: 包含一到多个服务器支持的版本的32位版本号,来供客户端选择。
版本协商报文没有任何保护,也没有报文编号字段、长度字段和任何帧。这是由于版本协商报文不需要确认,也就不需要报文编号;也不能与其他报文合并,长度从UDP数据报长度中就可以推断出来,不需要显式指定。
版本协商报文不需要服务器重传,因为重传可能会带来放大攻击。所以服务器针对一个UDP数据报只能回应一个版本协商报文(一个UDP数据报中可能包含数个QUIC报文,包括初始报文和0-RTT报文)。如果版本协商报文丢失了,客户端检测到一段时间内没有收到关于初始报文的回应,会重发初始报文,服务器收到重发的初始报文后再次回应版本协商报文。
短首部报文一般也叫作1-RTT报文,连接在协商出1-RTT密钥后就可以发送短首部报文,格式如图2-14所示。
短首部报文字段具体含义说明如下:
首部格式位(第1位): 表示此报文是长首部还是短首部,0代表短首部。
图2-14 短首部报文格式
固定位(第2位): 一般QUIC报文固定为1,经协商也可使用其他值。
自旋位(第3位): 延迟自旋位(Latency Spin Bit),提供给中间件观察,以检测连接的RTT,具体见3.7节。
保留位(第4~5位): 保留使用,明文值必须为0。这两位会被首部保护加密,移除首部保护后如果不是0则是非法报文。
KP(第6位): 即Key Phase,密钥阶段位,用来通知接收方密钥变化,此位被首部保护所加密。
报文编号长度(第7至8位): 用于指示报文编号的字节长度,实际报文编号的字节长度为此值加一。这两位也被首部保护所加密。
目的连接标识: 接收方选择的连接标识。如果连接建立期间接收方表示不使用连接标识,值可以为0。
报文编号: 长度是1~4字节,也就是8/16/24/32位。这里的值并不是实际的报文编号,而是经过压缩计算的值,具体见第2.4节。报文编号也被首部保护所加密。
负载: 1-RTT密钥保护的报文负载,包含STREAM帧在内的一种或多种帧。
短首部报文的报文格式位和目标连接标识字段是与版本无关的,其余字段由具体的QUIC版本决定。
注意 目的连接标识没有显式指定长度,这是因为两端已经通过其他方式知道了具体的值和长度。比如通过长首部报文的交互知晓了具体的连接标识,这包含值和长度;或者目标连接标识来自于端点通过NEW_CONNECTION帧发布的连接标识,这也包含值和长度;另外服务器还可以通过首选地址传输参数提供一个连接标识,同样包含值和长度。如果中间件需要解析短首部报文中的目的连接标识,如用来做出负载均衡的决定,则需要跟对应的端点协商使用固定长度连接标识,或者协商出编码的规则,具体见第6.2节。
自旋位的支持是可选的,每个端点都可以基于自己的选择决定是否禁用自旋位。当禁用时,报文中的自旋位设置为随机值。
无状态重置报文既不属于长首部报文也不属于短首部报文,全部明文没有任何加密保护,具体格式如图2-15所示。
图2-15 无状态重置报文格式
从报文格式可以看出,无状态重置报文设计跟短首部报文相近,想要尽可能地无法和常规短首部报文区分,这样的设计是为了防止中间人观测到具体报文类型,进而推测出连接信息和无状态重置令牌。
报文的第一个字节中,第一位是用来区分长首部和短首部,无状态重置报文需要模仿短首部,这样中间人会以为这就是一个普通的短首部报文,所以值为0;第二位是固定位,为1表示是QUIC报文;剩下6位在短首部中分别对应自旋位(第3位)、保留位(第4至5位)、密钥阶段位(第6位)、报文编号长度(第7至8位),这些值都不是固定的,除了自旋位都是经过首部保护加密的,所以在无状态重置报文中设置为不可预测的值。
最后128位是自己之前为该报文的目的连接标识签发的无状态重置令牌,这是接收方用来判断是否是无状态重置报文的字段,即最后128位是当前连接的无状态重置令牌则该报文为无状态重置报文。
虽然尽量伪装为短首部报文,但无状态重置报文中没有连接标识字段,在连接标识字段的位置是不可预测位的随机值。这是因为发送者丢失了连接上下文,收到的报文中仅包含了目的连接标识,即自己选择的连接标识,对方选择的连接标识的记录已经丢失,所以无法正确填充目的连接标识。这在中间人看来像是一个迁移到新连接标识的短首部报文,这样做可能会产生如下影响。
1)攻击者无法区分开迁移连接标识的短首部报文和无状态重置报文,无法根据报文类型提取连接信息。
2)合法中间件,如根据连接标识路由的负载均衡器,无法根据连接标识字段正确路由到正确的后端。这样等待报文的端点(通常是服务器)就收不到无状态重置报文,无法利用无状态重置的好处,只能等待超时。由于错误路由而接收到无状态重置报文的端点会认为这是一个正常短首部报文,可能会回复一个无状态重置报文,这就导致了无状态重置报文的循环。