尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JDK 21升级遇JCE无法认证BouncyCastle?排查与修复指南

JDK 21升级遇JCE无法认证BouncyCastle?排查与修复指南 1. 升级JDK 21后环境异常一次加密Provider认证失败实录先说结论这个问题几乎每个从JDK 8或JDK 11往JDK 21迁移的团队都会撞上只是报错时机和触发点不一样。我这次是在升级JDK 21后启动一个老Spring Boot服务时调用AES/ECB/PKCS5Padding加解密直接抛了异常。核心堆栈就是这个样子java.lang.SecurityException: JCE cannot authenticate the provider BC at javax.crypto.JceSecurity.getVerificationResult(JceSecurity.java:228) at javax.crypto.JceSecurity.getVerificationResult(JceSecurity.java:209) at javax.crypto.JceSecurity.canUseProvider(JceSecurity.java:208) at javax.crypto.Cipher.getInstance(Cipher.java:1564)第一眼看到这个报错很容易以为是JDK 21的JCEJava Cryptography Extension策略文件没有配置或者是无限制权限的JAR包没装。但实际上JDK 8u161之后JCE无限制权限策略已经是默认行为了根本不需要再去下载local_policy.jar和US_export_policy.jar。这个报错的根因基本都集中在BouncyCastleBCProvider 的版本和JDK 21的签名校验机制不兼容上。这篇文章把整个排查链路、底层机制和修复方案完整梳理一遍。对于正在做JDK升级、或者准备做JDK升级的团队应该有直接的参考价值。就算你现在还没升级到JDK 21这篇文章里提到的依赖冲突排查思路和加密Provider注册机制也一样适用。2. 底层机制JDK的JCE框架、Provider体系与BC的加载方式2.1 JCE框架的安全校验逻辑JDK从很早的版本开始就内置了JCE框架它不是一个单独的加密实现而是一套加密服务提供者架构。你可以把它理解成一个加密插件市场JDK自带一些默认插件比如SunJCE、SunPKCS11第三方也可以往这个市场里注册自己的插件BouncyCastle就是最典型的第三方插件。关键点来了JCE框架出于安全考虑会对所有第三方Provider做JAR签名验证。也就是当你的代码执行Cipher.getInstance(AES/ECB/PKCS5Padding, BC)时JVM会先检查BC这个Provider所在的JAR包是否被正确签名、签名是否可信。这个验证逻辑在JceSecurity类里一旦验证失败就会抛出我们前面见到的SecurityException: JCE cannot authenticate the provider BC。2.2 为什么BC会被判定为无法验证BouncyCastle的JAR包在发布时是用它自己的私钥签名的而JDK内部维护了一个受信任的签名证书列表。问题就出在这里BC发布新版本时如果更换了签名证书JDK之前的信任列表里没有这张新证书就会验证失败。BC旧版本的JAR包签名机制与新版JDK的严格校验逻辑不兼容。JDK 8时代校验比较宽松而JDK 21的JceSecurity对签名算法的强度、证书链的完整性有更严格的要求。项目里存在多个BC版本JAR包classpath里加载到的org.bouncycastle.jce.provider.BouncyCastleProvider来自一个旧版本而实际使用的加密API又依赖新版本导致Provider初始化时签名校验失败。这三个原因里前两个属于JDK升级后的客观不兼容第三个属于典型的依赖冲突。我这次遇到的就属于第三种但表现形态非常迷惑——单看报错信息完全想不到是依赖冲突。2.3 JDK 21相比JDK 8在加密层面的变化JDK 21在加密这块的变化很多人只关注到AES/ECB废弃这些API层面的东西却忽略了更深层的两个改动第一JDK 21删除了很多历史遗留的加密逻辑和弱算法支持。MD2、MD4这类早就废弃的算法在新版本里的处理路径被直接移除了如果某个旧库在初始化时触发了这些逻辑异常会表现为各种奇怪的SecurityException。第二JDK 21强化了Provider签名验证的失败处理机制。在JDK 8里JceSecurity验证失败时有时候只会把Provider标记为不可用然后继续运行但在JDK 21里失败会直接作为硬异常抛出不会给你带病运行的机会。这也是为什么很多老项目在JDK 8上跑得好好的升级JDK 21后立刻爆雷。3. 完整排查链路从堆栈信息到根因确认3.1 第一层确认触发点我先在测试环境复现拿到了完整堆栈。当时触发报错的是一段很老的数据加密工具类代码长这样public static String encrypt(String plainText, String key) throws Exception { SecretKeySpec spec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES); Cipher cipher Cipher.getInstance(AES/ECB/PKCS5Padding, BC); cipher.init(Cipher.ENCRYPT_MODE, spec); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); }注意这行Cipher.getInstance(AES/ECB/PKCS5Padding, BC)这就是问题的高危写法代码显式指定了使用BC Provider。这种写法在JDK 8时代很常见因为当时的SunJCE在某些算法上不如BC丰富。但在JDK 21环境下一旦BC的版本不被信任这行代码就成了定时炸弹。3.2 第二层检查当前实际加载的BC版本我用一段小代码打印运行时实际加载的BC版本import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; public class BcCheck { public static void main(String[] args) { BouncyCastleProvider provider new BouncyCastleProvider(); System.out.println(BouncyCastle version: provider.getVersion()); for (Object o : Security.getProviders()) { System.out.println(Provider: o); } } }运行结果让我很意外BouncyCastle版本是1.69而且Security.getProviders()里同时存在两个BC Provider实例。这说明classpath里至少有两条路径都加载了BC的类而且版本还不一致。3.3 第三层用依赖树定位冲突源头这是整个排查过程里最关键的一步。我用了Maven的依赖树命令mvn dependency:tree -Dincludesorg.bouncycastle输出结果里发现了问题所在[INFO] - org.springframework.security:spring-security-crypto:jar:5.8.5:compile [INFO] | \- org.bouncycastle:bcpkix-jdk18on:jar:1.77:compile [INFO] | \- org.bouncycastle:bcprov-jdk18on:jar:1.77:compile [INFO] \- com.xxx:legacy-util:jar:2.3.1:compile [INFO] \- org.bouncycastle:bcprov-jdk15on:jar:1.69:compile真相大白了项目的直接依赖里有一个老的自研工具包legacy-util它内部传递依赖了bcprov-jdk15on:1.69。而Spring Security Crypto又引入了bcprov-jdk18on:1.77。两个版本的BC类在classpath里共存JDK 21的JCE在验证Provider时优先加载到了旧版本的1.69签名校验直接失败。3.4 第四层确认JDK 21的策略文件和签名机制为了排除JDK层面策略文件的影响我还做了两个验证检查了JDK 21安装目录下的conf/security/java.security文件确认没有自定义的crypto.policy配置。手动打开旧版BC的JAR包查看META-INF/目录下的签名文件对比新版BC的签名算法。结果证实bcprov-jdk15on:1.69的JAR签名算法还是老标准而JDK 21默认要求更高强度的签名验证。即使没有依赖冲突这个旧版本在JDK 21上也跑不起来。4. 修复方案依赖版本、Provider注册与代码写法三路并进4.1 方案一强制升级BC依赖版本推荐最直接有效的方案就是让项目里所有BC相关依赖统一到官方支持JDK 21的版本。BC从1.70开始全面支持JDK 9模块化但真正对JDK 21有完整适配的是1.76及以上版本。我当时直接升到了1.77后面BC官方又发布了1.78现在建议直接用最新的1.78。在pom.xml里加入显式依赖管理锁定版本dependencyManagement dependencies dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcpkix-jdk18on/artifactId version1.78/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcutil-jdk18on/artifactId version1.78/version /dependency /dependencies /dependencyManagement这里有个细节需要注意BC从1.70开始把jdk15on和jdk18on两个系列做了明确拆分。JDK 15环境必须用jdk18on系列不能再引入jdk15on。旧系列的坐标是bcprov-jdk15on新系列是bcprov-jdk18on两者在包结构上相似但JAR签名和内部实现都不同核心就是给JDK 18用的JDK 21环境下必须要用这个。用了dependencyManagement之后之前的bcprov-jdk15on:1.69会被Maven的就近原则和依赖管理机制共同作用强制替换为bcprov-jdk18on:1.78。但要注意如果直接依赖legacy-util里用了反射或者硬编码字节码引用了旧版类光靠Maven替换还不够需要检查那个工具包的代码。我这次比较幸运legacy-util只是传递依赖了旧BC并没有在代码里硬编码特定版本。4.2 方案二移除显式的BC Provider注册如果项目代码里到处是Cipher.getInstance(AES/ECB/PKCS5Padding, BC)这种写法站在工程化角度这就是一个需要重构的坏味道。因为绝大多数场景下你根本不需要指定BCJDK自带的SunJCE就支持AES、RSA、SHA等常用算法。最简单粗暴的做法是把所有Cipher.getInstance(algorithm, BC)改成Cipher.getInstance(algorithm)。这样JVM会按java.security文件里配置的Provider顺序自动选择可用的实现通常就是SunJCE。改完之后加解密逻辑不需要BC参与自然就不会触发BC的签名验证。这里给个对照参考算法分组常见算法SunJCE是否支持是否需要BC对称加密AES、DES、Blowfish支持不需要非对称加密RSA、EC支持不需要摘要MD5、SHA-1、SHA-256支持不需要国密SM2、SM3、SM4不支持必须用BC特殊曲线Ed25519、X25519JDK 15支持低版本需要如果你的项目只用了表里前半部分的算法直接删掉BC参数最省事。如果用了国密或者特殊曲线算法那就必须保留BC这时候方案一才是正解。4.3 方案三移除双重Provider注册项目里如果存在多个BC版本Security.addProvider(new BouncyCastleProvider())被调用了很多次会在Provider列表里注册多个实例。JDK的JCE在验证时有时候会拿到排在前面的旧实例导致签名验证失败。我见过一个极端案例代码里手动注册了一次BCSpring的某个库启动时又自动注册了一次运维脚本里还用-Dbc.provider参数指定了另一个路径的BC。三个BC实例同时存在版本还都不一样这种情况下JVM怎么选都不会对。正确做法是只保留一处注册。如果是Spring Boot项目优先使用Security.addProvider在Configuration里显式注册一次并确保版本统一。或者直接把BC的注册信息写到java.security文件里security.provider.16org.bouncycastle.jce.provider.BouncyCastleProvider但改全局java.security文件会影响JDK下的所有应用不太推荐本地开发和测试环境调试一下可以生产环境还是建议在应用代码层面统一管理。4.4 方案四排除传递依赖精准移除旧BC包有些场景下直接升级传递依赖的版本会牵连到其他框架的兼容性这时候可以考虑在pom.xml里排除旧BC的传递依赖dependency groupIdcom.xxx/groupId artifactIdlegacy-util/artifactId version2.3.1/version exclusions exclusion groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15on/artifactId /exclusion /exclusions /dependency排除之后这个老工具包在运行时会使用项目里其他依赖提供的BC版本。操作前需要确认老工具包对BC API的调用没有使用旧版本独有的方法否则排除后会出现NoSuchMethodError属于把一个坑换成了另一个坑。我的经验是排除法适合快速验证是不是版本冲突导致的也就是临时让项目跑起来。长期维护的话还是方案一的dependencyManagement锁版本更干净。5. 实战备忘JDK 21环境下BC版本选择的硬性标准5.1 官方兼容性对照根据我个人测试和BC官方Release Notes把JDK 21环境下常见的BC版本兼容性整理了一下BC版本JDK 8JDK 11JDK 17JDK 21备注1.69及以下正常正常可能异常异常签名算法过旧JDK 21下不可用1.70-1.73正常正常正常有风险可能某些算法触发验证失败1.74-1.75正常正常正常可用但建议升级部分老API标记废弃1.76正常正常正常兼容官方声明支持JDK 211.77-1.78正常正常正常兼容推荐修复多个已知安全问题这个表只是参考真实环境还要看你项目里调用了哪些具体算法。最稳妥的方式是升级后跑一遍全量单元测试和接口回归测试重点覆盖所有加解密、签名验签、证书解析的逻辑。5.2 不同操作系统的JDK 21环境准备升级JDK 21时好多同事的报错其实是可以提前避免的。Windows环境下如果机器上同时存在JDK 11和JDK 21建议用JAVA_HOME加PATH两个环境变量共同控制切换set JAVA_HOMED:\dev\jdk-21 set PATH%JAVA_HOME%\bin;%PATH%macOS环境建议直接用Homebrewbrew install openjdk21 sudo ln -sfn /usr/local/opt/openjdk21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk # 查看当前默认JDK版本 /usr/libexec/java_home -V安装完成后务必确认一下运行时版本java -version javac -version很多JCE相关报错被误判为JDK没装好实际上是因为PATH里还残留着旧版JDK的bin目录java -version显示的是21但应用服务器启动脚本用的是另一个JDK路径。我建议在项目启动脚本里显式指定JAVA_HOME不要依赖服务器环境变量既省心又可控。5.3 从JDK 8迁移到JDK 21时需要检查的加密相关配置项我在多个项目里总结了一个检查清单每次JDK升级前过一遍能避免大部分莫名其妙的加密异常用mvn dependency:tree或gradle dependencies检查org.bouncycastle的依赖树确保只有一个系列jdk18on、一个版本。搜索所有Cipher.getInstance(...)调用检查是否显式指定了BC。查看java.security文件确认crypto.policy配置是否为默认值unlimited。检查Security.addProvider的调用次数项目中应该只有一处主动注册BC。确认代码里没有手工加载lib/ext目录下的旧BC JAR。如果使用了加密通信库检查它自带的Provider是否与BC版本冲突。上面任意一项命中都有可能在JDK 21环境下暴露成SecurityException: JCE cannot authenticate the provider BC。6. 复盘与延伸这个异常背后涉及的依赖管理思路6.1 为什么依赖冲突在JDK升级后才爆发这次排错有一个值得复盘的点为什么同样的bcprov-jdk15on:1.69在JDK 8上没崩升级到JDK 21就崩了因为JDK 8的JCE签名验证机制相对宽松旧版本BC在被加载时虽然也经过了验证但验证逻辑允许降级处理也就是验证失败了不一定会立刻抛出异常只是某些算法不可用而已。而JDK 21的JCE验证逻辑把这个问题从运行时警告变成了启动时硬失败。这其实是一个安全机制的正常强化只是很多历史遗留项目因为依赖管理不干净被这个强化的机制提前引爆了。这也给了一个启示日常开发中依赖冲突不会立刻报错不代表可以不管。尤其像org.bouncycastle这种横跨很多框架的公共库一旦出现多版本共存早晚会因为某个环境升级炸出来。提前用dependencyManagement统一版本比事后排查要省力得多。6.2 加密Provider注册的另一个坑先注册后被覆盖除了BC版本冲突还有一个容易被忽略的问题Security.addProvider(Provider)方法的顺序。如果代码里在应用启动阶段注册了BC但后续某个第三方库在初始化时又调用了Security.insertProviderAt(someProvider, 1)把其他Provider插到了BC前面那么某些算法在按Provider顺序查找时可能会先命中非BC的实现导致加解密结果与预期不一致。这种问题不会像JCE验证失败那样抛异常而是表现为同一个加密算法在JDK 8上输出正常在JDK 21上输出结果不对。排查起来比这次的异常还费劲因为Java层面的堆栈完全正常就是结果不对。我的建议是如果项目依赖了多个第三方加密库最好在应用启动后打一次Provider列表快照在日志里输出当前生效的Provider顺序和版本。后续出问题可以快速对照而不是靠猜。6.3 最后一点心得这次升级JDK 21的经历前后花了小半天时间真正定位到根因的过程其实只有几十分钟大部分时间浪费在反复验证和确认信息上。核心收获可以归纳成三条第一遇到JCE cannot authenticate the provider BC第一步不是改java.security策略文件而是去看classpath里的BC版本和依赖树。第二Cipher.getInstance(algo, BC)这种写法在JDK 21环境下是高风险写法能不用就不用让JVM自己选Provider是更安全的选择。第三JDK升级不是简单的换一个JDK目录加密基础设施的兼容性排查必须纳入升级规划。如果你正在做JDK 21升级建议先把BC版本统一到1.78把显式指定BC的代码全部清理一遍再跑测试环境回归大概率不会踩到这个坑。
返回列表