Java HTTPS 调用报 PKIX path building failed:一次证书信任链问题排查记录
一次证书信任链问题排查记录
Java HTTPS 调用报 PKIX path building failed:一次证书信任链问题排查记录
一、问题现象
最近在 Java 服务调用 HTTPS 接口时突然出现 SSL 证书验证失败。该接口此前一直可以正常调用,近期没有修改相关 Java 调用代码。
Java 异常如下:
Caused by: sun.security.validator.ValidatorException:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
运行环境为 JDK 8。
与此同时,通过 OpenSSL 检查目标服务:
openssl s_client \
-connect efppmi-test.cet.com:8443 \
-servername efppmi-test.cet.com \
-showcerts </dev/null
出现:
Verify return code: 21 (unable to verify the first certificate)
但是使用 curl 显式指定 CA 证书:
curl \
--cacert /usr/local/share/ca-certificates/ssl.cer \
https://efppmi-test.cet.com:8443/
却可以正常访问。
因此问题表现为:
Java 默认访问 ❌ PKIX 失败
OpenSSL 默认验证 ❌ Verify return code 21
curl --cacert ssl.cer ✅ 正常
二、首先理解 PKIX 报错意味着什么
Java 调用 HTTPS 服务时,需要验证服务端证书是否可信。
正常情况下会形成类似这样的信任链:
服务端证书
↓
Intermediate CA
↓
Root CA
↓
Java TrustStore
如果 Java 无法从服务端证书找到最终可信的 Trust Anchor,就可能出现:
PKIX path building failed
unable to find valid certification path to requested target
因此最开始重点怀疑:
- 服务端没有提供完整证书链;
- Java 没有信任对应 CA;
- Java 使用了错误的 TrustStore;
- JDK 的
cacerts中没有正确导入证书; - 服务器近期更换证书导致原来的信任关系失效。
三、为什么 curl 可以,而 Java 不可以
这是排查过程中非常重要的一点。
使用:
curl \
--cacert /usr/local/share/ca-certificates/ssl.cer \
https://efppmi-test.cet.com:8443/
相当于明确告诉 curl:
请使用 ssl.cer 作为 CA 信任依据
而 Java 默认不会直接读取:
/usr/local/share/ca-certificates/ssl.cer
Java 通常使用自己的 TrustStore,例如 JDK 8:
$JAVA_HOME/jre/lib/security/cacerts
因此:
curl --cacert ssl.cer
↓
显式使用 ssl.cer
↓
验证成功
并不能直接证明 Java 的 cacerts 中已经正确包含该证书。
四、确认 Java 是否真的可以使用这张证书
为了排除证书文件本身的问题,可以创建一个临时 TrustStore。
1. 创建测试 TrustStore
$JAVA_HOME/bin/keytool \
-importcert \
-trustcacerts \
-alias test-ca \
-file /usr/local/share/ca-certificates/ssl.cer \
-keystore /tmp/test-truststore.jks \
-storepass changeit \
-noprompt
查看是否导入成功:
$JAVA_HOME/bin/keytool \
-list \
-keystore /tmp/test-truststore.jks \
-storepass changeit
2. 强制 Java 使用测试 TrustStore
使用 JDK 8 自带的 jrunscript 做一个最小化 HTTPS 测试:
$JAVA_HOME/bin/jrunscript \
-J-Djavax.net.ssl.trustStore=/tmp/test-truststore.jks \
-J-Djavax.net.ssl.trustStorePassword=changeit \
-e '
var url = new java.net.URL("https://efppmi-test.cet.com:8443/");
var conn = url.openConnection();
conn.connect();
print("HTTPS 连接成功,响应码: " + conn.getResponseCode());
'
测试结果:
HTTPS 连接成功
到这里可以得到一个非常重要的结论:
ssl.cer本身可以被 Java 使用,并且能够完成目标 HTTPS 服务的证书验证。
因此问题不再是“Java 不支持这张证书”,而应该继续调查:
为什么默认 TrustStore 没有正确使用这张证书?
五、确认 Java 实际使用哪个 TrustStore
不能仅根据 $JAVA_HOME 猜测 Java 使用哪个 TrustStore。
开启 Java SSL 调试:
$JAVA_HOME/bin/jrunscript \
-J-Djavax.net.debug=ssl,trustmanager \
-e '
var url = new java.net.URL("https://efppmi-test.cet.com:8443/");
var conn = url.openConnection();
conn.connect();
print(conn.getResponseCode());
' 2>&1 | tee /tmp/ssl-debug.log
过滤 TrustStore 相关日志:
grep -iE "trustStore|cacerts|jssecacerts|trusted cert" \
/tmp/ssl-debug.log
实际得到:
Inaccessible trust store:
/usr/local/openjdk-8/jre/lib/security/jssecacerts
trustStore is:
/usr/local/openjdk-8/jre/lib/security/cacerts
trustStore type is: jks
Reloaded 130 trust certs
其中:
Inaccessible trust store: .../jssecacerts
并不是本次问题。
Java 会优先寻找:
$JAVA_HOME/jre/lib/security/jssecacerts
如果不存在,则使用:
$JAVA_HOME/jre/lib/security/cacerts
日志已经明确证明 Java 最终加载的是:
/usr/local/openjdk-8/jre/lib/security/cacerts
因此可以排除“Java 使用了另一个 cacerts”的猜测。
六、检查 cacerts 中的证书
Dockerfile 中原本已经执行过:
RUN keytool -import \
-trustcacerts \
-file /usr/local/share/ca-certificates/ssl.cer \
-alias your_certificate_alias \
-keystore $JAVA_HOME/jre/lib/security/cacerts \
-storepass changeit \
-noprompt
理论上证书已经进入 Java TrustStore。
通过 alias 查看:
$JAVA_HOME/bin/keytool \
-list \
-v \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-alias your_certificate_alias
确实能够找到对应证书。
这时候问题就比较奇怪了:
Java 加载的是正确 cacerts
↓
cacerts 中也存在指定 alias
↓
但 HTTPS 仍然 PKIX 失败
↓
使用临时 test.jks 却成功
下一步的关键就是:
不要只比较 alias,要比较证书本身。
七、通过 SHA256 指纹找到真正原因
首先查看当前证书文件的指纹:
$JAVA_HOME/bin/keytool \
-printcert \
-file /usr/local/share/ca-certificates/ssl.cer |
grep -E "SHA1:|SHA256:"
然后查看 Java cacerts 中对应 alias 的证书:
$JAVA_HOME/bin/keytool \
-list -v \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-alias your_certificate_alias |
grep -E "SHA1:|SHA256:"
最后检查刚才可以正常工作的测试 TrustStore:
$JAVA_HOME/bin/keytool \
-list -v \
-keystore /tmp/test-truststore.jks \
-storepass changeit \
-alias test-ca |
grep -E "SHA1:|SHA256:"
正常情况下应该满足:
ssl.cer
=
cacerts 中的证书
=
test-truststore.jks 中的证书
但是实际检查发现:
ssl.cer SHA256
=
test-truststore.jks SHA256
但是
cacerts 中 your_certificate_alias SHA256
≠
ssl.cer SHA256
至此问题基本定位。
八、最终根因:生产和测试证书使用了相同 alias
继续检查 Dockerfile 后发现,同时安装了生产环境和测试环境的证书,但是两张证书使用了相同 alias:
your_certificate_alias
例如:
# 生产证书
RUN keytool -importcert \
-trustcacerts \
-file /cert/prod.cer \
-alias your_certificate_alias \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
# 测试证书
RUN keytool -importcert \
-trustcacerts \
-file /cert/test.cer \
-alias your_certificate_alias \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
问题就在这里。
Java KeyStore 中:
alias = 一个证书条目的唯一标识
同一个 KeyStore 中不能用同一个 alias 正常保存两张不同的可信证书。
因此实际结果可能变成:
cacerts
│
└── your_certificate_alias
↓
生产环境证书
测试环境证书并没有按照预期成为一个独立的可信条目。
Java 调用测试环境:
测试环境服务端证书
↓
寻找可信证书
↓
cacerts 中没有正确的测试证书
↓
PKIX path building failed
这也解释了为什么把测试证书单独放入:
/tmp/test-truststore.jks
之后 Java 马上可以正常访问。
九、解决方法
生产环境和测试环境证书分别使用不同 alias。
例如:
RUN keytool -importcert \
-trustcacerts \
-file /usr/local/share/ca-certificates/prod.cer \
-alias cet-prod-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
RUN keytool -importcert \
-trustcacerts \
-file /usr/local/share/ca-certificates/test.cer \
-alias cet-test-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
最终:
cacerts
├── JDK 默认可信 CA
│
├── cet-prod-ca
│ └── 生产环境证书
│
└── cet-test-ca
└── 测试环境证书
可以通过:
keytool -list \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit |
grep -E "cet-prod-ca|cet-test-ca"
确认两张证书均存在。
再分别查看指纹:
keytool -list -v \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-alias cet-prod-ca |
grep -E "SHA1:|SHA256:"
keytool -list -v \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-alias cet-test-ca |
grep -E "SHA1:|SHA256:"
确保与对应证书文件一致。
十、如果需要更新同一个 alias
keytool 没有一个简单的 --overwrite 参数用于直接覆盖已有 alias。
如果确实需要更新同一个 alias,可以先删除:
keytool -delete \
-alias cet-test-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit
再重新导入:
keytool -importcert \
-trustcacerts \
-file /usr/local/share/ca-certificates/test.cer \
-alias cet-test-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
Dockerfile 中也可以写成:
RUN keytool -delete \
-alias cet-test-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit 2>/dev/null || true \
&& keytool -importcert \
-trustcacerts \
-file /usr/local/share/ca-certificates/test.cer \
-alias cet-test-ca \
-keystore "$JAVA_HOME/jre/lib/security/cacerts" \
-storepass changeit \
-noprompt
这样可以避免旧 alias 中残留旧证书。
十一、这次排查中最有价值的几个方法
这次问题看起来只是一个 alias 配置错误,但排查过程中有几个方法非常适合以后处理 Java HTTPS 问题。
1. 不要看到 PKIX 就直接导证书
先确认问题属于哪一层:
服务端证书链
↓
操作系统 CA
↓
Java TrustStore
↓
应用自定义 SSLContext
否则很容易反复导入证书却没有解决真正的问题。
2. curl 成功不代表 Java 一定成功
特别是:
curl --cacert xxx.cer
实际上已经人为给 curl 指定了信任来源。
而 Java 默认使用:
cacerts / jssecacerts / javax.net.ssl.trustStore
两者不能直接等价比较。
3. 用临时 TrustStore 做隔离测试非常有效
这次真正把问题范围缩小的是:
默认 cacerts → 失败
临时 test.jks → 成功
它直接证明:
证书本身没有问题
Java HTTPS 没有问题
服务端基本可验证
问题集中在默认 TrustStore
比不断修改业务代码有效得多。
4. 不要只看 alias,要比较证书指纹
这是本次最终定位问题的关键。
alias 相同 ≠ 证书相同
最可靠的是比较:
SHA256 Fingerprint
通过指纹最终发现:
证书文件
≠
cacerts 中同 alias 的证书
从而继续定位到 Dockerfile 中生产、测试证书 alias 冲突。
5. 不建议通过关闭 SSL 校验解决 PKIX
例如在 Java 中实现一个“信任所有证书”的 TrustManager,虽然可能让接口立即恢复,但本质上绕过了 HTTPS 身份验证。
正确方向应该是:
确认服务端证书链
↓
确认可信 CA
↓
确认 Java TrustStore
↓
确认实际加载的证书
十二、总结
本次问题最终不是 Java HTTPS 调用代码导致的,也不是证书文件本身无法使用,而是:
Docker 镜像同时向 JDK
cacerts导入生产和测试证书时使用了相同 alias,导致 Java TrustStore 中实际保存的证书与当前测试环境需要的证书不一致。
整个排查链路可以总结为:
Java HTTPS 调用
↓
PKIX path building failed
↓
OpenSSL 验证也存在证书链异常
↓
curl --cacert 可以正常访问
↓
创建独立 test-truststore.jks
↓
Java 强制使用 test.jks 后成功
↓
证明证书本身可以正常使用
↓
javax.net.debug 检查默认 TrustStore
↓
确认 Java 使用正确的 cacerts
↓
检查 cacerts 中 alias
↓
alias 存在
↓
比较 SHA256 指纹
↓
发现 cacerts 中证书 ≠ 当前 ssl.cer
↓
检查 Dockerfile
↓
发现生产/测试证书使用相同 alias
↓
分别使用独立 alias
↓
问题解决
这次问题也说明,在排查 Java PKIX path building failed 时,最重要的并不是反复执行 keytool -import,而是回答三个问题:
Java 实际使用哪个 TrustStore?
这个 TrustStore 中实际存放的是哪张证书?
这张证书是否真的能够建立目标服务的完整信任链?
把这三个问题确认清楚,大多数 Java HTTPS 证书问题都能快速缩小范围。