用 Rust 从零开始写 QUIC:尝试深入分析 QUIC 握手😂

on 2024-12-31

QUIC 握手为什么如此复杂


和 TCP 或者其他基于 UDP 的协议相比,QUIC 握手非常复杂。如果要问我 QUIC 握手为什么这么复杂,我脑海中的第一反应,就是 QUIC 集成了 TLS 1.3 协议,为应用层提供了安全性。但又没有选择像其他传统协议那样选择了传输层和安全加密层的分层,比如 TCP + TLS,又或者是 DTLS + UDP-based 协议,比如 UDT。而是将 TLS 1.3 协议融合进来,也就是 QUIC-TLS。

为什么我们需要加密层

在探究 QUIC 握手设计之前,我还是想阐述下,为什么我们需要加密层。首先,最重要的一点,就是保证网络通信的安全,严格遵守 TLS 协议的要求,是可以确保连接之间的通信安全,不会被中间人攻击。

其次,我觉得很有意思的一点,是可以确保协议不会僵化,这也是 TCP 这些年带给我们宝贵的经验之一。由于 TCP 协议和 TLS 协议是解耦的,太多中间传输设备会出于流量监控、安全或者 qos 治理等目的,可能对 TCP 协议做一些特殊处理,导致 TCP 协议如果有一些额外的改动,都很有可能引发一些奇怪的问题,哪怕这些改动是符合 TCP 协议的标准,比如引入一个新的 TCP 头部的 option。由于中间传输设备一旦部署,出于成本和稳定性的考虑,很难得到及时的升级,所以导致很多滞后的处理一直保留了下来。

而 QUIC 协议直接就是加密的,意味着中间设备基本无法基于 QUIC 协议流量进行特殊操作,确保了 QUIC 可以很好的对抗中间设备导致的协议僵化问题。

DTLS 和 TLS 的区别

一般来说,基于 UDP 的传输层协议,都会使用 DTLS 来确保安全性,而在 TCP 之上开发者会使用 TLS 协议,这里有个有意思的地方,就是 DTLS 是构建在 UDP 和基于 UDP 的传输协议之间,而 TLS 则是构建在 TCP 之上。从目标上来说,DTLS 是基于 TLS 的设计,确保可以提供和 TLS 等价的安全性。所以 DTLS 和 TLS 最大的区别就显而易见了,就是 DTLS 需要在不可靠的数据流上,基于 TLS 做出一些改动,达到 TLS 相同的效果arch-01 据我所知,TLS 握手的核心工作是完成非对称密钥协商,握手完成之后,再基于握手协商出来的对称密钥对后续数据进行加密。可以补充一点是 TLS 刚开始的 handshake 消息才是明文传输,因为只有握手进行到一定阶段后,握手阶段的对称密钥就绪可用了,handshake 消息才会被密钥加密保护。 arch-01

所以 DTLS 的第一件工作,就是确保使用对称密钥进行加密的消息,可以在不可靠的传输层上面正常工作。正常 TLS 消息所使用的 MAC 校验算法是需要当前 TLS Record layer 消息的序列号,由于 TLS Record layer 是基于可靠流传输,所以 TLS Record layer 消息的序列号都是隐式的,接收方自己负责推算。DTLS 则无法做到这一点,因为基于 UDP 的数据报文,不提供可靠性。所以 DTLS Record Layer 必须显示提供序列号字段。

除此之外,序列号字段对于 DTLS 来说,可以用来计算滑动窗口,还可以防一手重放攻击,毕竟 UDP 没有三次握手,别说 on-path 攻击者了,off-path 的攻击者都很容易伪造 UDP 流量来进行攻击。另外,DTLS 需要保证每个 UDP 数据报文中承载的都是完整 TLS Record layer 消息,这也是应对不可靠传输最基本的操作,只有这样,DTLS Record layer 消息的序列号才能发挥真正的作用。 如果需要 Application Data 传输的可靠性,那么就由 DTLS 之上的传输层协议来保证,比如说 UDT,又比如说 KCP

其次,也是最蛋疼的地方就是 DTLS 的握手了,毕竟数据的可靠性可以依赖 DTLS 之上的传输层协议来保证,但是 DTLS 握手必须要求是有序的,所以 DTLS 只能自己在握手时候实现一个简单的 ARQ,是的,DTLS 自己来保证握手的可靠性,因此 DTLS 不得不又自己考虑实现重传确认中各种问题,这些问题,我准备在实现 QUIC 自己的 ARQ 的时候,再详细讨论。

