最近在给一个 Spring Boot 服务接入 mTLS 时,遇到了一个比较容易误导人的问题:本地调试正常,但服务部署到 Kubernetes Pod 后,访问 mTLS 接口失败,并出现下面的异常。
text
Caused by: java.io.IOException: keystore password was incorrect
Caused by: java.security.UnrecoverableKeyException:
failed to decrypt safe contents entry:
javax.crypto.BadPaddingException:
Given final block not properly padded.
Such issues can arise if a bad key is used during decryption.
第一眼看这个异常,很容易判断为:keystore 密码配置错了。
但实际排查下来,密码并不是唯一原因。只要 JVM 在加载 .p12 文件时无法正确解密其中内容,都可能被包装成类似的异常。例如:
- 证书文件在 Maven 打包过程中被破坏;
- 本地JDK 和 Pod 中的JDK 版本不一致,导致 PKCS12 兼容性问题;
- key-store password、key password、trust-store password 配置混淆;
- 运行环境实际加载的证书文件不是预期文件;
- 访问端口配置错误,导致问题被误判为 mTLS 失败。
本文记录这次排查过程,也整理一套 Spring Boot mTLS 落地时比较实用的检查清单。
一、先区分:这是证书加载问题,还是网络访问问题
mTLS 相关问题通常可以分成两个阶段。
1.1 服务启动阶段
Spring Boot 启动时会根据配置加载服务端证书:
yaml
server:
ssl:
key-store: classpath:certs/dev/keystore.p12
key-store-password: xxx
key-store-type: PKCS12
key-alias: xxx
如果这个阶段加载 keystore 失败,通常会在应用启动日志中看到 IOException、UnrecoverableKeyException、BadPaddingException 之类的异常。
这类问题一般和下面因素有关:
- 证书文件是否存在;
- 证书文件是否被破坏;
- keystore 密码是否正确;
- key password 是否正确;
- key alias 是否正确;
- JDK 是否能兼容当前 PKCS12 文件。
1.2 请求访问阶段
如果服务已经启动成功,但客户端访问失败,问题可能出在:
- 客户端没有携带证书;
- 客户端证书不被服务端 truststore 信任;
- 服务端证书主机名不匹配;
- 端口没有开放;
- 网关、负载均衡、安全组没有放通对应端口。
这两类问题要分开看。
比如端口没有开放,一般会表现为连接超时、连接拒绝、502、503,而不是 BadPaddingException。
二、问题一:Maven 打包破坏了 .p12 证书文件
这次遇到的第一个问题,是证书文件在 Maven 打包过程中发生了变化。
.p12 是二进制文件,不能像普通文本配置一样做资源过滤。如果项目中启用了 Maven resource filtering,Maven 可能会尝试替换资源文件里的占位符,或者按文本编码处理资源文件。对于 .p12 这类二进制文件来说,这可能直接破坏文件内容。
破坏之后,JVM 加载 keystore 时就可能出现:
text
java.io.IOException: keystore password was incorrect
java.security.UnrecoverableKeyException
javax.crypto.BadPaddingException
这时异常提示的是"密码错误",但根因可能是"文件已经不是原来的文件"。
2.1 Maven 配置修复
可以在 pom.xml 中显式排除 .p12 文件的过滤处理:
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<encoding>UTF-8</encoding>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>p12</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
如果项目统一管理 Maven 插件版本,也可以把版本放到 pluginManagement 中。
2.2 如何确认证书是否被打包破坏
不要只看文件名是否存在,应该比较打包前后的文件哈希。
例如:
bash
shasum -a 256 src/main/resources/certs/dev/keystore.p12
再从 jar 包中解压出对应文件:
bash
jar xf target/app.jar BOOT-INF/classes/certs/dev/keystore.p12
shasum -a 256 BOOT-INF/classes/certs/dev/keystore.p12
如果两个哈希不一致,就说明打包后的证书文件发生了变化。
2.3 生产环境建议
开发环境把证书放在 src/main/resources 下可以降低调试成本,但生产环境不建议把证书直接打进 jar 包。
更推荐的方式是:
- Kubernetes Secret 挂载证书文件;
- 使用
file:/path/to/keystore.p12指向挂载路径; - 密码通过环境变量、配置中心或 Secret 注入;
- 不把证书和密码固化在应用制品中。
例如:
yaml
server:
ssl:
key-store: file:/etc/certs/server/keystore.p12
key-store-password: ${TLS_KEY_STORE_PASSWORD}
key-store-type: PKCS12
trust-store: file:/etc/certs/server/truststore.p12
trust-store-password: ${TLS_TRUST_STORE_PASSWORD}
trust-store-type: PKCS12
这样可以减少"同一个 jar 在不同环境需要不同证书"的维护成本,也更符合容器化部署习惯。
三、问题二:本地和 Pod 中的 JDK 版本不一致
第二个问题是:本地 JDK 和 Pod 镜像中的 JDK 版本不一致。
PKCS12 是标准格式,但具体到 Java 运行时,不同 JDK 版本、不同安全 Provider 对其中加密算法、MAC 算法和证书链解析的支持并不完全一致。
常见场景是:
- 本地用较新的 JDK 或 OpenSSL 生成
.p12; - Pod 中使用较旧 JDK 运行;
- 本地能正常加载,容器中加载失败;
- 最终表现为 keystore 解密失败或 password incorrect。
3.1 检查运行时 JDK
先确认本地和容器里的 JDK 版本:
bash
java -version
也可以进入 Pod 检查:
bash
kubectl exec -it <pod-name> -- java -version
3.2 用 keytool 验证证书能否被当前 JDK 读取
在运行环境中执行:
bash
keytool -list \
-storetype PKCS12 \
-keystore /etc/certs/server/keystore.p12
如果这里都读不出来,Spring Boot 启动时也大概率读不出来。
3.3 工程建议
mTLS 证书生成、验证、运行最好使用一致或兼容的 JDK 版本。至少要保证:
- 构建环境和运行环境的 JDK 版本明确;
- 容器基础镜像版本可追踪;
- 证书生成脚本中记录 JDK/OpenSSL 版本;
- 升级 JDK 后重新验证证书加载和握手流程。
这类问题不一定每天发生,但一旦发生,排查成本比较高。尤其是服务迁移到新基础镜像、升级 JDK、切换构建机时,建议把证书加载验证加入发布前检查。
四、问题三:8443 端口未开放导致访问失败
第三个问题是端口暴露。
我最开始把 Spring Boot 的 HTTPS 端口配置为 8443,但部署环境没有开放这个端口,导致外部访问失败。后来改成 443 后访问正常。
这里需要注意:端口未开放通常不是 BadPaddingException 的直接原因。它更可能导致:
- connection refused;
- connection timeout;
- 502/503;
- Ingress 或网关转发失败。
因此排查时要分清楚:
- 应用是否启动成功;
- keystore/truststore 是否加载成功;
- 服务端口是否监听;
- 网关是否放通。
五、Spring Boot 服务端 mTLS 配置示例
下面是一个服务端开启 mTLS 的 Spring Boot 配置示例。
5.1 pom文件配置
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.0.2</version>
<configuration>
<encoding>UTF-8</encoding>
<!-- 过滤证书文件 -->
<nonFilteredFileExtensions>
<nonFilteredFileExtension>p12</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
5.2 yml文件配置
yaml
server:
port: 443
ssl:
enabled: true
# KeyStore:服务端自己的证书和私钥
key-store: classpath:certs/dev/keystore.p12
key-store-password: xxx
key-store-type: PKCS12
key-alias: server
key-password: xxx
# TrustStore:服务端信任的 CA,用于验证客户端证书
trust-store: classpath:certs/dev/truststore.p12
trust-store-password: xxx
trust-store-type: PKCS12
enabled-protocols: TLSv1.2,TLSv1.3
# need 表示客户端必须提供证书
client-auth: need
几个配置需要特别注意。
key-store-password 和 key-password 不一定是同一个
key-store-password:打开 keystore 文件的密码;key-password:读取 keystore 中私钥条目的密码。
很多时候两者相同,所以容易被忽略。但如果生成证书时两者不同,配置错 key-password 也可能导致 UnrecoverableKeyException。
trust-store 决定服务端信任哪些客户端证书
开启 mTLS 后,服务端不只是提供自己的证书,还要验证客户端证书是否可信。
服务端会使用 trust-store 判断客户端证书链是否可信。如果客户端证书不是由 truststore 中的 CA 签发,或者证书链不完整,握手会失败。
client-auth: need 和 client-auth: want 的区别
need:客户端必须提供证书,否则握手失败;want:服务端会请求客户端证书,但客户端不提供也可能继续握手;none:不进行客户端证书认证。
如果目标是强制 mTLS,应该使用 need。
5.3 证书文件位置
5.4 是否要同时支持 HTTP 和 HTTPS
如果是已经上线的服务,直接切换到 mTLS 可能会影响旧客户端。为了兼容迁移,有时会临时同时开放 HTTP 和 HTTPS。
Spring Boot 使用内置 Tomcat 时,可以额外增加一个 HTTP Connector:
java
@Configuration
public class HttpConnectorConfig {
@Bean
public TomcatServletWebServerFactory tomcatServletWebServerFactory() {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
factory.addAdditionalTomcatConnectors(createHttpConnector());
return factory;
}
private Connector createHttpConnector() {
Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
connector.setScheme("http");
connector.setPort(8080);
connector.setSecure(false);
return connector;
}
}
但这个方案不能简单理解为"兼容一下就行"。
额外开放 HTTP 端口意味着:这个端口上的请求不会经过 TLS 握手,也不会进行客户端证书认证。如果 HTTP 端口可以访问同样的业务接口,就可能绕过 mTLS。
更稳妥的做法是:
- 只在迁移期短暂保留 HTTP;
- 对 HTTP 端口做网络层限制,只允许内网或指定网关访问;
- 敏感接口不要通过 HTTP 暴露;
- 配合 Spring Security 做应用层鉴权;
- 在网关层做 HTTP 到 HTTPS 的重定向;
- 迁移完成后关闭 HTTP 端口。
mTLS 解决的是传输层的双向身份认证,不应该被一个额外开放的明文端口绕过去。
六、HttpClient 访问 mTLS 服务示例
客户端访问 mTLS 服务时,需要准备两类证书材料:
- 客户端
key-store:包含客户端证书和私钥,用于向服务端证明"我是谁"; - 客户端
trust-store:包含服务端证书的 CA,用于验证"服务端是否可信"。
6.1 引入 httpclient 依赖:
xml
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.1.4</version>
</dependency>
6.2 证书文件位置
6.3 client类开发
java
public class MTLSClient {
// 密钥库/信任库密码(建议从配置文件读取,不要硬编码)
private static final String KEY_STORE_PASSWORD = "xxx";
private static final String TRUST_STORE_PASSWORD = "xxx";
public static void main(String[] args) {
try {
// 替换为你生成的 KeyStore 文件路径
String clientKeyStorePath = "certs/client.p12";
String trustStorePath = "certs/truststore.p12";
// 1. 创建 SSLContext
SSLContext sslContext = createMTLSSSLContext(clientKeyStorePath, trustStorePath);
// 创建 SSL 连接工厂,使用自定义主机名验证器
SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(
sslContext,
new String[]{"TLSv1.2", "TLSv1.3"}, // 指定协议
null,
new AllowAllHostnameVerifier()
);
// 连接池管理器
HttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setSSLSocketFactory(sslSocketFactory)
.setMaxConnTotal(20)
.setMaxConnPerRoute(10)
.build();
// 2. 创建 HttpClient
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.build();
// 3. 构建请求(替换为你的 mTLS 服务端地址)
HttpPost httpPost = new HttpPost("https://xxx:443/");
httpPost.setHeader("Content-Type", "application/json");
httpPost.setEntity(new StringEntity("请求体 body"));
// 4. 发送请求并处理响应
CloseableHttpResponse response = httpClient.execute(httpPost);
System.out.println("响应状态码: " + response.getCode());
HttpEntity entity = response.getEntity();
if (entity != null) {
String responseBody = EntityUtils.toString(entity);
System.out.println("响应体: " + responseBody);
EntityUtils.consume(entity);
}
} catch (Exception e) {
System.err.println("mTLS 请求失败: " + e.getMessage());
e.printStackTrace();
}
}
/**
* 从 KeyStore 文件创建 mTLS 专用的 SSLContext
*/
public static SSLContext createMTLSSSLContext(String clientKeyStorePath, String trustStorePath) throws Exception {
// 1. 加载客户端密钥库(PKCS12 格式)
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
Resource clientKeyStoreResource = new ClassPathResource(clientKeyStorePath);
try (InputStream keyStoreIn = clientKeyStoreResource.getInputStream()) {
clientKeyStore.load(keyStoreIn, KEY_STORE_PASSWORD.toCharArray());
}
// 2. 加载信任库(PKCS12 格式)
KeyStore trustStore = KeyStore.getInstance("PKCS12");
Resource trustStoreResource = new ClassPathResource(trustStorePath);
try (InputStream trustStoreIn = trustStoreResource.getInputStream()) {
trustStore.load(trustStoreIn, TRUST_STORE_PASSWORD.toCharArray());
}
// 3. 构建 SSLContext(自动加载密钥库和信任库)
return SSLContexts.custom()
.loadKeyMaterial(clientKeyStore, KEY_STORE_PASSWORD.toCharArray()) // 客户端证书+私钥
.loadTrustMaterial(trustStore, null) // 信任CA证书
.build();
}
/**
* 自定义主机名验证器,用于开发环境绕过主机名验证
*/
private static class AllowAllHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
return true; // 接受所有主机名
}
}
}
这里不建议在生产环境使用"跳过主机名校验"的 HostnameVerifier。
这会让客户端接受任意主机名,即使 mTLS 能验证客户端证书,也不代表可以跳过服务端证书的主机名校验。否则客户端可能信任了一个证书链合法但域名不匹配的服务端。
如果是本地调试,可以临时关闭校验,但应该把它限制在测试代码或开发 profile 中,不能进入生产默认路径。
8. 线上排查清单
遇到 keystore password was incorrect 时,不要只盯着密码,可以按下面顺序排查。
| 排查项 | 说明 |
|---|---|
| 证书文件路径 | 确认运行环境实际加载的是哪个 keystore/truststore |
| 文件是否存在 | classpath、挂载路径、Secret volume 是否正确 |
| 文件是否被破坏 | 比较打包前后或挂载前后的 sha256 |
| store password | 检查 key-store-password、trust-store-password |
| key password | 检查 key-password 是否和私钥条目密码一致 |
| alias | 检查 key-alias 是否存在于 keystore 中 |
| store type | 明确使用 PKCS12,避免依赖默认类型 |
| JDK 版本 | 对比本地、构建环境、Pod 运行环境 |
| keytool 验证 | 在容器内使用 keytool 直接读取证书 |
| Maven 资源过滤 | .p12、.jks 等二进制文件不要被 filtering 处理 |
| 容器镜像 | 确认基础镜像中的 JDK 和安全 Provider |
| 端口暴露 | 区分 SSL 加载失败和网络访问失败 |
| Service/Ingress | 检查 targetPort、port、TLS 转发方式 |
| 客户端证书 | 确认客户端是否携带证书和私钥 |
| truststore 内容 | 服务端是否信任客户端证书签发 CA |
| 主机名校验 | 服务端证书 CN/SAN 是否和访问域名匹配 |