RFC9204 QPACK:HTTP/3字段压缩
前言
本文是关于QPACK的网络规范文档译文,尚未完成翻译,欢迎指正。
摘要
本规范定义了QPACK:一种高效表示用于HTTP/3的HTTP字段的压缩格式。这是一个HPACK的变种,旨在降低队头阻塞。
备忘状态
本文是互联网标准追踪文档。
本文产自互联网工程任务组(IETF),已接受公开审查,并由互联网互联网工程指导委员会(IESG)批准出版。更多互联网标准相关信息详见RFC 7841第2章。
关于本文当前状态、勘误及反馈方式等相关信息请移步https://www.rfc-editor.org/info/rfc9204。
版权声明
版权所有(c)2022 IETF信托及确认为文档作者的个人。保留所有权利。
本文遵守BCP 78及在本文发布之日起生效的IETF信托涉及IETF文档的法律条文(https://trustee.ietf.org/license-info)。请仔细阅读相关条文,因为其描述了你对本文所有的权利及限制。从本文中摘录的代码组件必须包含信托法律条文第4.e章的简版BSD License文件,并且不附带任何该文件所描述的保证。
1. 引言
1.1. 约定及术语
本文中的关键字“必须(MUST)”、“必须不(MUST NOT)”、“需要(REQUIRED)”、“强烈要求(SHALL)”、“强烈要求不(SHALL NOT)”、“应该(SHOULD)”、“不应该(SHOULD NOT)”、“推荐(RECOMMENDED)”、“不推荐(NOT RECOMMENDED)”、“可以(MAY)”,以及“可选(OPTIONAL)”应理解为BCP 14 《RFC2119》《RFC8174》所描述的,当且仅当它们像本段一样以斜体加粗方式出现的时候。
本文使用下述术语:
HTTP字段(HTTP fields):作为HTTP消息的一部分发送的元数据。本术语包括头部和挂载字段。通俗来讲,术语“头部(headers)”已常用于指代HTTP头部字段和挂载字段,本文一般采用“字段(fields)”。
HTTP字段行(HTTP field line):作为HTTP字段组的组成部分发送的一个名值对(name-value pair)。详见《HTTP》的第6.3章和第6.5章。
HTTP字段值(HTTP field value):与字段名关联的数据,由该字段组内所有的同一字段名下的字段行构成,并通过逗号拼接在一起。
字段组(Field section):一个HTTP消息的所有有关HTTP字段行组成的有序集合。在一个字段组内,可以包含多个字段名相同的字段行。也可以包含相同的字段行。一条HTTP消息可以同时包含头部和挂载组。
指称(Representation):一种指称一个字段行的指令,可能通过引用动态表和静态表来表示。
编码器(Encoder):一种编码字段组的实现。
解码器(Decoder):一种解码字段组的实现。
绝对索引(Absolute Index):动态表中每个条目的唯一索引。
基点(Base):一个用于相对索引和反向索引的参考点。引用了动态表条目的指称,其索引值都是相对于基点来计算的。
插入计数(Insert Count):动态表中插入的条目的总数。
注意QPACK是一个名称而不是缩写。
1.2. 符号约定
2. 压缩过程概览
2.1. 编码器
编码器通过为字段组中的每条字段行创建一条索引指称或内联指称的方法,将头部或挂载转换成一系列指称,详见第4.5章。索引指称通过将明文的名称或值替换为一个静态表或动态表的索引,从而取得高压缩率。引用静态表和明文指称不需要任何动态状态,也不存在队头阻塞的风险。若编码器尚未收到一个表明在解码器中可以访问该条目的确认回复,则此时引用动态表存在队头阻塞的风险。
编码器可以向其选择的动态表插入任意条目,而不限于其正在压缩的字段行。
QPACK会维持每个字段组内的字段行的顺序。编码器必须以在字段组中出现的顺序发射字段指称。
对于可选的状态追踪特性,QPACK在设计上将此负担放在编码器中,使得解码器的实现相对简单。
2.1.1. 动态表插入限制
若表中存在不能被驱逐的条目,则将条目插入动态表可能不会成功。
动态表条目在插入后不能立即驱逐,即使其尚未被引用。一旦动态表条目的插入得到确认,且没有外部未被确认的指称引用到该条目,则该条目就变成可以被驱逐的了。注意,在编码器流上的引用从不妨碍条目的驱逐,因为会确保这些引用在指令驱逐条目之前得到处理。
若动态表在不驱逐其他条目的前提下没有足够空间留给一条新的条目,且将被驱逐的条目是不可驱逐的,那么编码器必须不插入该条目到动态表中(包括复制已经存在的条目)。为了避免这种情况,使用了动态表的编码器必须对被每个字段组引用的每条动态表条目保持追踪,直到这些指称被解码器确认,详见第4.4.1章。
2.1.1.1. 避免非法插入
为了确保编码器不被阻止添加新的条目,编码器可以避免引用接近被驱逐的条目。相较于引用这样的条目,编码器可以发射复制指令(详见第4.3.4章)并转而引用该复制条目。
确认哪些条目最接近被引用所驱逐,取决于编码器的决定。一种启发式方法是把一个动态表中的固定数量的空间锁定:不论未被使用的空间还是可以通过发射非阻塞条目而重用的空间。为了达到这个目标,编码器可以维持一个排空索引,它是将发射索引的动态表中最小的绝对索引(详见第3.2.4章)。随着新的条目的插入,编码器增加排空索引以维持其不会引用的表中的块。对那些绝对索引小于排空索引的条目,如果编码器没有对其创建新的引用,则其未确认的引用的数目将最终降为零,使之最终被驱逐。
2.1.2. 流阻塞
因为QUIC并不确保不同流之间的数据有序传输,解码器可能收到引用一个其尚未收到的动态表条目的指称。
每个编码的字段组包含一个“插入计数下限”(Required Insert Count,详见第4.5.1章),即解码字段组所需的插入计数最低值。对于一个编码时引用了动态表的字段组,其插入计数下限为所有动态表中受引用条目的绝对索引的最大值加1
。对于一个没有引用动态表的字段组,其插入计数下限是零。
当解码器收到一个插入计数下限大于解码器的插入计数的编码字段组时,流不能被立即处理,并被认为是“被阻塞的”,详见第2.2.1章。
解码器使用SETTINGS_QPACK_BLOCKED_STREAMS
(QPACK阻塞流数目)设置指定可以被阻塞的流数目的上限,详见第5章。编码器必须始终将可被阻塞的流的数目限制在SETTINGS_QPACK_BLOCKED_STREAMS
值内。如果编码器遇到被阻塞的流超过其承诺支持的数目时,其必须将之视为一个QPACK_DECOMPRESSION_FAILED
(解压失败)类型的连接错误。
注意,解码器可能不会在每条有被阻塞风险的流上阻塞。
编码器可以决定是否冒险让某个流变成阻塞状态。如果SETTINGS_QPACK_BLOCKED_STREAMS
的值允许,通常可以通过引用正在传输中的动态表条目提升压缩率,但是如果出现丢包或乱序,解码器侧的流可能变成阻塞状态。编码器可以通过只引用已经被确认的动态表条目规避阻塞风险,但是这可能意味着使用明文。由于明文会使得编码字段组更大,可能导致编码器被拥塞或流量控制限制所阻塞。
2.1.3. 避免流控死锁
在流上被流量控制所限制的写指令可能造成死锁。
解码器可能只会在编码流收到必要的更新后,才为传递已编码字段组的流提高流量控制额度。如果消耗与释放用于传递已编码字段组的流上的数据是能否确保编码流(或整条连接)的流量控制额度的前提,那么就可能引发死锁。
更一般地,如果解码器在收到完整的指令前扣留流量控制额度,那么包含着巨大指令的流就可能被死锁。
为了避免此类死锁,编码器不应该写入指令,除非流与连接的流量控制额度足够支持传输整条指令。
2.1.4. 已知接收计数
2.2. 解码器
正如在HPACK中,解码器处理一系列指称,并发射相关字段组。它也处理从编码流收到的涉及修改动态表的指令。注意,编码字段组和编码流指令分别从单独的流到达。HPACK则不同,其编码字段组(头部块)可以包含修改动态表的指令,且不存在专门的流用于传输HPACK指令。
解码器必须依照它们的指称在编码字段组中的次序发射字段行。
2.2.1. 解码阻塞
一旦收到编码字段组,解码器就验证其插入计数下限。当插入计数下限小于或等于解码器的插入计数时,可以立即处理该字段组。否则,收到该字段组的流被阻塞。
当被阻塞时,编码字段组数据应该继续呆在阻塞流的流量控制窗口内。直到流解除阻塞为止数据都不可用,而且过早地释放流量控制会让解码器容易遭受内存耗尽攻击。当插入计数大于或等于编码器已经开始读取的所有编码字段组的插入计数下限时,流退出阻塞状态。
正如第2.1.2章所规定的那样,当处理编码字段组时,解码器预期插入计数下限的值为解码字段组所需的插入计数的所有可能值中最小的那个。如果出现插入计数下限小于解码器所预期的值,必须视为一个QPACK_DECOMPRESSION_FAILED
(QPACK解压失败)类型连接错误,详见第2.2.3章;如果出现插入计数下限大于其所预期的值,可以视为一个QPACK_DECOMPRESSION_FAILED
类型连接错误。
2.2.2. 状态同步
解码器通过向解码流发送解码指令(详见第4.4章)通知对端下述事件。
2.2.2.1. 字段组处理完成
在解码器完成解码一个使用包含动态表引用的指称编码的字段组后,其必须发射一个“组确认(Section Acknowledgment)”指令(详见第4.4.1章)。一条流可能携带多个字段组用于临时响应、挂载以及推送请求。编码器将组确认指令解释为一条对给定流上包含动态表引用且未得到确认的字段组中最先发送的字段组的确认。
2.2.2.2. 放弃流
2.2.2.3. 新表条目
2.2.3. 无效引用
如果解码器碰到字段行指称中指向一条已被驱逐或其绝对索引大于等于声明的“插入计数下限(Required Insert Count,详见第4.5.1章)”的动态表条目的引用时,必须视为一个“QPACK解压失败(QPACK_DECOMPRESSION_FAILED
)”类型连接错误。
如果解码器碰到编码指令中指向一条已被驱逐的动态表条目的引用时,必须视为一个“QPACK编码流错误(QPACK_ENCODER_STREAM_ERROR
)”类型连接错误。
3. 引用表
与HPACK不同,QPACK静态表和动态表中的条目地址是单独分配的。接下来的章节描述了如何为这两张表中的条目分配地址。
3.1. 静态表
静态表由一组预定义的字段行组成,所有字段行的索引都是固定的。附录A中定义了静态表的条目。
静态表中的所有条目都具有一个名称和一个值。不过,值可以为空(也就是长度为0
).每个条目都用一个唯一的索引来识别。
注意,QPACK静态表的索引从0
开始,而HPACK静态表的索引从1
开始。
当解码器在某字段行指称中遇到一个无法识别的静态表索引时,它必须将这种情况视作类型为QPACK_DECOMPRESSION_FAILED
(解压失败)的连接错误。如果这样的索引是从编码器流中接收到的,那么必须将这种情况视作类型为QPACK_ENCODER_STREAM_ERROR
(编码流错误)的连接错误。
3.2. 动态表
动态表由一组按照先进先出顺序维护的字段行组成。QPACK编码器和解码器共享一张动态表,该表初始为空。编码器向动态表添加条目,并在编码流上通过指令向解码器发送这些条目;详见第4.3章。
动态表中可以包含重复的条目(也就是具有相同名称和相同值的条目)。因此,条目重复的情况必须不被解码器视作错误。
动态表条目的值可以为空。
3.2.1. 动态表尺寸
动态表的尺寸为其所有条目尺寸的总和。
某条目的尺寸为其名称以字节为单位的长度、其值以字节为单位的长度,和额外的32字节之和。计算条目尺寸时使用的是未经过哈夫曼编码的名称和值的长度。
3.2.2. 动态表容量与驱逐
动态表的容量是由编码器设置的,它的意义是动态表尺寸的上限。动态表的初始容量为零。编码器想要使用动态表,就要发送一条容量值非零的“设置动态表容量”(Set Dynamic Table Capacity,详见第4.3.1章)指令。
在向动态表添加条目前,先要从动态表的末尾开始驱逐条目,直到动态表的尺寸小于等于动态表容量与新条目尺寸的差。除非某条目是可驱逐的,否则编码器必须不驱逐它;详见第2.1.1章。随后,就可以向动态表添加新条目。如果编码器试图添加一条尺寸大于动态表容量的条目,那么会产生错误;解码器必须将这种情况视作类型为QPACK_ENCODER_STREAM_ERROR
的连接错误。
正在向动态表添加的新条目可以引用动态表中即将被驱逐的条目。如果被引用的条目在插入新条目前就被已经驱逐,那么实现要注意确保被引用的名称和值仍存在于表中。
一旦动态表的容量被编码器缩减(详见[第4.3.1章]()),那么就要从动态表的末尾开始驱逐条目,直到动态表的尺寸小于等于新的表容量。通过将容量设置为0
,随后恢复容量的方式,可以利用这项机制来完全清除动态表中的条目。
3.2.3. 动态表容量上限
为了约束自身的内存用量,解码器可以限制编码器能够设置的动态表容量的上限值。在HTTP/3中,该限制由解码器发送的参数SETTINGS_QPACK_MAX_TABLE_CAPACITY
的值来控制;详见第5章。编码器设置的动态表容量必须不超过该上限,但可以选择使用更低的动态表容量;详见第4.3.1章。
对于要使用HTTP/3中0-RTT数据的客户端来说,服务器的动态表容量上限是该设置的记忆值,若服务器未曾发送过该值,则为零。如果客户端在0-RTT的设置帧中发送的该参数值为零,那么服务器可以在其设置帧中将它设置为非零值。如果记忆值非零,那么服务器必须在其设置帧中使用这个非零值。如果服务器指定的是其他值,或在设置帧中省略了SETTINGS_QPACK_MAX_TABLE_CAPACITY
,那么编码器必须将这种情况视作类型为QPACK_DECODER_STREAM_ERROR
(解码流错误)的连接错误。
对于没有使用0-RTT数据的客户端(无论是没有主动使用0-RTT,还是0-RTT被拒绝)和所有HTTP/3服务器,直到编码器处理到SETTINGS_QPACK_MAX_TABLE_CAPACITY
值非零的设置帧前,动态表容量上限始终为0
。
当动态表容量上限为零时,编码器必须不向动态表插入条目,且必须不在编码流上发送任何编码指令。
3.2.4. 绝对索引
每个条目在其生存期内都拥有一个固定的绝对索引。首个被插入的条目,其绝对索引值为0
;索引会随每一次插入而上升1
。
3.2.5. 相对索引
3.2.6. 反向索引
反向索引被用于字段行指称中,指向绝对索引大于等于基点的条目。反向索引0
指向的是绝对索引等于基点的条目。反向索引的上升方向与绝对索引一致。
反向索引使得编码器能够单向地按顺序处理字段组(即“单通”),并使用指向在处理该(或其他)字段组时添加的条目的引用。
4. 数据通信格式
4.1. 基本数据类型
4.1.1. 前缀整型
4.1.2. 明文字符串
《RFC7541》的第5.2章中的明文字符串也得到大量使用。该字符串格式中包含可选的哈夫曼编码。
HPACK中定义的每个明文字符串的开头是一个前缀字节。前缀字节以一个信号比特位起始,本文称之为H
(它表明字符串是否经过哈夫曼编码),后面跟着被编码为7位前缀整型的以字节为单位的字符串长度,最后是与此长度一致的数据部分。当启用哈夫曼编码时,使用的是《RFC7541》的附录B中的哈夫曼表,且未经修改,同时前缀字节中的长度是字符串经过编码后的尺寸。
本文档扩展了明文字符串的定义,允许它们以不止一个字节长的前缀开始。“N位前缀的明文字符串”起始于某个字节的中间,在起始处之前的8 - N
位被分配给前一个字段。这样的字符串使用一个比特位,用于哈夫曼信号,后面跟着被编码为N - 1
位前缀整型的字符串长度。前缀尺寸N
的取值范围为2
至8
,包含两端。明文字符串定义的其余部分未作修改。
没有标记前缀长度的明文字符串就是8位前缀的明文字符串,严格遵守《RFC7541》中的定义。
4.2. 编码流与解码流
QPACK定义了两种单向流类型:
-
编码流是类型为
0x02
的单向流。它从编码器向解码器传递没有帧结构的编码指令序列。 -
解码流是类型为
0x03
的单向流,它从解码器向编码器传递没有帧结构的解码指令序列。
每个HTTP/3终端都具备一组QPACK编码器和解码器。每个终端必须发起至多一个编码流和至多一个解码流。同一种流类型,接收到第二条流的情况必须被视作类型为H3_STREAM_CREATION_ERROR
(流创建错误)的连接错误。
发送方必须不关闭这些流中的任意一条,且接收方必须不请求发送方关闭这些流中的任意一条。无论流类型,流被关闭的情况必须被视作类型为H3_CLOSED_CRITICAL_STREAM
(关键流遭关闭)的连接错误。
如果不会用到编码流,那么终端可以不创建它(例如,当编码器不希望使用动态表或对端允许的动态表容量上限为零时)。
如果终端的解码器将动态表的容量上限设置为零,那么终端可以不创建解码流。
即使连接的设置阻止终端使用编解码流,终端也必须允许其对端能够创建一条编码流和一条解码流。
4.3. 编码指令
编码器在编码流上发送编码指令,从而设置动态表容量,以及添加动态表条目。添加条目的指令中可以使用现有条目,以避免传输冗余信息。名称可以使用引用静态表或动态表中已有条目的方式传输,或者以明文字符串的方式传输。对于已经出现在动态表中的条目,还可以对其进行完整引用,创建出重复的条目。
4.3.1. 设置动态表容量
编码器使用以001
这3个比特位起始的指令来告知解码器动态表的容量发生了变化。这3位后面跟着以5位前缀整型表示的动态表新容量值;详见第4.1.1章。
新的容量必须小于等于第3.2.3章中描述的上限。在HTTP/3中,这个上限就是从解码器接收到的参数SETTINGS_QPACK_MAX_TABLE_CAPACITY
的值(详见第5章)。解码器必须将动态表新容量值超过该上限的情况视作类型为QPACK_ENCODER_STREAM_ERROR
(编码流错误)的连接错误。
缩减动态表容量会造成条目被驱逐;详见第3.2.2章。这必须不造成不可驱逐的条目被驱逐;详见第2.1.1章。动态表容量的改变不会得到确认,因为这条指令并没有插入条目。
4.3.2. 插入引用名称的条目
4.3.3. 插入明文名称的条目
当字段的名称和值都以明文字符串来表示时,编码器会使用以01
这2个比特位起始的指令来向动态表添加条目。
这2个比特位后面跟着的是以6位前缀的明文字符串表示的名称和以8位前缀的明文字符串表示的值;详见第4.1.2章。
4.3.4. 复制条目
4.4. 解码指令
解码器在解码流上发送解码指令,从而告知编码器有关字段组处理和表更新的信息,以确保动态表的一致性。
4.4.1. 组确认
4.4.2. 流取消
4.4.3. 插入计数提升
“插入计数提升(Insert Count Increment)“指令以00
这2个比特位起始,后面跟着以6位前缀整型编码的提升量。该指令使得已知接收计数(详见第2.1.4章)提升其参数所指示的量。解码器发送的提升量应该将已知接收计数提升至目前已处理的动态表插入与复制操作总数。
如果编码器接收到的提升量字段值为零,或使得已知接收计数超过了编码器已发送的数量,那么它必须将这种情况视作类型为QPACK_DECODER_STREAM_ERROR
的连接错误。
4.5. 字段行的指称
编码字段组由一个前缀和一些定义在该组中的指称序列(可以是空序列)组成。每条指称都对应着一条字段行。这些指称会引用静态表或特定状态下的动态表,但它们不会修改动态表的状态。
编码字段组是在由封装协议定义的流上,用帧结构来传递的。
4.5.1. 编码字段组的前缀
4.5.1.1. 插入计数下限
插入计数下限标识着处理编码字段组所需的动态表状态。处于阻塞状态的解码器使用插入计数下限来判断何时可以安全处理字段组的剩余部分。
编码器在编码前以这种方式转换插入计数下限:
if ReqInsertCount == 0:
EncInsertCount = 0
else:
EncInsertCount = (ReqInsertCount mod (2 * MaxEntries)) + 1
其中MaxEntries
是动态表中可能出现的条目数量最大值。最小的条目是名称和值均为空字符串的条目,其尺寸为32
字节。因此,MaxEntries
的计算方法为:
MaxTableCapacity
就是解码器指定的动态表容量上限;详见第3.2.3章。
这种编码方式能够限制前缀在持久连接中的长度。
解码器可以使用形如下文所述的算法来重建插入计数下限。如果解码器遇到的EncodedInsertCount
值不可能由对端的编码器产生出来,那么它必须将此情况视作类型为QPACK_DECOMPRESSION_FAILED
(解压失败)的连接错误。
TotalNumberOfInserts
为解码器动态表的总插入次数。
FullRange = 2 * MaxEntries
if EncodedInsertCount == 0:
ReqInsertCount = 0
else:
if EncodedInsertCount > FullRange:
Error
MaxValue = TotalNumberOfInserts + MaxEntries
# `MaxWrapped`为`ReqInsertCount`的最大可能值,
# 也就是`0 mod 2 * MaxEntries`
MaxWrapped = floor(MaxValue / FullRange) * FullRange
ReqInsertCount = MaxWrapped + EncodedInsertCount - 1
# 如果`ReqInsertCount`超过了`MaxValue`,
# 那么编码器的值一定被少包裹了一次
if ReqInsertCount > MaxValue:
if ReqInsertCount <= FullRange:
Error
ReqInsertCount -= FullRange
# 值`0`一定会被编码为`0`
if ReqInsertCount == 0:
Error
举例来说,如果动态表容量为100字节,那么插入计数下限将用mod 6
来编码。如果解码器已接收到了10次插入,那么编码值4
就表示该字段组的插入计数下限为9
。
4.5.1.2. 基点
基点被用来在动态表中解析引用,第3.2.5章介绍了解析过程。
为了节省空间,基点按照插入计数下限的值被编码为一个信号比特位(图12中的S
)和基点差值。信号位0
表示基点大于等于插入计数下限的值;解码器将基点差值与插入计数下限相加,得到基点的值。信号位1
表示基点钓鱼插入计数下限;解码器从插入计数下限中减去基点差值,再减去1,得到基点的值。也就是说:
单通的编码器在对字段组编码前就要算出基点的值。如果编码器在编码字段组时会向动态表插入条目并引用它们,那么插入计数下限将大于基点,所以编码出来的差值为负数,信号位被设置为1
。如果编码字段组时使用的指称没有引用最近插入动态表的条目,也没有插入任何新条目,那么基点就会大于插入计数下限,所以编码出来的插值为正数,信号位被设置为0
。
基点的值必须不为负数。尽管协议基点为负数时也能正确处理反向索引,但这是不必要且低效的。如果插入计数下限小于等于基点差值,那么终端必须将信号位为1
的字段块视作非法。
在对字段组编码前先对表进行更新的编码器可以将基点的值设置为插入计数下限的值。在这种情况下,信号位和基点差值均被设置为零。
如果编码字段组时没有引用动态表,那么基点可以是任意值;将基点差值设置为零是最高效的编码方式之一。
举例来说,当插入计数下限为9
时,解码器接收到了信号位1
和基点差值2
。它会将基点设置为6
,并为三个条目启用反向索引。在这个例子中,相对索引1
指向的是第五个被添加进表的条目;反向索引1
则指向的是第八个。
4.5.2. 索引字段行
4.5.3. 使用反向索引的索引字段行
4.5.4. 使用索引名称的明文字段行
使用索引名称的明文字段行的指称所编码的字段行,其字段名称要么与静态表中某条目的字段名称一致,要么与动态表中某个绝对索引小于基点值的条目的字段名称一致。
这种指称以01
这2个比特位起始。后面跟着比特位N
,它表示是否允许后续跃点的中间设备将该字段行加入动态表中。当设置了比特位N
时,必须始终用明文指称来编码字段行。特别是,当中间设备从对端接收到的字段行是用设置了比特位N
的明文字段行来表示的时,它必须使用明文指称来转发该字段行。该比特位的目的是保护字段值不会因压缩而处于风险之中;有关细节详见第7.1章。
第四个比特位T
表示引用指向的是静态表还是动态表。后面跟着的4位前缀整型(详见第4.1.1章)被用来为字段名称定位表中条目。当T
为1
时,该整型表示的是静态表中的索引;当T
为0
时,该整型表示的是动态表中某条目的相对索引。
只有字段名称取自动态表中的条目;字段的值使用8位前缀的明文字符串来编码;详见第4.1.2章。
4.5.5. 使用反向索引名称的明文字段行
4.5.6. 使用明文名称的明文字段行
5. 配置
6. 错误处理
为HTTP/3定义的以下错误码是为了指出QPACK中使得流或连接无法继续维持的错误。
- QPACK_DECOMPRESSION_FAILED(值为
0x0200
): -
解码器无法解释编码字段组,且无法继续解码该字段组。
- QPACK_ENCODER_STREAM_ERROR(值为
0x0201
): -
解码器无法解释在编码流上接收到的某条编码指令。
- QPACK_DECODER_STREAM_ERROR(值为
0x0202
): -
编码器无法解释在解码流上接收到的某条解码指令。
7. 关于安全性的考量
本章介绍了有关QPACK安全性的几个担忧:
-
利用压缩后的数据长度验证秘密值是否位于共享压缩上下文中的猜想。
-
通过耗尽解码器处理资源或内存容量的拒绝服务攻击。
7.1. 探测动态表的状态
QPACK通过利用HTTP等协议中固有的冗余来降低字段组经过编码后的尺寸。这么做的终极目标是降低发送HTTP请求或响应所需的数据量。
只要攻击者既有能力定义编码和传输的字段,又能够观测这些字段经过编码后的长度,用于编码头部和挂载字段的压缩上下文就可能被攻击者探测。这样的攻击者可以适当修改请求,从而确信关于动态表状态的猜测。如果某个猜测被压缩为了更短的长度,那么攻击者就能够观测编码后的长度并且推断出该猜测是正确的。
即便底层是传输层安全协议(详见《TLS》)和QUIC传输协议(详见《QUIC传输》),这种做法也是可行的,因为尽管TLS和QUIC为其内容提供可信度保护,但是它们为内容的长度信息提供的保护却有限。
注意:面对具有上述能力的攻击者,填充的策略只能提供有限的保护,最多就是增加攻击者为了判断某次猜测所对应的长度所需的尝试次数。填充还会因为增加传输数据量而与压缩的目的背道而驰。
形如CRIME(详见《CRIME》)的攻击说明了此类常见的攻击能力的可行性。特定的攻击还会利用DEFLATE(详见《RFC1951》)基于前缀匹配来降低冗余的事实。这使得攻击者能够一次验证对一个字符的猜测,将需要指数时间的攻击降低为线性时间。
7.1.1. 对QPACK和HTTP的适用性
通过强制猜测必须与整个字段行,而不是单个字符,匹配的方法,QPACK能够缓解,但不能完全阻挡,基于CRIME(详见《CRIME》)模型的攻击。攻击者只能够学习到猜测的正确与否,于是攻击者被限制为只能对给定的字段名称暴力猜测其字段值。
因此,破解特定字段值的可行性取决于值的熵。从而,具有高熵的值不太可能被成功破解。不过,具有底墒的值仍易受攻击。
无论何时,只要两个互不信任实体的请求或响应被放置在同一条HTTP/3连接上,那么这类攻击就是可行的。如果共享的QPACK压缩器允许某个实体向动态表添加条目,且允许另一个实体在编码所选的字段行时引用那些条目,那么攻击者(即“另一个实体”)就能够通过观测编码输出长度的方式学习到表的状态。
举例来说,请求和响应来自互不信任的实体的情况可能出现在某中间设备:
-
在同一条连接上向某源服务器发送来自多个客户端的请求时,或
-
从多个源服务器获取响应,再将它们放到通向同一个客户端的共享连接上时。
网络浏览器还应该假设在同一条连接上的不同网络源(详见《RFC6454》)的请求是由互不信任的实体产生的。还可能存在其他包含互不信任实体的场景。
7.1.2. 缓解措施
对头部和挂载字段要求可信度的HTTP用户可以使用具有足够的熵的值来使其无法被猜测到。然而,实际上不可能将此作为通用的解决方案,因为这要求所有HTTP用户都采取措施来缓解攻击。这会向HTTP的使用方式施加新的限制。
不同于向HTTP用户施加限制,QPACK的实现可以约束压缩的使用方式,从而限制动态表探测的潜在危害。
一项理想的解决方案是基于构造消息的实体来隔离对动态表的访问。将添加进表的字段值归属至某实体所有,只有创建某个值的实体才能访问该值。
为了提高该方案的压缩性能,某些实体可以被指定为公开。例如,网络服务器可以使得标头字段Accept-Encoding
的值在所有请求中都可用。
不了解字段值出处的编码器会受到惩罚,创建出许多字段名称相同但是字段值不同的字段行。该惩罚能够引发大量对字段值的猜测,使得该字段不会在将来的消息中与动态表条目相比较,从而有效地阻止更多的猜测。
该惩罚可能会在较短的字段值上更快到来。相比较长的值,对动态表中某字段名称的访问禁用可能在较短的值上更早出现,或有着更高的出现可能。
这项缓解措施在两个终端间最为有效。如果消息被不了解哪个实体构造了给定消息的中间设备重新编码,那么该中间设备可能不经意间合并了原始编码器有意隔离的压缩上下文。
注意:如果攻击者拥有可靠的方式使得值被重新设置,那么简单地将与字段相关的条目从动态表中移除的做法会变得徒劳无功。比如,在网络浏览器中加载图像的请求一般会包含标头
Cookie
(它对此类攻击来说是个潜在的高价值目标),且网站很容易强制某个图像得到加载,从而刷新动态表中的条目。
7.1.3. 请勿索引明文
实现还可以选择不压缩敏感字段,而是将它们的值编码为明文,来保护这些字段。
拒绝向动态表插入字段行的做法只有在所有跃点都采用它时才有效。可以使用比特位“请勿索引明文”(详见第4.5.4章)来向中间设备发出信号,告知它们某个值是有意以明文发送的。
如果某个值的明文指称上设置了比特位N
,那么中间设备必须不将它重编码为一个会引用该值的索引的指称。如果使用QPACK来重编码,那么必须使用设置了比特位N
的明文指称。如果使用HPACK来重编码,那么必须使用“请勿索引明文”指称(详见《RFC7541》的第6.2.3章)。
要不要选择将字段值标记为“请勿索引”取决于多项因素。由于QPACK并没有针对猜测整个字段值的攻击进行保护,较短的或低熵的值会更轻易地被攻击者破解。因此,编码器可以选择不对低熵的值进行索引。
编码器还可以选择不对它认为高度易受攻击或它认为对破解敏感的字段值进行索引,例如标头Cookie
和Authorization
。
相反地,编码器可能倾向于对破解后价值较低或无价值的字段值进行索引。例如,标头User-Agent
并不会因不同请求而经常变化,且会被发送给任意服务器。在这种情况下,即便攻击者确信请求中使用了特定的User-Agent
值,这也没什么价值。
注意,随着新型攻击的发现,这些用于决定要不要使用“请勿索引明文”指称的原则也会更新。
7.2. 静态哈夫曼编码
针对静态哈夫曼编码,目前没有已知的攻击手段。研究表明,使用静态哈夫曼编码表会产生信息泄漏;然而,该研究的结论中也表示攻击者无法利用该信息来恢复出任何有意义的信息(详见《PETAL》)。
7.3. 内存消耗
攻击者可以试图令终端耗尽其内存。QPACK的设计能够限制终端所需的内存量,无论是峰值还是平时。
QPACK通过指定动态表最大尺寸和阻塞流最大数目来限制编码器能够驱使解码器消耗的内存量。在HTTP/3中,这些值由解码器分别通过设置参数SETTINGS_QPACK_MAX_TABLE_CAPACITY
和SETTINGS_QPACK_BLOCKED_STREAMS
(详见第3.2.3章和第2.1.2章)来控制。对于动态表尺寸的限制考虑了存储在动态表中的数据尺寸,还为开销额外加上了少量的容许值。对于阻塞流数目的限制仅仅代表解码器所需的最大内存。实际的最大内存用量取决于解码去使用多少内存来追踪每条阻塞流。
解码器可以通过为动态表的最大尺寸设置合适的值的方式,限制用于动态表的状态数据所需的内存量。在HTTP/3中,这是通过为参数SETTINGS_QPACK_MAX_TABLE_CAPACITY
设置合适的值的方式来做到的。编码器可以通过选择一个比解码器允许的更小的动态表尺寸,再将它告知解码器的方式来限制状态数据所需的内存量(详见第4.3.1章)。
解码器可以通过为阻塞流的最大数目设置合适的值的方式,限制用于阻塞流的状态数据所需的内存量。在HTTP/3中,这是通过为参数SETTINGS_QPACK_BLOCKED_STREAMS
设置合适的值的方式来做到的。有可能被阻塞的流不会在编码器一侧消耗额外的内存。
编码器分配内存来追踪所有未得到确认的字段组中的动态表引用。实现可以通过只使用它希望追踪的动态表引用数量的方式,直接限制状态数据所需的内存用量;这不需要向解码器发出信号。不过,限制对动态表的引用会降低压缩效率。
编码器或解码器临时占用的内存量可以通过按序处理字段行的方式限制。解码器实现不需要在解码某个字段组时维护一份完整的字段行列表。编码器实现如果使用的是单通算法,那么它不需要在编码某个字段组时维护一份完整的字段行列表。注意,应用可能不得不出于其他理由维护一份完整的字段行列表;即便QPACK不要求这么做,应用自身的限制也可能强制该行为。
尽管经过协商的动态表尺寸限制占用了大量QPACK实现能够消耗的内存,不过因为流量控制而无法被立即发送出去的数据不会受此限制的影响。实现应该限制未发送数据的尺寸,特别是在不能自由发送数据的解码流上。对过量的未发送数据的应对做法包括限制对端打开新流的能力、使得编码流变为只读,或关闭整条连接。
7.4. 对实现的限制
8. 关于IANA的考量
本文档在《HTTP/3》定义的注册表中添加了多条注册项。本文档创建的注册项均具有“永久”状态、列出了一位来自IETF的变更责任人,且留下了HTTP工作组的联系方式(ietf-http-wg@w3.org)。
8.1. 设置注册项
8.2. 流类型注册项
8.3. 错误码注册项
9. 参考文献
9.1. 规范性参考文献
Fielding, R., Ed., Nottingham, M., Ed., and J. Reschke, Ed., “HTTP Semantics”, STD 97, RFC 9110, DOI 10.17487/RFC9110, June 2022, https://www.rfc-editor.org/info/rfc9110.
Bishop, M., Ed., “HTTP/3”, RFC 9114, DOI 10.17487/RFC9114, June 2022, https://www.rfc-editor.org/info/rfc9114.
Iyengar, J., Ed. and M. Thomson, Ed., “QUIC: A UDP-Based Multiplexed and Secure Transport”, RFC 9000, DOI 10.17487/RFC9000, May 2021, https://www.rfc-editor.org/info/rfc9000.
Bradner, S., “Key words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, https://www.rfc-editor.org/info/rfc2119.
Scott, G., “Guide for Internet Standards Writers”, BCP 22, RFC 2360, DOI 10.17487/RFC2360, June 1998, https://www.rfc-editor.org/info/rfc2360.
Peon, R. and H. Ruellan, “HPACK: Header Compression for HTTP/2”, RFC 7541, DOI 10.17487/RFC7541, May 2015, https://www.rfc-editor.org/info/rfc7541.
Leiba, B., “Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words”, BCP 14, RFC 8174, DOI 10.17487/RFC8174, May 2017, https://www.rfc-editor.org/info/rfc8174.
9.2. 资料性参考文献
Wikipedia, “CRIME”, May 2015, http://en.wikipedia.org/w/index.php?title=CRIME&oldid=660948120.
Thomson, M., Ed. and C. Benfield, Ed., “HTTP/2”, RFC 9113, DOI 10.17487/RFC9113, June 2022, https://www.rfc-editor.org/info/rfc9113.
Tan, J. and J. Nahata, “PETAL: Preset Encoding Table Information Leakage”, April 2013, http://www.pdl.cmu.edu/PDL-FTP/associated/CMU-PDL-13-106.pdf.
Deutsch, P., “DEFLATE Compressed Data Format Specification version 1.3”, RFC 1951, DOI 10.17487/RFC1951, May 1996, https://www.rfc-editor.org/info/rfc1951.
Barth, A., “The Web Origin Concept”, RFC 6454, DOI 10.17487/RFC6454, December 2011, https://www.rfc-editor.org/info/rfc6454.
Rescorla, E., “The Transport Layer Security (TLS) Protocol Version 1.3”, RFC 8446, DOI 10.17487/RFC8446, August 2018, https://www.rfc-editor.org/info/rfc8446.
附录A. 静态表
本表是通过分析2018年实际的互联网流量,过滤掉一些不受支持的和非标准的值,最后提取出最常见的头部字段来创建的。受限于该创建方法,部分条目间可能出现矛盾,或存在相似但不完全一致的条目。条目的顺序是经过优化的,这是为了将最常用的标头字段编码至最少的字节中。
索引 | 名称 | 值 |
---|---|---|
0 | :authority |
|
1 | :path |
/ |
2 | age |
0 |
3 | content-disposition |
|
4 | content-length |
0 |
5 | cookie |
|
6 | date |
|
7 | etag |
|
8 | if-modified-since |
|
9 | if-none-match |
|
10 | last-modified |
|
11 | link |
|
12 | location |
|
13 | referer |
|
14 | set-cookie |
|
15 | :method |
CONNECT |
16 | :method |
DELETE |
17 | :method |
GET |
18 | :method |
HEAD |
19 | :method |
OPTIONS |
20 | :method |
POST |
21 | :method |
PUT |
22 | :scheme |
http |
23 | :scheme |
https |
24 | :status |
103 |
25 | :status |
200 |
26 | :status |
304 |
27 | :status |
404 |
28 | :status |
503 |
29 | accept |
*/* |
30 | accept |
application/dns-message |
31 | accept-encoding |
gzip, deflate, br |
32 | accept-ranges |
bytes |
33 | access-control-allow-headers |
cache-control |
34 | access-control-allow-headers |
content-type |
35 | access-control-allow-origin |
* |
36 | cache-control |
max-age=0 |
37 | cache-control |
max-age=2592000 |
38 | cache-control |
max-age=604800 |
39 | cache-control |
no-cache |
40 | cache-control |
no-store |
41 | cache-control |
public, max-age=31536000 |
42 | content-encoding |
br |
43 | content-encoding |
gzip |
44 | content-type |
application/dns-message |
45 | content-type |
application/javascript |
46 | content-type |
application/json |
47 | content-type |
application/x-www-form-urlencoded |
48 | content-type |
image/gif |
49 | content-type |
image/jpeg |
50 | content-type |
image/png |
51 | content-type |
text/css |
52 | content-type |
text/html; charset=utf-8 |
53 | content-type |
text/plain |
54 | content-type |
text/plain;charset=utf-8 |
55 | range |
bytes=0- |
56 | strict-transport-security |
max-age=31536000 |
57 | strict-transport-security |
max-age=31536000; includesubdomains |
58 | strict-transport-security |
max-age=31536000; includesubdomains; preload |
59 | vary |
accept-encoding |
60 | vary |
origin |
61 | x-content-type-options |
nosniff |
62 | x-xss-protection |
1; mode=block |
63 | :status |
100 |
64 | :status |
204 |
65 | :status |
206 |
66 | :status |
302 |
67 | :status |
400 |
68 | :status |
403 |
69 | :status |
421 |
70 | :status |
425 |
71 | :status |
500 |
72 | accept-language |
|
73 | access-control-allow-credentials |
FALSE |
74 | access-control-allow-credentials |
TRUE |
75 | access-control-allow-headers |
* |
76 | access-control-allow-methods |
get |
77 | access-control-allow-methods |
get, post, options |
78 | access-control-allow-methods |
options |
79 | access-control-expose-headers |
content-length |
80 | access-control-request-headers |
content-type |
81 | access-control-request-method |
get |
82 | access-control-request-method |
post |
83 | alt-svc |
clear |
84 | authorization |
|
85 | content-security-policy |
script-src 'none'; object-src 'none'; base-uri 'none' |
86 | early-data |
1 |
87 | expect-ct |
|
88 | forwarded |
|
89 | if-range |
|
90 | origin |
|
91 | purpose |
prefetch |
92 | server |
|
93 | timing-allow-origin |
* |
94 | upgrade-insecure-requests |
1 |
95 | user-agent |
|
96 | x-forwarded-for |
|
97 | x-frame-options |
deny |
98 | x-frame-options |
sameorigin |
在字段名称或字段值内部出现的所有换行符都是由于格式限制而产生的。
附录B. 编码和解码样例
在接下来的例子中,展现了一系列编码器与解码器间的通信。设计这些通信的目的是将大多数QPACK指令运用起来,并强调也许会很常见的一些模式及其对动态表状态的影响。编码器发送了三个编码字段组,其中每个字段组中包含一条字段行,还试探性地发送了两次没有得到引用的插入。
编码器的动态表状态及其当前尺寸如图所示。图示中为每个条目指明了该条目的绝对索引(索引)、引用了该条目且在途的编码字段组数(引用)、该条目的名称,以及该条目的值。在“已确认”一行上方的条目已经得到了解码器的确认。
B.1. 使用索引名称的明文字段行
B.2. 动态表
B.3. 试探插入
B.4. 复制指令与流的取消
B.5. 动态表的插入与驱逐
附录C. 单通编码算法样例
单通编码的伪代码,其中不包含对复制、非阻塞模式、编码器流的流量控制,以及引用追踪的处理。
# 辅助函数:
# ====
# 编码出一个指定前缀和长度的整型
# Encode an integer with the specified prefix and length
encodeInteger(buffer, prefix, value, prefixLength)
# 编码出一个可选静态索引名称还是动态索引名称的动态表插入指令(只能选择其一)
# Encode a dynamic table insert instruction with optional static
# or dynamic name index (but not both)
encodeInsert(buffer, staticNameIndex, dynamicNameIndex, fieldLine)
# 编码出一个使用静态索引的引用
# Encode a static index reference
encodeStaticIndexReference(buffer, staticIndex)
# 编码出一个使用相对于基点的动态索引的引用
# Encode a dynamic index reference relative to Base
encodeDynamicIndexReference(buffer, dynamicIndex, base)
# 编码出一个可选使用静态索引名称的明文
# Encode a literal with an optional static name index
encodeLiteral(buffer, staticNameIndex, fieldLine)
# 编码出一个使用相对于基点的动态索引名称的明文
# Encode a literal with a dynamic name index relative to Base
encodeDynamicLiteral(buffer, dynamicNameIndex, base, fieldLine)
# 编码算法
# Encoding Algorithm
# ====
base = dynamicTable.getInsertCount()
requiredInsertCount = 0
for line in fieldLines:
staticIndex = staticTable.findIndex(line)
if staticIndex is not None:
encodeStaticIndexReference(streamBuffer, staticIndex)
continue
dynamicIndex = dynamicTable.findIndex(line)
if dynamicIndex is None:
# 没有匹配的条目,要么插入条目并引用索引,要么编码为明文
# No matching entry. Either insert+index or encode literal
staticNameIndex = staticTable.findName(line.name)
if staticNameIndex is None:
dynamicNameIndex = dynamicTable.findName(line.name)
if shouldIndex(line) and dynamicTable.canIndex(line):
encodeInsert(encoderBuffer, staticNameIndex,
dynamicNameIndex, line)
dynamicIndex = dynamicTable.add(line)
if dynamicIndex is None:
# 无法为它建立索引,使用明文
# Could not index it, literal
if dynamicNameIndex is not None:
# 编码为使用动态索引名称的明文,可能超过基点
# Encode literal with dynamic name, possibly above Base
encodeDynamicLiteral(streamBuffer, dynamicNameIndex,
base, line)
requiredInsertCount = max(requiredInsertCount,
dynamicNameIndex)
else:
# 编码出一个使用静态名称或明文名称的明文
# Encodes a literal with a static name or literal name
encodeLiteral(streamBuffer, staticNameIndex, line)
else:
# 对动态索引的引用
# Dynamic index reference
assert(dynamicIndex is not None)
requiredInsertCount = max(requiredInsertCount, dynamicIndex)
# 对`dynamicIndex`编码,可能超过基点
# Encode dynamicIndex, possibly above Base
encodeDynamicIndexReference(streamBuffer, dynamicIndex, base)
# 对前缀编码
# encode the prefix
if requiredInsertCount == 0:
encodeInteger(prefixBuffer, 0x00, 0, 8)
encodeInteger(prefixBuffer, 0x00, 0, 7)
else:
wireRIC = (
requiredInsertCount
% (2 * getMaxEntries(maxTableCapacity))
) + 1;
encodeInteger(prefixBuffer, 0x00, wireRIC, 8)
if base >= requiredInsertCount:
encodeInteger(prefixBuffer, 0x00,
base - requiredInsertCount, 7)
else:
encodeInteger(prefixBuffer, 0x80,
requiredInsertCount - base - 1, 7)
return encoderBuffer, prefixBuffer + streamBuffer
致谢
IETF QUIC工作组接收到了来自许多人员的大量支持。
压缩设计团队在探索问题和影响本文档的初版草案方面进行了大量工作。在此郑重致谢设计团队成员Roberto Peon、Martin Thomson和Dmitri Tikhonov的贡献。
以下人员对本文档做出了重要贡献:
-
Bence Beky
-
Alessandro Ghedini
-
Ryan Hamilton
-
Robin Marx
-
Patrick McManus
-
奥 一穂 (Kazuho Oku)
-
Lucas Pardue
-
Biren Roy
-
Ian Swett
本文档大量借鉴了《RFC7541》的内容。郑重致谢其作者的间接贡献。
Buck Krasic的贡献得到了其任职的谷歌公司的支持。
Mike Bishop的部分贡献得到了其任职的微软公司的支持。
联系作者
Christian Huitema
Private Octopus Inc.
427 Golfcourse Rd
Friday Harbor, WA 98250
United States of America
Email: huitema@huitema.net
Sara Dickinson
Sinodun IT
Oxford Science Park
Oxford
OX4 4GA
United Kingdom
Email: sara@sinodun.com
Allison Mankin
Salesforce
Email: allison.mankin@gmail.com
译
-
- Email: yunzhe@zju.edu.cn
-
- Email: fangqiuhang@163.com
-
- Email: ruokeqx@163.com