TLS 1.3 握手协议:一次彻底的改造
TLS 1.3 的握手协议,是一次真正意义上的推倒重来。说实话,我第一次看 RFC 8446 的时候,第一反应是:「哦?握手还能这么玩?」
我个人习惯把 TLS 1.3 的握手改进总结为三个核心:更快、更简、更安全。这不是随便说说的漂亮话,是真真切切在协议层面动了大手术。
核心变化一句话总结:TLS 1.3 将握手从 2-RTT 降到 1-RTT,并砍掉了一堆过时且不安全的功能。想慢一点?那是真的难。
1. 握手步骤大幅缩减
好,咱们先看看最直观的变化 —— 握手次数。
你想想看,TLS 1.2 完整握手要来回两次半,也就是 2-RTT。第一次往返是为了交换密钥参数,第二次是为了完成握手验证。这在网络条件好的时候还行,但要是用户在信号不好的移动网络上呢?每次多一次往返,用户体验就是肉眼可见的卡顿。
TLS 1.3 怎么做呢?它把这两次合并成了一次。客户端上来就把密钥协商的参数和证书验证相关的信息一并发过去。服务端收到后,直接回复协商结果和服务器证书。一次往返,握手完成。
我的经验:在高铁上测试过 TLS 1.3 的页面加载速度,比 1.2 快了不少。尤其是在丢包率高的场景下,少一次往返带来的提升太明显了。
具体步骤对比如下:
| 阶段 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 第一次握手 | ClientHello → ServerHello + 证书 + ServerKeyExchange | ClientHello(包含了密钥共享 + 密钥共享扩展) |
| 第二次握手 | ClientKeyExchange → ChangeCipherSpec → Finished | ServerHello + 证书 + Finished(一次性发送) |
| 第三次握手 | ChangeCipherSpec → Finished | 客户端Finished(一次性发送) |
| 总RTT | 2-RTT | 1-RTT |
2. 前向安全成为硬性要求
这一条我得着重讲讲。前向安全,这个概念其实在 TLS 1.2 里也支持,但只是作为选项存在。很多老旧系统为了省事,直接用了 RSA 密钥交换。RSA 的问题在哪?私钥一旦泄露,所有被它加密的会话都能被解密。这太要命了。
我在项目中就遇到过类似的麻烦。有一次做内部安全检查,发现某台老服务器还在跑 TLS 1.2 的 RSA 密钥交换。领导问「这有什么问题?」我说:「如果有人偷走了服务器的私钥,几年前的所有加密流量他都能解开。」领导听完脸都绿了。
TLS 1.3 直接砍掉了 RSA 密钥交换。所有的密钥协商都必须基于(EC)DHE。公钥私钥只用来做签名验证,不参与生成会话密钥。这样一来,即使服务器私钥泄露,也只能伪造成未来的连接,无法解密过去已经结束的会话。
避坑指南:我曾经见过有人为了兼容老旧客户端,在 TLS 1.3 的配置里尝试启用 RSA 密钥交换。结果可想而知,OpenSSL 直接报错「不支持」。记住,TLS 1.3 没有 RSA 密钥交换这个选项,别浪费时间找了。
3. 0-RTT 握手与重放攻击防护
0-RTT 是 TLS 1.3 最具吸引力的功能之一。它允许客户端在第一次连接结束后,缓存服务端协商出来的会话票据(Session Ticket)。下次再连接时,客户端直接发送请求数据和会话票据,不需要等服务端回复就能发数据。这就做到了 0-RTT。字面意思,零延迟。
但这里有个大坑 —— 重放攻击。
为什么会有这个问题?因为 0-RTT 的数据在服务端看来,就像是从天而降的加密数据包。服务端还没来得及验证这个会话票据是不是真实有效的,就得先处理数据。如果攻击者抓到了这个数据包,重复发送给服务端,服务端就可能重复处理同一条请求。对于幂等操作(比如查询),问题不大。但对于非幂等操作(比如转账、下单),那就是致命漏洞了。
// 0-RTT 数据发送示例(简化)
// 客户端:直接发请求,不握手
Client -- 0-RTT Data + Session Ticket --> Server
Server -- ServerHello + Finished --> Client
// 这里存在攻击面:0-RTT 数据可能被重放
建议:只有在确定请求是幂等的场景下才使用 0-RTT。例如查询天气、刷新缓存。不要用 0-RTT 发送下单、支付、登录等关键操作。这不是你偷懒的地方。
服务端如何处理重放?TLS 1.3 提供了几种方案:
- 单次使用票据:票据用过一次就失效,强行杜绝重放。但缺点也很明显——没法支持 0-RTT 的多次复用。
- 检查时间戳 + 记录最近使用的票据 ID:服务端记录过去 5 到 10 秒内使用过的票据 ID,收到重复的直接丢弃。
- 要求应用层自己处理去重:应用在请求里带一个唯一的请求 ID,服务端做一次去重检查。
我的经验:我一般会采用第二种方案:记录最近几秒的票据 ID。因为 0-RTT 数据几乎不可能在几秒后还能保持高速到达,所以做一小段滑动窗口去重就够了。你说是不是这个道理?
4. 密码套件大幅度精简
TLS 1.2 的密码套件数量,多得让人头皮发麻。我记得最早看 OpenSSL 的密码套件列表,直接翻了三页还没看完。光认证算法就有 RSA、DSA、ECDSA,加密算法有 AES、CHACHA20、3DES、CAMELLIA,再加上各种模式 CBC、GCM、CCM,简直是个排列组合噩梦。
TLS 1.3 对此做了个极其干脆的改动 —— 砍掉四分之三,只留下 5 个密码套件。
为什么这么做?一是减少攻击面。密码套件越多,客户端和服务端在协商过程中可能出现的漏洞就越多。二是简化实现。开发者和运维人员不用再纠结「到底用哪个套件更安全」。
| TLS 1.3 密码套件名称 | 密钥交换 | 认证 | 加密算法 | MAC算法 |
|---|---|---|---|---|
| TLS_AES_128_GCM_SHA256 | (EC)DHE | 证书 | AES-128-GCM | AEAD内建 |
| TLS_AES_256_GCM_SHA384 | (EC)DHE | 证书 | AES-256-GCM | AEAD内建 |
| TLS_CHACHA20_POLY1305_SHA256 | (EC)DHE | 证书 | CHACHA20-POLY1305 | AEAD内建 |
| TLS_AES_128_CCM_SHA256 | (EC)DHE | 证书 | AES-128-CCM | AEAD内建 |
| TLS_AES_128_CCM_8_SHA256 | (EC)DHE | 证书 | AES-128-CCM (8字节标签) | AEAD内建 |
最常用的、也是我强烈建议你重点关注的,是前三个。尤其是 TLS_AES_128_GCM_SHA256,性能和安全性平衡得非常好。如果机器支持硬件 AES 加速,选它没毛病。
个人习惯:服务器端只开启 TLS_AES_128_GCM_SHA256 和 TLS_CHACHA20_POLY1305_SHA256 两个套件。前者对有 AES 加速的硬件友好,后者对移动端这类没有硬件加速的设备更友好。其他的我一般不启用。
5. 握手消息加密与可扩展性
TLS 1.2 有个老问题:握手阶段除了 Finished 消息,其他消息(证书、证书验证、密钥交换参数)都是明文传输的。这意味着攻击者可以轻松看到服务器的证书链信息。在 2018 年之前,这不算什么大问题。但后来大家发现,通过观察证书的大小、私钥算法的细节,可以反推服务器的软件版本甚至特定实现漏洞。
TLS 1.3 的做法是:在 ServerHello 之后,所有消息都是加密的。就连证书本身也不例外。
具体怎么做?握手密钥在 ServerHello 完成后就推导出来了,然后用这个密钥加密后续的所有握手消息。客户端和服务端互相验证时,交换的也是加密后的数据。
// TLS 1.3 握手消息加密示意
// 服务端发送的证书,现在是加密的
Server -- Encrypted Certificate + CertificateVerify + Finished -- Client
// 攻击者只能看到加密黑盒,无法判断证书类型或长度
这一改动还带来了一个附加好处:增强了协议的可扩展性。TLS 1.3 引入了一个全新的 扩展(Extension)系统。任何新增的功能,只要注册一个新扩展ID,就可以在加密的握手消息里正大光明地传递。不像以前,想在握手里加个新功能得小心翼翼地考虑兼容性,唯恐被中间人篡改。
总结一下:TLS 1.3 通过握手消息加密,同时解决了隐私泄露和可扩展性的问题。这步棋走得相当高明。
6. 核心改进知识图谱
下面这张图,我把 TLS 1.3 握手的核心改进点做了一个完整的梳理,方便你对照理解。
仔细看这张图,你会发现所有改进都围绕着一个核心目标 —— 减少不确定性和攻击面。不管是砍掉 RSA、精简密码套件、还是握手加密,都是在降低协议在各种复杂环境下的不可控因素。
在我们后续的章节里,会深入到具体的实现和改造方法。但先把这个结构图印在脑子里,后面的内容会好理解很多。