另外 TLS 本身又是基于可靠流设计的,所以里面也有很多细节需要 DTLS 去兼容,比如说 DTLS 1.2 对应使用 TLS 1.2,那么 ChangeCipherSpec 消息是加密的启动标识,但是在不可靠流上,考虑到丢包和乱序,接收方肯定需要额外的处理,才能确保其正常工作,在这里 DTLS Reocrd Layer 中使用的 epochSequence Number 发挥着至关重要的作用。DTLS Record 层的 epoch 字段会负责提示数据是否被对称加密,取代了 ChangeCipherSpec 的作用。

再比如 TLS 握手消息长度很容易超过 UDP mtu 数值(比如需要携带证书的消息),而 DTLS 需要确保单独的 UDP 报文中携带完整 TLS 消息,由于 DTLS 之上的应用层协议一般都自己实现了 MTU 探测,所以 DTLS 握手完成后的数据可以通过上层协议来确保这一点。但是 DTLS 握手完全由 DTLS 自己负责,所以 DTLS 不得不对握手消息进行拆分和重组,以规避 IP 切片导致的传输性能问题。

本来 QUIC 也可以选择使用 DTLS 这条路,但是 QUIC 选择了更酷的实现方案。我之所以在这里描述这么多 DTLS 中的一些细节,是因为 QUIC 在设计握手的时候,也需要解决类似的问题。

TCP 三次握手的作用

终于开始回顾 TCP 的三次握手了,一个很经典的问题就是 TCP 三次握手究竟有什么用?由于这个问题太过于经典,我觉得很多人甚至都会反感这个问题,估计心里会想又问这种有的没的八股文?其实我觉得尝试站在传输层协议握手设计者的角度来思考,就可以很好的理解 TCP 三次握手的作用。

作为传输层,想要实现可靠有序的传输,就像上面的 DTLS,我们肯定是需要序列号来帮助我们实现这一点,TCP 也不例外(对于 DTLS 来说,序列号是每个消息的编号,而 TCP 则是字节流的编号)。所以,传输层协议在握手的时候,第一要务是协商好序列号从哪里开始。肯定会有人说,为什么序列号不直接从零开始,这样就不需要握手来协商了。很自然,如果 TCP 序列号从零开始,那么会存在两个问题:

  1. TCP 会很容易受到攻击,攻击者可以很轻易的猜测到某条 TCP 链接的序列号(考虑到 TCP 链接唯一性是基于四元组,很容易被确认链接级别的 TCP 流量),然后干扰 TCP 的正常工作,比如说 tcpkill 就是基于伪造 RST 来关闭活跃的 TCP 链接的
  2. TCP 链接可能会受到上个相同四元组的 TCP 链接还没有抵达的报文干扰。因为 TCP 链接的唯一性是基于四元组,而在一些特殊场景下,比如网络流量较大以及某些内核配置关闭的情况下,很容易出现短时间内,内核销毁了某个 TCP 四元组的链接后,又创建了相同 TCP 四元组的链接,这样如果老链接的 TCP 数据包姗姗来迟,则很容易会影响到新建的 TCP 链接。

其实,我印象中 KCP 这个协议的序列号就是固定从零开始的,因为我好久之前做过一个质量优化项目,把 KCP 引入到 NGINX 中,有一天大晚上被客户拉了电话会议,说他的客户端断流重连后,KCP 不能正常工作。我上去一看,他重连的时候,没有正常销毁老的 KCP 协议栈上下文,而是继续复用。我能快速发现,也是看到新请求的 KCP 序列号没有从零开始。这里,要知道 KCP 甚至没有设计自己的握手,这是因为 KCP 作者是故意把这一切都留给了开发者自己来负责。

言归正传,作为传输层,肯定需要在握手的时候,确认双方是否可信。有一个很经典的安全场景,攻击者伪造了一个 TCP 握手报文,该报文的源地址是伪造的,如果没有三次握手,接收方直接信任了伪造的数据报文,将流量打给伪造源地址,那么一方面被伪造的源地址就相当于是受到不明流量的攻击,另外一方面,接收方的资源也被白白消耗。有了三次握手之后,通信双方都互相证明了自己就是传输层中可信的一端,传输层才能安心进行数据传输。

