TLS 1.3 握手

本页目录

TLS 1.3 将密钥协商所需材料提前放入 ClientHello,使常见完整握手可以减少一轮等待;同时把握手流量与应用流量分阶段保护。它仍需验证身份、协商历史和密钥,0-RTT 也有不同于普通应用数据的安全属性。

截至 2026 年 9 月,TLS 1.3 更新规范为 2026 年 7 月发布的 RFC 9846,取代 RFC 8446;本篇以更新规范为入口,并保留旧资料常见名称的对应关系。ECH 已于 2026 年 3 月发布为 RFC 9849,不再只是一份草案。RFC 9846、RFC 9849

问题核心理解
为什么常见握手只需一 RTT客户端预先提供合适 key_share
为何会出现 HelloRetryRequest共享材料、cookie 等条件需要补充
证书加密前用什么密钥ServerHello 后先派生握手密钥
TLS 1.3 是否必然前向保密要区分非对称协商、PSK-only 和 early data
HKDF 三层为何不能直接 Extract 串起来层间有 derived、各分支有标签和 transcript
ECH 是否隐藏全部访问行为隐藏内层 ClientHello,元数据仍有边界

版本、套件和认证算法拆开协商

TLS 1.3 的版本选择看 supported_versions;兼容字段 legacy_version 使用 0x0303,因此看到这个字段不能直接判定最终协商 TLS 1.2。ServerHello 的 selected version 与完整握手上下文才是判断依据。

TLS_AES_128_GCM_SHA256 只表达记录 AEAD 与 HKDF/transcript 所用 hash,不像 TLS 1.2 套件名还绑定密钥交换和认证类别。key_share/supported_groups 与 signature_algorithms 分别表达其他约束,不能从 AES 套件名推断证书必须 RSA 或密钥交换必须 X25519。

TLS 1.3 移除了静态 RSA key exchange、CBC 记录保护等旧机制;RSA 签名并未因此全部消失。规范中的基础五套件也不意味着注册空间永远只可能有五个值。TLS 1.2 与 TLS 1.3 套件不可交叉使用。RFC 9846:协商与套件

一次往返中的四个关键动作

证书认证 + ECDHE 的常见完整握手:先协商秘密,再认证历史 客户端 服务器 ClientHello:supported_versions、key_share 等 ServerHello:选定版本、套件与 key_share 加密:EncryptedExtensions、Certificate、CertificateVerify、Finished 加密:Finished;随后使用应用密钥发送数据 ClientHello/ServerHello 基线仍可见;Finished 与 Application Data 使用不同阶段的 traffic keys。

客户端在 ClientHello 提供支持的组和所选组的公开 key share;服务端选择兼容材料,返回自身 share。常见 ECDHE 模式下,双方此时能计算共享秘密,派生握手流量密钥,于是后续 Certificate 等消息可以加密。

“已经能解密”不代表“已经认证对方”。客户端仍要验证证书服务身份、CertificateVerify 签名与 Finished;服务端收到客户端 Finished 后确认相应握手状态。在普通服务器认证场景,客户端第一 RTT 后可以发送 Finished 和随后应用数据,但二者使用不同阶段密钥。

这里的一 RTT 是 TLS 握手语义,不包括 TCP 建连、DNS 或业务响应。TLS 1.2 也并非所有握手消息全明文,它的 Finished 使用协商后的记录保护;TLS 1.3 的优势是更早保护更多握手内容,而非从“完全无保护”一步跳来。

HelloRetryRequest:需要材料时正常重试

supported_groups 列出支持的组,key_share 只携带其中某些组的实际材料;服务端需要未携带但双方支持的组时可发送 HRR,也可能要求 cookie。客户端按限制构造第二份 ClientHello,多出一次往返。

HRR 是具有特殊 Random 值的 ServerHello 结构,不能仅看消息类型就当普通 ServerHello。transcript 使用合成的 message_hash 纳入第一份 ClientHello 的哈希,不能简单丢弃它。实现还需检查不允许的第二次 HRR及非法参数变化。RFC 9846:HelloRetryRequest

是否发生 HRR 取决于部署组策略和客户端材料,不能用“X25519 几乎总猜中”作为永久事实。更新规范用更一般的非对称密钥建立表述相关机制,不能把全部未来 key_share 都定义成椭圆曲线上的裸公钥。

CertificateVerify 与 Finished 各证明什么

CertificateVerify 使用对应证书私钥对规定的上下文和 transcript hash 签名,把身份认证绑定到本次握手。该签名输入还含固定填充和角色上下文字符串,不是随意直接签一段 ClientHello 文本。

