解决Spring Boot应用中SSL握手失败与PKIX路径构建异常
问题现象
在本地开发或生产环境部署Spring Boot应用时,发起HTTPS请求可能会遇到以下SSL握手异常:
javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
原因分析
该异常的根本原因是Java运行环境(JRE/JDK)的默认信任库(通常为 cacerts)中缺少目标服务器SSL证书的根证书或中间证书。这在访问使用自签名证书、内部CA签发证书或证书链配置不完整的HTTPS站点时尤为常见。
解决方案:导入目标证书至JDK信任库
要彻底解决此问题,需要将目标服务器的证书提取出来,并导入到Java的信任库中。以下是具体的操作步骤。
1. 编译证书导入工具
创建一个名为 CertificateImporter.java 的文件,将以下重构后的工具类代码粘贴进去并编译。该工具会自动连接目标服务器、抓取证书链并生成新的信任库文件。
import javax.net.ssl.*;
import java.io.*;
import java.security.*;
import java.security.cert.*;
import java.util.Scanner;
public class CertificateImporter {
private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();
public static void main(String[] args) throws Exception {
if (args.length < 1 || args.length > 2) {
System.out.println("用法: java CertificateImporter <主机名>[:<端口>] [密钥库密码]");
return;
}
String[] hostAndPort = args[0].split(":");
String targetHost = hostAndPort[0];
int targetPort = hostAndPort.length > 1 ? Integer.parseInt(hostAndPort[1]) : 443;
char[] storePassword = (args.length == 2 ? args[1] : "changeit").toCharArray();
File keystoreFile = resolveKeystoreFile();
System.out.println("正在加载密钥库: " + keystoreFile.getAbsolutePath());
KeyStore keyStore = loadKeyStore(keystoreFile, storePassword);
SSLContext sslContext = SSLContext.getInstance("TLS");
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);
X509TrustManager defaultTm = (X509TrustManager) tmf.getTrustManagers()[0];
CertificateCapturingTrustManager capturingTm = new CertificateCapturingTrustManager(defaultTm);
sslContext.init(null, new TrustManager[]{capturingTm}, null);
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
System.out.println("正在连接 " + targetHost + ":" + targetPort + " ...");
try (SSLSocket socket = (SSLSocket) socketFactory.createSocket(targetHost, targetPort)) {
socket.setSoTimeout(10000);
socket.startHandshake();
System.out.println("握手成功,证书已受信任,无需额外操作。");
return;
} catch (SSLException e) {
System.out.println("握手失败,准备提取服务器证书...");
}
X509Certificate[] serverChain = capturingTm.getCapturedChain();
if (serverChain == null) {
System.err.println("未能获取服务器证书链。");
return;
}
printCertificateChain(serverChain);
System.out.print("请输入要添加到信任库的证书编号 (或输入 'q' 退出) [1]: ");
Scanner scanner = new Scanner(System.in);
String input = scanner.nextLine().trim();
if ("q".equalsIgnoreCase(input)) {
System.out.println("已取消操作。");
return;
}
int certIndex = input.isEmpty() ? 0 : Integer.parseInt(input) - 1;
X509Certificate targetCert = serverChain[certIndex];
String alias = targetHost + "-" + (certIndex + 1);
keyStore.setCertificateEntry(alias, targetCert);
File outputFile = new File("jssecacerts");
try (OutputStream out = new FileOutputStream(outputFile)) {
keyStore.store(out, storePassword);
}
System.out.println("成功将证书添加到 '" + outputFile.getAbsolutePath() + "',别名为 '" + alias + "'。");
}
private static File resolveKeystoreFile() {
String javaHome = System.getProperty("java.home");
File jssecacerts = new File("jssecacerts");
if (jssecacerts.isFile()) return jssecacerts;
File securityDir = new File(javaHome, "lib/security");
jssecacerts = new File(securityDir, "jssecacerts");
if (jssecacerts.isFile()) return jssecacerts;
return new File(securityDir, "cacerts");
}
private static KeyStore loadKeyStore(File file, char[] password) throws Exception {
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
try (InputStream in = new FileInputStream(file)) {
ks.load(in, password);
}
return ks;
}
private static void printCertificateChain(X509Certificate[] chain) throws Exception {
System.out.println("\n服务器返回了 " + chain.length + " 个证书:\n");
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
MessageDigest md5 = MessageDigest.getInstance("MD5");
for (int i = 0; i < chain.length; i++) {
X509Certificate cert = chain[i];
System.out.println(" " + (i + 1) + " 主题: " + cert.getSubjectX500Principal());
System.out.println(" 颁发者: " + cert.getIssuerX500Principal());
sha1.update(cert.getEncoded());
System.out.println(" SHA1: " + toHex(sha1.digest()));
md5.update(cert.getEncoded());
System.out.println(" MD5: " + toHex(md5.digest()));
System.out.println();
}
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 3);
for (byte b : bytes) {
int v = b & 0xFF;
sb.append(HEX_CHARS[v >>> 4]);
sb.append(HEX_CHARS[v & 0x0F]);
sb.append(' ');
}
return sb.toString().trim();
}
private static class CertificateCapturingTrustManager implements X509TrustManager {
private final X509TrustManager delegate;
private X509Certificate[] capturedChain;
public CertificateCapturingTrustManager(X509TrustManager delegate) {
this.delegate = delegate;
}
public X509Certificate[] getCapturedChain() {
return capturedChain;
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return delegate.getAcceptedIssuers();
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
delegate.checkClientTrusted(chain, authType);
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
this.capturedChain = chain;
delegate.checkServerTrusted(chain, authType);
}
}
}
在终端中执行编译命令:
javac CertificateImporter.java
2. 运行工具抓取证书
使用编译后的类文件运行工具,传入目标域名或IP(可带端口)。例如,访问 kyfw.12306.cn:
java CertificateImporter kyfw.12306.cn
程序会打印出服务器返回的证书链信息,并提示输入要信任的证书编号。通常情况下,输入 1 并回车即可将根证书或服务器证书加入信任库。
请输入要添加到信任库的证书编号 (或输入 'q' 退出) [1]: 1
3. 替换JDK默认信任库
执行成功后,当前目录下会生成一个名为 jssecacerts 的文件。接下来,需要将该文件复制到Java环境的安全目录中:
- JDK 8 及以下版本:复制到
$JAVA_HOME/jre/lib/security/目录下。 - JDK 9 及以上版本:复制到
$JAVA_HOME/lib/security/目录下。
注意:如果目标目录已存在 jssecacerts 文件,建议先备份原文件再进行覆盖。Java在启动时会优先加载 jssecacerts,若不存在则回退加载 cacerts。
4. 重启应用
完成信任库文件的替换后,重启Spring Boot项目。此时JVM将能够识别并信任目标服务器的SSL证书,握手异常即可消除。