此外关于三次握手的作用,还可以在关注下 TCP 握手数据包携带的内容,我们可以看到 TCP 三次握手还协商了一些传输参数,比如说接受窗口大小、MSS 的数值、是否支持一些优化提升选项(比如说是否支持 SACK),这些传输参数对传输层协议的传输效率至关重要。所以,我们基于设计者的角度、以及仔细观察 TCP 三次握手中做了哪些事情,数据包携带了哪些关键信息,就可以比较轻松的总结出 TCP 三次握手的作用。

接下来就是怎么从 TCP 握手中学习经验和教训了,TCP 很大一部分问题在于缺失安全性,on-path 的攻击者可以很轻松的对 TCP 进行攻击,所以 TLS 出现了。TLS 使用了 DH 非对称密钥算法,又有数字证书校验保护,就算是 on-path 的攻击者,也很难占到便宜。但是 TLS 位于 TCP 协议之上,只保护了 TCP 的 payload,所以针对 TCP 链接的攻击依然可以成功。另外,TLS + TCP 的握手也过于耗时,站着客户端的角度,TCP 握手需要 1 个 RTT,TLS1.3 握手也需要 1 个 RTT,两个 RTT 过去后,客户端才能真正发送数据,委实有点慢了。

合并 TCP 和 TLS 握手

由于 TLS 至关重要,于是有很多人提出过要将 TCP 和 TLS 的握手融合在一起,但是考虑到 TCP 的协议僵化和内核版本的升级难度,这还不算 TCP 和 TLS 为此要做出的修改,以及怎么做到向前兼容性,这无疑只能存在于草案之中。

但这无疑还是值得讨论一下的,想一想,如果 TCP 和 TLS 握手融合在一起,可能会有哪些好处。第一反应是,如果流量是被加密的(当然哪怕 TLS 握手也只是后半段是加密的),序列号终于可以大胆的从零开始了,毕竟除了通信双方,没人可以知道序列号是多少了。另外,也不需要烦恼不同链接的报文可能会互相影响,一般加密算法的 MAC 机制都可以确认数据包的真实性。但是在握手阶段,只是把 TCP 握手报文和 TLS 握手报文叠加,似乎没有办法享受到这些便利。

可如果还想确保合并后的握手,也只需要三次,那么会存在一个问题,就是 TCP 是通过三次握手验证通信双方是基本可信的,但是 TLS 握手和 TCP 叠加在一起的话,TLS 在三次握手过程中,没办法信任对端。这里肯定会有人提及 SYN-flood 攻击,确实,考虑到攻击者尝试来消耗服务端资源,而 TLS 协议的握手比 TCP 更容易被消耗资源,所以想实现合并后的三次握手,是会存在这样的风险。

感觉如果 TCP 和 TLS 只是简单的合并握手,总感觉还是存在诸多问题,并且投入和产出并不对等。而 QUIC 作为一个最近十年才出现的新协议,可以在这些经验和教训上更从容的做设计,所以我们接下来看看 QUIC 是怎么做的。

QUIC 握手是怎么做的


根据上文,我们知道目前需要解决的问题其实可以用一句话来描述:QUIC 需要给 TLS 提供可靠的字节流抽象,TLS 需要为 QUIC 提供安全性。 QUIC 给出的答案,就是全面融合 TLS1.3,做到你中有我,我中有你的效果,而不是简单的合并。

QUIC 怎么提供可靠字节流给 TLS1.3

如果是握手之后的阶段,对称密钥已经协商出来了,基本上 TLS 只需要负责加密解密 QUIC 报文的 payload 就可以确保安全性了,可以理解 QUIC 在这里扮演了 TLS Record 层的角色。QUIC 的 ARQ 实现可以保证 TLS 1.3 还是运行在可靠字节流之上的。

而对于握手环节,QUIC 也需要确保 TLS 握手基于可靠字节流完成的,在这里 QUIC 特别提供 QUIC Crypto Frame 来专门传递 TLS 的握手报文。QUIC Crypto Frame 中专门留了 offsetlength 字段,就是为了确保 TLS1.3 握手运行在 QUIC 上面,就像运行在 TCP 这种可以提供可靠字节流抽象的传输层协议上面一样。

