TLS 1.3 握手全流程与证书链排障:从 ClientHello 到会话复用
前面几篇我们把 DNS 解析、DHCP 拿地址、IP 选路都讲了一遍——这些都发生在「找到对方」的阶段。但从客户端点下回车到收到第一个字节,中间还夹着一段几乎没人细看的过程:TLS 握手。它决定了这次连接能不能建立、要花几个往返、以及你是不是「看起来安全但实际可被中间人替换」。
一、TLS 1.3 一次握手:一个往返就够
TLS 1.2 完整握手需要 两个往返(2-RTT),1.3 把它压到了 1-RTT,会话复用场景甚至能做到 0-RTT。压缩的关键在于:1.3 让客户端在第一条消息里就把「密钥交换的素材」一起送来,服务器不必再单独回一次。逐帧看是这样:
| # | 方向 | 消息 | 关键内容 |
|---|---|---|---|
| 1 | Client → Server | ClientHello | 支持的版本、密码套件列表、key_share(密钥交换公钥)、SNI、ALPN 列表 |
| 2 | Server → Client | ServerHello | 选定版本与套件、服务器的 key_share |
| 3 | Server → Client | {EncryptedExtensions} | 此后全部加密:选定的 ALPN 等扩展 |
| 4 | Server → Client | {Certificate} | 服务器证书链(含中间 CA) |
| 5 | Server → Client | {CertificateVerify} | 用证书私钥对握手内容签名,证明持有私钥 |
| 6 | Server → Client | {Finished} | 握手完整性校验值 |
| 7 | Client → Server | {Finished} | 客户端确认,握手完成,开始传应用数据 |
两点值得注意:
- 大括号表示"已加密"。从 ServerHello 之后的消息就全部被握手密钥保护了——包括服务器证书。这是 1.3 与 1.2 的一个显著差异:在 1.2 里证书是明文传输的,中间人可以观察到你在访问哪个站点用的哪张证书(当然看不到内容)。
- SNI 仍然是明文。因为服务器在拿到 ClientHello 时必须先知道「你要访问哪个域名」才能决定发哪张证书,所以 SNI 在 1.3 里依然是明文(除非启用 ECH)。这是「HTTPS 也泄露访问目标」的技术根源。
1.2 与 1.3 的差异对照
| TLS 1.2 | TLS 1.3 | |
|---|---|---|
| 完整握手往返 | 2-RTT | 1-RTT |
| 会话复用往返 | 1-RTT(Session Ticket) | 0-RTT(可带早期数据) |
| 证书是否加密 | 明文 | 加密 |
| 密钥交换 | RSA 或 (EC)DHE,均有历史遗留 | 仅 (EC)DHE,前向保密强制 |
| 对称加密 | 含 CBC / RC4 等弱模式 | 仅 AEAD(AES-GCM / ChaCha20-Poly1305) |
| 重协商 | 支持(曾多次被利用) | 移除 |
二、证书链验证:四个检查点,缺一不可
浏览器判断「这张证书可信」并不是看它本身,而是沿着一条信任链往上走,最终落到操作系统/浏览器内置的一个根证书上。这条链上的每一环都要通过四个检查:
| 检查 | 验证什么 | 失败典型报错 |
|---|---|---|
| ① 签名链 | 子证书的签名能否用父证书公钥验证,一路到受信任根 | unable to verify the first certificate |
| ② 有效期 | 链上每一张证书都在有效期内 | certificate has expired |
| ③ 吊销状态 | 是否被 CA 吊销(CRL / OCSP) | certificate revoked |
| ④ 主机名匹配 | 证书的 CN 或 SAN 是否覆盖你访问的域名 | Hostname mismatch / NET::ERR_CERT_COMMON_NAME_INVALID |
实践中最常出问题的不是「证书错了」,而是链不完整:服务器只发了自己的证书,没发中间 CA 证书,于是浏览器无法把链接到根上。
# 只看服务器发了什么链(-showcerts 打印全部)
openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null | grep -E "^(subject|issuer|Verify)"
# 证书链是否完整(返回 0 表示链完整可信)
openssl s_client -connect example.com:443 -servername example.com -verify_return_error </dev/null 2>&1 | grep -E "Verify return code"
openssl s_client 或目标客户端实测。证书类型速查
| 类型 | 覆盖域名数 | 典型用途 |
|---|---|---|
| DV 单域名 | 1 | 个人站、API 网关 |
| DV 通配符 | 主域 + 全部一级子域(*.a.com) | 多子域业务;注意不覆盖 a.com 本身与多级子域 |
| 多域名 SAN | 多个不相关域名 | 一次部署覆盖多个站点 |
| OV / EV | 单/多域名 + 组织实名 | 对信任等级有要求的场景 |
三、SNI 与 ALPN:握手阶段就决定了「给哪个网站、说哪种话」
这两个扩展都不涉及加密,却是虚拟主机与 HTTP/2 能否工作的开关。
- SNI(Server Name Indication):客户端在 ClientHello 里明说「我要访问 a.com」。服务器据此选择对应的证书与虚拟主机。没有 SNI,一台 IP 上就只能放一张证书——这也是早年 HTTPS 部署成本高的原因。
- ALPN(Application-Layer Protocol Negotiation):客户端列出
h2, http/1.1,服务器选定一个。这是现代 HTTP/2 启用的唯一正确途径——它把协议协商放在握手内部完成,省掉了额外的往返(对比 HTTP/1.1 的 Upgrade 头机制,那要一整轮往返才能升级)。
# 看 ALPN 是否协商到 h2
openssl s_client -connect example.com:443 -servername example.com -alpn h2,http/1.1 2>/dev/null | grep -i "ALPN"
# 指定 TLS 版本与套件排查(模拟老客户端)
openssl s_client -connect example.com:443 -tls1_2 -cipher ECDHE-RSA-AES128-GCM-SHA256
-servername 不能省。用 IP 直连做测试时如果不带 -servername,curl / openssl 的请求里就没有 SNI,服务器会回落到默认站点、发错证书,于是你得到一堆「域名不匹配」的假故障。测试脚本里务必显式指定 SNI,这也解释了为什么「用 IP 访问 HTTPS 站点」经常直接失败。四、OCSP Stapling:让证书吊销检查不再拖慢握手
验证证书是否被吊销,最原始的做法是客户端自己去问 CA 的 OCSP 服务器。问题有两个:多一次网络往返(握手变慢),而且等于把你的访问目标暴露给 CA(隐私问题)。
OCSP Stapling 的思路很聪明:让服务器自己去问 CA 拿一份带时间戳、由 CA 签名的吊销状态声明,然后在握手时一并捎给客户端。客户端不必外访,延迟和隐私问题同时解决。
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem; # 通常是「根 + 中间」证书
resolver 223.5.5.5 119.29.29.29 valid=300s; # 必须配 resolver,否则无法取 OCSP
resolver_timeout 5s;
排障时经常遇到「配了 stapling 但没生效」,最可能的原因就是漏配 resolver——Nginx 需要自己解析 OCSP 服务器域名。用 openssl s_client ... -status 可以直接看到服务器有没有捎带 OCSP 响应。
五、会话复用:为什么第二次握手快得多
完整握手有非对称运算(签名验证、密钥交换),成本远高于对称加密。用户连续访问同一站点时,每次都做完整握手是巨大浪费。两种复用机制:
| 机制 | 原理 | 特点 |
|---|---|---|
| Session ID | 服务器保存会话状态,用 ID 索引 | 有状态,多机部署需共享存储/粘性 |
| Session Ticket | 把会话状态加密后交给客户端保存,下次带回 | 无状态,天然适合负载均衡集群,1.3 的标准做法 |
ssl_session_cache shared:SSL:10m; # 10m 大约可存 4 万个会话
ssl_session_timeout 1d;
ssl_session_tickets on;
TLS 1.3 里去掉了 Session ID,统一用 PSK(预共享密钥) 模式:客户端在 ClientHello 里带上 pre_shared_key 扩展,服务器若能接受,直接在同一往返里完成握手,这就是 0-RTT。
ssl_early_data on 开启,但应用侧必须配合做重放防护。六、排障手册:从报错反推到配置
1. 一套通用诊断命令
# ① 握手是否成功、用的哪个版本和套件
openssl s_client -connect example.com:443 -servername example.com </dev/null | head -20
# ② 只看证书链与有效期
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# ③ 逐级看链上每张证书(谁签的谁)
openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
| grep -E "^ *[0-9]+ s:|^ *i:"
# ④ 模拟不支持 SNI 的老客户端,看默认站点返回什么证书
openssl s_client -connect example.com:443 </dev/null 2>&1 | grep "subject="
# ⑤ 在本机校验证书链是否完整
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt chain.pem
2. 常见报错与根因对照
| 报错 | 根因 | 处理 |
|---|---|---|
unable to verify the first certificate | 服务器没发中间证书 | 把 fullchain.pem(域名证书 + 中间 CA)配进 ssl_certificate |
certificate has expired | 链上某张证书过期(常是中间证书) | 更新证书后 reload,并检查自动化续期是否真的生效 |
Hostname mismatch | 证书 SAN 不含该域名 | 换证书,或为该域名单独配置 server 块 |
no peer certificate available | 客户端发的 SNI 命中不到任何 server 块,回落到默认站 | 加 server_name 或补默认站点证书 |
| 握手成功但页面卡住 | ALPN 协商到 h2,但中间设备不支持 HTTP/2 | 临时关闭 http2 或排查中间设备 |
| iOS / 微信内打不开,浏览器正常 | 证书链不完整,客户端不做 AIA 补全 | 同上,务必补齐中间证书 |
3. 一份可直接用的现代 TLS 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # TLS1.3 由客户端选更合适
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
ssl_certificate /etc/nginx/ssl/fullchain.pem; # 必须含中间证书
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # 多机部署时需共享 ticket key,否则建议关
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
resolver 223.5.5.5 119.29.29.29 valid=300s;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
max-age=31536000 意味着浏览器在整整一年内强制用 HTTPS 访问你的域名,并且会忽略用户点击「继续访问」的选项。如果站点里还有没配好证书的子域,加上 includeSubDomains 会把这些子域直接打死。建议上线顺序:先长期观察证书续期确实稳定 → 再用小 max-age(如 300 秒)验证 → 最后才提到一年。openssl s_client 和真实客户端去验。掌握了这一层,从 DNS 到 TLS 的整条访问链路就打通了。