用 Rust 从零开始写 QUIC:实现 TLS 1.3 握手和 QUIC-TLS Key Update
TLS 1.3 简单介绍
相关安全算法介绍
虽然我在工作中频繁使用 TLS,有时候为了生存,不得不去翻 OpenSSL 来排查问题,但是我从来不是安全这方面的专家,所以我在这里,先根据自己粗浅的理解,对 TLS 相关核心点是什么,以及为什么要这么做,还有涉及的安全算法,简单的描述一下。至于很多高深的安全算法,我会完全依赖 ring 这个 crate 来实现。
如果用一句话描述 TLS 协议,我的理解,TLS 使用非对称密钥加密的方式协商出对称密钥,然后使用对称密钥为上层提供安全性。所以问题来了,什么是非对称密钥,什么是对称密钥,为什么不能直接使用对称密钥,具体的安全性又是什么样的。
对称密钥算法,就是使用的密钥,既可以用来对数据加密,同时也可以用来对数据解密,由于主要是位运算,所以开销很小,但是缺点是知道加密方式后,容易被破解。而非对称密钥,持有两个密钥,公钥负责加密,私钥负责解密,同时非对称密钥相比于对称密钥,具备更好的安全性,但是计算复杂,执行速度慢。所以,TLS 采用在握手阶段使用非对称密钥,协商出对称密钥来负责后续通信安全,这样既可以享受到非对称密钥的安全性,也可以享受到对称密钥的性能优势。
主流的非对称密钥算法有 RSA 和 ECDH,我对他们最深的印象就是 ECDH 支持向前安全性。至于什么是向前安全性,一句话描述,就是即使非对称密钥依赖的公私钥泄漏,原先通信的安全性依旧可以保证。至于是如何保证的,关于 ECDH 实现原理网上有非常多的资料,我只强迫自己记住,这是因为 ECDH 除了公私钥外,还会依赖生成的临时密钥,并且临时密钥只是保存在内存中(依赖数学对称性的相关原理实现,故临时密钥不需要通过网络传输也禁止被保存),所以能确保向前安全性。而 TLS 1.2 中 RSA 和 ECDH 是可选协商项,而 TLS 1.3 则是强制使用 ECDH 作为非对称加密协商算法。
至于主流的对称加密算法有挺多的,比如 AES-GCM,AES-CBC+HMAC,当然还有 ChaCha20-Poly1305。对我而言,值得关注的是 AEAD 算法成为主流选择, AEAD 算法一句话概括核心能力就是可以同时提供加密和完整性保护,而 AES-CBC+HMAC 就是加密和认证分离的操作,并不算 AEAD 加密算法。在这里,相比较 TLS 1.2,TLS 1.3 是强制使用 AEAD 加密算法,淘汰了很多不够安全或者不够高效的对称加密算法,比如 3DES 和 AES-CBC。我处于好奇,还看了下 ChaCha20-Poly1305 和 AES-GCM 的区别,毕竟我印象中好像都是优先使用 AES-GCM,那 ChaCha20-Poly1305 肯定也是有一些特定的场景,毕竟存在即合理。简单的调研了一下,原来 AES-GCM 有硬件加速支持的优势,如果没有硬件加速的环境(移动端或者低功耗设备), ChaCha20-Poly1305 是性能更好的选择。
TLS 会使用密钥派生算法来对协商出的密钥进行扩展(在开始手写这个项目之前,我甚至都没有这样的概念😂)。不过转念一想,这也非常合理,毕竟非对称密钥协商一般只会协商出一个密钥,但是考虑到安全性,TLS 有多个加密阶段使用不同的密钥,那么密钥派生算法就是非常合理的存在,毕竟总不能更换密钥的时候,还要再走一遍非对称密钥协商吧。除了这样的灵活性,密钥派生算法肯定也能提供更好的安全性,考虑到对原密钥又进行了多次迭代计算,安全性肯定是大大增强了。这里需要关注的是,TLS 1.3 是使用了 HKDF 密钥派生算法,相比于 TLS 1.2 的 PRF 算法,虽然都是基于 HMAC,但是 HKDF 据说更安全、更灵活,是对 PRF 的进一步优化,但是你让我分析具体区别,我是暂时没精力投入在这里。
至于最后还有给公钥证明安全性的数字证书,毕竟非对称密钥协商的可信度是完全依赖公钥是否可行,所以数字证书对 TLS 来说也是必不可少的一环,但是我在实现代码的时候,直接跳过了数字证书校验,毕竟人生苦短,怎么正确的使用 HKDF 派生出 TLS 1.3 密钥已经把我搞得很辛苦了,实在没心情也没必要去实现一遍各种数字证书的校验环节。
TLS 1.3 和 TLS 1.2 握手细节对比
再来对比下 TLS 协议升级之后,握手细节的优化,毕竟我需要实现一个 TLS 1.3 的简单客户端,握手是重中之重。首先,TLS 1.3 和 TLS 1.2 在握手上面最大的不同,就是 TLS 1.3 握手简化了许多,只需要一个 RTT 即可完成,而 TLS 1.2 需要两个 RTT 才能完成。我觉得这个优化不是因为 TLS 1.3 使用了 ECDH 非对称密钥协商算法带来的,而是 TLS 1.3 选择了弱协商的方式,TLS 1.2 使用的是强协商。这里具体说一说,什么是强协商和弱协商,这是我自己理解的概念,即 TLS 1.2 会在刚发起握手的时候,把所支持的非对称密钥算法和加密套件都列举出来,等待服务端进行选择和确认,再进行选择之后的非对称密钥协商。而 TLS 1.3 则是列举出自己支持的相关算法,同时会选择其中几种,直接开始进行协商,而不是等待服务端的确认之后再发起。
这样的协商方式会更简单明了,毕竟没有必要去等待服务端的确认再进行,像 Key_Share 这样的拓展,就是为了 TLS 1.3 提前协商而服务。另外,像 TLS 1.3 废弃了很多不够安全的加密算法,也大大促进了弱协商的出现,毕竟选择变少了,协商成功的可能性大大增加,同时我们还有 Hello Retry Request
的协议设计来重新协商兜底。
其次,在握手的流程上, TLS 1.3 也更加的简洁明确。之前在 TLS 1.2 中,存在 ChangeCipherSpec
握手消息,来表示接下来握手数据将使用对称加密来保护。TLS 1.3 抛弃了这个实现,因为在 TLS 1.3 中,握手流程非常明确,抛开 0-RTT 不谈,client_hello
和 server_hello
是明文进行非对称密钥协商,随后客户端和服务端都具备了对称加密的条件,后续的诸如 encrypted_extensions
等握手消息都会被加密,当 finished
消息被校验成功后,代表 TLS 1.3 握手结束。所以,ChangeCipherSpec
是毫无用武之处。
最后,在淘汰密钥的时机上,TLS 1.3 做了严格的规定,要求每个阶段完成后,都需要尽快丢弃相关密钥,这个对安全性,特别是向前加密安全性是很有好处的。当然 QUIC-TLS 也是规定了 Initial 和 Handshake 相关密钥的明确淘汰时机。
Key Update
Key Update 原理和作用
这个 Key Update 机制应该本身是 TLS 1.3 中引入的,为的是每隔一段时间,通信的某一方可以主动汰换掉对称密钥,来提升 TLS 的安全性。另外有个极端的例子,比如说 AEAD 加密是存在数据上限的,过多的使用同一个密钥会降低 AEAD 加密算法的安全性。Key Update 实现的原理也很简单,就是利用 HKDF 密钥派生算法,在原有协商的密钥基础上,按照协议规定的细节,再派生一次即可。
Key Update 机制在 TLS 1.3 和 QUIC 上面实现的区别
本来可以就这么结束对 Key Update 的介绍,但是相比较 TLS 1.3, QUIC 对 Key Update 的实现做了一个更好的优化,我为了实现 QUIC Key Update,也多花了一些时间去理解这里的设计细节,所以我也想总结下我的收获。刚才在上面只是介绍了 Key Update 是怎么去更新对称密钥的,即基于 HKDF 派生算法。但是没有提及更多更关键的细节,比如说是触发 Key Update 的机制是什么样的。
首先,我们需要看一下 TLS 1.3 Key Update 的触发机制是什么样的,当 TLS 1.3 握手完成之后,也就是通信双方收到了各自的 finished
消息,Key Update 就处于随时可以触发的状态。然后某一方可以选择主动发送 key_update
消息,来通知对端进行密钥更新,主动发送方在后续的数据都会使用更新的密钥进行加密,接收方在收到 key_update
消息之后,会更新自己的密钥,同时再回复一个 key_update
消息,这样接收方后续发送的消息也会使用新的密钥。考虑到这是全双工的传输通道,通信双方作为发送方的时候,都需要在更新密钥之前,使用 key_update
消息显式通知对端,这样更为合理。甚至当出现了通信双方同时都希望更新密钥的小概率场景,也不过是双方同时互发 key_update
消息,完全不会影响协议正常运行。哪怕是某一方实现的有一点瑕疵,最坏的情况也不过是同时更新了两次密钥罢了。
但是对于 QUIC-TLS 而言,TLS 1.3 的 Key Update 机制没办法直接复用,这是因为 QUIC-TLS 是紧密融合在一起的,QUIC-TLS 并不是跑在可靠的字节流上面,所以像 TLS 1.3 那样使用 key_update
消息来通知对端是不合适的。这里,QUIC-TLS 采用了非常优雅的办法,在 QUIC Short Header Packet,也就是 application 空间的数据包头中,有一个比特位 key_phase
是专门表示密钥是否发生了更新。因为 Key Update 只会发生在握手结束之后,所以只需要 QUIC Short Packet 携带这个比特位。同时,key_phase
比特位出于安全的考虑,是会被 QUIC Header Protection 保护的,这里大家肯定会好奇,那 QUIC Header Protection 的密钥也会变化吗?答案是 No,Key Update 并不会影响到 QUIC Header Protection 的密钥,我理解这是为了保证接收方可以正确处理 key_phase
。
关键的问题来了,key_phase
能够解决 QUIC 数据包乱序的情况吗?比如说,发送方触发了 Key Update 机制,然后通过 key_phase
来通知接收方需要更新密钥,但是这里发生了 QUIC 数据包乱序的情况,接收方发现 key_update
比特位发生了改变或者说是翻转,已经更新了密钥,但是老密钥加密的 QUIC 数据包姗姗来迟。这个时候,只能丢弃,依赖对端重传吗?QUIC RFC 里面给了我们建议,为了确保传输性能尽量不受影响,RFC 建议在实现的时候,可以考虑延迟丢弃老的密钥(比如三倍的 PTO 之后再丢弃),这样可以正常解密出乱序的报文,减轻对传输性能的影响。
另外,我在一开始的时候,还有一个疑问,就是 key_phase
比特位真的可以应付全部场景吗?还是那个场景,旧密钥加密的数据报文延迟到了,QUIC 协议栈会发现老的数据报文 key_phase 又反转了,这个时候要不要去再更新密钥,当然这个处理起来也很简单了,毕竟不是当前密钥解密的,所以肯定不需要再去更新密钥。同理,如果老密钥被丢弃了,来了无法解密的数据包,这个时候因为数据包无法用当前密钥解密,所以肯定也不需要去更新密钥。换而言之,只有可以使用当前密钥成功解密,并且 key_phase
发生反转的数据包,才能触发更新。只要遵守这个原则,如果两边同时启动 Key Update,也可以正确的处理好这个场景。这就是 QUIC Key Update 实现的机制。
SSLKEYLOG
大家平时调试 TLS 流量的时候,肯定都使用相关的 SSL 库导出过 SSLKEYLOG file,然后使用 WireShark 这样的工具去解密 TLS 流量,非常好用。当然,现在有很多基于 eBPF 的工具,直接去 SSL 库或者应用内部直接抓取未加密流量,这不是我们今天的主题。
所以为了更好的调试 feather-quic,我这里也顺手支持了生成 SSLKEYLOG 文件的功能。说句实话,我平时在使用它的时候,并没有特别注意到它的构成内容细节,只是大概清楚对称密钥会存放在这个文件里面。为了实现 SSLKEYLOG,我首先是尝试先找到了它的 RFC 文档。
在文档中写的很明确,这个文件中每一行都代表了一个密钥,在一行中有三个字段,第一个是标签,代表密钥的类型,比如是握手阶段还是握手之后的。对了,Key Update 会更新密钥,所以握手和应用层密钥的标签后面都有数字,方便递增来描述更新后的密钥。突然想到,SSLkeylog 中有时候会有很多密钥,我以前并没有特别关注,这个时候才恍然大悟,原来是 Key Update 搞的鬼。
第二个字段是 client_random
,这个也很好理解,是 client_hello
消息中的 Random
字段,用来识别唯一的 TLS 链接。第三个字段就是对称密钥的具体数值了,用十六进制描述的。所以,我们就可以很轻松在 TLS 1.3 握手的时候,实时生成 SSLKEYLOG 文件,来帮助我们后续的问题排查了。
编码实现细节
首先,我真心的感谢 TLS 1.3 在 TLS 1.2 的基础上做了很多精简,这让我实现起来真的简单了好多。 另外,我也得感谢 ring 帮我实现了我根本不想触碰的安全算法,比如说这里使用 ring 提供的 X25519 来实现 ECDH 非对称密钥协商算法,还有上面提到的,各种稀奇古怪的安全算法,诸如 AES-GCM、HKDF 之类的。
但是,我在 HKDF 上怒踩一坑,非常蛋疼,花了不少时间。我回顾了下,发现首先是我根本不熟悉 HKDF 算法细节,导致我在调用 ring 提供的接口时候,直接调用错误了。其实我本来想的很简单,就是提供好 HKDF 需要的材料即可。但是 Prk::expand
这个接口非常逆天,感觉还是上个世纪的做法,里面的 info
参数是一个字节数组,有着非常蛋疼的拼装方式,如果是 c 代码,我觉得没有问题,但是好歹是 Rust,我觉得 ring 是不是得至少做个简单的封装,而不是让我自己去填😭。我花了一些时间,跑去翻 rustls
crate 中的实现,才大概明白怎么使用。另外,相关接口的文档也有点简单,完全没有介绍 info
参数的情况,只给了 HKDF 的 RFC 文档链接,看来是默认只有专业人士才会来使用,没有考虑过我这种安全门外汉,也会闲的蛋疼来直接调用 ring。
另外一个就是想吐槽的是 TLS 1.3 RFC 在 HKDF 派生细节的描述,非常的精炼,对我这种一时兴起只想快速实现一个 TLS 1.3 HKDF 派生密钥原型的人非常不友好。当然,我没有说 RFC 写的不够好,相反我觉得写的还挺好的,只不过对于我来说,需要更多的时间才能理解(是的,是我的问题),另外我得把每个阶段的密钥都打印出来,参考 OpenSSL 里面的相关实现,才能把代码跑通。对了,还有网上有完整的 TLS 1.3 密钥生成的实例,这个实例文档也确实是帮了我大忙,当然,我是直接谷歌硬搜 TLS 1.3 起步阶段固定密钥的十六进制,才找到的😉。
最后,我为了能够顺利的完成握手,在客户端的 finished
消息我是正儿八经按照 RFC 协议生成的,确保对端校验不会出现问题。但是我没有对服务端发送过来的 certicate
以及 finished
消息做任何该做的校验,正如我之前所说,这是个学习工具,也许有一天我会改邪归正,真正使用健壮、高效的知名 SSL 开源库来支撑 feather-quic。
尾声
到这里,其实 QUIC 握手已经大差不差了,和 QUIC-TLS 相关的,应该只剩下最难也是最有意思的 0-RTT 没有实现了。不过在正式开始实现 0-RTT 之前,我想赶快完成 QUIC ARQ 相关的实现,毕竟没有重传确认机制,QUIC 协议还不算能正常工作。等我把 QUIC 基本功能搞完,再回过头来写非常有意思的 0-RTT 吧。另外,其实我得好好构思一下怎么把边边角角的情况都给测试到位,因为有不少场景还挺难测试到的,不过这个工作就在后续功能实现的时候,顺带着带着完成吧。
这是这篇文章相关实现的 pr 和分支,时间拖的有点久了,因为守望先锋国服又重新上线了,每天都得玩一会,另外最近也在尝试玩一玩漫威争锋(学习新游戏比学新技术难多了,真的),我的暗黑四第六赛季的通行证任务也还没有做完,这周末没忍住还熬夜看完了《流人》第三第四季,真的太累了😭。