这里再稍微多说一些,就是 QUIC 协议提供了 QUIC frame 的抽象,像 TCP 的很多控制信息都是放在 TCP 头部的(比如 SYN 或者 FIN 这样的 flag,或者是 ACK 信息),而 QUIC 则是设计了不同的 frame 来存放这些信息,相当于将控制信息和传输通道进行了解耦。这样做的好处有很多,比如 QUIC 核心能力 multi-plexed stream 也是受益于此,另外在实现 ARQ 的时候,QUIC 也有了很多优势。 arch-01 另外需要注意,QUIC作为传输层协议,也有很多传输参数需要协商来提升传输效率,QUIC 没有像 TCP 一样放在头部或者是头部的选项扩展中,而使用的是 Transport Parameters,考虑到 QUIC 握手的数据传递能力得完全依赖 TLS 1.3 握手,所以 Transport Parameter 会作为 TLS 握手消息中的扩展来传递(QUIC Transport Parameters Extension)。这里,可以感受到 QUIC 和 TLS 是融合,而不是简单的叠加。

QUIC 如何使用 TLS1.3 加密

TLS 主要的消息一般是 Handshake 和 Application Data 两种,但是 Handshake 中也可以区分出明文和加密的消息,本质上从加密角度来区分,TLS 可以划分成 TLS unencrypted HandshakeTLS encrypted HandshakeApplication Data,当然这里不考虑 0-RTT 和 Key Update 的情况。所以 QUIC 就更直接将数据报文划分成了三个空间,分别是 initialhandshakeapplication。当然除了加密之外,QUIC 的 ARQ 机制会针对这三个空间独立生效,每个空间都拥有自己独立的序列号,互不干扰。

首先,QUIC 会使用 TLS 1.3 协商出来的对称密钥,来对每个 QUIC 包的 Payload 进行加密,这里会按照上面划分的三个区域 initialhandshakeapplication 使用不同的对称密钥进行加密。这样的话,QUIC 所有对话通信都具备了安全性。其中稍微有点区别的是,initial 并不是明文,而是基于 QUIC rfc 规定 salt 生成的对称密钥来加密,可以说是近乎是明文,只不过防君子不防小人,或者只是增加攻击者一些开销。而 QUIC handshakeapplication 对称密钥分别是基于 TLS1.3 协议中握手协商出来的 handshakeapplication 对称密钥,进一步使用 HKDF 按照 QUIC-TLS 规定派生出来的。

上面的加密还只是对 QUIC Payload 的保护,除此之外,QUIC 引入了独立于之前 PayLoad 加密的 Header Protection,另外对 QUIC header 进行了加密保护,比如说上面提到的包序列号以及 QUIC flag 的部分比特位就被加密保护了,这样才算是真正融合了 TLS1.3,而不是简单的叠加。另外 Header Protection 使用的是 AES-ECB 加密算法,对比于 PayLoad 使用的 AEAD 加密模式,会更轻量级,虽然存在一定的安全风险(我在 ring 里面找了半天没找到,才知道因为安全性不够,没有被 ring 支持),但是用来保护 QUIC header 的保护是足够的,并且性能也足够好。 arch-01

最后,是 QUIC PayLoad 加密和 Header Protection 的执行顺序,在构建 QUIC 报文的时候,PayLoad 加密先执行,然后再进行 Header Protection,自然而然,在解析 QUIC 报文的时候,就是先去除 Header Protection,再进行 PayLoad 解密。我觉得这样的执行顺序,在处理解析 QUIC 报文的时候会比较友好,方便 QUIC 协议层感觉 QUIC 报文的类型和序列号,优先做一些传输层的正常处理逻辑,比如是否重复报文之类的,比较符合直觉吧。

QUIC Retry

到目前为止,这里描述了 QUIC-TLS 握手的基本运作方式,既达到了传输层握手的效果,也完成了 TLS 非对称密钥协商的需要。但是这里也存在一个安全问题,就是三次握手可以确保对端可信性的验证,但是现在 QUIC-TLS 的三次握手融合了 TLS 握手,所以这意味着,在进行 TLS1.3 握手的时候,其实对端并没有被有效的验证过。而 TLS1.3 握手其实对服务端的资源开销是比较大的,虽然 QUIC 协议规定,服务端响应客户端握手包时,不能发出三倍客户端握手包的数据量作为保护,但是这也给了放大攻击的空间。