Finished 则从该方向握手流量秘密派生 finished_key,以 HMAC 认证 transcript hash。SHA256 套件下 verify_data 为 32 B,SHA384 为 48 B,不能照搬 TLS 1.2 常见 12 B。证书验证、私钥持有证明与共享密钥确认互补,缺一项都不能仅靠“TLS 能解密”弥补。RFC 9846:认证消息

兼容模式可能看到虚设 CCS,但 TLS 1.3 不用 TLS 1.2 那样的 CCS 来切换真实加密状态。抓包应按握手阶段与 traffic secret 理解记录,而非把每个 CCS 都当密钥切换事件。

HKDF:Extract 混入秘密,Expand 分离用途

HKDF-Extract 以 salt 和输入密钥材料得到 PRK;HKDF-Expand 再用 info 和长度派生输出。TLS 的 HKDF-Expand-Label 在 info 中编码长度、带 tls13 前缀的标签和 context。标签是协议字节,不能因自然语言变量改名而改变。RFC 5869:HKDF

密钥计划:两处 derived 步骤不能省略 Early Secret = Extract(0, PSK 或零值) 早期流量密钥从这一阶段派生 Handshake Secret = Extract(derived₁, 共享秘密) 派生客户端/服务端握手流量秘密 Main Secret = Extract(derived₂, 零值) 派生应用流量秘密、exporter 与恢复秘密 Derive-Secret(Early Secret, "derived", 空消息) Derive-Secret(Handshake Secret, "derived", 空消息) 图为常见混入非对称共享秘密的路径;traffic secret 再用带标签的 Expand 派生各方向 key、IV 等。

记 Derive-Secret(S,label,messages) 为使用 transcript hash 的标签派生。常见非对称协商路径为:

  1. Early = Extract(zeros, PSK),未使用 PSK 时输入按 hash 长度填零。
  2. derived₁ = Derive-Secret(Early, "derived", empty_messages)。
  3. Handshake = Extract(derived₁, shared_secret)。
  4. derived₂ = Derive-Secret(Handshake, "derived", empty_messages)。
  5. Main = Extract(derived₂, zeros)。

两次 Extract 之前都需要先派生 derived secret;省略任意一次,后续秘密与流量密钥都会改变。Main Secret 在 RFC 8446 资料中称 Master Secret;更新规范改变术语,但保留兼容所需标签。RFC 9846:密钥计划

握手 traffic secret 绑定至 ServerHello 的 transcript,初始应用 traffic secret 绑定至服务器 Finished,恢复相关秘密还绑定至客户端 Finished。并非每个 Expand 都混入“此刻所有消息”,key/IV/finished 等分支有各自 context 规则,不能统一替换。

记录 nonce 与开销

应用 traffic secret 派生 key 和 IV;TLS 1.3 记录 nonce 由该方向 IV 与补齐的记录序号异或得到,不像 TLS 1.2 GCM 那样在线上另发 8 B 显式 nonce。密钥更新后按新状态管理序号,不能让同一 key/nonce 被重复使用。

无额外 padding 的 AES-GCM 例子:1000 B 内容加 1 B 内层 content type、16 B tag、5 B 外层头,总计 1022 B TLS 数据。内层真实类型加密,外层受保护记录使用相应兼容类型;这不隐藏包长和时序。

同一个秘密,为什么能派生不同方向的密钥?

方向切换应只改变traffic及其子分支,不改变上游Handshake Secret;修改transcript也是如此。勾选省略derived则改变Extract的salt,影响从Handshake开始的全部后续值。通过变化边界来读依赖图。

正在呈现知识画面
同一个秘密,为什么能派生不同方向的密钥?

标签和context是派生输入,不是说明文字。c/s分支共享父秘密却分离用途;省略derived会在更早的Extract步骤产生不同结果。

PSK 恢复与 0-RTT 是两件事

恢复使用已有 PSK 状态建立新连接,可以选择普通 1-RTT,也可在获准时尝试 early data。NewSessionTicket 的票据字段是服务端可识别的恢复标识/封装,不是把 PSK 明文送出;客户端依据恢复秘密与 ticket nonce 等派生对应 PSK。

ClientHello 的 PSK identity 指定候选,binder 证明客户端持有相关 PSK,并绑定规定的部分 ClientHello。它不认证尚未收到的服务器新消息,也不自动使 early data 不可重放。

模式新非对称共享秘密前向保密边界
证书 + 临时非对称协商有取决于算法和秘密销毁等条件
PSK + 新非对称协商有普通后续流量可受新秘密保护
PSK-only(psk_ke)无不能宣称同样具有新密钥交换的前向保密
0-RTT early data尚未混入服务端新 share主要依赖 PSK,不具同等前向保密

