# TLS 1.3 握手详解：逐条消息拆开看

> 逐条讲解 TLS 1.3 握手：ClientHello 各扩展、HelloRetryRequest、加密的服务器消息、Finished、会话票据与 0-RTT，附 openssl 实测输出。

Source: https://ssl.sh/zh/learn/tls-handshake/
Last updated: 2026-11-10

TLS 1.3 握手只有 1 个往返：客户端发出带 key share 的 ClientHello，服务器回 ServerHello 和一组加密消息（EncryptedExtensions、Certificate、CertificateVerify、Finished），客户端再回一个 Finished 就完成了。ServerHello 之后的内容全部加密。消息定义以现行规范 RFC 9846 为准，它在 2026 年 7 月取代了最初的 RFC 8446，版本号不变、向后兼容。

## 完整握手全景图

TLS 1.3 完整握手共三轮发送，下图包含可选的客户端认证。花括号表示用握手密钥加密，方括号表示用应用数据密钥加密，`*` 表示可选消息。

```text
客户端                                                      服务器

ClientHello
  + supported_versions, key_share, signature_algorithms,
  + server_name, ALPN, psk_key_exchange_modes,
  + pre_shared_key*, early_data*                 -------->
（0-RTT [应用数据*]）                            -------->
                                                       ServerHello
                                                 + supported_versions
                                                 + key_share
                                                 + pre_shared_key*
                                             {EncryptedExtensions}
                                             {CertificateRequest*}
                                                    {Certificate*}
                                              {CertificateVerify*}
                                                        {Finished}
                                 <--------           [应用数据*]
{EndOfEarlyData*}
{Certificate*}
{CertificateVerify*}
{Finished}                                       -------->
                                 <--------     [NewSessionTicket*]
[应用数据]                       <------->            [应用数据]
```

图里出现三组密钥：早期数据密钥（只给 0-RTT 用）、握手密钥（ServerHello 到 Finished 之间）、应用数据密钥（Finished 之后）。它们都出自同一个密钥调度过程，后文有图。

## ClientHello：客户端的开场报价

ClientHello 把服务器选参数、算密钥所需的东西一次带齐。固定字段大多是历史包袱，真正的协商都在扩展里。

