摘要
本文介绍了Quic的连接建立、数据包格式、多路复用以及可靠性。
Introduction
Quic是一个基于UDP的多路复用和安全传输协议,目的是为各种应用程序提供一系列灵活的安全传输特性。
- 版本协商
- 低时延连接建立
- 验证和加密数据包头部和负载
- 流多路复用
- 流和连接级别流量控制
- 连接迁移&NAT重绑定恢复
定义:
- Client: 发起Quic连接的终端
- Server: 接受Quic连接的终端
- Endpoint: 连接的客户端或服务端
- Stream: Quic连接中的一个逻辑单向或双向通道,传输已排序数据
- Connection: 两个Quic终端之间的交流通道,多路复用多个流
- Connection ID: 终端中标记一个Quic连接的唯一标识,每个终端通过数据包告诉对端该值
- QUIC packet: Quic终端能够交换的最小单位
2 流
Quic中的流提供一个轻量的、按字节序的流抽象,包含单向和双向流两种基本类型。流在发送数据时创建,流控制操作(结束、取消、流量控制)被设计为需要最小开销。
偏移量使得一个流中的八位位组能够被按顺序处理。流是单独进行流量控制的,允许终端限制内存和进行背压。流的建立也是需要流量控制的,连接的两个对端会协商最大的流ID。
2.1 流标识
流由流ID标识,是一个62位整数,剩余2位用于标识流的类型(单向or双向)以及发起端。
- 最小位(0x1)用于标识流的发起端,为0表示客户端发起,流ID为偶数;为1表示服务器发起,流ID为奇数。
- 倒数第二位(0x2)用于标识流的类型(单向or双向),为1表示单向,为0表示双向。
一个终端不能重复使用同一个流ID,同类型的流ID值是按数值顺序增加的。
2.2 流并发
Quic允许并发操作任意数量的流,终端通过限制最大流ID来限制并发流的数量。一个连接的双方分别为对方指定可以初始化的最大流ID,终端不能处理超过其设置最大流ID的流,除非是为了改变初始设置的流。
2.3 发送&接收数据
终端通过装载数据的流帧来发送和接收数据。流帧中的一个标志位能够表示流的结束。
流帧首部
流是一个由顺序字节流形成的抽象,Quic中没有具体的结构。Quic无法确保流数据被可靠、按序传输,终端必须能够将接收到的数据组装成按序的字节流提供给应用程序,这就要求终端缓存接收到的乱序的数据。
2.4 流优先级
按照经验,若能为流设置优先级,流多路复用对提高应用性能有显著的作用。Quic允许运行其上的协议根据使用场景定义流的优先级。流优先级与决定连接中哪个流的数据先发送有关,可能应用于流量控制和拥塞控制。
- 优先传输自己的管理帧,保证了协议的高效运行。例如优先处理除流帧之外的帧,能确保丢失恢复、拥塞控制和流量控制等操作高效。
- 加密帧的优先级必须设为最高以完成握手,实现连接建立。
- 重传数据的帧优先于新数据,除非应用程序另外指示。
3 流状态:流的生命周期
本章介绍Quic流的两种状态机--发送&接收状态机。
3.1 流发送状态机
发送状态机应用程序or协议能够打开一个流。
- Ready:一个新的流能够接收应用程序的数据。数据可能会被缓存以等待发送。
- Send:发送第一个STREAM帧或STREAM_BLOCKED帧使得流进入该状态。一个流的ID可以在进入该状态后才分配,以进行更好的优先级设置。在此状态,终端通过STREAM帧传输数据,并接收对端的MAX_STREAM_DATA帧以遵循对端的流量控制。如果触发流量控制的限制,终端会产生STREAM_BLOCKED帧。
- DATA Sent:应用程序指示数据传输完毕而且发送了包含FIN位的STREAM帧后,进入该状态。此状态中,终端只会重传必要的数据。
- Data Recv:当所有数据被正确确认,进入该终止状态。
- Reset Sent:对于Ready、Send或DATA Sent状态,应用可以发信号指示放弃传输数据,或者终端可能接收到对端的STOP_SENDING帧,此时终端发送RST_STREAM帧,进入该状态。
- Reset Recvd:当RST_STREAM帧被确认,进入该状态。
3.2 流接收状态机
接收流只跟踪数据到应用程序或应用程序协议的传递。
接收状态机
- Recv:当收到对端的STREAM、STREAM_BLOCKED、RST_STREAM或MAX_STREAM_DATA帧时初始化,进入该状态。数据被缓存并按序聚合以传输给应用。当缓存中的数据被应用消费后,终端会向对端发送MAX_STREAM_DATA帧。
- Size Known:接收到包含FIN的STREAM帧后进入此状态。此时只接受重传数据。
- Data Recvd:当所有数据接收后进入此状态。
- Data Read:当所有数据被应用消费,进入此终止状态。
- Reset Recvd:Recv、Size Known接收到RST_STREAM帧,流会立刻进入该状态。
- Reset Read:当应用发信号指示接收流已重置,进入此终止状态。
允许的帧类型
流发送端发送的帧:STREAM帧、STREAM_BLOCKED帧、RST_STREAM帧。
- 不能在终止状态发送这些帧
- 发送RST_STREAM帧后不能发送STREAM帧、STREAM_BLOCKED帧
流接收端发送的帧:MAX_STREAM_DATA帧、STOP_SENDING帧。
- 在Recv状态下,只发送MAX_STREAM_DATA帧
- 在没收到RST_STREAM帧时,任何情况下都能发送STOP_SENDING帧。
3.4 双向流状态机
3.5 请求状态转换
- 发送STOP_SENDING帧后接收到的STRAM帧仍然计入流量控制窗口以防止二义性。
- 接收到STOP_SENDING帧的终端必须发送RST_STRAM帧,即使是在Data Sent状态(以防止重传)。
- STOP_SENDING帧只能由未重置的接收流发送。
- STOP_SENDING帧若丢失,需要重传,除非进入了Data Recvd、Data Read、Reset Recvd、Reset Read状态。
4 流量控制
Quic的流量控制:接收端发布在整个连接周期内,对于某一个流,其准备接收多少八位位组。
流量控制是credit-based的,分为两个级别:
- 流级流量控制:防止单个流消耗了整个连接的接收端缓存。
- 连接级流量控制:防止发送端发送超过接收端缓存容量的数据。
在连接握手建立时,接收端发送传输参数来为所有的流设置credit。接收端可以通过发生帧来设置其他credit:
- MAX_STREAM_DATA帧:一个流中最大的字节偏移量,用于流级流量控制。发送端可能会在多个包中发送该帧以确保接收端能接收更新信息。
- MAX_DATA帧:所有流中字节偏移量之和的最大值,用于连接级流量控制。接收端需要维护一个累积和,以决定要发布的最大数据限制。
发送端可以通过发送以上两个帧来增加最大字节偏移量,但无法减小。发送端会无视不增加流量控制限制的帧。
若发送端发送的数据操作了流量控制的限制,接收端将发送FLOW_CONTROL_ERROR来关闭连接。
若发送端还有数据要发送但被流量控制限制了,需要发送STREAM_BLOCKED或BLOCKED帧,这两个帧经常当作调式和网络管理使用。
4.1 撤销流处理
在接收RST_STREAM帧后,终端会断开流并忽视该流的后续数据。这可能会导致两端不同步,因为RST_STREAM帧可能不是按序到达的,会有其他后续数据还会到达,发送端会把后续数据计算入连接级流量控制中,而如果接收端不把这部分数据计算入流量控制中,则会导致两端不同步。因此,接收端需要学习计算这部分的数据。
为了确保两端有一致的连接级流量控制状态,在RST_STREAM帧中会包含流已发送的最大偏移量。发送端收到RST_STREAM帧后就能知道在RST_STREAM帧前发送端已经发送了多少数据。收到RST_STREAM帧后的操作由应用指定。
4.2 数据限制的增加
接收端通过MAX_STREAM_DATA帧或MAX_DATA帧来增加数据限制,但它们也会造成连接的开销,因此频繁的小额数据限制增加也是不可取的。而偶尔的大幅度的增长对避免阻塞是必要的。因此,在确定多久公布多大的限制时,在资源承诺和开销之间存在权衡。接收端可以仿照TCP实现,基于RTT评估以及接收端应用程序消费数据的速率,来自动调节增加数据限制的频率和数值。为了尽可能防止发送端阻塞,接收端至少每两个RTT内需要发送MAX_STREAM_DATA帧或MAX_DATA帧。发送端在一次到达数据限制中只能发送一次STREAM_BLOCKED或BLOCKED帧,除非确定该帧丢失了。
4.3 流最终偏移量
最终偏移量是指一个流中传输的八位位组数。终端在流进入Size Known或Reset Recvd状态时就知道该值了。一旦知道这个值,该值就不可再改变了;终端也不能发送超过该值的数据,否则终端需要响应一个FINAL_OFFSET_ERROR。
4.4 用于加密握手的流控制
CRYPTO帧中的数据不像STREAM帧中的数据一样受流量控制的限制。
4.5 流限制的增加
终端通过最大流ID来限制当前活跃的流数量。最大流ID在初始化时通过传输参数设置,并可以通过MAX_STREAM_ID帧来修改。
STREAM_ID_BLOCKED帧用于指示可用流ID的不足。
5 连接
QUIC连接是两个QUIC端点之间的单个会话。Quic连接建立将版本协商与密码和传输握手相结合,以减少连接建立延迟。一旦连接建立,连接任一端可以迁移到不同的IP或端口。并且可以由任一端终止。
5.1 连接ID
每个连接都由一个连接ID集合标识,该集合是连接的每个终端单独为对端选择,以供对端使用的。连接ID的首要目的就是为了确保底层协议(UDP、IP或更低)的地址变动不会导致Quic连接中的包传输到错误的终端。终端通过特定的方法为对端选择连接ID,使得对端包含这些连接ID的包能够被路由到该终端,并被该终端识别。
在同一个连接中,连接ID不能包含与其他连接ID有关的信息。特别的,一个连接中,同一个连接ID只能被使用一次。
Version Negotiation包中包含客户端选择的连接ID集合,以确保服务器的响应能正确地路由到客户端以及客户端能够验证是服务器响应的Initial包。终端也可以选择0长度连接ID,那就只能通过四元组通信,无法进行连接迁移了。
5.1.1 发布连接IDs
每个连接ID都与一个序列号关联,以防止重复信息。连接ID序列号的初始值在连接握手建立时通过long packet header发布,初始值设为0,但如果传输参数preferred_address已传输,那么初始值为1。
其他的连接ID可以通过NEW_CONNECTION_ID帧来发布。每次新发布的连接ID的序列号增加1。由客户端随机选择放在Initial包中的连接ID和Reset包中的连接ID不分配序列号,除非服务器选择它作为初始连接ID。
连接ID一旦发布,终端就必须接收包含该连接ID的包,除非其对端通过RETIRE_CONNECTION_ID帧使该ID无效。终端单独选择发布多少连接ID,但至少维持8个连接ID。开始连接迁移并要求非0长度连接ID的一端需要在迁移之前给对端发送新的连接ID。
5.1.2 连接ID的消费和无效化
终端会保存对端为其选择的连接ID集合,集合中的每个ID都能在终端发送的数据包中使用。对端可以通过RETIRE_CONNECTION_ID帧来删除某个连接ID,使用NEW_CONNECTION_ID帧来提供新的连接ID。对端在发送RETIRE_CONNECTION_ID帧删除某个ID后,会保留该ID一段时间或直到该帧被确认。
每个连接ID都只能由一个地址的包发送,若连接发生迁移,迁移端的所有连接ID都将无效。
5.2 连接匹配包
终端收到数据包后会对其进行分类:与现有的连接关联或建立一个新连接(for服务器)
- 若包的目的连接ID与现有连接匹配,则Quic根据情况处理该包
- 若包中包含0长目的连接ID,如果其中四元组匹配到一个连接,Quic把该包当作该连接的一部分,否则丢弃该包
- 若包无法与现有连接匹配,终端需要发送Stateless Reset
5.2.1 客户端处理数据包
发送给客户端的合法数据包的目的连接ID会匹配客户端选择的值。对于0长目的连接ID,客户端通过四元组验证。无法匹配到连接的数据包丢弃。
5.2.2 服务器处理数据包
- 若服务器收到一个包含不支持版本的数据包,
- 如果该包的大小足够发起一个新的连接,那么服务器会发送一个Version Negotiation包。
- 否则丢弃该包。
- 若服务器收到一个包含支持版本的数据包或不包含版本字段,将用现有连接的连接ID或四元组(0长连接ID)与之匹配,如果匹配不到,则
- 若该包是经过确认的Initial包, 则服务器启动握手建立新的连接。
- 若服务器当前不接收新连接,那么服务器发送一个Initial包,包含一个带有SERVER_BUSY错误码的CONNECTION_CLOSE帧。
- 若是一个0-RTT包,服务器会缓存一定数量的包以等待未到达的Initial包。客户端在收到服务器响应之前不能发送Handshake包,因此服务器可以忽略这些包。
- 丢弃包。
6 版本协商
版本协商确保了客户端和服务器同意同一个支持的Quic版本进行通信。在版本协商成功后,开始处理连接建立。客户端发送的第一个包的大小决定了服务器是否响应一个Version Negotiation包,因此应该给该包填充数据以使得该包的大小超过所有支持版本的最低包大小。
Version Negotiation包6.1 发送Version Negotiation包
当客户端选择的版本号服务器是不支持的时候,服务器响应一个Version Negotiation包,包含服务器支持的版本号列表。服务器会限制发送Version Negotiation包的数量,例如能够将包识别为0-RTT的服务器可能会选择不发送版本协商包来响应0-RTT包,因为它期望最终会收到一个Initial包。
6.2 处理Version Negotiation包
当客户端收到Version Negotiation包时,会验证其源目的连接ID是否匹配,若不匹配则丢弃该包。若匹配,则选择其中一个可接受的版本号再一次尝试与服务器建立连接。虽然Initial包中的内容不会变化,但包号仍需加1,而且依旧使用long headers帧,并且使用协商好的版本号,直到收到了1-RTT密钥以及服务器发送的Version Negotiation包外的包。
- 客户端不能改变版本号,除非是响应服务器的Version Negotiation包。
- 客户端必须忽略包含其选中版本的Version Negotiation包。
- 客户端在收到Version Negotiation包后,就可以尝试发送0-RTT包。
Version Negotiation包没有加密保护,其协商结果在后续的加密握手过程中必须重新验证。
6.3 使用预留版本
对于将来要使用新版本的服务器,为了确保客户机能够正确处理不支持的版本,服务器在产生Version Negotiation包时需要包含一个预留的版本号。客户端可以发送一个包含预留版本号的包来请求服务器支持的版本号列表。
7 加密和传输握手
Quic依靠结合加密和传输握手来最小化连接建立时延。Quic使用CRYPTO帧来传输加密握手信息。不同版本的Quic使用不同的加密握手协议。
Quic提供可靠、按序传输的加密握手数据。Quic包保护保证了加密握手协议的保密及完整性保护要求:
- 认证密钥交换
- 服务器总是经过身份验证
- 客户端的身份验证是可选的
- 每个连接都产生不同的和不相关的密钥
- 加密材料对0-RTT和1-RTT包的包保护都有作用
- 1-RTT有正向保密
- 对端传输参数值的认证
- 版本协商的认证
- 应用协议的协商的认证
客户端发送的第一个CRYPTO帧必须包含在单独一个包中。触发地址验证的帧也必须包含在单独一个包中。
客户端发送的第一个加密握手协议的包必须符合1232个八位位组的包有效负载。这包括减少加密握手协议可用空间的开销。
7.1 握手示例
一旦版本协商和地址验证完成,加密握手过程就由Initial包和Handshake包启动。
1-RTT握手示例图中每一行的意思是:包类型[包号]:帧[帧负载]
注意,多个Quic包可能被封装到一个UDP包中,因此上述过程最少可以用4个UDP包完成。
0-RTT握手示例7.2 协商连接ID
Long Header Packet在握手过程中,Long Header包用于终端为其对端设置可用的连接ID。终端通过在Source Connection ID字段设置连接ID,以让对端知道终端选择的连接ID。对端发送的数据包的Destination Connection ID字段设为该连接ID,终端收到后便可以验证其是否合法。
客户端在第一次发送给服务器的Initial包中,Destination Connection ID字段填写一个无法预测的值,但至少为8个八位位组。在收到服务器的包之前,客户端必须使用同一个值,除非客户端放弃该连接。这个初始Destination Connection ID字段的值用于确定Initial包的包保护密钥。在收到服务器的Initial包或Retry包后,客户端便知道了服务器选择的连接ID了。这意味着客户端会改变其发送包的Destination Connection ID字段的值。
7.3 传输参数
在建立连接的时候,连接两端单方面地对其传输参数进行身份验证声明。终端必须遵循这些传输参数的限制。
Quic将传输参数编码在加密握手中,一旦握手完成,传输参数开始生效。每个终端验证对端提供的数据。版本协商必须在连接建立完成前验证。在给定的传输参数扩展中,任何给定参数最多只能出现一次,否则产生TRANSPORT_PARAMETER_ERROR。
7.3.1 0-RTT传输参数的值
发送0-RTT数据的客户端必须记住服务器的传输参数。服务器在连接建立期间发布的传输参数适用于所有使用握手期间建立的密钥材料进行重连的客户端连接。服务器用这些传输参数来决定是否接受0-RTT数据。服务器可能接受0-RTT数据并为新的连接提供不同的传输参数,但不能小于之前设置的传输参数的值。
重连过程中,服务器之前设置的参数prederred_address不能再使用,客户端必须在握手过程中遵守服务器新的prederred_address。
如果客户端提供的参数值服务器不支持,服务器必须拒绝0-RTT数据或中断握手。
7.3.2 新的传输参数
新的传输参数可用于协商新的协议行为。如果没有传输参数,则禁用使用该参数协商的任何可选协议特性。
7.3.3 版本协商验证
虽然加密握手有完整性保护,但有两种形式的Quic版本降级打击:
- 攻击者替换了Initial包中的版本号
- 攻击者发送一个虚假的Version Negotiation包
为了防止这些攻击,传输参数包括三个编码版本信息的字段。这些参数用于对版本的选择进行回溯性验证。
加密握手作为传输参数的一部分为协商版本号提供完整性保护,这样的话对版本协商的攻击就能被探测到。
客户端在其传输参数中包含initial_version字段。如果服务器不发送Version Negotiation包,则该initial_version的值为服务器的传输参数negotiated_version的值。
以有状态方式处理所有包的服务器可以记住如何执行版本协商并验证initial_version值。对于无状态服务器,如果initial_version匹配上正在使用的Quic版本,则接受该值;否则无状态服务器必须检查,如果它收到了一个具有指定initial_version的包,那么它是否已经发送了一个版本协商包。如果服务器接受initial_version中包含的版本,并且该值与正在使用的QUIC版本不同,则服务器必须使用VERSION_NEGOTIATION_ERROR错误终止连接。
negotiated_version为正在使用的版本号,该值必须由服务器从其接收的Initial包中获取的版本号设置的。客户端若是收到一个negotiated_version与正在使用的版本号不匹配,则必须使用VERSION_NEGOTIATION_ERROR错误终止连接。
服务器在supported_versions字段包含将会在任何版本协商包中发送的版本列表。服务器填充此字段,即使它没有发送版本协商包。
客户端验证negotiated_version是否在supported_versions中,并且它会选择协商版本。若当前版本号不在supported_versions中,客户端必须使用VERSION_NEGOTIATION_ERROR错误终止连接。若版本协商后客户端选择的版本不在supported_versions中,客户端必须使用VERSION_NEGOTIATION_ERROR错误终止连接。
当端点接受多个QUIC版本时,它会由它所支持的任何QUIC版本的定义解释传输参数。使用传输参数对QUIC包头中的version字段进行验证。传输参数中的版本字段的位置和格式必须在不同的QUIC版本之间是相同的,或者是完全不同的,以确保它们的解释不会混淆。引入新格式的一种方法是使用不同的代码点定义TLS扩展。
8 地址验证
Quic使用地址验证来避免流量放大攻击。流量放大攻击是指攻击者发送一个包给服务器,该包的源地址填写的是受害者的地址,若是服务器响应更大或者更多的包,则攻击者能够使用该服务器向受害者发送比攻击者直接发给受害者更多的数据。
防止流量放大攻击的方法是验证包中指定的地址对应的终端能否接收数据。地址验证主要在连接建立和连接迁移时启动。
8.1 连接建立时的地址验证
连接建立时暗含着连接两端的地址验证。例如客户端收到一个受握手密钥保护的包意味着该包是服务器的Initial包;一旦服务器处理了客户端的Handshake包,意味着客户端地址被验证了。
在对客户端地址验证前,为了限制流量放大攻击的量级,服务器不能发送超过3倍于接受到数据大小的数据。
服务器可以使用地址严重token实现在加密握手前对客户端进行地址验证。该token可以在连接建立时使用Retry包传输,也可以在上一个连接中使用NEW_TOKEN帧传输。
8.1.1 使用Retry包进行地址验证
任何时候服务器想要验证客户端地址,就向客户端发送一个攻击者无法产生的token,若是客户端能够返回该token,证明其确实收到服务器发送的包了。
使用Retry包进行地址验证连接建立时,服务器在收到客户端的Initial包后,可以发送包含token的Retry包要求地址验证。
Retry包客户端在手打Retry包后再次发送一个Initial包,包含服务器给的token。
Initial包8.1.2 未来连接的地址验证
服务器可能会在一个连接中给客户端发送一个token以供它们的下一个连接使用。该token是通过NEW_TOKEN帧传输的。该token会有一个过期时间,可能服务器直接提供,或者服务器发布一个时间戳以计算过期时间。
NEW_TOKEN帧客户端在下一个连接的Initial包中包含该token,以实现地址验证。如果客户端要打破连接的连续性,可以忽略该token。
客户端不能将Retry包中的token用于下一个连接的地址验证。客户端不能忽略Retry包中的token。
对于无状态服务器,可以将加密和认证了的token传输给客户端,以供后续服务器能够恢复这个状态并验证客户端地址。但该token可以被重复使用,为了防止被攻击,服务器可以将token的使用限制为只使用所需的信息来验证客户端地址。
8.1.3 地址验证token的完整性
地址验证token必须难以猜测,在token中包含一个足够大的随即数是很重要的,不过这需要服务器能够记住该值。该token需要进行完整性保护以防止虚假客户端的篡改和猜测。token不需要定义一个格式,因为只有服务器需要消费该数值。token可以包含:
- 声明的客户端地址:IP&端口
- 时间戳
- 其他补充信息
8.2 路径验证
当连接迁移发生时,发生迁移的终端通过路径验证来测试新地址是否可以连通。路径验证是测试新的IP-端口与对端的IP-端口的连通性,需要同时验证对端能否正确发送和接收数据,并且验证迁移端是否携带虚假的源地址。
路径验证可以由连接的任一端发起。例如一个终端需要检查在一个时期的静默之后其是否还拥有着原本地址。
终端可能会将用于路径验证的PATH_CHALLENGE和PATH_RESPONSE帧与其他帧绑定在一起。
在探测一个新的路径时,终端可以将PATH_CHALLENGE帧和NEW_CONNECTION_ID帧放在同一个包中,以确保对端将未使用的连接ID响应回来。
8.3 初始化路径验证
终端发送包含随机数的PATH_CHALLENGE帧以初始化路径验证,可以发送多个帧以防止包丢失,但终端发送PATH_CHALLENGE帧的频率不能高于Initial包,以防新的路径没有那么多容量支持新连接的建立。每个PATH_CHALLENGE帧的随即数都是新的,这样对端才会响应它们。
8.4 响应路径验证
在接受到PATH_CHALLENGE帧后,终端会将该帧中的数据放在PATH_RESPONSE帧中进行响应。但由于PATH_CHALLENGE帧可能由虚假地址发来的,因此终端需要限制PATH_RESPONSE帧的发送速率。
8.5 成功的路径验证
当接收到包含在前一个PATH_CHALLENGE中发送的数据的PATH_RESPONSE帧时,将认为新地址是有效的。成功的路径验证,需要路径两端都发送了PATH_CHALLENGE并且都受到了对方的PATH_RESPONSE帧响应。
8.6 失败的路径验证
路径验证只有在尝试验证路径的终端放弃验证了才会失败。终端会在计时器结束后放弃验证路径,这意味着路径是无效的,但这并不意味这连接失败,终端可以继续尝试其他路径。若是没有路径可行,终端也可以等待一个可行路径或连接关闭。
9 连接迁移
连接ID的使用终端地址改变后连接仍然有效。在完成握手和拥有1-RTT密钥之前,终端不能初始化连接迁移。在握手过程中,终端会保留一个稳定的地址。
对端在握手时发送了disable_migration传输参数,终端不能初始化连接迁移,否则对端会报INVALID_MIGRATION错误。
并不是所有的地址变化都是连接迁移。例如终端可能经历了NAT重绑,NAT重绑不是连接迁移,但需要进行路径验证。
9.1 探测新路径
终端可以在连接迁移之前用路径验证来探测一个新地址的连通性。若一个路径验证失败,可以尝试另一个路径。
使用新地址的终端可以使用一个新的连接ID来测试。这时终端需要确认对端至少还有一个可用的连接ID,所以测试包中可以包含NEW_CONNECTION_ID帧。
PATH_CHALLENGE、PATH_RESPONSE、NEW_CONNECTION_ID和PADDING帧都是“探测帧”,其他所有帧都是“非探测帧”。只包含探测帧的包称为“探测包”,而包含其他任何帧的包称为“非探测包”。
9.2 初始化连接迁移
终端通过发送非探测帧来初始化连接迁移。
9.3 响应连接迁移
本文假设所有的连接迁移都是由客户端地址改变发起的。
收到客户端(使用新地址)的非探测帧意味着对端已经迁移到新的地址,服务器需要将接下来的数据包发送给新的地址并初始化路径验证以证明客户端对新地址的拥有权。如果新的地址最近使用过,上述验证也可以跳过。在验证了新的客户端地址后,服务器需要向客户端发送新的地址验证token。
终端只更改它的接收地址,以响应编号最高的非探测包。这样防止了终端将数据发送给旧的对端地址。在将接收地址转变到发送非探测包的地址后,终端可以放弃其他地址的路径验证。
9.3.1 处理对端虚假地址攻击
为了防止流量放大攻击,在客户端新地址验证合法之前,服务器发送给该地址的数据大小被限制在每个RTT内发送最小拥塞窗口以内。由于新地址的RTT还未被测量,因此其值只能设置一个初始值。
9.3.2 处理路径攻击者的地址欺骗
路径攻击是指攻击者复制并修改一个数据包的地址,虚假的数据包在原始包到达服务器之前到达,会使得服务器认为客户端发生了连接迁移,并在原始包到达后丢弃。在进行后,对客户端的新地址的验证无法通过,因为其没有必要的加密密钥来读取或响应PATH_CHALLENGE帧。
为了防止这种虚假的连接迁移会导致原有的连接失,一旦验证地址失败,
- 服务器将恢复到最后一个使用的客户端地址
- 若是服务器是无状态的,那么服务器将关闭连接
若是收到了合法客户端地址发来的包号更大的数据包,会导致另一个连接迁移,这样虚假的连接迁移的地址验证将被放弃。
9.4 丢包探测和拥塞控制
连接迁移过程中,旧路径的容量、拥塞控制和RTT与新路径可能不一样,因此,在确认了客户端的新地址后,服务器必须马上为新路径重置拥塞控制器以及RTT估计器。
在连接迁移期间,由于新旧两个路径都可能有数据包和探测包传输,因此包的接收端需要发送覆盖所有接收到的包的ACK。对于多个路径传输,一个拥塞控制上下文和一个丢包恢复上下文可能就足够了。但发送端的探测包的丢包探测是独立的,当其发送PATH_CHALLENGE时,会设置一个独立的定时器,若PATH_RESPONSE及时响应,则停止计时;否则重新发送PATH_CHALLENGE,重置该定时器。
9.5 连接迁移的隐私含义
在多个网络路径上使用稳定的连接ID允许被动观察者将这些路径之间的活动关联起来,这会暴露迁移终端的隐私,因此终端迁移后会使用不同的连接ID。终端必须确保其提供的连接ID集合不会被其他实体所链接。
这就避免了使用连接ID来链接来自不同网络上相同连接的活动。包号的保护确保包号不能用于关联活动。这并不妨碍使用包的其他属性(如时间和大小)来关联活动。
为了满足在不同路径上的数据包不会被关联的隐私要求,发起迁移并使用长度大于零的连接ID的端点应该在迁移之前向其对等点提供新的连接ID。
9.6 服务器的首选地址
Quic允许服务器使用一个IP地址接受连接并在握手后的连接马上转移到其首选地址。
如果客户端收到的数据包来自一个新的服务器地址,而该地址不是传输参数preferred_address指示的,那么客户端需丢弃这些数据包。
9.6.1 沟通首先地址
服务器在TLD握手过程中将其首选地址通过传输参数preferred_address发送给客户端。当握手完成,客户端需要使用传输参数preferred_address中的连接ID初始化对服务器的首选地址进行路径验证。
- 若验证成功,客户端立刻使用新的连接ID发送数据给服务器的新地址;
- 若验证失败,客户端必须继续发送数据给服务器的原始地址。
9.6.2 响应连接迁移
服务器可能在接受连接后的任何时候接收到一个指向其首选IP地址的数据包。如果该包包含PATH_CHALLENGE帧,那么服务器响应一个PATH_RESPONSE。
服务器还应该使用其首选地址和接收客户端探测的地址来启动客户端的路径验证。这有助于防范攻击者发起的虚假迁移。
9.6.3 客户端迁移和首选地址的交互
客户端可能需要在迁移到服务器的首选地址之前执行连接迁移。在这种情况下,客户端应该同时从客户端的新地址对原始和首选服务器地址执行路径验证.
- 如果服务器首选地址的路径验证成功,客户端必须放弃对原始地址的验证,转而使用服务器首选地址。
- 如果服务器首选地址的路径验证失败,但服务器原始地址的验证成功,客户端可以迁移到其新地址并继续发送到服务器的原始地址。
如果到服务器首选地址的连接不是来自相同的客户机地址,则服务器必须防止潜在的攻击,如流量放大攻击或路径攻击。此时服务器需要初始化对客户端新地址的路径验证。
10 终止连接
- idle timeout
- immediate close
- stateless reset
10.1 closing和draining连接状态
closing和draining连接状态确保连接干净地关闭,延迟或重新排序的数据包正确地丢弃。这些状态需要维持3倍RTO的时间。
终端在初始化immediate close后立即进入closing状态。这时终端不能发送数据包,除非包含CONNECTION_CLOSE或APPLICATION_CLOSE帧。端点仅保留足够的信息(连接ID和Quic版本号)来生成包含关闭帧的包,并将包标识为属于连接。终端将保存包保护密钥来读取和处理关闭帧。
终端收到对端进入closing和draining连接状态(如收到关闭帧、无状态重置等)后就进入draining状态。这时终端不能发送数据包,也无需保存包保护密钥。draining会在closing结束时结束,并停止重传关闭包。
在关闭或耗尽期间结束之前处理连接状态可能会导致延迟或重新排序的数据包处理不当。终端可能会使用短暂的draining状态以实现更快的资源恢复。一旦closing和draining连接状态结束,终端要丢弃所有的连接状态,使得新的包以通用的方式处理。
在发送了stateless reset后终端不会进入closing和draining连接状态。
10.2 Idle Timeout
如果设置了空闲超时,那么连接在空闲时间超过设置的时间后会进入draining状态。终端分别为对端设置空闲超时时间(两端的空闲超时时间可以不一样),空闲超时从最后一个接收的包开始计时。
- 在发送包后延迟空闲超时。
- 如果发送的另一个包包含ACK或PADDING之外的帧,并且该包没有被确认或声明丢失,则端点不会延迟空闲超时。
- 只包含ACK或PADDING帧的包也不会延迟空闲超时,因为对端会等到确认其他帧时一起确认ACK或PADDING帧。
如果对端可能会在一个RTO时间内超时,那么终端在发送数据之前最好能测试一下对端的活性。
10.3 Immediate Close
终端发送关闭帧(CONNECTION_CLOSE或APPLICATION_CLOSE帧)来立即终止连接,进入closing状态,所有流立刻关闭,打开流被隐式重置。在这期间,该终端收到数据包后,响应时都需要附带上关闭帧。但终端必须限制发送包含关闭帧的数据包的数量。
终端在接收到closing帧后,可能会发送一个包含关闭帧的包,在进入draining状态,之后就不能再发送包了,因为这可能导致在双方closing状态终止前持续的关闭帧交换。
应用之间可以通过APPLICATION_CLOSE消息和适当的错误码进行协商,使得连接进入Immediate Close并优雅地关闭。
- 若连接建立完成,终端需要在1-RTT包中发送关闭帧
- 若连接建立前,终端没有1-RTT密钥,可以通过HandShake包发送关闭帧
- 若终端没有Handshake密钥,或不确定对端有没有Handshake密钥,可以通过Initial包放关闭帧
- 如果有多种包发送,可以合并这些包后进行重传
10.4 Stateless Reset
Stateless Reset状态是一个没有进入连接状态的连接最后的手段。例如崩溃或中断可能导致对端继续向无法正常继续连接的终端发送数据,如果终端有足够的状态来通信致命的连接错误,那么它必须使用关闭帧。可以使用token来解决这个问题。token可以通过以下方式传输:
-
NEW_CONNECTION_ID帧:
NEW_CONNECTION_ID帧 - 服务器可以通过传输参数stateless_reset_token指定,但客户端不行,因为客户端传输参数没有机密保护。
终端收到无法处理的包后,响应Stateless Reset包。
Stateless Reset包Stateless Reset包与用short header的普通包没有区别。Random Octets字段至少包含20 octets的随机数或无法预测的值。这是为了允许目标连接ID具有最大允许长度、包号和最小有效负载。Stateless Reset Token对应于包保护AEAD的最小扩展。如果终端可以协商一种包保护方案,并使用更大的最小AEAD扩展,则可能需要更多的随机octets。
Stateless Reset包大小不能超过其接收到的数据包大小太多,也不能小于19 octets。
终端可以发送无状态重置来响应具有长报头的包。如果对等方还不能使用Stateless Reset Token,则此方法将无效。在这个QUIC版本中,只有在建立连接时才使用长报头的数据包。由于无状态重置令牌在连接建立完成或接近完成之前不可用,因此忽略具有长报头的未知包可能更有效。
Stateless Reset包的Destination Connection ID是随机的,看起来是移动到使用NEW_CONNECTION_ID帧提供的新连接ID的结果。但这会导致两个问题:
- 数据包可能无法到达对端,导致连接错误无法被及时探测并恢复,只能通过像计时器等方法探测连接失败。
- 随机生成的连接ID可用于对端以外的实体,以将其标识为潜在的无状态重置。偶尔使用不同连接id的终端可能会对此带来一些不确定性。
无状态重置不适用于发出错误条件的信号。希望通信致命连接错误的终端必须使用CONNECTION_CLOSE或APPLICATION_CLOSE帧(如果有足够的状态)。
10.4.1 探测Stateless Reset
当shorter header包无法解密或被标记为重复包时,终端检测潜在的无状态重置。然后,终端将数据包的最后16 octets与对端提供的Stateless Reset Token进行比较,这些token要么位于NEW_CONNECTION_ID帧中,要么位于服务器的传输参数中。如果这些值相同,终端必须进入draining状态,并且在此连接上不再发送任何数据包。如果比较失败,可以丢弃数据包。
10.4.2 计算Stateless Reset Token
Stateless Reset Token必须难以猜测。
同一终端的所有连接使用同个静态密钥,通过使用second iteration of a preimage-resistant 函数(如HMAC或HKDF),以将静态密钥和连接ID作为输入,生成证明。截取证明的16octets就能产生连接的Stateless Reset Token。但该方法需要对端发来的数据包中都包含连接ID,并且终端的所有连接需要使用固定长度的连接ID或者编码了连接ID的长度以达到恢复Stateless Reset Token的目的。
失去状态的终端可以使用以上方法来产生一个合法的Stateless Reset Token,其中连接ID来源于收到的数据包。
一个Stateless Reset Token只能被使用一次。用于计算Stateless Reset Token的连接的ID不能用于共享静态密钥的节点上的新连接。
注意,Stateless Reset包没有任何加密保护。
10.4.3 循环
Stateless Reset包与一个有效的包没有什么区别,这意味着其可能触发响应Stateless Reset包,这可能导致无限循环。
- 终端必须确保它发送的每个Stateless Reset包都小于触发它的包,除非它保持足够的状态以防止循环。
在循环事件中,这会导致包最终太小而无法触发响应。 - 终端可以记住它发送的Stateless Reset包数量,一旦达到某个限制,就停止生成新的Stateless Reset包。
将Stateless Reset包的大小降至37 octets(建议的最小规格)会暗示观察者这是一个Stateless Reset包。另外,拒绝向太小的包响应Stateless Reset包,会使得只能发送小包的错误连接无法被探测,这类错误只能由其它方法探测,如计时器。
终端可以将数据包填充到至少38 octetes来增加数据包触发Stateless Reset包的几率。
11 错误处理
检测到错误的终端应该向对端发出该错误存在的信号。传输级错误和应用级错误都会影响整个连接,而应用程序级错误可以影响到单个流。
最合适的错误代码应该包含在表示错误的帧中。当此规范标识错误条件时,它还标识所使用的错误代码。
Stateless Reset包不适合可以用CONNECTION_CLOSE,APPLICATION_CLOSE或RST_STREAM帧指示的错误。Stateless Reset包不能用于必须发送帧的终端。
11.1 连接错误
必须使用CONNECTION_CLOSE或APPLICATION_CLOSE帧来表示导致连接不可用的错误,例如明显违反协议语义或破坏状态,从而影响整个连接。终端可以以这种方式关闭连接,即使错误只影响单个流。
应用协议可以使用APPLICATION_CLOSE帧发出特定于应用程序的协议错误的信号。特定于传输的错误,包括本文档中描述的所有错误,都包含在CONNECTION_CLOSE框架中。除了它们携带的错误代码类型外,这些帧在格式和语义上是相同的。
包含CONNECTION_CLOSE或APPLICATION_CLOSE帧的包可能丢失。如果终端在终止的连接上接收到更多的包,那么它应该准备重新传输包含任一帧类型的包。限制重发的数量和发送最后一个数据包的时间限制了终止连接所花费的精力。选择不重发CONNECTION_CLOSE或APPLICATION_CLOSE帧的终端在它们丢失时,只能通过stateless reset来处理连接了。
接收到无效CONNECTION_CLOSE或APPLICATION_CLOSE帧的终端不能向其对端发出错误存在的信号。
11.2 流错误
如果应用程序级别的错误只影响单个流,但连接处于可恢复状态,终端可以发送带有适当错误代码的RST_STREAM帧,仅终止受影响的流。
RST_STREAM必须由应用程序触发,并且必须携带应用程序错误代码。在不知道应用程序协议的情况下重置流可能导致协议进入不可恢复状态。应用程序协议可能需要可靠地交付某些流,以确保端点之间的状态一致。
12 包与帧
Quic终端通过交换包进行通信,包放在UDP数据报中,有机密性和完整行保护。
-
在建立连接时,使用Long Header包进行通信:
Long Header包 -
1-RTT密钥建立后,使用Short Header包进行通信:
Short Header包
- 使用Long Header的包有:
- Initial包
- Retry包
- Handshake包
- 0-RTT保护包
- 使用Short Header的包是在连接建立后使用的,被设计为花费最少。
- 版本协商使用特殊格式的包。
12.1 保护包
除了版本协商和Retry包外,所有QUIC包都使用带附加数据的认证加密(AEAD)来提供机密性和完整性保护。
- Initial包由对端提供的静态密钥保护。这个保护不是有效的机密性保护,只是用于确保发送方实在网络路径上而已,接收该Initial包的任意实体能够恢复删除包保护或生成将成功验证的包所需的密钥。
- 所有其他数据包都使用来自加密握手的密钥进行保护。long header包的类型或short header包的键相用于标识使用的加密级别(因此也就是密钥)。使用0-RTT和1-RTT密钥保护的数据包应具有保密性和数据来源认证;加密握手确保只有通信终端接收相应的密钥。
Quic包的packet number字段提供了额外的加密性保护。每发送一个包,底层的包号就会增加。
12.2 合并包
发送方可以将多个QUIC包合并成一个UDP数据报。这可以减少完成加密握手和开始发送数据所需的UDP数据报的数量。接收端必须能够处理合并的数据包。一个UDP数据报中的每一个QUIC包是独立和完整的,虽然包头中的某些字段可能是冗余的,但是没有字段被省略。合并后的QUIC包的接收者必须单独处理每个QUIC包,并分别识别它们,就好像它们是作为不同UDP数据报的有效负载接收的一样。
以加密级别增加的顺序(Initial、0-RTT、Handshake、1-RTT)合并数据包,使接收端更有可能在一次遍历中处理所有数据包。short header包不包含长度,因此它总是UDP数据报中包含的最后一个包。
不同连接的Quic包不能合并在同一个UDP包中;若是一个UDP数据报中的包Destination Connection ID不同,那么接收端需要忽略与第一个包不同的包。
在UDP数据报中,Retry包、Version Negotiation包和short header包之后不能放置其他Quic包。
12.3 包号
Quic包中的packet number字段是一个长度可变的整型,取值0~2^62-1。该值用于确定包保护的加密nonce,==即包号确定了加密密钥==。每个终端各自为发送和接收包维护着独立的包号。
Retry包、Version Negotiation包不包含包号。不同的包类型使用不同的加密密钥。
Quic中,包号空间是可以处理和确认包的上下文,不同类型包有不同的包号空间:
- Initial空间:所有Initial包都在这个空间,只能用Initial包保护密钥发送,并在Initial包中确认。
- Handshake空间:所有Handshake包都在这个空间,只在握手加密级别发送,只能在Handshake包中确认。
- 应用数据空间:所有0-RTT和1-RTT加密的包都在这个空间,使得包丢失恢复算法更容易实现。
不同包号空间的包的加密是独立的,每个包号空间的值从0开始。一个连接中的相同包号空间不同重复使用同一个包号,若是包号达到2^62-1,发送端必须直接关闭连接,不能发送其他包;若是对端收到其他包,则响应Stateless Reset包。
12.4 帧与帧类型
Version Negotiation、Stateless Reset和Retry包不包含帧,其他Quic包在去除包保护后,就剩下了负载,也就是一系列的帧,至少包含一个帧:
Quic包负载
一个帧必须放在一个Quic包中,并且不能超过Quic包的界线。一个帧由帧类型+帧负载组成:
帧格式
所有的Quic帧都是幂等的,也就是说,多次接收到一个合法的帧不会导致不良副作用或错误。
为了确保帧解析的简单高效实现,帧类型必须使用尽可能短的编码。如果终端接收到的帧使用的编码比必要的长,则必须视为PROTOCOL_VIOLATION错误。
13 分包与可靠性
发送方可以通过在一个QUIC包没达到最大规格之前会等待一小段时间,以捆绑尽可能多的帧来最小化每个包的带宽和计算成本,防止大量的小包。至于等待多长时间,是由具体实现决定的。
流多路复用是通过将多个流中的STREAM帧交错放到一个或多个QUIC包中来实现的。一个QUIC包可以包含来自一个或多个流的多个STREAM帧。
QUIC的优点之一是避免了跨多个流的前端阻塞。当发生包丢失时,只有在该包中有数据的流被阻塞,等待接收到重传,而其他流可以继续处理。注意,当来自多个流的数据被捆绑到一个QUIC包中时,该包的丢失会阻止所有这些流继续处理。建议实现将尽可能少的流捆绑到一个输出包中,而不会使传输效率降低。
13.1 包处理和确认
在成功删除包保护并处理包中包含的所有帧之前,不能确认包。对于STREAM帧,这意味着数据已经进入队列,准备由应用程序协议接收,但它不需要交付和使用数据。一旦包被处理完成,接收端会发送一或多个包含接收到包包号的ACK帧以确认接收了该包。
13.1.1 发送ACK帧
为了防止无限循环,终端不确认只包含ACK帧或PADDING帧的包。但是终端在响应其他类型的包时必须发送只包含ACK帧或PADDING帧的包。由于只包含PADDING帧的包不会被确认,这样会导致发送端受到拥塞控制的限制,因此,发送端应该在发送PADDING帧时附带上其他帧。
终端不能发送超过一个只包含一个ACK帧的包来确认一个包。
接收端可以延迟确认接收包,但延迟时间不能超过当前RTT估计或传输参数max_ack_delay设置的值。发送端基于接收端的max_ack_delay参数来设置超时重传。
为了将ACK块限制为发送方尚未接收到的那些块,接收方应该跟踪哪些ACK帧已被其对等方确认。一旦确认了ACK帧,它所确认的数据包就不应该再被确认。标准Quic丢包恢复算法是在确认了足够数量的新包(包号比较大)后认为一个包丢失,因此,接收端应重复确认新到的包而不是以前收到的包,这样一旦确认了足够的新包,以前的包会被认为丢失而重传。
13.1.2 ACK帧和包保护
ACK帧只能在与被确认包具有相同包号空间的包中携带。
客户端使用0-RTT包保护发送的包必须由服务器在由1-RTT密钥保护的包中确认,这可能意味着,如果服务器加密握手消息延迟或丢失,客户端将无法使用这些确认。注意,同样的限制也适用于由受1-RTT键保护的服务器发送的其他数据。
13.2 重传信息
Quic包丢失后不一定需要整个重传,可以在一个新的帧中发送需要的信息就可以了。
不同帧中的数据丢失,处理方式不同:
- CRYPTO帧中的数据需要重传,直至所有的数据都被确认。
- STREAM帧中的应用数据在新的STREAM帧中重传,除非收到RST_STREAM帧。
- 一个ACK帧应该包含所有未确认包的确认。
- 停止流传输,就像在RST_STREAM帧中进行的那样,直到确认或所有流数据被对等方确认为止。重传RST_STREAM帧的内容不能更改。
- 在接收流进入Data Recvd或Reset Recvd状态之前,将发送取消流传输的请求(STOP_SENDING帧中)。
- 连接关闭信号(包括CONNECTION_CLOSE和APPLICATION_CLOSE帧)丢失后不会重传。
- 当MAX_DATA帧丢失或终端决定更新最大连接数据,会发送MAX_DATA。MAX_STREAM_ID、MAX_STREAM_DATA同理。当流进入Size Known状态后不能再发送MAX_STREAM_DATA帧。
- BLOCKED(连接级)、STREAM_BLOCKED(流级)和STREAM_ID_BLOCKED帧(一类流)发布阻塞信号。当终端在相应的限制上被阻塞时,如果某个作用域最新帧丢失,则发送新帧。这些帧总是包含在传输时导致阻塞的限制。
- PATH_CHALLENGE帧定时发送,直到收到PATH_RESPONSE响应或者没有路径验证的必要。
- 用于响应路径验证的PATH_RESPONSE帧只能发送一次。
- NEW_CONNECTION_ID和RETIRE_CONNECTION_ID帧如果丢失,需要重传。
- PADDING帧丢失可以忽略。
13.3 显式拥塞通知(ECN)
ECN是指网络节点在探测到网络拥塞时,在数据包的IP头部中设置一个码点作为标记,而不是丢弃该数据包。Quic终端使用ECN来探测网络拥塞,并通过降低发送速率来应对。
要使用ECN, QUIC终端首先确定路径是否支持ECN标记,并且对端能够访问IP头部中的ECN码点。如果在路径上删除ECN标记的包或重写ECN标记,则网络路径不支持ECN。终端需要在连接建立期间和迁移到新路径时验证路径是否支持ECN。
13.3.1 ECN计数器
- Congestion Experienced (CE):指示是否拥塞。
- ECN-Capable Transport(ECT):指示是否支持ECN,ECT(0)、ECT(1)均表示支持ECN。
在接收到一个设置了ECT或CE码点的包后,如果终端支持ECN,那么会增加对应ECT(0)、ECT(1)和CE的计数,并在接下来的ACK帧中包含这些计数值。但是重复的包不会影响计数值。
13.3.2 ECN验证
为了验证路径是否支持ECN以及对等端是否能够提供ECN反馈,终端必须在所有传出包的IP报头部中设置ECT(0)码点。
- 如果IP报头部中的ECT码点集没有被网络设备损坏,那么接收到的包要么包含对端发送的码点,要么包含正在经历拥塞的网络设备所设置的拥塞(CE)码点。
- 如果设置ECT码点的包被对端在没有ECN反馈的ACK帧中确认,则终端停止在后续包中设置ECT码点,因为网络或对端不支持ECN。
为了保护连接免受网络对ECN代码点的任意破坏,终端在接收到ACK帧时验证以下内容:
- ECT(0)、ECT(1)计数器的增量至少对应确认包中对应码点的数量
- ACK帧中报告的ECT(0)、ECT(1)和CE计数器的总数必须至少是该ACK帧中新确认的包的总数。
当ACK帧丢失时,端点可能会错过数据包的确认。因此,ECT(0)、ECT(1)和CE计数器的总增量可能大于ACK帧中确认的数据包数量。当发生这种情况时,必须增加本地引用计数以匹配ACK帧中的计数器。
如果ECN验证成功,那么终端将在接下来的包中设置ECT码点。如果验证失败,那么取消ECT码点设置。若是终端发送设置ECT码点的包触发重传,或者确认是网络元素破坏了ECN码点,那么取消ECT码点设置。
14 包大小
Quic包大小包括QUIC包头和完整性检查,但不包括UDP或IP报头。客户端必须确保第一个发送的包含Initial包的UDP数据报大小至少为1200 octots,以确保网络路径支持合理的MTU,并防止流量放大攻击。通过在UDP数据报中填充该Initial包或者加入一个0-RTT包能够满足这个要求。服务器若是收到的第一个Initial包所在的UDP数据报小于1200 octets,可能会发送带有PROTOCOL_VIOLATION错误码的CONNECTION_CLOSE帧进行响应。
14.1 路径最大传输单元(PMTU)
PMTU是指整个IP头部、UDP头部、UDP负载的最大规格。UDP负载包括Quic包头部、保护负载、认证字段。所有的QUIC数据包的大小应该符合估计的PMTU,以避免IP碎片或数据包丢弃。
终端需要使用PMTU发现来探测PMTU的值、设置合适的PMTU值以及保存上一个PMTU值,以最大化带宽效率。Quic终端不能发送超过1280 octets的IP包。
部署了PMTU发现的终端需要维持对两端的PMTU估计。每一端的PMTU可以不一样的。QUIC依赖于网络路径支持至少1280字节的MTU。这是IPv6最小MTU,因此也得到了大多数现代IPv4网络的支持。终端不能将其MTU降低到该数字以下,即使它接收到的信号表明可能存在更小的限制。
如果一个QUIC终端点确定任何一对本地和远程IP地址之间的PMTU低于1280 octets,它必须立即停止在受影响的路径上发送QUIC数据包。如果找不到其他路径,这可能导致连接终止。
14.1.1 IPv4 PMTU发现
Quic是基于UDP实现的,IPv4的PMTU发现过程需要防止偏离路径的攻击:
- 在一小部分IPv4包上设置DF位,这样当没有未完成的DF数据包时,若是大多数报告无效的ICMP消息到达,就可以被认为是伪造的。
- 存储DF包的IP或UDP头部中的其他信息(例如IP ID或UDP校验和),以进一步验证“数据报太大”信息。
- 任何由ICMP包中包含的报告导致的PMTU减少都是暂时的,直到QUIC丢失检测算法确定包实际上丢失为止。
14.2 分组层PMTU发现的特殊考虑
PADDING帧为PMTU探测包提供了一个有用的选项。PADDING帧生成确认,但是它们不需要可靠地交付。因此,探测包中PADDING帧的丢失不需要重传。然而,PADDING帧会消耗拥塞窗口,这可能会延迟后续应用程序数据的传输。
15 版本号
Quic的版本号由一个32位的无符号整型数值标识。
版本0x00000000保留用于表示版本协商。
16 变长整数编码
Quic的包和帧广泛使用了变长整数编码来表示非负整数。变长整数编码确保小的整数需要更少的otets来编码。
QUIC变长整数编码保留了第一个octet的两个最大位,以整数编码长度的对数为底2对八位元进行编码。整数值按网络字节顺序编码在剩余的位上。
变长整数编码
17 Quic包格式
所有数值都按网络字节顺序编码(即大端),所有字段大小都以位为单位。
17.1 包号编码与解码
长包头和短包头中的包号编码如下:
包号编码
发送方使用的包号大小必须能够表示比最大的已确认包和正在发送的包号之间的差值大两倍以上的范围。接收端将正确地解码数据包号,除非数据包在传输过程中被延迟,使其在接收到许多编号较高的数据包后到达。终端应该使用足够大的包号编码,使得该包在后边很多包到达后才到达还能够正确恢复包号。因此,包号大小至少比【log2 相邻未确认包(包括新包)数量】大1。
在接收端,在恢复完整的包号之前删除包号的保护。然后,根据有效位的数量、这些位的值以及成功验证的包上接收到的最大包号重构完整的包号。要成功地删除包保护,必须恢复完整的包号。
- [ ] 包号解码伪代码(没看懂。。。):
DecodePacketNumber(largest_pn, truncated_pn, pn_nbits):
expected_pn = largest_pn + 1
pn_win = 1 << pn_nbits
pn_hwin = pn_win / 2
pn_mask = pn_win - 1
// The incoming packet number should be greater than
// expected_pn - pn_hwin and less than or equal to
// expected_pn + pn_hwin
//
// This means we can’t just strip the trailing bits from
// expected_pn and add the truncated_pn because that might
// yield a value outside the window.
//
// The following code calculates a candidate value and
// makes sure it’s within the packet number window.
candidate_pn = (expected_pn & ~pn_mask) | truncated_pn
if candidate_pn <= expected_pn - pn_hwin:
return candidate_pn + pn_win
// Note the extra check for underflow when candidate_pn
// is near zero.
if candidate_pn > expected_pn + pn_hwin and candidate_pn > pn_win:
return candidate_pn - pn_win
return candidate_pn
17.2 Long Header包
Long Header包用于版本协商和1-RTT密钥建立之前。
Long Header包
- 1:long header包的第一个octet的第一位设为1。
- Type:指示128中包类型。
- Version:版本号,32位。
- DCIL和SCIL:目的和源连接ID长度,前4位表示DCIL,后4位表示SCIL,值为0表示连接ID长度为0。将非零编码的长度增加3,以获得连接ID的完整长度(4-18 octets)。
- Destination Connection ID:目的连接ID,4-18字节。
- Source Connection ID:源连接ID,4-18字节。
- Length:Packet Number和Payload字段所占的字节数,是一个变量。
- Packet Number:包号,长度为1、2或4 octets。机密性保护和包保护是独立开来的。
- Payload:负载。
17.3 Short Header包
Short Header包用于1-RTT密钥协商完成之后。
Short Header包
- 0:Short Header包的第一个octet的第一位设为0。
- K:键相,允许接收者分辨用户保护包的保护密钥。
- 0:Short Header包的第一个octet的第五位设为0,Google Quic多路分解位。
- R:保留位。
- Protected Payload:由1-RTT密钥保护的负载。
17.4 Version Negotiation包
Version Negotiation包是对包含服务器不支持的版本的客户端包的响应,只由服务器发送。
Version Negotiation包
- Unused:由服务器随机选取。
- Version:必须为0x00000000。
- Supported Version:32位,服务器支持的版本号。
客户端不能使用ACK确认Version Negotiation包,服务器收到另一个Initial包就暗含着对Version Negotiation包的确认。Version Negotiation包没有包号和长度字段,因此,一个Version Negotiation包需要由一个UDP数据报传输。
17.5 Initial包
Initial包属于long header包,类型为0x7F,用于携带第一个CRYPTO帧以实现密钥交换,并携带ACK帧确认收到的包。为了防止不知道版本号的中间件的干预,Initial包有连接级或版本指定的密钥保护,但只能防止偏离路径攻击,无法防止线上攻击。
Initial包
- Type:0x7f,表示Initial包的类型
- Token Length:Token的长度(字节),变量。若没有token,该字段为0。服务器发送的Initial包该字段比为0,否则客户端会报PROTOCOL_VIOLATION错误。
- Token:token
- Payload:负载,包括CRYPTO帧(包含加密握手信息)、ACK帧,或两者,PADDING或CONNECTION_CLOSE帧也可以。
服务器发送第一个Initial包来响应客户端的Initial包。服务器可以发送多个Initial包。客户端发送的第一个数据包总是包含一个带有全部加密握手消息内容的第一个CRYPTO帧。这个数据包和加密握手消息必须包含在一个UDP数据报中。
17.5.1 初始包号
任意终端发送的第一个Initial包的包号为0。此后包号必须单调增加。Initial包的包号空间中与其他包不同。
17.5.2 0-RTT包号
所有0-RTT和1-RTT加密的包都使用同个包号空间。在客户端收到Retry或Version Negotiation包时,其发送的0-RTT包可能已经丢失或被服务器丢弃,需要在发送新的Initial包后重发这些0-RTT包。
客户端只有在握手成功后才会收到对其0-RTT包的ACK,因此服务器会希望收到的0-RTT包的包号是从0开始的。在决定0-RTT包的包号长度时,客户端需要假设所有的包都还未到达,因此包号长度应该大一些。
17.6 Handshake包
Handshake包属于long header包,类型为0x7D,用于携带ACK帧和加密握手信息。一旦客户端从服务器接收到Handshake包,就使用Handshake包向服务器发送随后的加密握手消息和确认。Handshake包有自己的包号空间,服务器第一个Handshake包的包号为0.
Handshake包中的目标连接ID字段包含由包的接收方选择的连接ID;源连接ID包括数据包发送方希望使用的连接ID。
Handshake包携带CRYPTO帧,也可以带上PADDING和ACK帧,也可能CONNECTION_CLOSE或APPLICATION_CLOSE帧。
17.7 Retry包
Retry包属于long header包,类型为0x7E,是服务器发送地址验证token的以实现无状态重试的。
Retry包
- ODCIL:原目标连接ID的长度。
- Original Destination Connection ID:原客户端发送的Initial包中的目标连接ID,设置为传输参数original_connection_id的值。
- Retry Token:一个唯一的token,以供服务器对客户端的地址验证。
服务器必须选择一个与客户端发来的包的目标连接ID不同的源连接ID。
服务器可以用一或多个Retry包响应Initial包和0-RTT包,但客户端在一个连接中一次只能接收并处理一个Retry包。客户端使用包含Retry Token的Initial包响应Retry包,以继续建立连接。该Initial包以及之后的Initial包都必须使用Retry包的连接ID。
客户端可以在接收到Retry包后尝试发送0-RTT包到服务器提供的连接ID。在Retry包后,客户端发送额外的没有构造新的加密握手消息的0-RTT包,不能将其包号重置为0。
重试包不包含包号,并且不能被客户端显式地确认。
传输参数编码
传输参数定义如下:
// Definition of TransportParameters
uint32 QuicVersion;
enum {
initial_max_stream_data_bidi_local(0),
initial_max_data(1),
initial_max_bidi_streams(2),
idle_timeout(3),
preferred_address(4),
max_packet_size(5),
stateless_reset_token(6),
ack_delay_exponent(7),
initial_max_uni_streams(8),
disable_migration(9),
initial_max_stream_data_bidi_remote(10),
initial_max_stream_data_uni(11),
max_ack_delay(12),
original_connection_id(13),
(65535)
} TransportParameterId;
struct {
TransportParameterId parameter;
opaque value<0..2^16-1>;
} TransportParameter;
struct {
select (Handshake.msg_type) {
case client_hello:
QuicVersion initial_version;
case encrypted_extensions:
QuicVersion negotiated_version;
QuicVersion supported_versions<4..2^8-4>;
};
TransportParameter parameters<0..2^16-1>;
} TransportParameters;
struct {
enum { IPv4(4), IPv6(6), (15) } ipVersion;
opaque ipAddress<4..2^8-1>;
uint16 port;
opaque connectionId<0..18>;
opaque statelessResetToken[16];
} PreferredAddress;
Quic将传输参数编码成一系列octets,并放在加密握手过程中传输。
18.1 传输参数定义
- idle_timeout(0x0003):16位无符号整型,单位:s。
- max_packet_size (0x0005):16位无符号整型,说明终端愿意接收的包最大规格。默认为65527字节,若该值小于1200则为无效。
- ack_delay_exponent (0x0007):8位无符号整型,解码ACK帧中的ACK Delay字段的指数。默认为3,大于20则为无效。
- disable_migration (0x0009):值长度为0,说明终端不支持连接迁移。对端不能发送除握手外的其他包。
- max_ack_delay (0x000c):8位无符号整型,指示终端会延迟发送确认的最大时间,单位为毫秒。
任何一个对端都可以为它们可能接收数据的每种类型的流的流控制声明一个初始值。以下传输参数均为32位无符号整型:
- initial_max_stream_data_bidi_local (0x0000):在客户端传输参数中,这适用于标识符以0x0结尾的流;在服务器传输参数中,这适用于以0x1结尾的流。
- initial_max_stream_data_bidi_remote (0x000a):在客户端传输参数中,这适用于标识符以0x1结尾的流;在服务器传输参数中,这适用于以0x0结尾的流。
- initial_max_stream_data_uni (0x000b在客户端传输参数中,这适用于标识符以0x3结尾的流;在服务器传输参数中,这适用于以0x2结尾的流。
连接控制:
- initial_max_data (0x0001):32位无符号整型,连接能发送的最大数据量。
- initial_max_bidi_streams (0x0002):16位无符号整型
- initial_max_uni_streams (0x0008):16位无符号整型
服务器发送Retry包时必须包含传输参数:
- original_connection_id (0x000d):送Retry包响应的客户端发Initial包的目的连接ID。
服务器可能会包含以下传输参数:
- stateless_reset_token (0x0006):用于验证无状态重置,16 octets。
- preferred_address (0x0004):用于握手结束时的服务器地址迁移。
19 帧类型和格式
一个Quic包中包含一或多个帧。
19.1 PADDING帧
type=0x00,用于增加包的大小。可以将Initial包大小填充到所需的最小规格,或者防止保护的包被流量分析。
19.2 RST_STREAM帧
type=0x01,用于中断一个流。发送RST_STREAM帧后,发送端停止在该流上传输或重传STREAM帧;接收端丢弃在该流上已经收到的所有数据。
RST_STREAM帧- Application Protocol Error Code:16位,说明流为什么要关闭。
- Final Offset:整型变量,说明RST_STREAM帧发送端写入此流的数据到末尾的绝对字节偏移量。
19.3 CONNECTION_CLOSE帧
type=0x02,用于指示Quic层的错误,或者NO_ERROR错误,通知对端连接即将关闭。该帧只能放在一个Quic包中传输。
CONNECTION_CLOSE帧
- Error Code:16位,指示关闭连接的理由。
- Frame Type:整型变量,指示触发错误的帧的类型,为0表示帧类型未知。
- Reason Phrase Length:整型变量,Reason Phrase的长度(字节)。
- Reason Phrase:解释连接关闭的人类可读的理由。
19.4 APPLICATION_CLOSE帧
type=0x03,指示使用Quic的协议的错误。
APPLICATION_CLOSE帧19.5 MAX_DATA帧
type=0x04,用于告知对端该连接能发送的最大数据总量,以实现流量控制。
MAX_DATA帧
- Maximum Data:整型变量,单位octet。
所有流(包括终止的流)上发送的STREAM帧的数据都会计入这个限制,若超过这个限制,终端会使用FLOW_CONTROL_ERROR来终止连接,除非是发送一个包来增加限制。
19.6 MAX_STREAM_DATA帧
type=0x05,用于告知对端该流能发送的最大数据总量,以实现流量控制。
MAX_STREAM_DATA帧
- Stream ID:整型变量。
- Maximum Stream Data:整型变量,单位octet。
19.7 MAX_STREAM_ID帧
type=0x06,用于告知对端允许打开的最大连接ID。
MAX_STREAM_ID帧对端不能初始化大于最大流ID的流ID,狗则终端必须使用STREAM_ID_ERROR错误码终止连接,除非是用于增加限制的。
19.8 PING帧
type=0x07,用于验证对端是否存活或可达。接收PING帧的终端只需要确认包含该帧的包就行。
当应用程序或应用程序协议希望防止连接超时时,可以使用PING帧保持连接处于活动状态。应用程序协议应该提供关于建议生成PING的条件的指导。此指导应指示预期发送PING的是客户端还是服务器。如果两个端点都发送PING帧而不进行协调,则会产生过多的数据包,并且性能很差。推荐的连接空闲超时时间为2min,每15-30s发哦是那个一个PING帧能防止大部分中间件失活。
19.9 BLOCKED帧
type=0x08,用于发送端想要发送数据但由于连接级流量控制而无法发送的情况。
BLOCKED帧- Offset:整型变量,指示在哪个连接级的偏移量上发生了阻塞。
19.10 STREAM_BLOCKED帧
type=0x09,用于发送端想要发送数据但由于流级流量控制而无法发送的情况。
STREAM_BLOCKED帧19.11 STREAM_ID_BLOCKED帧
type=0x0a,用于发送端想要打开一个流,但由于对端最大流ID的限制而无法打开的情况。发送该帧无法打开一个新的流,但会告知对端需要一个新的流。
STREAM_ID_BLOCKED帧- Stream ID:整型变量,指示发送端被允许打开的最大连接ID。
19.12 NEW_CONNECTION_ID帧
type=0x0b,用于为对端提供打破连接迁移关联性的替代连接ID集合。
NEW_CONNECTION_ID帧- Length:8位无符号整型,表示连接ID的长度(octet),小于4或者大于18的值是无效的。
- Sequence Number:发送方分配给连接ID的序列号。
- Stateless Reset Token:128位,当关联的连接ID被使用后,该token可用于无状态重置。
如果终端当前要求其对等端发送具有零长度目的连接ID的数据包,则该终端不能发送此帧。
传输错误、超时和重传可能会导致多次接收相同的NEW_CONNECTION_ID帧。多次接收同一帧不能视为连接错误,接收方可以使用NEW_CONNECTION_ID帧中提供的序列号来识别新连接ID和旧连接ID。
19.13 RETIRE_CONNECTION_ID帧
type=0x1b,用于终端通知对端将不再使用对端提供的某个连接ID(该ID对应的Stateless Reset Token也会失效),也被当作是请求对端发送额外的连接ID集合以供后续使用。对端可以通过NEW_CONNECTION_ID帧提供新的连接ID集合。
RETIRE_CONNECTION_ID帧- Sequence Number:即将无效的连接ID对应的序列号。
如果对端提供零长连接ID,则终端不能发送此帧。
19.14 STOP_SENDING帧
type=0x0c,用于说明应用程序要求数据一旦被接收就丢弃,指示了对端终止流上的数据传输。接受到的STOP_SENDING帧必须在对应的流是存在的并且不处于Ready状态才有效。
STOP_SENDING帧- Application Error Code:16位,应用指定的发送端忽略该流的理由。
19.15 ACK帧
types= 0x1a和0x1b,用于告知发送端已经接收和处理了哪些包。一个ACK帧包含一或多个ACK块,ACK块是已确认包的范围。如果帧类型为0x1b,那么该ACK帧包含到目前为止连接上接收到的ECN码点之和。
QUIC确认是不可撤销的。一旦确认,即使它没有出现在未来的ACK帧中也是确认过的。不同包号空间是可以使用相同的包号的。ACK帧只在与接收到的包的相同的包号空间中确认发送端的数据包。
Version Negotiation和Retry包没有包号,因此不能对它们进行显式确认,但可以通过客户端的下一个Initial包隐式确认。
ACK帧- Largest Acknowledged:整型变量,表示对端确认的最大包号,该包号没有被截取。
- ACK Delay:整型变量,表示已确认最大包号对应的包被对端接收到该ACK帧发送的时间差(单位:ms)。该字段的值可以通过乘以【2的ack_delay_exponent次幂】来进行缩放,ack_delay_exponent为发送端设置的传输参数,默认为3,也就是倍数为8。
- ACK Block Count:整型变量,表示First ACK Block字段后还有多少个ACK Block字段。
- ACK Blocks:包含一或多个已成功接收的包号块。
19.15.1 ACK Block区
ACK Block区由交替的Gap和ACK块字段按降序分组号组成,Gap和Additional ACK Block字段的数量由ACK Block Count字段确定。
Gap和ACK块字段使用相对整数编码以提高效率。虽然每个编码的值都是正的,但是要减去这些值,这样每个ACK块就可以逐步地描述编号较低的包。只要数据包的连续范围很小,变长整数编码就可以确保每个范围可以用少量的octet表示。
ACK Block区每个ACK块确认了最大包号之前的一个连续范围内的所有数据包。而Gap表示未被确认的包的范围,未被确认的包的数量为Gap字段的值加1。
例如,给定一个最大包号largest,而ACK块为ack_block,则该范围内的最小数据包号为smallest = largest - ack_block,也就是说,该数据块确认了smallest ~ largest之间的所有数据包。若ack_block为0,表示只确认最大包号对应的包。
其中,First ACK Block的最大包号largest由Largest Acknowledged字段指示;而Additional ACK Blocks字段的最大包号largest由上一个ACK块的最小包号previous_smallest以及两个ACK块之间的gap决定:largest = previous_smallest - gap - 2。
19.15.2 ECN区
该区只有当ACK帧类型为0x1b时才会被解析。该区由三个ECN码点计数器组成:
ECN区
- ECT(0) Count:整型变量,表示接收到包含ECT(0)码点的数据包的总数。
- ECT(1) Count:整型变量,表示接收到包含ECT(1)码点的数据包的总数。
- CE Count:整型变量,表示接收到包含CE码点的数据包的总数。
19.16 PATH_CHALLENGE帧
type=0x0e,用于检验对端是否可达以及连接迁移时的路径验证。
PATH_CHALLENGE帧- Data:8字节,包含任意数据,难以猜测。
接受到该帧的终端需要响应一个PATH_RESPONSE帧,包含上述Data字段的数据。
19.17 PATH_RESPONSE帧
type=0x0f,用于响应PATH_CHALLENGE帧,格式与PATH_CHALLENGE帧一样。
19.18 NEW_TOKEN帧
type=0x19,用于服务器向客户端提供一个token以供以后连接的Initial包使用。
NEW_TOKEN帧19.19 STREAM帧
types=0x10 到 0x17(0b00010XXX),用于隐式打开一个流和携带流数据。
- OFF位(0x04):指示该帧中是否有Offset字段,若为1则有;若为0则没有,则流数据从偏移量为0开始,这表示该帧包含流的第一个字节或者流的结束。
- LEN位(0x02):指示该帧中是否有Length字段,若为1则有;若为0则没有,则该帧中的数据将到达包的最后。
- FIN位(0x01):指示该帧是否包含流的最后一个offset,即流的结束。
- Offset:整型变量,指示该帧中的数据在流中的字节偏移量。取值范围为0~2^62-1。
- Length:整型变量,指示该帧中的数据的长度,若LEN位为0,则把该包之后的所有数据当作流数据。若长度为0,则Offset表示将发送的下一个字节的偏移量。
- Stream Data:流中传输的数据。
19.20 CRYPTO帧
type=0x18,用于传输加密握手消息。能够在所有Quic包类型中传输。CRYPTO帧为加密协议提供一个按序的字节流。CRYPTO帧不受流量控制的约束。
CRYPTO帧- Offset:整型变量,指示该帧中的数据在流中的字节偏移量。
- Length:整型变量,指示该帧中的数据的长度。
在每个加密级别中都有一个单独的加密握手数据流,每个握手数据流的偏移量从0开始。
Extension帧
QUIC帧不使用自定义编码。因此,终端在成功处理数据包之前需要理解所有帧的语法。
这允许对帧进行有效编码,但这意味着终端不能发送其对端未知的类型的帧。
想要使用新类型帧的QUIC扩展必须首先确保对端能够理解该帧。终端可以使用传输参数来表示它愿意接收一个或多个扩展帧类型。
扩展帧必须被拥塞控制,并且必须发送ACK帧。
例外是替换或补充ACK帧的扩展帧。
扩展帧不包括在流量控制中,除非在扩展中指定。
网友评论