TLS(Transport Layer Security)是 HTTPS 的地基,但 TLS 1.2 的握手要两个往返(2-RTT)才能开始传数据,密码套件里塞满了早已不安全的 RC4、CBC 与静态 RSA 密钥交换,还留着压缩、重协商这些被攻击者反复利用的边角。TLS 1.3(RFC 8446)做的是「减法」:砍掉所有已知不安全的选项,把握手压缩到 1-RTT,并引入 0-RTT 让重复连接几乎零延迟。
本文按协议的实际执行顺序展开:先看 1-RTT 握手里每个报文的字段与密钥用途,再拆解 0-RTT 与会话恢复的 PSK 机制及其重放风险,然后回到证书链如何从信任锚一路验证到叶证书,最后落到密钥派生(HKDF)与生产环境的抓包验证、配置调优。
一、TLS 1.3 的设计动机与握手流程
1.1 从 TLS 1.2 到 1.3 的减法
TLS 1.3 不是加功能,而是砍功能。凡是历史上出过漏洞、或者能用更简单方式替代的机制,一律删除,剩下的组合更少、更容易审计。这直接改变了协议的安全默认值:前向保密从「可选」变成「强制」。
TLS 1.2 → TLS 1.3 删除项:
- RSA 静态密钥交换(无前向保密,密钥泄露可回溯解密)
- CBC 模式与 RC4 流密码(BEAST、POODLE、Lucky13)
- 压缩(CRIME 攻击可推断明文)
- 重协商(Renegotiation,历史上可注入前缀)
- 自定义 DH 参数(Logjam 攻击)
- 明文形式的 Certificate 之外的多余字段
1.2 1-RTT 完整握手
TLS 1.3 把密钥交换算法(如 X25519)的公钥直接放进 ClientHello 的 key_share 扩展,服务端收到即可计算共享密钥。因此服务端在第一个响应里就能加密发送证书与 Finished,客户端验证后立刻发送应用数据——整个握手只要一个往返。
Client Server
------ ------
ClientHello
+ supported_versions = 1.3
+ key_share (X25519 公钥)
+ signature_algorithms
+ server_name (SNI)
------------------>
ServerHello
+ key_share
{EncryptedExtensions}
{Certificate}
{CertificateVerify}
{Finished}
<------------------
{Finished}
+ Application Data
------------------>
Application Data
注意 {} 包裹的报文全部是加密的:从 ServerHello 之后,握手记录就受握手密钥保护。这意味着 SNI 之外的握手内容在链路上不可见,只有 SNI 仍是明文(除非用 ECH)。
1.3 HelloRetryRequest 与密钥共享
如果客户端猜的密钥交换组服务端不支持(比如客户端只发了 X25519,服务端只配了 P-256),服务端会回一个特殊的 ServerHello——HelloRetryRequest,要求客户端换组重发。这会多花一个往返,所以生产环境应让客户端预置常见组(X25519 优先)。
HelloRetryRequest 触发条件:
- ClientHello 的 key_share 不含服务端偏好的组
- 服务端在 supported_groups 里选了另一个组
代价:握手从 1-RTT 退化为 2-RTT
缓解:客户端默认发送 X25519 + P-256 两个 key_share
二、0-RTT 与会话恢复
2.1 PSK 与 Session Ticket
TLS 1.3 的会话恢复统一走 PSK(Pre-Shared Key)机制:服务端在首次握手后用 NewSessionTicket 下发一个加密的票据,客户端下次连接时在 ClientHello 里带上它,双方直接派生密钥,跳过证书验证。
会话恢复(1-RTT,PSK 模式):
1. 首次握手完成后,服务端发送 NewSessionTicket
- ticket_lifetime
- ticket_age_add(用于 0-RTT 年龄计算)
- ticket_nonce(派生 PSK 用)
2. 客户端缓存 ticket
3. 下次连接,ClientHello 携带 pre_shared_key 扩展
4. 服务端解密 ticket,恢复 PSK,直接进入加密握手
2.2 0-RTT 与重放风险
0-RTT(Early Data)允许客户端在收到任何服务端响应之前就发送应用数据,代价是这部分数据不具备前向保密,且可能被重放。攻击者截获 0-RTT 数据包后原样重发,服务端无法区分,因为 0-RTT 数据不参与握手的 nonce 校验。
0-RTT 的安全边界:
- 无前向保密:PSK 泄露可解密 early data
- 可重放:无服务端挑战,重放不可检测
- 因此只允许幂等请求(GET、安全查询)
- 禁止:POST 转账、下单、任何有副作用的写操作
2.3 反重放机制
服务端可以用「单次票据」或「时间窗口 + 缓存」来限制重放。单次票据把 ticket 用一次即作废,最简单但牺牲恢复率;时间窗口方案缓存最近见过的 early data,拒绝重复的,代价是状态开销。
| 方案 | 原理 | 代价 |
|---|---|---|
| 单次票据 | ticket 用过即失效 | 恢复率下降,并发连接互斥 |
| 时间窗口 | 缓存最近窗口内的 early data 指纹 | 内存状态,窗口越大越贵 |
| 拒绝 0-RTT | 只接受 1-RTT 恢复 | 零风险,但失去 0-RTT 延迟收益 |
三、证书链与验证
3.1 X.509 证书结构与关键扩展
TLS 服务端在 Certificate 报文里发送证书链:叶证书(叶/服务器证书)在前,中间证书随后。根证书不发送,客户端本地信任库已有。验证时逐级用上级公钥验证下级签名。
X.509 证书关键字段:
tbsCertificate:
version, serialNumber
signatureAlgorithm # 签发算法
issuer / subject # 颁发者 / 主体 DN
validity # notBefore / notAfter
subjectPublicKeyInfo # 公钥
extensions:
subjectAltName (SAN) # 关键:现代校验只看 SAN
basicConstraints # CA:TRUE 才能签发下级
keyUsage / extKeyUsage # serverAuth
CRLDistributionPoints / OCSP # 吊销信息
authorityInfoAccess (AIA) # OCSP 与签发者地址
signatureValue
3.2 链式验证过程
验证的核心是「信任锚 → 中间 CA → 叶证书」的签名链,再叠加一系列约束检查。任何一环缺失都会导致 unable to verify the first certificate。
验证步骤:
1. 从叶证书向上构建链,直到命中本地信任库中的根证书
2. 每一级:用 issuer 公钥验证 subject 签名
3. 检查 basicConstraints:中间证书必须 CA:TRUE
4. 检查有效期(notBefore / notAfter)
5. 检查 SAN 是否匹配目标主机名(不再看 CN)
6. 检查 keyUsage / extKeyUsage 是否允许 serverAuth
7. 查询吊销状态(CRL 或 OCSP)
3.3 OCSP Stapling、CT 与 SNI
在线查询 OCSP 会带来隐私与延迟问题,OCSP Stapling 让服务端主动附上由 CA 签名的吊销状态,客户端无需外联。CT(Certificate Transparency)则要求证书写入公开日志,任何为域名签发的证书都可被监控发现。
| 机制 | 解决的问题 | 部署要点 |
|---|---|---|
| OCSP Stapling | 吊销查询延迟与隐私泄露 | Nginx ssl_stapling on,需配 resolver |
| Must-Staple | 防止降级到在线查询 | 证书扩展 status_request |
| CT 日志 | 检测未授权证书签发 | 现代 CA 强制,浏览器校验 SCT |
| SNI | 单 IP 多域名虚拟主机 | 明文发送,可用 ECH 加密 |
# 查看证书链与验证结果
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
# 只看叶证书的 SAN 与有效期
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName -dates
# 验证链是否完整(-CAfile 指定信任锚)
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem
四、密钥派生与密码套件
4.1 HKDF 密钥调度
TLS 1.3 用 HKDF(HMAC-based Key Derivation Function)分两阶段派生密钥:先 HKDF-Extract 把共享密钥和盐揉成固定长度的伪随机密钥,再 HKDF-Expand 按不同标签派生出各个用途的密钥。所有握手密钥都从同一个「密钥调度」树状展开。
密钥调度(简化):
0
|
PSK ───> HKDF-Extract ──> Early Secret
|
Derive-Secret
|
(EC)DHE ─> HKDF-Extract ──> Handshake Secret
|
Derive-Secret
|
0 ───> HKDF-Extract ──> Master Secret
|
Derive-Secret
|
client_application_traffic_secret_0
server_application_traffic_secret_0
每个 Secret 再派生出:key(AEAD 密钥)+ iv(nonce 基数)
4.2 AEAD 密码套件
TLS 1.3 只保留 AEAD(带关联数据的认证加密)套件,且把密钥交换、认证算法与 AEAD 算法解耦。套件名里只剩 AEAD 与哈希两个参数,签名算法单独协商。
| 密码套件 | AEAD | 哈希 | 特点 |
|---|---|---|---|
| TLS_AES_128_GCM_SHA256 | AES-128-GCM | SHA-256 | 有 AES-NI 时最快 |
| TLS_AES_256_GCM_SHA384 | AES-256-GCM | SHA-384 | 更高强度 |
| TLS_CHACHA20_POLY1305_SHA256 | ChaCha20-Poly1305 | SHA-256 | 无 AES 加速时更快 |
| TLS_AES_128_CCM_SHA256 | AES-128-CCM | SHA-256 | 物联网/受限设备 |
4.3 KeyUpdate 与密钥轮换
长时间连接下,TLS 1.3 允许任一方发送 KeyUpdate 请求轮换应用密钥,限制单个密钥加密的数据量,降低密钥被破解后的暴露面。响应方更新自己的密钥,请求方在收到响应后更新。
KeyUpdate 语义:
update_requested -> 对方必须回复自己的 KeyUpdate
update_not_requested -> 单向轮换,对方可稍后再轮换
约束:每轮换一次,traffic_secret 序号 +1
五、工程实践与排错
5.1 用 OpenSSL 验证握手细节
排查 TLS 问题时,s_client 是最直接的工具。加上 -tls1_3 强制版本,-msg 打印握手报文,-keylogfile 导出密钥后可在 Wireshark 里解密。
# 强制 TLS 1.3,查看协商的套件与组
openssl s_client -connect example.com:443 -tls1_3 -groups X25519 </dev/null 2>&1 | head -30
# 打印握手报文(观察 key_share、EncryptedExtensions)
openssl s_client -connect example.com:443 -tls1_3 -msg </dev/null 2>&1 | grep -A2 ">>>"
# 导出密钥供 Wireshark 解密
openssl s_client -connect example.com:443 -keylogfile keys.log </dev/null
5.2 Nginx 生产配置要点
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # TLS 1.3 下交给客户端选更快
ssl_session_cache shared:SSL:50m; # 会话恢复缓存
ssl_session_timeout 1d;
ssl_session_tickets on; # 0-RTT 依赖 ticket
ssl_early_data on; # 开启 0-RTT(仅幂等接口)
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 valid=300s; # OCSP Stapling 需要
add_header Strict-Transport-Security "max-age=63072000" always;
5.3 常见故障对照
| 现象 | 原因 | 排查 |
|---|---|---|
| unable to verify first certificate | 服务端未发中间证书 | openssl s_client -showcerts |
| certificate has expired | 证书过期 | 检查 notAfter,配置自动续期 |
| handshake failure | 套件/版本不匹配 | 强制 -tls1_3 复现,看协商日志 |
| 首包慢、后续快 | 未启用会话恢复 | 检查 ssl_session_cache 与 ticket |
| 0-RTT 数据被拒 | 反重放窗口触发 | 仅对幂等请求启用 early data |
5.4 会话恢复与 OCSP 的性能权衡
会话恢复能省掉一次完整的证书验证与密钥交换,但不同恢复方式的开销与安全属性差异很大。选择哪种,取决于对「延迟」和「前向保密」的偏好。
| 恢复方式 | 额外往返 | 前向保密 | 适用场景 |
|---|---|---|---|
| 完整握手 | 1-RTT | 有 | 首次连接 |
| PSK 恢复(含 DHE) | 1-RTT | 有 | 通用推荐 |
| PSK 恢复(纯 PSK) | 1-RTT | 无 | 极低延迟内部服务 |
| 0-RTT | 0-RTT | 无 | 幂等请求 |
大集群下推荐「有状态缓存 + 无状态票据兜底」:缓存命中走内存,未命中用票据解密,避免所有节点共享会话表。注意票据密钥需在集群内轮换与同步。
5.5 硬件加速与卸载
现代 CPU 的 AES-NI、ARM 的 Crypto Extensions 能把 AES-GCM 的吞吐提升数倍;Linux 的 kTLS 则把记录层的加解密下推到内核甚至网卡。判断是否需要,先看 CPU 是否成为瓶颈——用 top 观察 si(软中断)与 sy(系统)占比。
# 确认 CPU 是否支持 AES-NI
grep -o aes /proc/cpuinfo | head -1
# 测本地加解密能力(对比 aes-128-gcm 与 chacha20-poly1305)
openssl speed -evp aes-128-gcm
openssl speed -evp chacha20-poly1305
# 观察软中断与系统 CPU 占比,判断是否需卸载
mpstat -P ALL 1
# 开启 kTLS:把 TLS 记录层发送卸载到内核
ssl_conf_command Options KTLS;
| 优化点 | 手段 | 收益 |
|---|---|---|
| 对称加密 | AES-NI / ChaCha20 | 数倍吞吐 |
| 记录层 | kTLS 内核卸载 | 减少用户态拷贝 |
| 握手 | 会话恢复 + 0-RTT | 降低往返 |
| 证书验证 | OCSP Stapling | 消除在线查询延迟 |
| 密钥交换 | 优先 X25519 | 更快、更安全 |
5.6 一次握手的耗时分解
定位 TLS 慢,最好把握手拆成可测量的阶段。用 openssl s_client -timed 或浏览器开发者工具的 Timing 面板,能看清时间花在哪里。
典型 HTTPS 连接时间线(冷启动):
DNS 解析 : 5 ~ 50 ms
TCP 三次握手 : 1 个 RTT
TLS 握手 : 1 个 RTT(TLS 1.3)
首字节(TTFB) : 1 个 RTT
------------------------------------
总计 : DNS + 3 个 RTT
优化点:
- DNS:预解析、HTTPDNS、缩短 TTL 链路
- TCP+TLS:TLS 1.3 把两者合并到 2 个 RTT
- 复用:会话恢复或 0-RTT 直接砍掉 TLS 往返
六、小结
| 主题 | 核心要点 | 落地建议 |
|---|---|---|
| 握手 | 1-RTT,密钥交换内联在 ClientHello | 客户端预置 X25519 + P-256 |
| 0-RTT | PSK 恢复,可重放、无前向保密 | 只对幂等请求开启 |
| 证书链 | 信任锚到叶证书逐级验签 | 服务端必须发完整中间链 |
| 密钥派生 | HKDF 分层派生,AEAD 加密 | 优先 AES-GCM,无加速选 ChaCha20 |
| 排错 | s_client 抓报文与密钥 | 保存 keylog 便于 Wireshark 解密 |
TLS 1.3 把「安全」从配置项变成了协议默认值:前向保密强制、AEAD 强制、握手加密。理解握手报文顺序、密钥调度树与证书链验证,是排查一切 HTTPS 问题的共同起点——想进一步看它在传输层的邻居,可以对照 网络安全与加密通信 里的证书管理,以及 HTTP/3 与 QUIC 中 QUIC 自带的 0-RTT 实现差异。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。