朕喜技术(ZhenxiJS)
2026.09.10
1 分钟阅读

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),信任链完整,客户端可回溯到受信任的根。

六、经验与避坑

  1. 证书链 vs 叶子证书:很多人只把「叶子证书」配给服务端,漏掉中间 CA。现代浏览器/curl 拿不到中间证书就无法把 leaf 回溯到受信任的根。判断标准——信任了根仍报 21/unable to get local issuer,基本就是缺中间 CA。
  2. 判断方法: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 不是根,就是缺中间证书了。
  3. 顺序不能反:拼接证书链时叶子证书在前、中间 CA 在后。顺序反了部分客户端/服务端会不认。可用 openssl crl2pkcs7 -nocrl -certfile chain.crt | openssl pkcs7 -print_certs -noout 或 openssl x509 -in chain.crt -noout -subject -issuer 逐一核对。
  4. Nginx 场景:ssl_certificate 指向 fullchain.pem(leaf + 中间),而不是单 leaf。常见写法 cat server.crt intermediate.crt > fullchain.pem。
  5. 检查根证书是否信任:确认客户端 --cacert/系统信任库里放的是根 CA,而不是中间 CA 或叶子证书。若客户端只信根,就要保证服务端下发含中间 CA 的完整链;若客户端只信中间 CA,则服务端只发 leaf 也能过(但通常不推荐)。
  6. compose 路径坑:compose 的 dockerfile 应相对于 build context 解析。若 context: ../.. 已是仓库根,dockerfile 应写 .devcontainer/Dockerfile,写 ../../.devcontainer/Dockerfile 会导致 resolve: lstat /.devcontainer: no such file or directory。
  7. 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 启动服务

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-10-0800-https-cert-chain-incomplete-missing-intermediate-ca

分享这篇文章