TLS 1.3 握手协议深度剖析

高性能网络 2026-09-02 · 阅读约 24 分钟

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 完整握手的报文时序:

  1. RTT-1:ClientHello → ServerHello + Certificate + ServerKeyExchange(ECDHE 公钥及签名)+ ServerHelloDone;
  2. RTT-2:ClientKeyExchange(客户端 ECDHE 公钥)+ ChangeCipherSpec + Finished → 服务端 CCS + Finished;
  3. 此后才能发应用数据。

两个结构性浪费:其一,密钥交换参数要等服务端选定套件后才能发——客户端猜不出服务端用哪组曲线,只能干等一个 RTT;其二,协商完成前所有报文都是明文,且 ChangeCipherSpec 这类历史包袱占用往返却不携带安全价值。

二、TLS 1.3 的 1-RTT:先猜测再捎带

1.3 的关键洞察是把密钥交换从『先协商再交换』改成『先预测再捎带』。实践中 x25519 几乎一统天下,客户端有极大概率一次猜中:

  1. ClientHello:直接捎带 key_share 扩展——客户端预测服务端最可能选择的曲线,把该曲线的 ECDHE 公钥随第一个报文发出;同时携带 supported_versions、signature_algorithms、PSK(若有)等扩展。
  2. ServerHello:服务端接受预测的 key_share 则回送自己的 ECDHE 公钥——此刻双方已共享密钥,之后所有握手报文开始加密;若没猜中,回 HelloRetryRequest 要求重发指定组(退化为 2-RTT)。
  3. 加密的后续报文:EncryptedExtensions → Certificate → CertificateVerify(用私钥对整个握手副本哈希签名,证明持有证书私钥且握手未被篡改)→ Finished。
  4. 客户端验证证书链与签名后回自己的 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. 以为 1.3 只是『更快的 1.2』:它同时是安全模型换代——强制前向安全、全 AEAD、握手大面积加密,许多 1.2 时代的攻击面被连根移除。
  2. 0-RTT 无脑全开:对转账、改密等非幂等接口开 0-RTT 等于主动引入重放风险,应只在幂等路径启用。
  3. 用自写代码实现 TLS:本文实验仅用于理解协议流程,生产环境自实现密码协议几乎必然引入侧信道与验证缺陷,必须使用成熟库并跟踪安全公告。
  4. 忽视证书链与 OCSP:1.3 把证书报文加密了,但证书验证责任一点没少;未配置 OCSP stapling 时客户端在线状态查询会额外增加首包时延。
  5. 把 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.