核心问题:HTTPS 握手失败的所有可能原因是什么?
一、问题从这里开始
监控告警在凌晨推过来:
curl: (60) SSL certificate problem: unable to get local issuer certificate
或者是用户反馈页面打开提示"不安全",或者是 CI 流水线的集成测试突然报 certificate has expired。HTTPS 的故障往往不在代码里------它发生在握手的某个毫秒级时间窗口里,错误消息含糊,诊断工具文档散碎,排查链路长而隐蔽。
本文的组织逻辑是:先理解握手流程,再看每个环节会怎么坏,最后给出每种坏法的诊断命令。
二、TLS 握手:不只是"加密建立"
2.1 TLS 1.2 与 TLS 1.3 的握手差异
两种握手在 RTT 数量上有根本区别:
服务端 客户端 服务端 客户端 ── TLS 1.2 Full Handshake(2-RTT)── 应用数据传输开始(共 2 RTT) ── TLS 1.3 Full Handshake(1-RTT)── 应用数据传输开始(共 1 RTT) ── TLS 1.3 Session Resumption(0-RTT)── 0 RTT 即可发送应用数据 ClientHello(版本/随机数/密码套件列表) ServerHello + Certificate + ServerKeyExchange + ServerHelloDone ClientKeyExchange + ChangeCipherSpec + Finished ChangeCipherSpec + Finished ClientHello(密钥份额 key_share + 支持的组) ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished Finished ClientHello + early_data(携带 PSK + 应用层数据) ServerHello + Finished(若接受 0-RTT)
TLS 1.3 的三个关键改进:
- 握手从 2-RTT 缩减到 1-RTT:服务端在第一次响应里直接完成密钥协商(ECDHE),不需要再来一轮
- 证书加密传输:TLS 1.3 中服务端证书在握手早期即被加密,中间人无法窥探服务端使用哪张证书
- 废弃不安全算法:RSA 密钥交换、RC4、SHA-1、DES 全部移除,不允许协商
2.2 密钥协商的本质
TLS 1.3 强制使用 ECDHE(Elliptic-Curve Diffie-Hellman Ephemeral),每次握手生成临时密钥对。这意味着:
- 前向保密(Forward Secrecy):私钥泄露不能解密过去的流量,因为每次会话密钥独立
- 代价:没有前向保密的 RSA 静态密钥交换在 TLS 1.3 中彻底消失,某些老旧系统如果只支持 RSA 密钥交换会协商失败
TLS 1.3 ECDHE(有前向保密)
发送临时公钥 g^a
发送临时公钥 g^b
客户端
服务端
双方各自计算 g^ab
临时密钥用后即弃
✅ 私钥泄露 → 历史流量仍安全
TLS 1.2 RSA 密钥交换(无前向保密)
用服务端公钥加密
Pre-Master Secret
用私钥解密
得到 Pre-Master Secret
客户端
服务端
⚠️ 私钥泄露 → 可解密所有历史流量
三、证书链:信任的传递路径
3.1 三层结构
一张服务端证书不能独立被信任,它的可信度来自于一条完整的证书链:
签发
签发
检查签名链
检查签名链
与本地信任库对比
🏛️ 根证书(Root CA)
例:ISRG Root X1
自签名,内置在操作系统/浏览器
🔗 中间证书(Intermediate CA)
例:R11(Let's Encrypt 中间证书)
由根证书签发
📄 服务端证书(Leaf Certificate)
由中间证书签发
🖥️ 客户端验证逻辑
验证路径上的每个检查项:
| 检查项 | 失败表现 | 典型场景 |
|---|---|---|
| 证书有效期 | certificate has expired |
证书续期失败或未自动续期 |
| 证书链完整性 | unable to get local issuer certificate |
服务端未配置中间证书 |
| 域名匹配(SAN/CN) | hostname mismatch |
SNI 与证书 Subject Alternative Name 不匹配 |
| 签名算法可信 | unsupported certificate algorithm |
SHA-1 签名证书被浏览器拒绝 |
| 证书未被吊销 | certificate revoked |
OCSP 查询返回 revoked |
| 根证书信任 | self-signed certificate in chain |
使用自签名证书未导入 CA |
3.2 最常见的链断裂场景
问题:服务端只发了叶证书,没有发中间证书。
客户端(浏览器)有时能自动下载中间证书(AIA Extension),但 curl、openssl s_client、Java 客户端、大多数 CLI 工具不做 AIA 追踪 ,会直接报 unable to get local issuer certificate。
这是生产环境 HTTPS 故障中占比最高的一类------开发人员在浏览器里测没问题,但 API 服务端对服务端通信时就报错。
bash
# 诊断:查看服务端实际发送了几张证书
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| grep -A2 "Certificate chain"
# 正常输出(3层链):
# 0 s:CN=example.com
# i:CN=R11, O=Let's Encrypt
# 1 s:CN=R11, O=Let's Encrypt
# i:CN=ISRG Root X1, O=Internet Security Research Group
# 2 s:CN=ISRG Root X1 ...
#
# 断链输出(只有1层):
# 0 s:CN=example.com
# i:CN=R11, O=Let's Encrypt
# (这里没有 1,客户端找不到 R11 的签发者)
修复:在 Nginx 中将中间证书追加到服务端证书文件末尾:
bash
cat server.crt intermediate.crt > fullchain.crt
# nginx.conf 中使用 fullchain.crt
ssl_certificate /etc/nginx/ssl/fullchain.crt;
四、TLS 握手失败的完整诊断路径
4.1 工具选型
certificate has expired / hostname mismatch
SSL handshake failure / protocol version error
connection refused / timeout
curl: 60 unable to get local issuer
否
是
HTTPS 故障
报错类型?
openssl s_client 检查证书详情
openssl s_client -tls1_2 / -tls1_3 协议版本测试
先确认 TCP 443 端口可达
nc -zv host 443
检查证书链完整性
openssl verify -CAfile
读取证书有效期/SAN/Issuer
判断服务端支持的协议版本和密码套件
链完整?
拼接中间证书 fullchain.crt
检查客户端信任库是否包含该 Root CA
4.2 核心诊断命令速查
检查证书有效期和 SAN
bash
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject -ext subjectAltName
# 输出示例:
# notBefore=Jan 1 00:00:00 2025 GMT
# notAfter=Apr 1 00:00:00 2026 GMT
# subject=CN=example.com
# X509v3 Subject Alternative Name:
# DNS:example.com, DNS:www.example.com
检查服务端发送的完整证书链
bash
openssl s_client -connect example.com:443 -servername example.com \
-showcerts 2>/dev/null | grep -E "^(subject|issuer|-----)"
验证证书链的签名完整性(离线)
bash
# 将服务端证书和中间证书分别保存后
openssl verify -CAfile root.crt -untrusted intermediate.crt server.crt
# 返回 OK 才代表链完整
测试特定 TLS 版本和密码套件
bash
# 测试服务端是否支持 TLS 1.3
openssl s_client -connect example.com:443 -tls1_3 -servername example.com
# 测试服务端是否仍允许 TLS 1.0(不应该)
openssl s_client -connect example.com:443 -tls1 -servername example.com
# 如果出现 "no protocols available",说明服务端已正确禁用 TLS 1.0
# 列出服务端接受的密码套件(需要 nmap)
nmap --script ssl-enum-ciphers -p 443 example.com
检查 SNI 匹配
bash
# 不加 -servername 时,服务端收到的 SNI 为空,可能返回默认证书(不匹配目标域名)
openssl s_client -connect example.com:443 # 无 SNI
openssl s_client -connect example.com:443 -servername example.com # 正确姿势
# 同一 IP 托管多个域名时(虚拟主机),-servername 至关重要
curl 完整握手时间分解
bash
curl -w "
dns_lookup: %{time_namelookup}s
tcp_connect: %{time_connect}s
tls_handshake: %{time_appconnect}s
first_byte: %{time_starttransfer}s
total: %{time_total}s
" -o /dev/null -s https://example.com
time_appconnect - time_connect 就是 TLS 握手耗时。生产环境中,健康的 TLS 1.3 握手在同区域应 < 20ms,跨地域 < 150ms。
五、OCSP 与证书吊销:一个常被忽略的隐患
5.1 OCSP 基础:浏览器如何知道证书是否被吊销
证书吊销有两种机制:
- CRL(Certificate Revocation List):定期下载全量吊销列表,文件可达 MB 级,延迟高
- OCSP(Online Certificate Status Protocol):实时请求 CA 的 OCSP 服务器,查询单张证书状态
OCSP 的问题在于:每次握手都需要向 CA 发一次 HTTP 请求,这带来:
- 额外的网络延迟(OCSP 服务器可能与用户相距千里)
- 隐私泄露(CA 可以知道哪些用户在访问哪些服务)
5.2 OCSP Stapling:服务端代客户端查
CA OCSP 服务器 Nginx 服务端 浏览器 CA OCSP 服务器 Nginx 服务端 浏览器 提前(定期) 握手时 OCSP 查询请求(证书序列号) 带 CA 签名的 OCSP 响应(有效期 ~24h) 缓存 OCSP 响应 ClientHello Certificate + OCSP Stapling 响应(CA 签名) 验证 OCSP 响应签名 → 无需再访问 CA
Nginx 启用 OCSP Stapling
nginx
server {
ssl_certificate /etc/nginx/ssl/fullchain.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_stapling on;
ssl_stapling_verify on;
# 提供根证书链以便 Nginx 验证 OCSP 响应的签名
ssl_trusted_certificate /etc/nginx/ssl/chain.crt;
# OCSP 请求的 DNS 解析器
resolver 8.8.8.8 1.1.1.1 valid=300s;
resolver_timeout 5s;
}
验证 Stapling 是否生效
bash
openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null \
| grep -A5 "OCSP response"
# 生效时输出:
# OCSP Response Status: successful (0x0)
# This Update: ...
# Next Update: ...
#
# 未生效时输出:
# OCSP response: no response sent
5.3 must-staple 陷阱
证书可以在扩展字段中声明 must-staple(TLS Feature Extension),意思是"客户端必须检查 OCSP Stapling,否则拒绝连接"。
陷阱在这里 :如果服务端配置了带 must-staple 的证书,但 Nginx 的 OCSP Stapling 配置不正确(比如 resolver 解析 CA 的 OCSP 服务器失败),客户端会拒绝握手。
查看证书是否带 must-staple:
bash
openssl x509 -in server.crt -text -noout | grep -A2 "TLS Feature"
# 如果输出 "status_request",说明带了 must-staple
六、0-RTT 的安全边界
TLS 1.3 的 0-RTT 通过 PSK(Pre-Shared Key,来自上次会话的 session ticket)让客户端在握手的第一条消息里附带应用层数据。代价是:
6.1 重放攻击(Replay Attack)的具体路径
服务端 攻击者 服务端 攻击者 截获了客户端的 0-RTT ClientHello 可重复发送,每次触发一次转账 重放 ClientHello + Early Data(POST /transfer?amount=1000) 200 OK(若没有防重放机制)
根本原因 :PSK 是共享密钥,0-RTT 数据无法用时间戳或 nonce 保证单次使用。TLS 1.3 规范明确说明 0-RTT 不提供前向保密性,且对重放攻击仅有"best-effort"防护(服务端通过 session ticket 单次使用机制限制,但分布式场景难以保证)。
6.2 安全使用 0-RTT 的规则
| 请求类型 | 允许用 0-RTT? | 原因 |
|---|---|---|
GET /api/data |
✅ 允许 | 幂等,重放无副作用 |
HEAD /healthz |
✅ 允许 | 无副作用 |
POST /login |
❌ 禁止 | 可能产生重放登录 |
POST /transfer |
❌ 禁止 | 金融操作重放导致重复转账 |
PUT /config |
❌ 禁止 | 状态变更操作不幂等 |
Nginx 配置 0-RTT 防护
nginx
server {
ssl_protocols TLSv1.3;
ssl_early_data on; # 启用 0-RTT
# 服务端在收到 0-RTT 数据时设置 Early-Data 请求头
# 后端应用必须检查此头并拒绝非幂等操作
proxy_set_header Early-Data $ssl_early_data;
}
后端应用收到 Early-Data: 1 头时,必须拒绝所有非幂等操作并返回 425 Too Early(RFC 8470 专为此设计的状态码)。
七、Nginx 生产 TLS 配置最佳实践
nginx
server {
listen 443 ssl http2;
server_name example.com;
# ── 证书配置 ──
ssl_certificate /etc/nginx/ssl/fullchain.crt; # 含中间证书
ssl_certificate_key /etc/nginx/ssl/server.key;
# ── 协议版本:只允许 TLS 1.2 和 TLS 1.3 ──
ssl_protocols TLSv1.2 TLSv1.3;
# ── 密码套件 ──
# TLS 1.3 密码套件由 OpenSSL 内部控制,下面仅影响 TLS 1.2
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # TLS 1.3 下应设 off,让客户端优先选择
# ── 性能:Session Cache(TLS 1.2 复用) ──
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # 禁用 session ticket 以保持前向保密
# ── OCSP Stapling ──
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.crt;
resolver 1.1.1.1 8.8.8.8 valid=300s;
# ── HSTS(HTTP Strict Transport Security) ──
# includeSubDomains 覆盖子域;preload 需先提交至 HSTS Preload List
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
# HTTP → HTTPS 跳转
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
参数解释
| 参数 | 作用 | 为什么这样设 |
|---|---|---|
ssl_prefer_server_ciphers off |
TLS 1.3 场景 | TLS 1.3 只剩安全套件,客户端选择性能更好 |
ssl_session_tickets off |
禁用 Session Ticket | Session Ticket 密钥长期有效会破坏前向保密 |
ssl_stapling_verify on |
验证 OCSP 响应签名 | 防止伪造的 OCSP 响应 |
八、Let's Encrypt 证书自动续期
Let's Encrypt 证书有效期 90 天,手动管理不现实。Certbot 的自动续期依赖 Systemd Timer 或 cron。
bash
# 安装 certbot(以 Debian/Ubuntu 为例)
apt install certbot python3-certbot-nginx
# 首次申请(Nginx 插件自动修改配置并验证域名所有权)
certbot --nginx -d example.com -d www.example.com
# 测试续期流程(不实际续期)
certbot renew --dry-run
# 查看当前证书过期时间
certbot certificates
配置续期后重载 Nginx
Certbot 在 /etc/letsencrypt/renewal-hooks/deploy/ 目录下的脚本会在成功续期后执行:
bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
nginx -t && systemctl reload nginx
监控证书过期(CI/CD 集成)
bash
# 检查证书剩余天数(< 30 天触发告警)
EXPIRY=$(openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate \
| cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [ $DAYS_LEFT -lt 30 ]; then
echo "WARNING: Certificate expires in $DAYS_LEFT days"
fi
九、HSTS 的不可逆性警告
HSTS 一旦在浏览器中被记录,在 max-age 到期前,该域名的所有 HTTP 请求都会在浏览器本地被强制改写成 HTTPS,服务端收不到原始 HTTP 请求。
这带来一个陷阱:如果某天需要下线 HTTPS,把域名降回 HTTP,已缓存 HSTS 的用户在 max-age 到期前无法访问。
部署 HSTS 的正确顺序:
先部署 HTTPS
确认证书链完整
max-age 设小值
例 86400(1天)
观察 1 周,确认无问题
max-age 调大
例 31536000(1年)
确认子域均已上 HTTPS
再加 includeSubDomains
提交至 HSTS Preload List
(可选,不可撤销)
preload 是双向不可逆操作:提交后浏览器厂商会将域名写入代码,即使不带 HSTS 响应头也会强制 HTTPS,移除周期长达数月。没有把握前不要加 preload。
十、故障速查卡片
certificate has expired
unable to get local issuer certificate
hostname mismatch
SSL handshake failure
connection refused on 443
certificate revoked
self-signed certificate
HTTPS 故障
curl 报什么错?
openssl s_client 查 notAfter
检查 certbot renew 是否执行
检查证书链层数
拼接 fullchain.crt
检查 SAN 字段
确认 -servername 参数是否正确
测试 TLS 版本兼容性
-tls1_2 / -tls1_3 分别测试
先排除 TCP 层问题
nc -zv host 443
OCSP 查询证书吊销状态
openssl ocsp 命令
确认 Root CA 是否在系统信任库
或加 -k 跳过验证(仅测试)
修复后:openssl s_client 验证 → curl 验证
十一、小结
TLS 握手失败的根因可以落在以下几层,每层有对应的诊断命令:
| 层级 | 典型故障 | 诊断命令 |
|---|---|---|
| TCP 层 | 443 不可达 | nc -zv host 443 |
| 证书层 | 过期/链断/域名不匹配 | openssl s_client -showcerts |
| 协议层 | 版本不兼容/套件不匹配 | openssl s_client -tls1_2/-tls1_3 |
| OCSP 层 | 吊销/Stapling 失效 | openssl s_client -status |
| 应用层 | SNI 未传/HSTS 锁死 | curl -v + -servername |
核心判断原则:浏览器和 CLI 工具(curl、openssl)对证书链的处理方式不同------浏览器会尝试 AIA 追踪下载中间证书,curl 不会。服务端配置应始终发送完整链,不依赖客户端补全。