TLS 1.3与证书链:从握手到mTLS
TLS 1.3与证书链:从握手到mTLS
"加了 HTTPS 就安全了"这句话掩盖了大量细节:握手省掉的往返换来了什么,证书链为什么在浏览器里正常、在 curl 里报错,mTLS 到底验证了谁。本文按一次真实连接的顺序拆开 TLS 1.3:密钥怎么协商、证书怎么验、会话怎么恢复,最后落到证书轮换与长连接这两个线上最容易出问题的位置。
先说结论式的地图
一次 HTTPS 连接里发生的事
TCP 三次握手
└─ TLS 握手:协商算法、验证身份、派生密钥
└─ 应用数据(HTTP/2、gRPC……)
└─ 断连:close_notify 或直接 RST
TLS 解决三件事:机密性(别人看不懂)、完整性(改了能被发现)、身份认证(对面确实是它自称的那个域名)。前两件靠对称加密,第三件靠证书链,而对称密钥的安全交换靠非对称算法——所有握手优化都在压缩"密钥交换 + 身份验证"这两个动作的成本。
TLS 1.2 到 1.3 删掉了什么
| 项目 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 握手往返 | 2-RTT | 1-RTT,会话恢复 0-RTT |
| 密钥交换 | RSA 静态密钥交换 / ECDHE | 只保留(EC)DHE 类前向安全方案 |
| 对称算法 | CBC、RC4、3DES 等 | 只保留 AEAD(AES-GCM、ChaCha20-Poly1305) |
| 压缩 | 支持(CRIME 漏洞来源) | 删除 |
| 重协商 | 支持 | 删除 |
| 证书 | 默认明文传输 | 默认加密(除服务器身份外全部加密) |
两条最关键的删除:静态 RSA 密钥交换(私钥泄露即可解密历史流量,没有前向安全)和 CBC 模式(padding oracle 类漏洞反复出现)。TLS 1.3 把"能选错"的选项直接砍掉了,这是它比 1.2 更安全的本质原因——不是加了什么,而是少了很多。
1-RTT 握手全过程
Client Server
│ ClientHello │
│ supported_versions = 1.3 │
│ key_share = X25519 公钥 A │
│ signature_algorithms, ALPN, SNI │
├───────────────────────────────────────────────▶│
│ │ 选参数,算共享密钥
│ ServerHello │
│ key_share = 公钥 B │
│ {EncryptedExtensions} │
│ {Certificate} │ ← 此后全部加密
│ {CertificateVerify} │ ← 用私钥签名握手记录
│ {Finished} │
│◀───────────────────────────────────────────────┤
│ 验证证书链、验证签名、算密钥、发 {Finished} │
├───────────────────────────────────────────────▶│
│ 应用数据 │
要点有四个:
- 密钥协商与身份验证在同一次往返完成。客户端一次性带上
key_share,猜对了组就在 1-RTT 内完成;猜错了服务器回 HelloRetryRequest,多一个往返。 CertificateVerify是签名而不是加密。服务器用证书私钥签一段包含握手 transcript 的数据,证明它确实持有私钥,同时绑定握手内容、防止中间人替换。- 证书之后的报文全部加密。包括客户端证书请求,这让被动观察者连"用了哪家 CA"都看不到。
- 密钥由 HKDF 分层派生:early secret → handshake secret → master secret,每层用 transcript hash 做上下文,任何一处报文被篡改都会导致 Finished 校验失败。
证书链是怎么验的
服务器通常只发"叶子证书 + 中间 CA",根证书在客户端本地。验证是一条自下而上的链:
叶子证书(your.domain)
│ 由
▼
中间 CA(R3、E1 之类)
│ 由
▼
根 CA(本地信任库,自签名)
每一步要检查:签发者与主体匹配、签名有效、Basic Constraints: CA=TRUE、Key Usage 允许签证书、有效期覆盖当前时间。链建起来之后,还要单独验证叶子证书的身份:
- 主机名必须出现在 SAN(Subject Alternative Name)里。用 CN 匹配是二十年前的规则,现代客户端直接忽略 CN。
- 通配符只匹配一级:
*.example.com能匹配a.example.com,不能匹配a.b.example.com。 - 证书用途要匹配:
Extended Key Usage里没有serverAuth的证书不能用于服务端。
这就解释了一个高频问题:为什么浏览器能打开、curl 报错。浏览器有"补链"机制,会去下载缺失的中间证书;curl、Java、Go 的默认行为是严格按服务器发来的链验证。服务器漏发中间证书时,只有浏览器能救。
吊销检查为什么几乎不可靠
| 机制 | 做法 | 问题 |
|---|---|---|
| CRL | 下载完整吊销列表 | 文件大、更新慢、隐私泄露 |
| OCSP | 实时问 CA | 增加一次 RTT、CA 不可用时阻塞连接 |
| OCSP Stapling | 服务器把响应附在握手里 | 需要服务器定期刷新,配置不当会失效 |
| 短有效期 | 90 天甚至 7 天证书 | 事实上替代了吊销,成为当前主流 |
结论很直接:吊销机制不要当成强保证,真正在起作用的是短有效期 + 自动化续期。证书有效期越短,泄露窗口越小,吊销的重要性就越低。
0-RTT 与重放风险
TLS 1.3 的会话恢复用 PSK:客户端带着上次的票据,第一个报文就能发应用数据。省掉一个往返,但这段 early data 不保证唯一性——攻击者可以原样重放。
0-RTT 的约束
✓ 只用于幂等请求(GET、静态资源)
✗ 不能用于支付、下单、任何有副作用的 POST
✗ 不能防止重放,服务端必须自己去重
很多库默认开启 0-RTT,而业务代码并不知道自己收到了重放请求。要么在服务端显式关闭 0-RTT,要么按接口白名单开启。
mTLS:双向验证
普通 TLS 只验服务器。mTLS 在握手里加一个 CertificateRequest,客户端也出示证书,服务器按同样的链验证逻辑验客户端。它是服务间通信的事实标准:
服务 A ──┐ ┌── 服务 B
│ ClientHello │
│ {Certificate(客户端证书)}│
│ {CertificateVerify} │
└─────────────────────────▶│ 验证 A 的证书链
│ 从 SAN / URI 提取身份
│ 授权:这个身份能调哪些接口
三个容易踩空的地方:
- 验证链不等于授权。证书合法只说明"它是集群里某个服务",能不能调这个接口要另外做鉴权(比如 SPIFFE ID 到 RBAC 的映射)。
- 证书轮换与长连接。证书换了,已建立的连接不会自动重握手,可能继续用旧身份跑到进程重启。服务网格用 SDS 主动推新证书并触发连接重建,自建方案要自己处理。
- 客户端证书的 SAN 要写对。很多实现从 CN 取身份,但现代校验只看 SAN,CN 为空会导致身份提取失败。
长连接与证书过期的隐性冲突
一个典型线上事故:证书 90 天有效,服务是 gRPC 长连接,连接建立后一直复用。证书在第 91 天过期,但连接没断,业务继续跑;直到某次发布重启,所有新连接握手失败,故障才爆发。
处理方式是把"证书有效期"纳入连接生命周期:
- 客户端设置最大连接存活时间(比如 24 小时),到期主动重建。
- 服务端在证书剩余有效期低于阈值时主动发
close_notify或 GOAWAY。 - 监控证书剩余天数并提前告警,而不是等到过期。
常见坑
- 只配叶子证书不配中间证书。浏览器能补链,命令行工具和 SDK 不能,表现为"只有部分客户端握手失败"。
- 用 CN 做主机名匹配。现代客户端忽略 CN,证书必须包含 SAN。
- OCSP 阻塞握手。同步查询 OCSP 且 CA 超时,会让所有新连接卡住,应改用 stapling 或直接不查。
- 开启 0-RTT 却写非幂等接口。重放攻击不需要破解任何加密,只要原样重发第一个包。
- 证书与私钥不匹配。部署时只换了证书没换私钥,握手阶段才报错,往往在发布后才发现。
- 服务器时钟漂移。校时失败会让"证书尚未生效"或"已过期"随机出现,排查时容易误判为证书问题。
- mTLS 只验签不鉴权。任何持有集群内任意证书的服务都能调所有接口,双向认证退化成"有票就能进"。
总结
| 环节 | 关键点 |
|---|---|
| 握手 | 1-RTT 完成密钥协商与身份验证,证书之后全部加密 |
| 密钥 | 只用前向安全的临时密钥交换,HKDF 分层派生 |
| 证书 | 链要完整、身份看 SAN、用途看 EKU |
| 吊销 | 不可依赖,短有效期 + 自动化续期才是主线 |
| 0-RTT | 只给幂等请求,服务端要能识别重放 |
| mTLS | 验证身份之后必须单独做授权,并处理轮换 |
| 运维 | 证书过期要和连接生命周期、监控告警一起设计 |
TLS 1.3 把协议层的选择空间收窄了,剩下的风险几乎都在配置和运维上:链是否完整、证书是否续期、长连接是否感知轮换。这些不是密码学问题,而是工程问题。
- 点赞
- 收藏
- 关注作者
评论(0)