HTTPS 证书链不完整(缺少中间 CA)导致 unable to get local issuer certificate 排查与解决
日期:2026-09-10 08:00 场 技术栈:HTTPS / TLS / openssl / Node.js / Nginx / 证书链 / 中间 CA / 服务器证书 相关报错:
curl: (60) SSL certificate problem: unable to get local issuer certificate、Verify return code: 21 (unable to verify the first certificate)、error 20 at 0 depth lookup关键词:证书链不完整、缺少中间证书、unable to get local issuer certificate、fullchain.pem、ssl_certificate、中间CA、CA:TRUE、TLS握手、openssl s_client、curl 60、Verify return code 21
一、问题背景
给公司部署 HTTPS 服务,证书来自内部 CA 体系(Root CA → 中间 CA → 服务器 leaf 证书 三级),客户端明明信任了根证书,访问时却报:
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
用 openssl s_client 看:
subject=C = CN, O = Demo Server, CN = localhost
issuer=C = CN, O = Demo Inter CA, CN = Demo Inter CA
Verify return code: 21 (unable to verify the first certificate)
证书本身没过期,根 CA 也信任了,为什么还是验证失败?——服务端在 TLS 握手中只发了叶子证书,没把中间 CA 一并下发。
二、复现步骤
前置:生成三级证书链
cd scenario/2026-09-10-0800
./gen_certs.sh # 生成 Root CA → 中间 CA → leaf,并拼接 chain.crt
生成结果在 fixtures/:rootCA.crt(信任锚)、inter.crt(中间 CA,常被漏发)、server.crt(leaf)、server.key、chain.crt(= server.crt + inter.crt,leaf 在前)。
方式 1:Docker 一键复现
cd scenario/2026-09-10-0800
docker compose up -d --build # 默认 CERT=fixtures/server.crt(只发 leaf,故意触发错误)
curl -sS --cacert fixtures/rootCA.crt https://localhost:8443/
# → curl: (60) SSL certificate problem: unable to get local issuer certificate
方式 2:宿主机直接复现(无需 Docker)
cd scenario/2026-09-10-0800
CERT=fixtures/server.crt KEY=fixtures/server.key node server.js &
curl -sS --cacert fixtures/rootCA.crt https://localhost:8443/
# → curl: (60)
echo | openssl s_client -connect localhost:8443 -CAfile fixtures/rootCA.crt 2>/dev/null | grep "Verify return code"
# → Verify return code: 21 (unable to verify the first certificate)
kill %1
复现时关键输出
HTTPS server listening on https://localhost:8443
cert = fixtures/server.crt # 只发叶子证书,故意不带中间 CA
curl: (60) SSL certificate problem: unable to get local issuer certificate
openssl s_client → Verify return code: 21 (unable to verify the first certificate)
三、根本原因
- 证书链是 Root CA → 中间 CA(inter) → 叶子证书(server) 三级结构。
- 客户端校验服务端证书时,必须能把它一层层回溯到一个自己信任的根 CA。它手里有
rootCA.crt,但 leaf 的签发者是中间 CA,不是根。 - 客户端要验证 leaf,就必须拿到签发 leaf 的中间 CA 证书。由于服务端在 TLS 握手时只发送了 leaf,没发送中间 CA,客户端拿不到中间 CA,信任链断在中间。
- 于是报
unable to get local issuer certificate(意思是:找不到本地颁发者证书)。
关键认知:
- 自签根证书直接签发叶子(单级链)时,客户端用根就能校验 leaf,通常没问题。
- 一旦引入中间 CA(多级信任链),就必须下发完整链(leaf + 中间 CA,或更高层),否则即使信任了根也会失败。
四、解决办法
让服务端在 TLS 握手时下发完整证书链:叶子证书在前、中间 CA 在后(顺序不能反)。
1. 拼接证书链文件
cat fixtures/server.crt fixtures/inter.crt > fixtures/chain.crt # leaf 在前,中间 CA 在后
2. Node.js 场景(本项目 server.js)
// 把 cert 从只用 leaf 改为完整链
https.createServer({
key: fs.readFileSync('fixtures/server.key'),
cert: fs.readFileSync('fixtures/chain.crt'), // server.crt + inter.crt
}, handler)
3. Nginx 场景(同理)
cat server.crt intermediate.crt > fullchain.pem
server {
listen 443 ssl;
ssl_certificate /path/fullchain.pem; # 注意:是 fullchain,不是单 leaf
ssl_certificate_key /path/server.key;
}
注意:
ssl_certificate应指向「叶子 + 中间证书」拼接的文件,而不是只含叶子证书的文件;顺序必须 leaf 在前、中间 CA 在后。
五、验证
Docker 验证(用完整链)
cd scenario/2026-09-10-0800
cat > docker-compose.fixed.yml <<'EOF'
services:
app:
environment:
CERT: fixtures/chain.crt
KEY: fixtures/server.key
EOF
docker compose -f docker-compose.yml -f docker-compose.fixed.yml up -d
curl -sS --cacert fixtures/rootCA.crt https://localhost:8443/
# → hello from tls server(成功)
echo | openssl s_client -connect localhost:8443 -CAfile fixtures/rootCA.crt 2>/dev/null | grep "Verify return code"
# → Verify return code: 0 (ok)
宿主机 openssl 校验证书链一致性
# 完整链校验应 OK
openssl verify -CAfile fixtures/rootCA.crt -untrusted fixtures/inter.crt fixtures/server.crt
# → fixtures/server.crt: OK
# 单叶子证书直接对根校验应失败(复现根因)
openssl verify -CAfile fixtures/rootCA.crt fixtures/server.crt
# → error 20 at 0 depth lookup: unable to get local issuer certificate
检查服务端实际下发的证书链(修复后应有 2 张证书)
echo | openssl s_client -connect localhost:8443 -CAfile fixtures/rootCA.crt 2>&1 | grep -E "s:|i:"
0 s:C = CN, O = Demo Server, CN = localhost
i:C = CN, O = Demo Inter CA, CN = Demo Inter CA # leaf 由中间 CA 签发
1 s:C = CN, O = Demo Inter CA, CN = Demo Inter CA
i:C = CN, O = Demo Root CA, CN = Demo Root CA # 中间 CA 由根签发
服务端如今同时下发 leaf(cert 0)和中间 CA(cert 1),信任链完整,客户端可回溯到受信任的根。
六、经验与避坑
- 证书链 vs 叶子证书:很多人只把「叶子证书」配给服务端,漏掉中间 CA。现代浏览器/curl 拿不到中间证书就无法把 leaf 回溯到受信任的根。判断标准——信任了根仍报 21/unable to get local issuer,基本就是缺中间 CA。
- 判断方法:
openssl s_client看Verify return code。0= OK;21= unable to verify the first certificate(通常是缺中间);19= self-signed;18/20= 证书链断/找不到 issuer。用grep -E "s:|i:"数一下服务端下发了几张证书,若只有一张 leaf 而它的 issuer 不是根,就是缺中间证书了。 - 顺序不能反:拼接证书链时叶子证书在前、中间 CA 在后。顺序反了部分客户端/服务端会不认。可用
openssl crl2pkcs7 -nocrl -certfile chain.crt | openssl pkcs7 -print_certs -noout或openssl x509 -in chain.crt -noout -subject -issuer逐一核对。 - Nginx 场景:
ssl_certificate指向fullchain.pem(leaf + 中间),而不是单 leaf。常见写法cat server.crt intermediate.crt > fullchain.pem。 - 检查根证书是否信任:确认客户端
--cacert/系统信任库里放的是根 CA,而不是中间 CA 或叶子证书。若客户端只信根,就要保证服务端下发含中间 CA 的完整链;若客户端只信中间 CA,则服务端只发 leaf 也能过(但通常不推荐)。 - compose 路径坑:compose 的
dockerfile应相对于 build context 解析。若context: ../..已是仓库根,dockerfile应写.devcontainer/Dockerfile,写../../.devcontainer/Dockerfile会导致resolve: lstat /.devcontainer: no such file or directory。 - compose 环境变量覆盖:yml 里的
environment:会覆盖 shell 传入的同名环境变量。想改配置应在 yml/覆盖文件中显式改,如docker compose -f compose.yml -f override.yml up。
七、可复现命令汇总
# 复现(缺中间 CA)
cd scenario/2026-09-10-0800
docker compose up -d --build
curl -sS --cacert fixtures/rootCA.crt https://localhost:8443/ # curl 60
echo | openssl s_client -connect localhost:8443 -CAfile fixtures/rootCA.crt 2>/dev/null | grep "Verify return code" # 21
# 修复(发送完整链 leaf + 中间 CA)
cat fixtures/server.crt fixtures/inter.crt > fixtures/chain.crt
# 用 CERT=fixtures/chain.crt 启动服务