虽然 aead 的 MAC 机制可以校验消息的合法性,但是在握手的过程中,是没有办法借助 MAC 机制的。所以 QUIC 也引入了 QUIC retry 机制,来为 QUIC 握手提供对应的安全性。QUIC retry 机制其实是类似 TCP 握手,通过一来一回的通信,验证了通信双方的可信度。但是 QUIC retry 不同于 TCP 是序列号协商来验证,而是使用了更严谨的方式,通过 retry token 和 tag 的方式,让客户端和服务端双方都可以有效验证对方的可信度。比如 tag 的验证,就是利用了 aead 对称加密模式中可以验证消息的真实性的方法,来帮助客户端确认接收到的 retry tag 的可信度。

我记得我们当年上线我们自研 UDP-based 的传输层协议的时候,一开始就有这样会出现放大攻击的漏洞,后来被组里的一个大哥发现,并且快速修复了。像 DTLS 中存在 HelloRetryRequest 消息的设计,除了帮助 DTLS 加密参数重新协商以外,也存在校验对端可信度这样类似 QUIC retry 的作用。

编码实现和结尾


接下来就是编码实现了,我没有使用 rustls 或者 OpenSSL 提供的 QUIC-TLS 实现的能力,而是基于 ring 和 aes 提供了 QUIC 需要的安全算法的第三方库,来实现自己的 QUIC-TLS。正如我之前博客所说的,QUIC 握手非常有意思,如果只是使用相关的 SSL 库的话,对我来说,会失去不少乐趣。当然,代价就是这个项目无法享受到专业 SSL 库的提供的稳定性与安全性保证,不过不会违背这个项目的初衷。另外,也是我好久之前读完 NGINX QUIC 实现之后的一次学习成果的检验,考虑到 NGINX QUIC 实现可以适配不支持 QUIC 的 OpenSSL 库版本(这里有很多有趣的细节),这些细节是支持我可以不使用 SSL 库而是选择自己来尝试实现的信心来源。

这里,我首先需要根据 QUIC RFC 中的设计细节,派生出 initial 空间的对称密钥,其实这块对于我而是遇到了一些麻烦,特别考虑到我并不太熟悉很多安全相关算法的细节。在实际的调试过程中,NGINX 服务端提示我派生的密钥是有问题的,所以我不得不对着 OpenSSL 和 rustls 的源码进行调试参考,花了一些时间,才确认原因。在完成 initial 空间的对称密钥派生之后,handshake 和 application 的密钥也是可以完全复用代码,只不过基于的 secret key 是由 TLS 来提供,而不是 initial 空间那样定义在 RFC 里面。

另外,在实现 QUIC retry 机制的时候,考虑到我还没有实现一个完整的 QUIC 握手(TLS1.3 相关实现没有做),为了方便调试,我伪造了 TLS 相关的数据包,好在 NGINX 并没有严格检查 QUIC Crypto frame 的刚开始的内容(或者说,OpenSSL 的 do_ssl_handshake 还没有来得及报错),这方便我可以轻松验证 QUIC retry 相关的实现。

还有很多 QUIC 设计的细节优化,可编码长度,序列号的压缩设计,我都没有提及,但是在实现中,都是需要仔细对着 RFC 进行揣摩的。最后,我也提供了很多命令行参数,方便在使用这个 QUIC 客户端工具的时候,来验证各种场景,比如说可以自定义首个握手 QUIC 包的长度,或者自定义客户端的 Source Connection IDOriginal Destination Connection ID

对了,虽然只是在实现 QUIC 握手,但是在编码过程中,我必须简单设计出 feather-quic 的发送队列的结构框架,这里涉及到一个问题,就是 feather-quic 的发送队列的单位是 QUIC Packet 还是 QUIC Frame,这里考虑到 QUIC 后续要实现 ARQ 中的重传,并且 QUIC-TLS 同样支持 Key Update,意味着重传的时候,必须要重新加密生成 QUIC Packet,所以发送队列的基本单位必然是 QUIC Frame,这样灵活度也会很高,每个 QUIC Frame 都可以自由按照优先级拼凑在 QUIC Packet 里面,也负责自己有没有被对端确认接收,方便后面很多功能的实现。

arch-01

最后,QUIC 握手设计的非常复杂,但是也非常值得学习,我在这篇博客里面,我尝试去按照自己的理解,去描述里面实现的关键点,考虑到篇幅的原因,没有涉及 TLS1.3 握手的细节和实现,重点放在了 QUIC-TLS 的整体实现,这里是相关的 pr分支,下一篇博客,会完成 TLS1.3 相关的实现。