TLS 1.3 握手协议深度剖析
TLS 1.3(RFC 8446,2018)是 TLS 协议二十年来最大的一次重构:握手从 2-RTT 压到 1-RTT,并支持 0-RTT 提前数据;密码套件从几十个砍到 5 个且全部为 AEAD;废弃 RSA 密钥交换、静态 DH、CBC、RC4、SHA-1 等一整代已被证明脆弱的构件;ServerHello 之后的握手报文几乎全部加密。本文按报文流拆解 1-RTT 握手的每个环节,再用一段纯 Python 教学代码把 ECDHE 密钥交换、transcript hash 驱动的密钥派生与 Finished 完整性校验完整跑通,并模拟中间人篡改被检测的全过程。
一、TLS 1.2 的两个 RTT 花在哪里
TLS 1.2 完整握手的报文时序:
- RTT-1:ClientHello → ServerHello + Certificate + ServerKeyExchange(ECDHE 公钥及签名)+ ServerHelloDone;
- RTT-2:ClientKeyExchange(客户端 ECDHE 公钥)+ ChangeCipherSpec + Finished → 服务端 CCS + Finished;
- 此后才能发应用数据。
两个结构性浪费:其一,密钥交换参数要等服务端选定套件后才能发——客户端猜不出服务端用哪组曲线,只能干等一个 RTT;其二,协商完成前所有报文都是明文,且 ChangeCipherSpec 这类历史包袱占用往返却不携带安全价值。
二、TLS 1.3 的 1-RTT:先猜测再捎带
1.3 的关键洞察是把密钥交换从『先协商再交换』改成『先预测再捎带』。实践中 x25519 几乎一统天下,客户端有极大概率一次猜中:
- ClientHello:直接捎带
key_share扩展——客户端预测服务端最可能选择的曲线,把该曲线的 ECDHE 公钥随第一个报文发出;同时携带 supported_versions、signature_algorithms、PSK(若有)等扩展。 - ServerHello:服务端接受预测的 key_share 则回送自己的 ECDHE 公钥——此刻双方已共享密钥,之后所有握手报文开始加密;若没猜中,回 HelloRetryRequest 要求重发指定组(退化为 2-RTT)。
- 加密的后续报文:EncryptedExtensions → Certificate → CertificateVerify(用私钥对整个握手副本哈希签名,证明持有证书私钥且握手未被篡改)→ Finished。
- 客户端验证证书链与签名后回自己的 Finished,握手完成:从第一个字节到应用数据只花一个 RTT。
三、密钥调度:transcript hash 驱动的 HKDF 流水线
TLS 1.3 把密钥派生组织成统一的 HKDF(Extract-then-Expand)流水线:PSK 先混入 Early Secret(可派生 0-RTT 密钥);ECDHE 共享密钥再混入得到 Handshake Secret,派生双向握手流量密钥 c_hs_traffic / s_hs_traffic;最终得到 Master Secret,派生应用流量密钥、导出密钥与会话恢复密钥。
两处设计精髓:
- 每一级派生的标签都混入当时的 transcript hash(迄今全部握手报文的哈希)。任何一字节被中间人改动,双方 transcript 立即分叉,派生密钥随之不同——密钥协商与握手完整性被密码学地焊在一起;
- 密钥分代、分方向:握手密钥与应用密钥分离、两个方向各自独立,并支持 KeyUpdate 前向滚动更新。
四、Finished:整个握手的『收据』
Finished 报文内容 = HMAC(finished_key, transcript_hash(全部握手报文)),双向各发一份。验证通过意味着双方不仅算出了相同密钥,而且看到的握手报文逐字节相同。这直接终结了 TLS 1.2 时代的降级攻击(如 FREAK/Logjam 诱导协商到弱参数):攻击者改过的任何协商字段都会让 Finished 校验失败。
五、动手实验:把 1-RTT 握手跑一遍
下面代码用纯 Python(教学曲线 + 标准库 HMAC/HKDF)实现教学版握手:正常流程验证双方密钥一致,随后模拟中间人篡改 ServerHello 的 key_share,观察 Finished 如何检测攻击。注意:这是教学模型,不是密码学安全实现——曲线运算未做常数时间防护、省略证书与签名层,真实环境必须使用 OpenSSL/BoringSSL。核心代码:
import hashlib, hmac, os
P = 2**256 - 2**32 - 977 # secp256k1 教学曲线
G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
def ec_add(a, b):
if a is None: return b
if b is None: return a
if a[0] == b[0] and (a[1] != b[1] or a[1] == 0): return None
lam = ((3*a[0]*a[0]) * pow(2*a[1], P-2, P) if a == b
else (b[1]-a[1]) * pow((b[0]-a[0]) % P, P-2, P)) % P
x = (lam*lam - a[0] - b[0]) % P
return (x, (lam*(a[0]-x) - a[1]) % P)
def ec_mul(k, pt):
r, add = None, pt
while k:
if k & 1: r = ec_add(r, add)
add = ec_add(add, add); k >>= 1
return r
def ecdhe(sk, pk): # ECDHE 共享密钥
return ec_mul(sk, pk)[0].to_bytes(32, 'big')
def hkdf_extract(salt, ikm):
return hmac.new(salt or b'\x00'*32, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, n):
out, t, c = b'', b'', 1
while len(out) < n:
t = hmac.new(prk, t+info+bytes([c]), hashlib.sha256).digest()
out += t; c += 1
return out[:n]
# 每个端点维护 transcript 日志; 派生密钥时把 transcript 哈希混入标签:
# hs_secret = HKDF-Extract(derived, ecdhe_share)
# c_hs = HKDF-Expand(hs_secret, "c hs traffic" + transcript_hash, 32)
# Finished = HMAC(finished_key, transcript_hash)
# 篡改实验: 中间人替换 ServerHello 中的 key_share,
# 双方 transcript 分叉 -> 派生密钥不同 -> Finished 校验失败
完整脚本在沙箱(Python 3,无第三方库)上的真实运行输出:
== 正常 1-RTT 握手 ==
ECDHE 握手密钥一致: True
双方流量密钥一致: c_hs=True, s_hs=True
Finished 校验: 通过
== 中间人篡改 ServerHello key_share ==
双方 transcript 一致: False
双方握手密钥一致: False
Finished 校验: 失败 -> 握手中止, 攻击被检测
== 握手时延模型(RTT=50ms) ==
TLS 1.2 完整握手 : 2 RTT = 100ms 后首个应用字节
TLS 1.2 会话复用 : 1 RTT = 50ms 后首个应用字节
TLS 1.3 完整握手 : 1 RTT = 50ms 后首个应用字节
TLS 1.3 0-RTT : 0 RTT = 0ms 后首个应用字节
全部校验通过: 密钥一致 / 篡改可检测
六、结果解读
- 正常握手:双方各自用 ECDHE 独立算出相同共享密钥,transcript 相同,两级 HKDF 派生的双向流量密钥完全一致,Finished 通过——这是『对称密钥从公钥交换中涌现』的直观演示。
- 篡改场景:中间人替换 key_share 后,双方 transcript 不一致(False)、派生密钥不一致(False)、Finished 校验失败。即便攻击者自己知道两个共享密钥(经典 MITM 形态),也无法伪造能通过校验的 Finished——握手中止,攻击暴露。这就是『对整个握手副本做认证』的力量。
- 时延收益:RTT=50ms 时,1.3 完整握手比 1.2 快一倍(50ms vs 100ms 到首字节),移动端高 RTT 网络下收益按比例放大;0-RTT 则直接把首字节等待清零。
七、0-RTT 与 PSK:时延与安全的交换
握手完成后服务端可签发 NewSessionTicket(内含与恢复主密钥关联的 PSK)。下次连接时客户端在 ClientHello 里捎带 PSK 与用早期流量密钥加密的应用数据,服务端解出即交付——首字节 0 个 RTT。代价有二:0-RTT 数据无前向安全性(PSK 泄露则历史 0-RTT 数据可解密),且可被重放。RFC 8446 明确建议 0-RTT 只用于幂等请求,服务端应做单次票据与时间窗校验等重放缓解。
八、工程应用
- Nginx 启用 1.3:
ssl_protocols TLSv1.2 TLSv1.3;(OpenSSL ≥ 1.1.1)。1.3 套件由库内置,不再由 ssl_ciphers 控制。 - 会话票据密钥轮换:ticket key 应定期轮换并跨实例同步,否则滚动重启会击落全部会话复用与 0-RTT。
- 中间盒兼容:1.3 用兼容模式伪装成 1.2 会话复用(legacy_version、legacy_session_id)穿过只认 1.2 的旧中间件,是协议设计向现实妥协的教科书案例。
- ECH:在 1.3 之上进一步加密 SNI 等明文元数据,Cloudflare 等已部署。
- 与 QUIC 的关系:QUIC 直接复用 TLS 1.3 握手作为其加密握手,把传输与加密协商合并到同一个 RTT,本站《QUIC 协议》一篇会展开。
九、常见误区
- 以为 1.3 只是『更快的 1.2』:它同时是安全模型换代——强制前向安全、全 AEAD、握手大面积加密,许多 1.2 时代的攻击面被连根移除。
- 0-RTT 无脑全开:对转账、改密等非幂等接口开 0-RTT 等于主动引入重放风险,应只在幂等路径启用。
- 用自写代码实现 TLS:本文实验仅用于理解协议流程,生产环境自实现密码协议几乎必然引入侧信道与验证缺陷,必须使用成熟库并跟踪安全公告。
- 忽视证书链与 OCSP:1.3 把证书报文加密了,但证书验证责任一点没少;未配置 OCSP stapling 时客户端在线状态查询会额外增加首包时延。
- 把 HelloRetryRequest 当异常:预测的 key_share 未命中时 HRR 是正常协商路径;多组配置(如同时启用 x25519 与后量子混合组 X25519MLKEM768)的部署中会出现,不属于故障。
十、参考文献
- Rescorla, E. The Transport Layer Security (TLS) Protocol Version 1.3. RFC 8446, 2018.
- Krawczyk, H., Eronen, P. HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869, 2010.
- Dowling, B. et al. A Cryptographic Analysis of the TLS 1.3 Handshake Protocol. Journal of Cryptology 34(4), 2021.
- Bhargavan, K. et al. Downgrade Resilience in Key-Exchange Protocols. IEEE S&P 2016.
- Langley, A. et al. The QUIC Transport Protocol: Design and Internet-Scale Deployment. SIGCOMM 2017.