Skip to content

浏览器能打开的 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 的约定由服务端在握手时和叶证书一起发过来。

服务端发来叶证书和中间证书叶证书localhost中间证书由服务端一起发送根证书客户端的信任库里签发者签发者HTTP 200服务端只发来叶证书叶证书localhost中间证书没发,客户端也没有根证书客户端的信任库里签发者?PKIX pathbuilding failed同样的报错还有另一个原因:链是完整的,但客户端信任的是另一个根区分办法:看服务端实际发来了几张证书,再看链顶端的签发者在不在客户端的信任库里浏览器常常能自己补上中间证书(缓存或按证书里的地址下载),JDK 默认不会JDK 25 上打开下载要两个开关:enableAIAcaIssuers 与 allowedAIALocations,后者默认全部拒绝
图 1 · 客户端手里只有根证书。要信任服务端的叶证书,它得找到一条从叶证书一级一级签上去、最后到达某个根证书的路径。中间证书由服务端在握手时一起发来;服务端只发叶证书时,路径中间断了一环,客户端不知道是谁签的这张叶证书。断的这一环如果恰好在客户端本地存着,握手就能成功,所以同一个站点在不同的机器上结果不同。

服务端的证书配置里如果只放了叶证书,握手时发出去的就只有一张。客户端拿着它,知道签发者叫什么,却没有那张证书,链在第一环就断了。

二、同一条报错,两种原因 ​

实验里客户端是 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 failed0 次
-Dcom.sun.security.enableAIAcaIssuers=truePKIX path building failed0 次
再加 -Dcom.sun.security.allowedAIALocations=http://localhost:18080/HTTP 2001 次

第二行是需要更新的旧认识:很多资料里写的是「加上 enableAIAcaIssuers=true 就可以」。在 JDK 25.0.4.1 上,java.security 里多了一项 com.sun.security.allowedAIALocations,它是允许下载的地址白名单,默认值为空,含义是全部拒绝。只开第一个开关,JDK 一次下载都不会发起。

即便两个开关都打开,这也只是权宜之计:握手过程中要多发一次 HTTP 请求,下载地址不可达时仍然失败,而且让客户端去访问证书里写的任意地址本身就是 JDK 加上白名单的原因。正确的修复在服务端。

四、区分与修复 ​

第一步:看服务端发来了什么。

bash
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 下载的两个开关(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权