| 字段或扩展 | 在 TLS 1.3 中的作用 |
|---|---|
| `legacy_version` | 为了兼容固定写 `0x0303`（TLS 1.2），真实版本看 `supported_versions` |
| `random` | 32 字节随机数，进入握手记录哈希 |
| `legacy_session_id` | 兼容模式下是随机 32 字节，否则为空 |
| `cipher_suites` | TLS 1.3 套件如 `TLS_AES_128_GCM_SHA256`；仍支持 1.2 的客户端还会列出 1.2 套件 |
| `supported_versions` | 支持的版本，如 `0x0304`（TLS 1.3）、`0x0303`（TLS 1.2） |
| `supported_groups` | 可接受的密钥交换组，按偏好排序（X25519MLKEM768、X25519、P-256……） |
| `key_share` | 针对其中一个或几个组的真实公钥，相当于押注服务器会选哪个 |
| `signature_algorithms` | 客户端能验证的签名算法，如 `ecdsa_secp256r1_sha256`、`rsa_pss_rsae_sha256`、`ed25519` |
| `server_name` | 要访问的域名（[SNI](https://ssl.sh/zh/glossary/sni/)，RFC 6066），服务器据此选证书 |
| `application_layer_protocol_negotiation` | [ALPN](https://ssl.sh/zh/glossary/alpn/) 列表，如 `h2`、`http/1.1` |
| `psk_key_exchange_modes` | 会话恢复时能否跳过 (EC)DHE（`psk_ke`）还是必须带上（`psk_dhe_ke`） |
| `pre_shared_key` | 用于会话恢复的票据标识，必须是最后一个扩展 |
| `early_data` | 表示后面跟着 0-RTT 数据 |

浏览器还会塞进 GREASE 值（RFC 8701）：一些随机的保留编号，用来逼服务器正确忽略不认识的值，避免协议“锈死”。

key share 让 ClientHello 比以前大了很多。用 OpenSSL 3.6 实测，默认同时发送 X25519MLKEM768 和 X25519 两个 key share，ClientHello 共 1533 字节，其中 `key_share` 扩展就占 1258 字节：混合 share 是 1184 字节的 ML-KEM-768 公钥加 32 字节的 X25519 公钥。ClientHello 超出一个 TCP 报文段后，浏览器刚启用混合密钥交换时曾让一些有缺陷的中间设备出问题，详见[后量子 TLS](https://ssl.sh/zh/learn/post-quantum-tls/)。

## HelloRetryRequest：押错了 key share

如果客户端带的 key share 服务器一个都不支持，但客户端列出的其他组里有服务器支持的，服务器就回一个 HelloRetryRequest，客户端换上对应的 key share 重发 ClientHello，握手因此多 1 个往返。

HelloRetryRequest 在线路上其实是一条 ServerHello，只是它的 `random` 是一个固定的特殊值（字符串 "HelloRetryRequest" 的 SHA-256）。它可以带 `cookie` 扩展让客户端原样带回，方便服务器做无状态处理。为了控制握手记录的长度，第一条 ClientHello 在哈希里会被替换成一条合成的 `message_hash` 消息。

下面是对一台只接受 P-384 的测试服务器的实测（OpenSSL 3.6，`-msg` 输出，去掉了记录头行）：

```text
>>> TLS 1.3, Handshake [length 0601], ClientHello
<<< TLS 1.3, Handshake [length 0058], ServerHello        <- 这条是 HelloRetryRequest
>>> TLS 1.3, Handshake [length 017e], ClientHello        <- 换成 secp384r1 重发
<<< TLS 1.3, Handshake [length 00bb], ServerHello
<<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0193], Certificate
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Handshake [length 0034], Finished
```

服务器应当支持主流客户端会直接发 share 的组（X25519；截至 2026 年 11 月，X25519MLKEM768 也越来越普遍），避免这笔额外开销。

## ServerHello：服务器拍板

ServerHello 确定密码套件并给出服务器的 key share，之后双方都能算出握手秘密。它包含：

- `cipher_suite`：从客户端列表里选中的一个 TLS 1.3 套件；
- `supported_versions`：`0x0304`，即真实协议版本；
- `key_share`：服务器在所选组上的公钥。X25519MLKEM768 下它是 1120 字节，即 1088 字节的 ML-KEM 密文加 32 字节 X25519 公钥；
- `pre_shared_key`：会话恢复时接受了哪张票据。

服务器的 `random` 还承担降级保护：支持 TLS 1.3 的服务器如果最终协商成 TLS 1.2 或更低版本，会把最后 8 字节写成 `DOWNGRD` 加 `01` 或 `00`，支持 TLS 1.3 的客户端看到就中止连接。random 受服务器签名保护，攻击者无法抹掉这个标记。

## EncryptedExtensions

EncryptedExtensions 是第一条加密消息，装的是所有不参与密钥计算的协商结果：服务器选定的 ALPN 协议（如 `h2`）、对 `server_name` 的空确认、服务器的 `supported_groups` 偏好（供下次参考）、是否接受 0-RTT 的 `early_data` 等。这些信息挪出 ServerHello 后，被动观察者就看不到了。

## CertificateRequest（可选）

只有服务器要求客户端证书时才发 CertificateRequest，也就是[双向 TLS](https://ssl.sh/zh/learn/mtls/) 的场景。它包含 `certificate_request_context`、服务器接受的 `signature_algorithms`，以及可选的 `certificate_authorities`（可接受的签发者列表）。客户端如果声明了 `post_handshake_auth`，TLS 1.3 还允许握手完成后再要证书。

## Certificate

Certificate 消息携带服务器证书链：先是站点证书，再是中间证书。每个证书条目都可以带自己的扩展，所以 OCSP 装订响应（`status_request`）和 SCT 时间戳会挂在对应证书上一起发送。根证书一般不发，因为客户端本来就得信任它；漏发中间证书则是[缺少中间证书](https://ssl.sh/zh/errors/missing-intermediate-certificate/)报错的经典原因。证书链还可以用 RFC 8879 压缩，比如 Chrome 会协商 Brotli 压缩。

## CertificateVerify

CertificateVerify 证明服务器确实持有证书私钥。服务器对“64 个空格 + 上下文字符串 `TLS 1.3, server CertificateVerify` + 0 字节 + 目前为止的握手哈希”签名，上下文字符串防止签名被挪用到别的协议。使用 RSA 密钥的服务器必须用 RSA-PSS 签这条消息（`rsa_pss_rsae_sha256` 等），哪怕证书本身是 PKCS#1 v1.5 签的。客户端这边，RFC 9963（2026 年 4 月）新增了 `rsa_pkcs1_sha256_legacy` 等编号，让只能做 PKCS#1 v1.5 签名的智能卡、U 盾等硬件也能在 TLS 1.3 下完成客户端认证，前提是服务器在 CertificateRequest 里提供这些编号。

## Finished

Finished 是用握手密钥派生出的 finished_key 对整个握手记录做的 HMAC。它确认双方看到的消息完全一致、算出的密钥也一致；ClientHello 被篡改或有人试图降级，这一步都会失败。服务器发完 Finished 就可以开始发应用数据（所谓 0.5-RTT 数据），因为此时它已经有了密钥，只是还没验证客户端身份。

## 客户端的第二轮发送

客户端验证证书链、域名和 CertificateVerify 签名，校验服务器的 Finished，然后发送：

1. `EndOfEarlyData`：只有发过且被接受了 0-RTT 数据时才发；
2. `Certificate` 和 `CertificateVerify`：只有服务器发过 CertificateRequest 时才发，没有证书的客户端发一条空的 Certificate；
3. `Finished`。

应用数据可以和这一轮一起发出，所以 HTTP 请求在 ClientHello 之后 1 个往返就上路了。

## NewSessionTicket 与会话恢复

握手完成后，服务器可以发一张或多张 NewSessionTicket。每张票据是一段不透明数据，附带有效期（最长 604800 秒，即 7 天）、年龄混淆值和 nonce。下次连接时客户端把票据放进 `pre_shared_key`。使用 `psk_dhe_ke` 模式时，恢复握手仍会做一次新的 (EC)DHE，保留[前向保密](https://ssl.sh/zh/glossary/forward-secrecy/)，而服务器可以省掉 Certificate 和 CertificateVerify。见[会话恢复](https://ssl.sh/zh/glossary/session-resumption/)。

多台服务器挂在负载均衡后面时（比如阿里云 SLB、腾讯云 CLB 后面的多台 Nginx），票据加密密钥要么在各台之间同步，要么在负载均衡上统一终止 TLS，否则请求落到另一台机器上就恢复不了会话。

## 0-RTT 早期数据

持有允许 0-RTT 的票据时，客户端可以在第一轮就发应用数据，用客户端早期数据密钥加密。省下一个往返，但有两个弱点：它相对票据密钥不具备前向保密，而且攻击者可以把它**重放**给服务器。TLS 1.3 规范建议服务器做防重放处理，RFC 8470 规定了 HTTP 的处理方式：服务器可以返回 `425 Too Early`，代理转发早期数据时加上 `Early-Data: 1` 头。只有 GET 这类幂等请求才适合用 [0-RTT](https://ssl.sh/zh/glossary/0-rtt/) 发送。

## 一张图看懂密钥调度

TLS 1.3 的所有密钥都来自三次 HKDF-Extract（RFC 5869）。简化如下：

```text
           PSK（没有就用全 0）
                 |
                 v
          HKDF-Extract ----> Early Secret ----> binder key、client_early_traffic_secret
                 |
           Derive-Secret("derived")
                 |
   (EC)DHE ----> HKDF-Extract ----> Handshake Secret
                 |                    +--> client_handshake_traffic_secret（CH..SH 的哈希）
                 |                    +--> server_handshake_traffic_secret
           Derive-Secret("derived")
                 |
      全 0 ----> HKDF-Extract ----> Main Secret
                                      +--> client/server_application_traffic_secret_0
                                      |      （CH..服务器 Finished 的哈希）
                                      +--> exporter_secret
                                      +--> resumption_secret -> 票据 PSK
```

每个流量密钥都绑定在某一时刻的握手哈希上，密钥因此和实际交换的消息一一对应。长连接后续还可以用 KeyUpdate 消息把应用密钥向前滚动更新。

图中用的是 RFC 9846 的命名：它把 RFC 8446 里带 "master" 的叫法改成了 "main" 或更短的名字，Master Secret 改称 Main Secret，exporter_master_secret、resumption_master_secret 改称 exporter_secret、resumption_secret。RFC 9846 还禁止在不同连接之间复用 key share，限制了 KeyUpdate 消息的数量，并把“在达到密钥使用上限之前发起 KeyUpdate”提升为必须遵守的要求。

## TLS 1.2 与 TLS 1.3 握手对比

| | TLS 1.2（RFC 5246，现已被 RFC 9846 取代） | TLS 1.3（RFC 8446，现为 RFC 9846） |
|---|---|---|
| 完整握手往返数 | 2 | 1（遇到 HelloRetryRequest 为 2） |
| 会话恢复带数据 | 1 个往返 | 可以 0-RTT |
| 密钥交换 | RSA、DHE 或 ECDHE，放在 ServerKeyExchange | (EC)DHE 或混合算法，放在 `key_share`；没有静态 RSA |
| 证书对旁观者可见 | 可见，明文发送 | 不可见，加密发送 |
| 服务器如何证明持有私钥 | 签 ServerKeyExchange，或用 RSA 解密 | CertificateVerify 签整段握手记录 |
| ChangeCipherSpec | 必需 | 只是兼容中间设备的假消息 |
| 重协商 | 支持 | 删除，由 KeyUpdate 和握手后认证代替 |

## 用 OpenSSL 观察握手

`openssl s_client -msg` 会打印每条握手消息。下面是对一台运行 OpenSSL 3.6、使用 ECDSA 证书的测试服务器的实测输出（去掉了记录头行）：

```bash
openssl s_client -connect example.com:443 -servername example.com -msg </dev/null 2>&1 | grep -E '^(<<<|>>>)'
```

```text
>>> TLS 1.3, Handshake [length 0601], ClientHello
<<< TLS 1.3, Handshake [length 04ba], ServerHello
<<< TLS 1.3, ChangeCipherSpec [length 0001]
<<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
<<< TLS 1.3, Handshake [length 0193], Certificate
<<< TLS 1.3, Handshake [length 004f], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished
```

`-trace`（OpenSSL 编译时带 `enable-ssl-trace` 才有，Homebrew 版本带了）会逐个解码扩展：

```text
    ClientHello, Length=1533
        extension_type=server_name(0), length=16
        extension_type=supported_groups(10), length=18
        extension_type=signature_algorithms(13), length=54
        extension_type=supported_versions(43), length=5
        extension_type=psk_key_exchange_modes(45), length=2
        extension_type=key_share(51), length=1258
            NamedGroup: X25519MLKEM768 (4588)
            NamedGroup: ecdh_x25519 (29)
    ServerHello, Length=1206
      cipher_suite {0x13, 0x02} TLS_AES_256_GCM_SHA384
        extension_type=supported_versions(43), length=2
        extension_type=key_share(51), length=1124
            NamedGroup: X25519MLKEM768 (4588)
```

CentOS 7 自带的 OpenSSL 1.0.2 不支持 TLS 1.3，看不到这些消息；macOS 自带的是 LibreSSL，参数也不完全相同。想在 Wireshark 里看加密部分，给 `s_client` 加 `-keylogfile keys.txt`，或给浏览器设置 `SSLKEYLOGFILE` 环境变量，再把文件导入 Wireshark。更多命令见[连接测试命令手册](https://ssl.sh/zh/cookbook/test-connections/)。
