8.6 TLS 握手、证书与 mTLS
一句话
TLS 握手只干三件事——验证身份、协商密钥、开始加密;TLS 1.3 把两次往返压成一次,会话复用再压成 0-RTT,而线上大多数 “握手慢/连不上” 不是加密太慢,而是证书链不全、OCSP 阻塞、或没开复用。
1.2 与 1.3:差的是往返,不是算法
| 维度 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 完整握手往返 | 2-RTT | 1-RTT |
| 密钥交换 | RSA 或 ECDHE(RSA 无前向保密) | 仅 ECDHE(前向保密成为默认) |
| 证书是否明文 | 明文(可被被动观察) | 加密 |
| 会话复用 | Session ID / Session Ticket | PSK(预共享密钥),可 0-RTT |
| 可协商的套件 | 多且杂(含弱套件) | 精简到 AEAD 套件 |
一次完整握手的耗时约等于 2 × RTT(TLS 1.2)或 1 × RTT(TLS 1.3);跨洲 RTT 150ms 时,这直接是 300ms 与 150ms 的差别——首屏时间的固定成本。
证书链:最常见的 “部分用户连不上”
叶证书(你的域名)
└── 中间 CA(服务端必须一起下发)
└── 根 CA(客户端信任库内置)
| 错误 | 客户端表现 | 为什么难查 |
|---|---|---|
| 中间证书没下发 | 桌面浏览器正常(会补全),Android/Java/curl 失败 | 你的环境正常,用户环境失败 |
| 证书过期 | 全量失败,报 certificate has expired | 监控没覆盖到期时间 |
| 域名不在 SAN 里 | 全量失败(CN 已不再被信任) | 用了通配符却漏了二级子域 |
| 自签 / 私有 CA | 未装 CA 的客户端失败 | 内网互调容易 “大家都装了” 而忘记新服务 |
| 客户端时钟偏差 | 报 “证书尚未生效” | 容器/设备时间不准 |
# 看服务端到底下发了哪几张证书(链全不全)
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -E "^ [0-9] s:|i:"
# 只验链(不建连接)
curl -vI https://example.com 2>&1 | grep -E "SSL certificate|expire|subject"
判据:s_client 输出里应能看到 叶 + 至少一张中间 CA;只有叶证书就是链不全。
SNI 与虚拟主机:一个 IP 多个域名
服务端在 TLS 握手时靠 SNI(客户端明文告知的域名)选择证书。三个推论:
- 老客户端不支持 SNI → 只能拿到默认证书(如今罕见,但内网设备仍有)。
- 直接
curl https://<IP>会因 SNI 缺失/不匹配而证书校验失败——用 IP 测 HTTPS 失败不代表服务坏了。 - 多域名共用 IP 时,证书要覆盖所有 SNI 名字,否则 “A 域名正常、B 域名报错”。
mTLS:把 “你是谁” 也放进握手
| 维度 | 单向 TLS | 双向(mTLS) |
|---|---|---|
| 谁验证谁 | 客户端验证服务端 | 双方互验证书 |
| 客户端身份 | 靠 token/cookie(应用层) | 证书(传输层),应用层无需再传凭证 |
| 证书管理 | 服务端一套 | 每个工作负载一套,轮换是主要成本 |
| 典型场景 | 公网 HTTPS | 服务网格、零信任内网、B2B 对接 |
mTLS 的工程成本集中在三件事:签发(谁当 CA、有效期多长)、分发(每 Pod 一张证书,如何注入)、轮换(短有效期 + 自动续签,否则到期即全站中断)。所以实践中它通常由服务网格或 SPIFFE 类身份框架统一承担(见 30.5 Service Mesh 与 Sidecar、38.8 零信任)。
会话复用与 0-RTT:省一次往返,换一个风险
| 机制 | 省下的 | 风险 |
|---|---|---|
| Session ID / Ticket(TLS 1.2) | 免去完整握手 | 票据密钥泄露 = 可解密历史流量 |
| PSK(TLS 1.3) | 1-RTT 变 0-RTT | — |
| 0-RTT 数据 | 首包即可带应用数据 | 可重放:只能用于幂等请求(GET),不能用于下单/转账 |
0-RTT 的正确用法是 “CDN 上的静态 GET 用它,写请求回退到 1-RTT”。
反模式
| 反模式 | 后果 |
|---|---|
| 只配叶证书,忘记中间证书 | 部分客户端(Android/Java)全量失败 |
| 证书到期靠人记 | 到期当天全站不可用;应做自动续期 + 到期告警(提前 30/7/1 天) |
| 用 IP 做健康检查且走 HTTPS | SNI 不匹配 → 探针永远失败 |
| 对所有请求开 0-RTT | 写请求被重放,产生重复订单 |
| 内网明牌 “不需要 TLS” | 内网横向移动可直接嗅探;零信任要求内网也加密 |
| mTLS 证书有效期 1 年且手工换 | 轮换窗口巨大,一次遗漏就是全站断连 |
| 关掉证书校验 “先跑通” | 中间人攻击面完全敞开,且很难再改回来 |
三条铁律
🔑 链要完整:叶 + 中间 CA 一起下发;只发叶证书 = 部分用户不可用。
🔑 证书要自动化:签发、续期、告警都交给流水线,人只处理异常。
🔑 0-RTT 只给幂等请求;写操作必须等完整握手。
自检
Q1 浏览器访问正常,安卓 App 报 “证书链不完整”。为什么浏览器没事?
▸ 答案:因为桌面浏览器多数会自动补全缺失的中间证书(AIA fetching),而 Android/Java/Go 的默认校验只认服务端下发的链。修法是把中间证书拼进服务端证书文件(顺序:叶 → 中间),并用 openssl s_client -showcerts 验证下发了两张。
Q2 curl https://1.2.3.4 报证书错误,说明服务坏了?
▸ 答案:不能说明。用 IP 访问时没有 SNI,服务端只能返回默认证书,而默认证书的 SAN 里不会有这个 IP(除非专门签了 IP 证书)。正确验证方式是 curl --resolve example.com:443:1.2.3.4 https://example.com,保留 SNI 与 Host。
Q3 为什么 TLS 1.3 的 0-RTT 不能用于下单?
▸ 答案:因为 0-RTT 数据可以被重放:攻击者录下第一个 0-RTT 包再发一次,服务端会当成新请求处理(此时还没完成防重放协商)。下单/支付/改状态这类非幂等操作会被执行两次。工程规则是 “GET 类可 0-RTT,写类必须 1-RTT”,并在服务端做幂等兜底(见 25.4 幂等设计与去重)。
Q4 内网服务之间要不要 mTLS?代价是什么?
▸ 答案:要,但要看代价能否被平台吸收。收益是把 “应用层 token 泄露即被冒充” 变成 “没有私钥就无法建连”,并为零信任提供传输层身份。代价是三件事:每个工作负载一套证书、短有效期带来的轮换压力、以及握手带来的 CPU 与延迟(会话复用可摊薄)。团队没有证书自动化能力时,先上网格(由 sidecar 代管)比自己写轮换脚本更稳。
Q5 握手慢,你会先看哪三个指标?
▸ 答案:① RTT 次数(1.2 的 2-RTT vs 1.3 的 1-RTT,未开复用则每次都完整握手);② 证书链大小(超过约 4KB 会跨多个 TCP 段,慢速链路上显著变慢);③ OCSP/CRL 是否阻塞(未开 stapling 时客户端要额外请求 CA,可能几百毫秒甚至超时)。三者任一都可能让 “加密” 背锅。
关联
- 前置:25.4 幂等设计与去重(0-RTT 的重放风险)
- 并列:30.5 Service Mesh 与 Sidecar(mTLS 的工程承载)
- 下游:38.8 零信任
- 下游:38.4 加密 (对称 / 非对称 / TLS / 密钥管理)