症状:证书链不完整到底长什么样?
上周五下午,我们一个客户的Android应用突然大面积报错——javax.net.ssl.SSLHandshakeException: Handshake failed。iOS和桌面浏览器都正常,唯独Android翻车。这种“Android独享”的SSL问题,老运维一看就懂了:证书链不完整。
用 Qualys SSL Labs 扫一下你的站点,如果看到这条提示:
Chain issues: Incomplete This server’s certificate chain is incomplete. Grade capped to B.
恭喜,你踩坑了。
具体症状包括:
- 移动端(尤其是Android < 7.1 或旧版WebView)反复弹SSL警告
- curl 带
--cacert参数正常,但裸curl https://yoursite.com报错 - 浏览器地址栏锁头图标带黄色三角警告
- 运维监控里突然冒出
SSL_ERROR_BAD_CERT_DOMAIN或certificate unknown错误
根因分析:到底谁丢了?
说穿了就一句话:服务器在TLS握手时,没把中间证书(Intermediate CA)发给客户端。
SSL证书链的结构是三层:
根证书 (Root CA) —— 浏览器/系统内置信任
└── 中间证书 (Intermediate CA) —— 需要服务器主动下发
└── 服务器证书 (Leaf Certificate) —— 你买的那张
问题就出在中间证书这一层。服务器只甩了个叶子证书给客户端,客户端手里又没有对应的中间证书,链子接不上,自然就报"Incomplete"。
几个常见翻车场景:
- 证书拼接顺序错了 —— 有人把服务器证书放在中间证书前面,或者把根证书也塞进去了
- 只配了服务器证书,压根没装中间证书 —— 最常见,尤其是一些半自动部署脚本
- 证书过期了但没更新中间链 —— 中间证书本身也有有效期
- CDN或反向代理没透传完整链 —— NGINX、Apache、Cloudflare、AWS ALB都有各自的坑
修复步骤:从诊断到解决
第一步:诊断——确认问题
先看看你的服务器到底发了个啥:
# 方法1:openssl s_client(最推荐)
openssl s_client -connect yoursite.com:443 -showcerts </dev/null 2>/dev/null | grep "subject="
# 输出应该看到至少两个证书:
# subject=CN = yoursite.com ← 服务器证书
# subject=CN = R3, O = Let's Encrypt ← 中间证书
# 如果只有一行,说明没发中间证书
# 方法2:用curl验证(模拟Android行为)
curl -v --tls-max 1.2 https://yoursite.com/ 2>&1 | grep "SSL certificate verify"
# 方法3:Qualys在线扫描(最直观)
# https://www.ssllabs.com/ssltest/analyze.html?d=yoursite.com
我个人的习惯是直接用 openssl s_client 看返回值。如果 verify error:num=20:unable to get local issuer certificate 冒出来,基本实锤。
第二步:获取正确的中间证书
去CA官网下载中间证书,或者直接从证书提供商那里拿。以Let’s Encrypt为例:
# 从Let's Encrypt获取中间证书
wget https://letsencrypt.org/certs/lets-encrypt-r3.pem
关键点:要拿到的是中间证书(Intermediate CA),不是根证书。根证书已经在操作系统和浏览器里了,你不需要(也不应该)把它塞进服务器配置里。
第三步:拼接完整证书链
这是最多人翻车的地方。顺序必须严格正确:
[服务器证书]
[中间证书]
注意:
- 不要把根证书加进去
- 不要把中间证书放在服务器证书前面
- 不要留多余的空行或乱码
用cat合并:
# 正确姿势:服务器证书在前,中间证书在后
cat yoursite.com.pem lets-encrypt-r3.pem > fullchain.pem
# 验证合并后的文件
openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -text | grep "Subject:"
# 应该看到两个Subject:你的域名 + 中间CA
第四步:配置Web服务器
NGINX配置:
server {
listen 443 ssl;
server_name yoursite.com;
# 关键:使用合并后的完整链文件
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/yoursite.com.key;
# 其他SSL配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
}
Apache配置:
<VirtualHost *:443>
ServerName yoursite.com
SSLEngine on
SSLCertificateFile /etc/apache2/ssl/yoursite.com.pem
SSLCertificateKeyFile /etc/apache2/ssl/yoursite.com.key
# Apache需要单独指定中间证书文件
SSLCertificateChainFile /etc/apache2/ssl/lets-encrypt-r3.pem
</VirtualHost>
注意:Apache 2.4.8+ 可以直接把中间证书拼到 SSLCertificateFile 里,但更推荐用 SSLCertificateChainFile 显式指定。
AWS ALB / CloudFront: 通过AWS Console上传证书时,确保在“Certificate chain”字段里粘贴中间证书。AWS的UI设计有点反人类,很多人只填了证书正文忘了链。
第五步:验证修复结果
# 1. 重启Web服务
sudo systemctl restart nginx # 或 httpd/apache2
# 2. 再次用openssl验证
openssl s_client -connect yoursite.com:443 -showcerts </dev/null 2>/dev/null | grep "subject="
# 3. 用curl模拟完整验证
curl -v https://yoursite.com/ 2>&1 | grep "SSL certificate verify"
# 4. 跑Qualys扫描
# 访问 https://www.ssllabs.com/ssltest/analyze.html?d=yoursite.com
# 确认 "Chain issues: None" 和 "Grade A"
各平台证书链配置对比
| 平台/软件 | 证书链配置方式 | 常见坑点 | 推荐做法 |
|---|---|---|---|
| NGINX | ssl_certificate 指向合并文件 | 拼接顺序反了(中间证在叶子前) | 用 cat server.crt intermediate.crt > fullchain.crt |
| Apache | SSLCertificateChainFile 单独指定 | 忘记配置这个指令 | 2.4.8+ 可直接合并到 SSLCertificateFile |
| AWS ALB | Console上传时填Chain字段 | UI里Chain字段容易被忽略 | 用AWS CLI上传:aws acm import-certificate |
| CloudFront | 通过ACM上传 | 必须选US East (N. Virginia)区域 | 跨区域证书会报错 |
| HAProxy | crt 指向PEM合并文件 | 需要包含私钥?不需要,但顺序要对 | 确保PEM文件只有证书链,不含私钥 |
| IIS | 通过MMC导入中间证书 | Windows自动信任根,但中间证需要手动导入 | 在“中间证书颁发机构”里手动导入 |
FAQ
为什么iOS正常但Android报错?
Android < 7.1 没有内置Let’s Encrypt等CA的中间证书。iOS和桌面浏览器有自己的证书存储机制,会自动下载缺失的中间证书。Android 7.1+ 虽然改进了,但很多国产ROM仍然有问题。解决方案:服务器必须主动下发完整链。
证书链拼接时要不要包含根证书?
不要。根证书已经在客户端信任存储里了。把根证书拼接进去不仅多余,在某些实现里反而会引发问题(比如某些旧版OpenSSL会报链过长)。
如何检查证书链是否完整?
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | grep -E "subject=|issuer="
如果只看到一行 subject=,说明不完整。正常应该至少两行(叶子+中间)。
Let’s Encrypt的证书自动续期后链会变吗?
会。Let’s Encrypt在2024年更换了中间CA(从R3迁移到E1等),如果你的手动拼接脚本写死了旧中间证书,续期后链会断。建议用Certbot全自动管理,它会自动处理链更新。
一个真实案例
上个月帮一个电商客户排查,他们的SSL证书在Qualys上一直是B级(扣分原因就是Incomplete Chain)。运维说“我明明配了证书啊”。我让他跑了一下 openssl s_client,发现只有一行叶子证书。
查了半天,发现是CI/CD流水线里有个脚本,只把服务器证书复制到了部署目录,中间证书压根没动。修复脚本后,分数从B跳到A+,Android用户反馈的SSL错误直接清零。
这种问题,说白了就是自动化流程里少了一行 cat 命令。
预防性建议
- 自动化部署脚本里务必包含链拼接步骤 —— 不要手动操作,人总会忘
- 配置监控 —— 定期用
openssl s_client检查证书链完整性 - 使用Certbot等成熟工具 —— 它能帮你管理整个生命周期,包括中间证书更新
- 不要在CDN和源站之间只配一个证书 —— 如果用了CDN,CDN到源站也要配完整链
最后,记住这个口诀:叶子在前,中间在后,根证书不要凑。
社区灵感与参考 (References & Community Insights)
本文探讨的架构演进与技术实现方案,深度提炼自 Hacker News、Reddit 等极客社区的真实工程师讨论、线上事故复盘(Post-mortems)以及一线技术博客的实战经验分享。