从Android 7 TLS握手失效到根证书信任裂痕:轻云互联服务器SSL证书链交叉签名排障实录
背景:突然翻车的HTTPS客户端兼容性
某日凌晨,监控告警触发:生产环境(部署在轻云互联美国洛杉矶云服务器,CentOS 7.9,Nginx 1.20.1)的多个站点在部分移动端用户上报“无法建立安全连接”。客户反馈集中在Android 6/7设备,以及部分老款iPhone。Web端和最新版Android/iOS均正常。核心业务是API接口,影响订单回调。
第一步:复现与收集证据
使用老设备模拟:从同事抽屉翻出一台Android 7.0手机,访问站点出现“NET::ERR_CERT_AUTHORITY_INVALID”。同一域名在Chrome 100+正常。初步怀疑证书链不完整。
服务器上执行openssl s_client查看服务端返回的证书链:
openssl s_client -connect example.com:443 -showcerts < /dev/null 2>&1 | grep -A1 "subject="
输出显示三级链:LE证书 → R3(Let's Encrypt中间) → ISRG Root X1。注意缺失了用户根到公开根的过渡,并且返回的根证书是ISRG Root X1(自签名),但多数老设备的信任存储里根本没有这个新根。它们只信任旧的DST Root CA X3(即IdenTrust交叉签名的根)。
第二步:深入根因——交叉签名被“优化”了
检查服务器证书文件:
cat /etc/letsencrypt/live/example.com/fullchain.pem
内容确实是三个PEM块:证书、R3中间、ISRG Root X1。但问题在于:ISRG Root X1并不是公开信任的根(对于老系统而言)。Let's Encrypt早期依赖IdenTrust的交叉签名:ISRG Root X1本身由DST Root CA X3签发,这个旧根才广泛被预置。而acme.sh默认使用--fullchain-path时生成的fullchain只包含“最简路径”,即去掉交叉签名的最终根,导致老设备无法完成信任链构建。
验证一下:在Android 7上,根证书存储只有DST Root CA X3,没有ISRG Root X1。从服务器获取的链以ISRG Root X1结尾,但系统不认,且缺少中间桥梁,所以握手失败。
第三步:用 openssl verify 模拟老系统
创建一份老系统信任存储(仅含DST Root CA X3):
mkdir /tmp/oldcapath
# 下载DST Root CA X3证书(openssl 1.0.2的cafile)
cp /etc/pki/tls/certs/ca-bundle.crt /tmp/oldcapath/
# 或者手动获取独立文件
openssl crl2pkcs7 -nocrl -certfile /etc/pki/tls/certs/ca-bundle.crt | openssl pkcs7 -print_certs -text | grep -A 100 "DST Root CA X3" > /tmp/dstroot.pem
c_rehash /tmp/oldcapath
然后跑验证:
openssl verify -CApath /tmp/oldcapath -untrusted /etc/letsencrypt/live/example.com/chain.pem /etc/letsencrypt/live/example.com/cert.pem
返回 error 20 at 0 depth lookup: unable to get local issuer certificate。一旦换上包含交叉签名的完整链(即加上DST Root CA X3签发给ISRG的交叉证书),验证通过。
第四步:彻底解决方案——生成正确的证书链
修改acme.sh的续签参数,强制使用首选旧根。Let's Encrypt提供了一个备用链,包含交叉签名。在acme.sh中:
acme.sh --issue --domain example.com --preferred-chain "DST Root CA X3" --keylength ec-256
注意--preferred-chain参数从acme.sh 3.0.0+开始支持。如果不指定,默认使用最小链(ISRG Root X1)。指定后,acme.sh会生成有三个证书的链:LE证书 → R3 → ISRG Root X1,并且ISRG Root X1是由DST Root CA X3签发的(服务器端返回四条PEM块?实际返回顺序:LE -> R3 -> ISRG -> DST Root)。但这样链长度变长,不过兼容性最好。
然后更新nginx ssl配置:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; # 这个chain也自动正确
重启nginx后再次测试:
openssl s_client -connect example.com:443 -showcerts 2>&1 | grep "subject="
看到返回了四个证书(包括ISRG Root X1和DST Root CA X3)。在Android 7上测试:连接成功。
第五步:系统层面根治——更新ca-certificates
虽然服务端配置对了,但旧系统自身的CA包也需要更新,否则其它内部工具(如curl、wget)也可能校验失败。CentOS 7上执行:
yum install -y ca-certificates
然后更新CA信任:
update-ca-trust force-enable
update-ca-trust extract
但注意:ca-certificates 2024年后的版本才包含ISRG Root X1。如果无法升级,建议保持依赖交叉签名链。
总结:不要信任“最小链”的默认行为
这次故障暴露了SSL证书链选择与客户端信任存储的兼容性鸿沟。对于面向全球用户的业务,尤其是老设备占比不低的情况下,应使用带交叉签名的完整链,并且定期检查openssl verify结果。轻云互联的云服务器默认提供的系统镜像(CentOS 7)确实老旧,但在这类场景下反而更明显地暴露了问题,让我们能快速定位。之后我们在自动续签脚本中加入--preferred-chain参数,并配置监控openssl s_client -verify_return_error来提前发现链问题。
最后的忠告:不要以为fullchain.pem就是万能的,请主动看它最后一条是不是自签名根,以及该根在目标客户端是否被信任。交叉签名和专属CA链是两种哲学,在IoT设备和安卓碎片化环境中,后者更容易踩坑。