浏览器能打开的 HTTPS 地址,Java 调用报 PKIX
一个 HTTPS 接口,浏览器访问正常,
curl在同事的机器上也正常,Java 服务调用它却抛出PKIX path building failed: unable to find valid certification path to requested target。证书没有过期,域名也对。
这条异常的字面意思是:客户端没能从服务端的证书出发,找到一条通往自己信任的根证书的路径。原因通常是两种里的一种:服务端少发了一张证书,或者客户端的信任库里没有对应的根。两种原因的报错一字不差,处理方向却相反:前一种该改服务端,后一种才该动客户端。
本文在本地用一套现场生成的三级证书把几种情况跑一遍,说明怎么区分,以及为什么浏览器和 Java 的结果会不同。实验在 JDK 25.0.4.1 上运行,见文末。
一、信任是一条链
公开的 HTTPS 证书很少由根证书直接签发。常见的结构是三级:根证书签发中间证书,中间证书签发站点的叶证书。客户端的信任库里只预置根证书。
握手时,客户端要完成一件事:从叶证书开始,找到签发它的证书,再找签发那张证书的证书,直到到达信任库里的某个根。每一级都要验证签名、有效期和用途。中间证书不在客户端的信任库里,按 TLS 的约定由服务端在握手时和叶证书一起发过来。
服务端的证书配置里如果只放了叶证书,握手时发出去的就只有一张。客户端拿着它,知道签发者叫什么,却没有那张证书,链在第一环就断了。
二、同一条报错,两种原因
实验里客户端是 JDK 的 HttpClient,信任库里只有根证书。每个场景另外用一个不做校验的连接,记录服务端实际发来了几张证书。
| 场景 | 服务端发来 | 客户端结果 |
|---|---|---|
| 密钥库里是叶证书加中间证书 | 2 张 | HTTP 200 |
| 密钥库里只有叶证书 | 1 张 | SSLHandshakeException:PKIX path building failed … unable to find valid certification path to requested target |
| 链完整,但客户端信任的是另一个根 | 2 张 | 与上一行完全相同的异常 |
| 只有叶证书,但客户端的信任库里另有那张中间证书 | 1 张 | HTTP 200 |
第二、三行的异常信息一字不差。只看异常无法知道是哪一种,要看服务端发来了什么。
第四行解释了「有的机器能调通、有的不能」:缺的那张中间证书如果恰好在客户端本地存在(被人导入过信任库,或者浏览器缓存过),握手就能成功。服务端的配置其实是错的,只是在这些机器上没有暴露。
三、为什么浏览器没事
叶证书里通常有一个扩展字段(Authority Information Access,AIA),写着「签发我的那张证书可以从这个地址下载」。主流浏览器遇到链不完整时,会用缓存里见过的中间证书,或者按这个地址把它下载回来补上。所以配置有缺陷的站点在浏览器里常常一切正常。这是浏览器为了容错做的额外工作,不是 TLS 要求的,各浏览器的做法也不一样。
JDK 默认不这样做。它支持按 AIA 下载,但要显式打开。实验里用一张带 AIA 扩展的叶证书(地址指向本机的一个 HTTP 服务),服务端仍然只发叶证书:
| JVM 参数 | 结果 | 中间证书被下载 |
|---|---|---|
| 默认 | PKIX path building failed | 0 次 |
-Dcom.sun.security.enableAIAcaIssuers=true | PKIX path building failed | 0 次 |
再加 -Dcom.sun.security.allowedAIALocations=http://localhost:18080/ | HTTP 200 | 1 次 |
第二行是需要更新的旧认识:很多资料里写的是「加上 enableAIAcaIssuers=true 就可以」。在 JDK 25.0.4.1 上,java.security 里多了一项 com.sun.security.allowedAIALocations,它是允许下载的地址白名单,默认值为空,含义是全部拒绝。只开第一个开关,JDK 一次下载都不会发起。
即便两个开关都打开,这也只是权宜之计:握手过程中要多发一次 HTTP 请求,下载地址不可达时仍然失败,而且让客户端去访问证书里写的任意地址本身就是 JDK 加上白名单的原因。正确的修复在服务端。
四、区分与修复
第一步:看服务端发来了什么。
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null输出里 Certificate chain 一段列出服务端发送的每张证书的主体(s:)和签发者(i:)。只有一张、并且它的签发者不是根,就是服务端漏发了中间证书。curl 和浏览器成功不能说明链是完整的:它们可能用了系统或自己的缓存。
第二步:按原因处理。
- 服务端漏发中间证书。 把中间证书补进服务端的证书配置:Nginx 的
ssl_certificate指向「叶证书在前、中间证书在后」拼成的文件;Java 的密钥库里私钥条目要带完整的证书链;负载均衡、网关上传证书时一并上传链。根证书不需要发。接口是别人的,就把openssl的输出发给对方。 - 客户端不信任这个根。 常见于企业内部的私有 CA、自签证书,以及基础镜像或很旧的 JDK 里根证书列表过期。把这个根证书(不是叶证书)导入客户端的信任库。
不要做的事:把叶证书导入信任库(证书一续期就再次失败);把中间证书导入信任库来绕过服务端的缺陷(掩盖了问题,换一个客户端又会遇到);换成不做任何校验的 TrustManager(等于放弃了身份验证,中间人可以读到并改写全部流量)。
信任库在哪里。 JDK 默认用 $JAVA_HOME/lib/security/cacerts,与操作系统的证书库无关。在操作系统里安装了公司的根证书,curl 能用,Java 仍然不认,原因在此。容器里的 Java 服务,信任库来自镜像里的 JDK。需要追加私有根时,建议单独准备一个信任库文件,用 -Djavax.net.ssl.trustStore 或在代码里构造 SSLContext 指定,而不是修改 JDK 自带的 cacerts(升级 JDK 时会被覆盖)。
五、另外两种握手失败
证书链完整、根也受信任时,还有两种常见的失败,报错与上面不同:
| 场景 | 异常信息 |
|---|---|
| 证书里的域名与访问的域名不符 | No subject alternative DNS name matching localhost found. |
| 叶证书已过期 | PKIX path validation failed … validity check failed(根因 CertificateExpiredException) |
域名不符最常见的来源是用 IP 或内部别名访问、证书签发给的是另一个域名。现代客户端只看证书的 Subject Alternative Name 扩展,不看 CN。过期的报错是 path validation failed,路径已经找到、验证没通过;前面的是 path building failed,路径没找到。两个词的差别可以直接用来分类。
证书过期属于可以提前发现的问题:对每个对外的证书监控剩余有效期,并且监控的是握手时实际发出的那张,而不是配置仓库里的文件。
请求在握手之前的各个阶段失败时各是什么表现,见 请求失败时,先看它停在了哪一步。
配套实验
- codesphere-labs/java/tls-certificate-chain:用 openssl 现场生成根、中间、叶三级证书,JDK 25.0.4.1 的
HttpClient在完整链、只有叶证书、信任另一个根、域名不符、证书过期时的握手结果,以及 AIA 下载的两个开关(验证记录)
参考资料
- RFC 8446:TLS 1.3,§4.4.2 Certificate(服务端发送的证书列表:叶证书在前,每一张由后一张签发,根可以省略)
- RFC 5280:X.509 证书,§4.2.2.1 Authority Information Access
- JDK 25 文档:Java PKI Programmer's Guide(
com.sun.security.enableAIAcaIssuers等系统属性) - JDK 25 文档:JSSE Reference Guide(信任库的查找顺序与
javax.net.ssl.trustStore) - RFC 6125:在 TLS 中验证服务身份
- Nginx 文档:配置 HTTPS 服务器(SSL certificate chains)
- OpenSSL 文档:openssl-s_client