线上偶发 “证书不受信任”“握手失败”“只能 HTTP 能开、HTTPS 间歇 525”,根因往往不在业务代码,而在 TLS 握手协商 与 X.509 证书路径校验。浏览器地址栏的小锁背后,是一次版本/密码套件协商、密钥交换、身份证明和密钥确认;服务端 PEM 链顺序错一环,客户端就会在完全不同的报错路径上失败。
本文基于 RFC 8446(TLS 1.3)、RFC 5280(PKIX 证书与路径校验),并对照 MDN 的 TLS/HTTPS 说明、OpenSSL s_client 与 Nginx ngx_http_ssl_module 官方文档,把机制、可复现排查命令与工程配置落到同一条链路上。
问题背景:TLS 到底在保护什么
MDN 对 Transport Layer Security(TLS)的定位很直接:让客户端在不可信网络上与服务器 安全通信。在 Web 上,用 TLS 保护 HTTP 的结果就是 HTTPS。TLS 从三方面加固连接:
| 目标 | 含义 |
|---|---|
| 机密性(Encryption) | 传输中数据被加密,窃听者难以直接读内容 |
| 完整性(Integrity) | 中间人无法在不被发现的情况下篡改数据 |
| 身份认证(Authentication) | 一端能向另一端证明“我是谁” |
在公开 Web 上,服务端向客户端认证 是常态;客户端向服务端出示证书 相对少见(多见于 mTLS / 专用 API)。HTTPS 也是对抗 中间人(MITM) 的关键防线:攻击者插在浏览器与站点之间时,若无法通过证书路径与握手完整性校验,就难以“静默”读写业务流量。
因此工程排错要分清两层:
- 握手层:版本、密码套件、密钥交换、Finished 校验是否完成。
- 信任层:证书链能否锚定到本地信任锚、名字是否匹配、是否在有效期内、是否被吊销策略拒绝。
核心原理:TLS 1.3 握手的三个阶段
RFC 8446 说明:安全通道的密码参数由 握手协议 产生。握手用于协商协议版本、选择密码算法、可选地互相认证,并建立共享密钥材料;完成后双方用该密钥保护应用层流量。握手失败或其它协议错误会终止连接(可选先发 alert)。
TLS 1.3 支持三种基本密钥交换模式:
- (EC)DHE(有限域或椭圆曲线上的 Diffie-Hellman)
- 仅 PSK
- PSK 与 (EC)DHE 组合
完整握手消息流(1-RTT)
规范 Figure 1 给出了基本完整握手(* 表示可选/场景相关;{} 表示用握手流量密钥保护;[] 表示用应用流量密钥保护):
Client Server
Key ^ ClientHello
Exch | + key_share*
| + signature_algorithms*
| + psk_key_exchange_modes*
v + pre_shared_key* -------->
ServerHello ^ Key
+ key_share* | Exch
+ pre_shared_key* v
{EncryptedExtensions} ^ Server
{CertificateRequest*} v Params
{Certificate*} ^
{CertificateVerify*} | Auth
{Finished} v
<-------- [Application Data*]
^ {Certificate*}
Auth | {CertificateVerify*}
v {Finished} -------->
[Application Data] <-------> [Application Data]
可按三个阶段理解:
Key Exchange(密钥交换)
客户端发送ClientHello(含随机数、可接受版本、对称密码/HKDF 哈希对列表,以及key_share/pre_shared_key等扩展)。服务端回ServerHello选定连接参数;若用 (EC)DHE,服务端key_share必须 落在客户端给出的某一组上。ClientHello 与 ServerHello 共同决定共享密钥。此阶段之后的消息均加密。Server Parameters(服务端参数)
EncryptedExtensions:对那些不决定密码参数、也不绑定单张证书的 ClientHello 扩展作应答。CertificateRequest:若需要基于证书的客户端认证则出现;否则省略。
Authentication(认证)
证书类认证使用同一套消息:Certificate、CertificateVerify、Finished(PSK 认证则作为密钥交换的副作用发生)。- Certificate:端点证书及链上支撑证书。约定使用证书认证时服务端 必须 发送;客户端仅当收到
CertificateRequest时发送。 - CertificateVerify:证明持有与证书对应的私钥,并对握手到该点的完整性提供签名保护;服务器基于证书认证时 必须 发送,且紧挨在 Certificate 之后、Finished 之前。
- Finished:认证块的最后一条消息,用派生自 Base Key 的 MAC 对握手 transcript 做确认;收方必须校验,错误则
decrypt_error终止。
- Certificate:端点证书及链上支撑证书。约定使用证书认证时服务端 必须 发送;客户端仅当收到
双方都发送并成功校验对端 Finished 后,才可在连接上收发应用数据(0-RTT 与服务端首飞后抢发等例外见规范;后者在完成握手前 无法 确认对端身份与活性)。
相对 TLS 1.2 的关键变化(工程相关)
RFC 8446 §1.2 列出若干重大差异,与排障直接相关的包括:
| 变化 | 工程含义 |
|---|---|
| 对称算法只保留 AEAD | 旧 CBC/非 AEAD 套件不再进入 1.3 协商 |
| 去掉静态 RSA / 静态 DH 套件 | 基于公钥的密钥交换提供 前向保密(forward secrecy) |
| ServerHello 之后握手消息加密 | 证书等不再明文暴露在握手中后期 |
| 密钥派生改用 HKDF | 密钥分离与分析模型更清晰 |
| 版本协商改为扩展中的版本列表 | 兼容历史上错误实现版本协商的服务端 |
| 会话恢复 / 旧 PSK 套件统一为新 PSK 交换 | 恢复与 0-RTT 都围绕 PSK 建模 |
| 可选 0-RTT early data | 省一轮 RTT,但牺牲部分安全属性 |
0-RTT:快,但默认不适合“写操作”
当客户端与服务端已共享 PSK(外部配置或先前握手得到)时,客户端可在 首飞 发送 early data。规范强调:0-RTT 在连接建立时节省往返,代价是某些安全属性。Nginx 文档进一步写明:early data 内的请求 可能遭受重放(replay);应用层可用 $ssl_early_data 识别,例如:
proxy_set_header Early-Data $ssl_early_data;
工程默认建议:
- 保持
ssl_early_data off;(Nginx 默认即为off),除非上层明确幂等且能处理重放。 - 切勿把“首次提交订单/支付/改密”暴露给 0-RTT 路径。
证书链校验:从信任锚走到叶子
握手里的 Certificate 消息只是把 证书序列 交给对端;真正“信不信”取决于客户端的 认证路径校验。
RFC 5280 §6 指出:Internet PKI 的路径处理验证 主体可分辨名和/或主体备用名(SAN) 与主体公钥之间的绑定;该绑定还受路径上证书约束与依赖方输入限制。基本路径校验中:
- 有效路径从信任锚(trust anchor)签发的证书开始。
- 信任锚选择是策略问题(层级 PKI 顶端 CA、组织自建根、系统信任库中的根等),但路径校验过程本身与“选了哪颗根”无关。
- 校验相对于 当前日期时间(实现也可支持对过去某时刻的验证,但不能对有效期之外的时间做校验)。
- 客户端 必须拒绝 含有不支持 critical 扩展的证书。
叶子身份:SAN 才是名字的主战场
subjectAltName(SAN)把身份绑定到证书主体:可含邮箱、DNS 名、IP、URI 等。规范要求:只要要把这类身份绑进证书,就 必须 使用 SAN(或 issuer 对应扩展);DNS 名也可额外用 subject 的 domainComponent 表示,但现代浏览器与库几乎都以 SAN DNS 做主机名匹配。
线上“证书有效但浏览器仍报警”的常见原因:
- 证书只写在 CN、未正确填 SAN。
- SAN 有
example.com却没有www.example.com(或反过来)。 - 多站点共用 IP,TLS 客户端未发 SNI,服务端回了默认虚拟主机证书。
CA 与路径长度:basicConstraints
basicConstraints 标明主体是否为 CA,以及可包含该证书的路径最大深度。若 v3 证书无此扩展,或存在但 cA 未断言,则该公钥 不得 用于验证证书签名——这也是“把服务器叶子证书误当成中间 CA 拼进链”会立刻失败的原因之一。
服务端交付的链 vs 客户端验证出的链
OpenSSL s_client -showcerts 显示的是 服务端发送的证书列表(发送顺序),文档明确:它不是已验证链。测试工具默认在证书校验错误后仍可能继续握手;生产客户端不应如此。需要“校验失败就中止”时使用 -verify_return_error。
实践:用 OpenSSL 复现握手与链问题
1)看协议版本、套件与服务端发送的证书
# 强制 TLS 1.3,展示服务端 certificate_list,SNI 指定主机名
openssl s_client -connect example.com:443 \
-servername example.com \
-tls1_3 \
-showcerts </dev/null
关注输出中的:
Protocol/Cipher(是否真是 TLSv1.3 与 AEAD 套件)Certificate chain段:叶子是否在前、中间证书是否齐全Verify return code(在默认“测试继续”模式下仍会打印校验结果)
OpenSSL 说明:若未提供 -servername,会尽量用 -connect 里的 DNS 名填充 SNI;从 1.1.1 起默认行为如此。排查“默认证书错了”时,刻意省略 SNI 与 显式指定 SNI 对比往往立刻见分晓:
# 对比:不发 SNI(排查默认 vhost 证书)
openssl s_client -connect 203.0.113.10:443 -noservername -showcerts </dev/null
2)让校验错误真正失败(贴近生产客户端)
openssl s_client -connect example.com:443 \
-servername example.com \
-verify_return_error \
-CAfile /etc/ssl/certs/ca-certificates.crt </dev/null
-verify_return_error 会在服务端证书校验失败时返回错误并通常中止握手,而不是“打印 warning 仍连上”。
3)从 PEM 文件本地拆解链
# 分拆 fullchain.pem 中的每张证书主题/颁发者/SAN/有效期
openssl crl2pkcs7 -nocrl -certfile /etc/nginx/ssl/fullchain.pem \
| openssl pkcs7 -print_certs -noout -text \
| egrep 'Subject:|Issuer:|DNS:|Not Before|Not After|CA:'
核对顺序:叶子 → 中间 →(一般 不要 把公共根塞进服务端发送链,根应由客户端信任库提供)。
4)Nginx 侧:链顺序与协议底线
官方 ssl_certificate:同一 PEM 内先主证书(叶子),再中间证书;密钥用 ssl_certificate_key。ssl_protocols 默认已是 TLSv1.2 TLSv1.3——新站点通常不必再打开 TLS 1.0/1.1。
server {
listen 443 ssl http2;
server_name api.example.com;
# 叶子在前,中间在后(官方顺序)
ssl_certificate /etc/nginx/ssl/api.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/api.key;
# 与当前默认一致;显式写出便于审计
ssl_protocols TLSv1.2 TLSv1.3;
# TLS 1.3 套件由库侧管理;此指令主要影响 ≤1.2
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off;
# 多 worker 共享会话参数,降低重复握手成本
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# OCSP stapling:需让 Nginx 知道签发者证书
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/api.chain-only.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# 默认关闭 0-RTT;开启前先确认业务幂等
ssl_early_data off;
# 仅在 mTLS 场景打开
# ssl_verify_client on;
# ssl_client_certificate /etc/nginx/ssl/client-ca.pem;
# ssl_verify_depth 2;
}
说明(均来自 Nginx 官方指令文档):
ssl_session_cache默认none;生产建议shared:...并配合合适超时。ssl_stapling默认off;开启时签发者证书须可知(可在 fullchain 或ssl_trusted_certificate中提供)。ssl_verify_client默认off;ssl_verify_depth默认1。ssl_early_data默认off;开启后请求可能被重放。
常见坑与排查清单
| 现象 | 更可能的原因 | 建议动作 |
|---|---|---|
| 浏览器报 NET::ERR_CERT_COMMON_NAME_INVALID | SAN 不匹配访问主机名 | openssl x509 -in leaf.pem -noout -ext subjectAltName |
| 手机信任、某台服务器不信任 | 服务端少发中间证书 / 系统信任库过旧 | -showcerts 数链长;补全 intermediate |
| 同 IP 多站,偶发证书串站 | SNI 未传到正确 vhost | 对比 -servername 有无 |
| 仅部分客户端握手失败 | 仍协商到 TLS 1.0/1.1 或过旧套件 | 收紧 ssl_protocols;抓包看 ClientHello |
s_client 显示 Verify error 但仍连上 | 工具默认不因校验失败中止 | 加 -verify_return_error |
| 开启 early data 后偶发重复写 | 0-RTT 可重放 | 关闭 early data,或业务读 $ssl_early_data 拒绝非幂等 |
| stapling 无效 / 日志 OCSP error | 缺签发者证书或 resolver | 配 ssl_trusted_certificate + resolver |
| mTLS 客户端被拒 | 深度/信任锚/optional 语义不符 | 查 ssl_verify_client / ssl_verify_depth / 客户端链 |
补充运维习惯:
- 证书轮换演练:新 fullchain 应用后,立刻用外网 VPS 跑一遍
s_client -verify_return_error,不要只在本机curl -k。 - HTTP→HTTPS:MDN 建议对明文入口
301到 HTTPS,并配合 HSTS(Strict-Transport-Security)降低 SSL stripping 窗口;同时杜绝 mixed content(HTTPS 页加载 HTTP 子资源)。 - 密钥权限与进程身份:私钥仅 Nginx worker 可读;配置重载失败时注意旧进程仍握旧 fd。
总结
- TLS 1.3 握手可粗分为 密钥交换 → 服务端参数 → 认证;ServerHello 之后的握手消息受握手流量密钥保护,
CertificateVerify证明私钥持有,Finished做握手完整性确认。 - 1.3 强制 AEAD、去掉静态 RSA 密钥交换、引入统一 PSK/0-RTT 模型——配置与排障要以 前向保密 + 加密握手 + 谨慎 early data 为默认心智。
- 证书信任是 从信任锚出发的路径校验(RFC 5280),不是“PEM 里有几段文字”;SAN、basicConstraints、中间证书顺序与 SNI 是线上最高频的失败点。
- 用
openssl s_client区分“服务端发送了什么”和“客户端验证是否通过”,用 Nginx 官方顺序拼 fullchain,并按业务风险决定是否打开会话缓存、stapling 与 0-RTT。
把“能连上”升级为“能证明连对了人、用对了算法、链完整且可复现验证”,HTTPS 才真正从开关变成可运维的安全能力。
参考资料
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3(
.txt:https://www.rfc-editor.org/rfc/rfc8446.txt) - RFC 5280: Internet X.509 Public Key Infrastructure Certificate and CRL Profile(
.txt:https://www.rfc-editor.org/rfc/rfc5280.txt) - MDN: Transport Layer Security (TLS)
- MDN: HTTPS
- OpenSSL: openssl-s_client
- Nginx: Module ngx_http_ssl_module