所以“TLS 1.3 只允许 ECDHE,强制所有数据前向保密”不成立。psk_dhe_ke 名称中的 dhe 在更新规范中是历史命名,其覆盖相应非对称密钥建立模式。RFC 9846:PSK 模式

early data 的接受与重放

服务器可拒绝 early data,同时继续握手。客户端要区分“握手成功”和“早期操作被接受”,并按应用语义决定后续如何重发。max_early_data_size 约束可接收规模,不能作为业务去重机制。

攻击者可能重放有效早期数据,服务端需结合票据使用、时间窗、反重放状态和应用设计处理;跨节点缓存也可能使一次性使用难以保证。HTTP 方法名不是安全证明,GET 若有副作用仍不适合轻率提前执行。0-RTT 减少发送前等待,不代表在零时间收到业务结果。RFC 9846:0-RTT 与反重放

KeyUpdate:换方向密钥,不重新认证身份

连接建立后可用 KeyUpdate 推进应用流量秘密,随后按新 key/IV 保护该方向记录,并按规则要求对端响应更新。它不重新发证书,不等于重新执行非对称密钥交换,也不是让已经泄漏的当前秘密自动恢复全部安全。

已关闭的 TLS renegotiation、握手后的客户端认证和 KeyUpdate 是不同机制。应用若需要刷新授权状态,应在自己的生命周期中设计,不能把“换了流量 key”当作用户权限已重新验证。

ECH:保护内层 ClientHello,不消灭所有元数据

RFC 9849 定义内外 ClientHello:真实敏感参数放入被保护的内层,外层提供供前端处理的公开信息;持有相应私钥的服务端解密内层。它保护的不只是裸 SNI 字段,也需要处理接受确认、配置过期和失败重试。RFC 9849:ECH

客户端需要先取得 ECHConfigList。DNS SVCB/HTTPS 的 ech 参数是一种标准引导方式,配置获取的真实性与隐私也属于完整部署的一部分;并非只要域名存在 HTTPS 记录就必然启用了 ECH。RFC 9848:DNS 引导 ECH

ECH 配合加密 DNS 减少名称直接暴露,但目的 IP、外层公开名称、流量大小、时间和服务端日志仍可见。一个独占 IP 也可能直接暗示站点,因此不能承诺“用户访问了哪个网站已彻底藏住”。

本地 HKDF 验证与派生对照

import hashlib
import hmac
import struct

def extract(salt,ikm):
    return hmac.digest(salt,ikm,'sha256')
def expand(prk,info,length):
    previous,result = b'',b''
    for counter in range(1,(length+31)//32+1):
        previous = hmac.digest(prk,previous+info+bytes([counter]),'sha256')
        result += previous
    return result[:length]
def expand_label(secret,label,context,length):
    label = b'tls13 '+label
    info = struct.pack('!H',length)+bytes([len(label)])+label+bytes([len(context)])+context
    return expand(secret,info,length)
def derive(secret,label,messages):
    return expand_label(secret,label,hashlib.sha256(messages).digest(),32)

# RFC 5869 Appendix A.1,SHA256 测试向量。
ikm = bytes([11])*22
prk = extract(bytes.fromhex('000102030405060708090a0b0c'),ikm)
assert prk.hex()=='077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5'
assert expand(prk,bytes.fromhex('f0f1f2f3f4f5f6f7f8f9'),42).hex()==(
    '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf'
    '34007208d5b887185865')
zero = bytes(32)
early = extract(zero,zero)
# RFC 8448 中无 PSK 的 early secret。
assert early.hex()=='33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a'
shared = bytes(range(32)) # 教学输入,不是真实密钥交换结果。
handshake = extract(derive(early,b'derived',b''),shared)
assert handshake != extract(early,shared)
main = extract(derive(handshake,b'derived',b''),zero)
client = derive(handshake,b'c hs traffic',b'hello transcript')
server = derive(handshake,b's hs traffic',b'hello transcript')
assert client!=server
assert len(expand_label(client,b'key',b'',16))==16
assert len(expand_label(client,b'iv',b'',12))==12
assert len(main)==32 and 1000+1+16+5==1022
print('HKDF 标准向量、early secret、derived 步骤与记录开销检查通过')

此例验证 HKDF 原语和部分派生关系;没有实现完整 transcript、证书认证、真实 ECDHE/ECH 或 TLS 互操作。公开测试值不是生产秘密。RFC 8448:握手测试材料

继续查阅