☰
JDK升级后JCE无法认证Provider BC的排查与修复
2026/10/1 4:58:11 网站建设 项目流程

上周把一套还在跑的老服务从 JDK 8 挪到 JDK 17,本地mvn clean package之后跑得好好的加解密逻辑,一进容器就抛SecurityException: JCE cannot authenticate the provider BC,日志里前面还跟着一串at javax.crypto.JceSecurity.verifyProviderJar。这个报错在 JDK、SecurityException、JCE、provider BC 这几个关键词上搜索量一直不低,因为它踩的是 JDK 升级里最容易被忽略的一块——不是语法不兼容、不是模块不兼容,而是 JCE 的provider 认证机制在换版本之后换了判定条件。它解决起来不难,但难在很多人第一反应是"BC 版本太老,换个新的",结果换了三四个版本依然报同一个错,白白耗掉半天。

按我这些年的经验,遇到这个异常的基本都是三类人:一是把老项目从 JDK 8 往 JDK 11/17/21 上迁的,二是用了 shade、assembly 之类插件把依赖打成一个大 jar 的,三是在容器里自己管 JDK 环境的。这篇文章就把这条报错从头到尾拆开:它到底在校验什么、升级 JDK 之后哪几个条件变了、四条修法各自适合什么场景、怎么一步步复现和定位、以及我自己踩过的那些坑。看完你应该能自己判断手上的工程属于哪一类,而不是挨个试版本。

1. 先把这条异常读懂:JCE cannot authenticate the provider BC 到底在说什么

1.1 异常抛出点与调用链还原

这条异常不是你写的代码抛的,是 JDK 内部抛的,位置在javax.crypto.JceSecurity这个类里。它的完整链路是这样的:你在代码里调了Security.addProvider(new BouncyCastleProvider()),这一步通常不报错;真正触发校验的是第一次使用该 provider 提供的加密服务,比如Cipher.getInstance("SM4/ECB/PKCS5Padding", "BC")。这时 JCE 会去拿 provider 的校验结果,如果缓存里没有,就走一遍校验流程,校验失败就抛出SecurityException。

关键点在于:Security.addProvider只是把 provider 注册进java.security.Security这张表里,它本身不做任何签名校验。很多人看到"注册时就该报错"的直觉是错的,所以会出现"启动日志没有异常,一调加密接口才炸"的现象,排查时容易误判成业务代码问题。理解了这一点,你就能明白为什么同一个 BC 版本,有的接口能跑、有的接口一调就挂——取决于该接口有没有真正走到 BC 的 provider 上。

校验流程的核心方法是JceSecurity.verifyProviderJar(URL codeBase)。它做的事情是:先拿到 BC 的BouncyCastleProvider这个类是从哪个 jar 加载的(也就是ProtectionDomain里的CodeSource),然后打开那个 jar,逐个 class 条目检查它们是否都被完整签名,任何一个条目没有有效签名,整个 provider 就判定为不可认证。抛异常时会带上 provider 的名字,所以我们看到的就是固定的JCE cannot authenticate the provider BC这个字符串。

这里有个细节值得记住:如果codeBase拿不到,也就是 provider 类的CodeSource为 null,JCE 会直接放行,不做校验。这个分支是后面所有"绕过式方案"的理论基础,也是老一代"把 jar 丢到扩展目录"技巧能生效的原因。反过来讲,只要codeBase是一个正常的 jar 文件 URL,校验就躲不掉。

1.2 JCE 认证机制的设计初衷

搞清楚"它为什么存在",比记命令有用得多。JCE 是 Java 的加密扩展,在很长一段历史时期里,加密算法的强度是受到出口管制的,弱强度的实现可以随便用,强强度的实现需要单独授权。JDK 的做法就是:自带的 SunJCE provider 由官方签名,第三方 provider 必须提供一个可验证的签名 jar,JCE 靠这个签名来确认"这是一个被认可的加密实现",而不是随便谁塞进来的一段代码。

所以这套机制的本质是一个来源可信性检查,只不过它检查的是 jar 的签名完整性,而不是检查功能对不对。这解释了一个经常让人困惑的现象:明明我的 BC 能正常跑 AES 加解密,为什么 JCE 还要拦我?因为拦你的不是 BC,是 JCE 这个守门人,它拦的是"这个 jar 的签名状态"。

顺着这个设计意图往下想,就能推出两件事。第一,官方发布的bcprov是带签名的,正常情况下不会有问题。第二,任何会破坏签名的操作——重打包、合并 jar、手动改 jar 内容——都会把这个认证打破,哪怕类字节码本身一个字节都没变。这就是为什么"我什么都没改,只升级了 JDK"这个描述通常是不准确的,中间一定发生了打包方式或者加载方式的变化。

1.3 从 JDK 8 到 JDK 17,这套认证是怎么变的

JDK 8 到 JDK 11 之间,这套机制在"校验什么"上没有根本性变化,但在"什么情况下跳过校验"上变化很大,这才是升级后集中爆发的根源。

JDK 8 时代,jre/lib/ext是真实存在且生效的扩展目录。你把bcprov-jdk15on-xxx.jar往这个目录一扔,JVM 的扩展类加载器就把它加载进来,codeBase的判定会走"可信"分支,签名校验直接略过。当年大量项目就是靠这一招解决的,配置文件里躺着一句"把 BC 放进 jre/lib/ext"的注释,一躺就是好几年,谁也没意识到这是个隐患。

从 JDK 9 开始,扩展机制被标记为废弃并给出警告;到 JDK 11,jre目录整体消失,java.ext.dirs这个系统属性不再影响类的加载,-Djava.ext.dirs=...也基本失效。于是原来那个"扔进 ext 就完事"的路径彻底断了。如果此时你的工程又把 BC 打进了 fat jar,签名在打包过程中被破坏,那两条路一起断,报错就必然出现。这也是我遇到过的绝大多数案例的真实成因。

再补一句 JDK 9 之后的模块化影响:BC 的类被放到了未命名模块里,这本身不影响签名校验,但会让CodeSource的取值更"干净"——一定是那个具体的 jar 文件。以前在某些容器里CodeSource可能是 null 从而侥幸绕过,现在绕不过去了,等于把原来被掩盖的问题暴露出来。所以严格来说,不是升级 JDK 引入了 bug,而是升级 JDK 把一直存在的隐患照了出来。

2. 升级 JDK 之后集中爆发的四类根因

2.1 根因一:jre/lib/ext 扩展目录在 JDK 11 之后彻底消失

这是出现频率最高的一类。典型特征是你去翻老项目的部署脚本或者运维文档,能找到类似"将 bcprov 拷贝至$JAVA_HOME/jre/lib/ext"这样一条步骤,而新环境的 JDK 目录下压根没有jre这个子目录。这时候 BC 要么根本加载不到(报ClassNotFoundException),要么因为你同时在 pom 里引了 BC,从 classpath 加载,签名校验上线,报 SecurityException。

要确认是不是这一类,可以做个很直接的检查。执行java -XshowSettings:properties -version 2>&1 | grep -i ext.dir,在 JDK 8 上会打印出java.ext.dirs = ...且指向真实的jre/lib/ext;在 JDK 11 之后的版本上,这个属性要么不存在,要么指向一个与扩展加载无关的位置。这个命令我很推荐,三十秒就能排除掉一大类猜测。

需要提醒的是,不要试图用-Djava.ext.dirs=/some/path在新 JDK 上"救回来"。JDK 9 里这个属性已经被忽略,JDK 11 之后完全没有效果,写了只会让你误以为配好了。正确的心态是:扩展目录这条路径已经在现代 JDK 上不存在了,必须换一种加载方式或解决签名问题,而不是找它的替代品。

还有一个变体:有人把 BC 的 jar 放进了容器的共享 classpath 目录,比如某些中间件的lib/ext或者自建的common/lib。这在 JDK 8 上可能侥幸能跑,因为共享 classpath 由特定类加载器装载,判定分支不同;升级之后类的加载路径变了,codeBase变成了具体 jar,校验就又上线了。这类问题最隐蔽,因为配置文件看起来"什么都没动"。

2.2 根因二:shade / assembly 重打包把 BC 的签名文件弄没了

第二类是高发区,尤其在微服务和"一个大 jar 走天下"的部署模式里。BC 的官方 jar 里带有一组签名相关的元数据文件,位置在META-INF/下,常见的文件名是BCxxx.SF、BCxxx.RSA或BCxxx.DSA,还有META-INF/MANIFEST.MF里与之对应的一批摘要条目。JCE 校验时会读这些文件,逐个条目核对摘要。

重打包插件的行为是:把各个依赖 jar 解开,把 class 文件按包名合并到同一个目录树,然后重新压成一个 jar。这个过程里,签名文件要么被直接丢弃(很多插件默认排除META-INF/*.SF这类文件),要么被保留但内容已经对不上了(因为 class 被重新组织、其他依赖的摘要也混在一起)。两种结果对 JCE 来说都是致命的:前者是"完全没有签名",后者是"签名存在但验证不通过"。

这里有个特别容易误判的点:很多人发现修复的第一步是"在 shade 配置里排除签名文件",照做之后错误依旧,于是怀疑方案不对。其实排除签名文件只是为了让重打包过程不报冲突,它本身不能解决 JCE 认证问题——一个完全没有签名的大 jar,JCE 照样拒绝。正确的完整链条是"先排除旧签名,再对整个大 jar 重新签名",第二步才是关键,漏掉它等于白干。

顺带说一个对照情况:Spring Boot 的spring-boot-maven-plugin用的是嵌套 jar 布局,各个依赖以原始 jar 的形式放在BOOT-INF/lib/下,BC 的 jar 是原封不动搬进去的,签名文件完好,所以通常不会触发这个异常。如果你的项目正好是这种布局却依然报错,那问题多半不在打包,而在别处,比如 classloader 展开或者自定义了加载逻辑。把这个对照记在心里,能帮你快速判断"我这算不算重打包问题"。

2.3 根因三:BC jar 命名与 JDK 版本对不上号

BC 的构件命名有个历史包袱,早年的bcprov-jdk15on表示"适配 JDK 1.5 及以上",bcprov-jdk15to18表示"适配 1.5 到 1.8",后来主流换成了bcprov-jdk18on,意思是"适配 JDK 1.8 及以上"。名字里那个数字是最低兼容版本,不是"只能用在某个版本"。这一点很多人理解反了,以为jdk15on就是给 JDK 15 用的。

虽然命名本身不会直接导致 SecurityException,但它会间接把你带进坑里。典型场景是:升级 JDK 时顺手把 BC 从bcprov-jdk15on换成了某个更新的版本,同时打包方式没变,结果原来那个"签名文件被排除"的老配置还在,新版本 jar 的签名结构不同,处理逻辑对不上,异常依旧。另一种是保留了很老的 BC 版本,那个版本发布的 jar 签名算法已经过时,某些 JDK 上用默认参数重新签名后校验会失败。

我的建议很直接:JDK 8 及以上统一用bcprov-jdk18on系列,或者用 LTS 分支bcprov-lts8on,别在jdk15on这类老命名上纠结。选版本的时候也不要去追最新的那个数字,选一个比你的 JDK 发布稍晚一点的稳定版就够了,太新的版本有可能用到你 JDK 还不支持的语言特性,反而引入新问题。版本对照我在第 5 节整理了一张表,可以直接对着查。

2.4 根因四:继承 BouncyCastleProvider 的自定义类把签名带偏

这一类知道的人少,但确实存在。有些项目为了修改默认配置,会写一个class MyProvider extends BouncyCastleProvider,然后在里面调put(...)改参数或者注册自定义算法,最后Security.addProvider(new MyProvider())。问题在于,MyProvider这个类是你自己工程编译出来的,它在你的 jar 里,不在 BC 的签名 jar 里。

JCE 校验时取的是 provider 实例的类,也就是MyProvider的CodeSource,而不是父类所在的 BC jar。于是校验对象变成了你的业务 jar——这个 jar 显然没有 BC 的签名,认证自然失败。这类问题的迷惑性在于,报错信息里 provider 名字可能还是BC(因为你在构造函数里设了名字),让人完全想不到是自定义类惹的祸。

修法有两种。稳妥的是别继承,改成组合:先Security.addProvider(new BouncyCastleProvider())注册官方实例,需要改配置时通过系统属性(比如Security.setProperty)或者在使用处显式指定参数,不要动 provider 的类继承关系。另一种是接受现实,把自定义 provider 所在的那个 jar 一起重新签名,让签名覆盖到它。选哪种取决于你的自定义程度,如果只是改几个参数,强烈建议走第一种。

3. 方案选型:四条路线怎么挑

3.1 四条路线横向对比

在动手之前先把选项摆清楚,比一头扎进其中一个方案要高效得多。按投入成本和适用场景,我把它们分成四条路线,下面这张表是我自己选型时会过一遍的对照。

路线核心动作适用场景改动成本主要风险
保留官方签名不做重打包,BC 以独立 jar 加载Spring Boot 嵌套布局、能改打包方式的工程低需要调整构建链路
重新签名剥离旧签名后对整个产物签名必须打单一 fat jar 的场景中需管理密钥、签名环节易漏
独立加载用外部 lib 目录 + classpath 扩展容器化部署、运维可控中classloader 关系要理清
弃用 BC改用 JDK 内置 provider 或自研实现只用 AES/RSA/ECDSA 这类通用算法低到中国密等算法无法替代

选型时问自己三个问题就够了:这个工程必须打成单 jar 吗?运维能不能接受多一个 lib 目录?业务里有没有强依赖 BC 独有算法(比如 SM 系列、Ed25519 早期版本、某些 PGP 相关能力)?三个问题的答案基本就锁定了路线。

我个人的偏好顺序是:能用嵌套 jar 就用嵌套 jar,因为它零成本且不留后患;不能的话优先重新签名;再不行才考虑独立 lib 目录,因为多一个目录意味着多一处部署失误的可能;最后才是弃用 BC,因为改算法是业务级改动,风险和成本都不低。

3.2 路线一:保留官方签名 jar,别做重打包

这条路线的核心思想是"别碰 BC 的 jar"。只要 BC 从官方发布的、带签名的 jar 加载,JCE 认证天然通过,你不需要做任何额外配置。听起来像废话,但它恰恰是大多数人绕了一大圈之后回到的答案。

具体怎么做取决于你的构建工具。如果是 Spring Boot,确认用的是spring-boot-maven-plugin的repackage目标,而不是 shade 插件,并且没有开layout=NONE。后者会把嵌套 jar 展开,直接把签名问题带回来。如果是普通 Java 应用,把 shade 或 assembly 插件换成maven-dependency-plugin把依赖复制到target/lib,启动时用java -cp "app.jar:lib/*"或者对应的 classpath 通配写法。

容器化场景下这个方案尤其顺手:镜像里本来就有一层放依赖的目录,把 BC 的原始 jar 单独放进去即可。需要注意别让构建工具在复制过程中"顺手"过滤掉META-INF目录,某些企业内部的构件仓库或者镜像构建脚本会做这类清洗,这是个隐蔽的坑。复制完之后用jarsigner -verify抽查一下,能省掉后面很多来回。

3.3 路线二:用 jarsigner 自己重新签名

当工程确实必须产出单一 fat jar 时,重新签名是最干净的做法。这里有个原理必须讲清楚,否则你不会相信"自己签的也能过":JCE 的 jar 校验用的是 jar 里携带的签名者证书公钥来做签名有效性验证,它验证的是"这个 jar 的内容和签名自洽",并不要求证书链条能追溯到系统的cacerts信任库。所以自建密钥签出来的 jar,在 JCE 眼里和官方签的没有区别。

这个特性意味着你不需要去买证书,也不需要 BC 官方的私钥,自己生成一个 keystore 就够。操作分三步:先把大 jar 里残留的旧签名元数据剥掉,再用keytool生成密钥,最后用jarsigner对整个 jar 签名。顺序不能反,先签再剥等于没签。命令细节我在第 4 节完整给出来。

需要提醒的是,密钥和口令的管理别偷懒放在代码库里。这类签名密钥一旦泄露,别人可以伪造你产物的签名,虽然在这个场景下危害有限,但规范上不该开这个口子。我一般用 CI 的环境变量注入,本地开发用一个单独的测试密钥,两边分开。另外签名的 digest 算法建议显式指定 SHA-256 及以上,别用默认值,老版本的jarsigner默认可能还是 SHA-1,某些 JDK 会拒绝。

3.4 路线三:让 BC 以独立 jar 的形式加载

这条路线的适用场景是:构建产物不好改,但部署环节你说了算。做法是把 BC 的原始 jar 放在应用的 classpath 之外的一个固定目录,通过启动参数把它加进 classpath。比如java -cp app.jar:/opt/app/lib/bcprov-jdk18on-1.78.jar com.example.Main,或者用 manifest 里的Class-Path条目声明。

关键在于"原始 jar"这四个字。如果你放在那个目录里的 jar 本身就已经被重打包过了,签名早没了,这条路也走不通。所以这个方案通常要配合前一个方案一起用:先保证手里有一个带原始签名的 BC jar,再想办法让 JVM 从它加载。

要验证效果,可以在代码里打印一下new BouncyCastleProvider().getClass().getProtectionDomain().getCodeSource().getLocation(),看看输出的路径是不是你放的那个原始 jar。如果不是,说明加载路径被别的东西抢先了,常见原因是工程依赖树里还藏着一份被打包的 BC,classloader 先加载了它。用mvn dependency:tree -Dincludes=org.bouncycastle查一遍,把重复依赖排掉。

3.5 路线四:能不用 BC 就不用

这条经常被忽略,但值得先评估。如果你的项目用 BC 只是为了 AES、RSA、ECDSA、SHA 这类通用算法,那 JDK 自带的 provider 完全够用,而且从 JDK 8u161 起,AES-256 这类强算法的密钥长度限制已经默认放开,不需要额外策略文件。这种情况下把 BC 摘掉,报错自然消失,还顺带少了一个依赖和一层签名约束。

不能摘的典型场景是国密算法(SM2/SM3/SM4)、某些老版本的 Ed25519(JDK 15 之后内置,之前需要 BC)、部分 PGP 相关能力,以及一些小众的椭圆曲线。这些确实只能靠 BC 或者自研实现。评估的方法很简单:全局搜一下代码里所有用了"BC"作为 provider 名的位置,逐个看算法名,判断 JDK 内置能不能替代。这项工作一次做完,收益是长期的。

如果只是部分算法依赖 BC,还有个折中方案:把 BC 的使用范围收窄到只走那几个独有算法的代码路径,其余走默认 provider。这样即使 BC 的加载出问题,核心业务链路也不至于全挂。这个思路在稳定性要求高的系统里挺实用,我一直推荐。

4. 手把手实操:从复现到修好

4.1 搭一个最小复现工程

排查这种东西,先把手上的大工程放一边,用最小工程复现出问题,再回到大工程改,效率高得多。最小区分点在于"BC 从哪加载"和"签名是否完整",所以这个最小工程要能自由控制打包方式。

先写一小段探针代码,注意这里选 SM4 是为了确保必须走 BC,因为 JDK 内置 provider 不提供这个算法,能排除掉"其实没用 BC"的干扰:

import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.security.Security; public class BcProbe { public static void main(String[] args) throws Exception { Security.addProvider(new BouncyCastleProvider()); System.out.println("provider = " + Security.getProvider("BC")); System.out.println("code source = " + BouncyCastleProvider.class.getProtectionDomain() .getCodeSource().getLocation()); Cipher c = Cipher.getInstance("SM4/ECB/PKCS5Padding", "BC"); c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(new byte[16], "SM4")); byte[] out = c.doFinal("hello".getBytes()); System.out.println("out len = " + out.length); } }

先用一个普通的mvn dependency配置,依赖org.bouncycastle:bcprov-jdk18on,用exec:java直接跑,正常应该不报错,code source指向你本地仓库里那个原始 jar。这一步是基线,确认环境本身没问题。

然后把依赖换成通过 shade 插件打一个 fat jar,再用java -jar跑同一段代码。如果 JCE 校验的逻辑成立,这里就应该复现出JCE cannot authenticate the provider BC。如果没复现,说明你的 shade 配置恰好保留了签名文件,那是另一回事,可以手动删掉META-INF/*.SF再跑一次确认。这个对照实验做完,你对根因的把握就非常确定了。

4.2 三步定位签名到底丢在哪

复现出来之后,用三条命令就能把"签名丢在哪个环节"定位清楚。第一条,看产物里到底还有没有签名文件:

jar tf target/app.jar | grep -Ei 'META-INF/.*\.(SF|RSA|DSA|EC)'

这条命令没有输出,说明签名元数据已经被丢掉;有输出也不能掉以轻心,要看文件名和内容是否对应得上。

第二条,用jarsigner验证整个 jar 的签名状态:

jarsigner -verify -verbose -certs target/app.jar | tail -n 20

注意这里的判定标准:看到jar verified只能说明签名自洽,不代表 JCE 会接受;看到jar is unsigned就是明确没有签名;如果出现大量entry is not signed,说明只有部分条目被覆盖,这在 JCE 眼里同样是不合格的。我踩过的一个坑就是只看最后一行jar verified就以为没问题,实际上中间混着未签名的条目,跑了半天才发现。

第三条,看运行时的实际加载来源,用上面探针里的那行code source输出即可。这一步是确认"JVM 到底从哪个 jar 加载 BC",很多人修了半天签名,结果 JVM 加载的根本不是被修的那个 jar,白费功夫。三条命令走完,你手上就有了完整的事实链。

4.3 重新签名的完整命令与验证

确认要走重新签名这条路之后,按下面的顺序操作。先准备一个密钥,有效期给长一点,避免以后换密钥还得重新过一遍流程:

keytool -genkeypair \ -alias build-signer \ -keyalg RSA -keysize 2048 -validity 3650 \ -keystore build-sign-ks.p12 -storetype PKCS12 \ -storepass "your-store-pass" \ -dname "CN=internal-build, OU=platform, O=example, C=CN"

这里的-keyalg RSA -keysize 2048是必须显式指定的,不要用默认值,某些 JDK 的默认密钥算法和长度会让后续签名算法选择出问题。-validity 3650是十年,够用。

接着剥掉产物里的旧签名残留。这一步用zip命令最直接,注意通配符要加引号,不然会被 shell 提前展开:

zip -d target/app.jar \ "META-INF/*.SF" "META-INF/*.RSA" "META-INF/*.DSA" "META-INF/*.EC"

如果zip报zip error: Nothing to do,说明这些文件本来就不存在,可以放心继续。然后执行签名,把 digest 和签名算法都显式写出来:

jarsigner \ -keystore build-sign-ks.p12 -storetype PKCS12 \ -storepass "your-store-pass" \ -digestalg SHA-256 -sigalg SHA256withRSA \ -signedjar target/app-signed.jar target/app.jar build-signer

签完之后立刻验证,这一步不能省:

jarsigner -verify -verbose -certs target/app-signed.jar | tail -n 5

看到jar verified且提示签名时间是你刚刚操作的时间就对了。然后用探针里的 fat jar 方式再跑一次,异常应该消失。如果还是报,按上一节的三条命令重新走一遍,大概率是哪个环节没生效,最常见的是签名之后又被另一个构建步骤覆盖了产物,注意检查构建顺序。

4.4 打包插件的针对性改法

手动命令只是验证思路,真正落地要写进构建配置。Maven 下用 shade 的情形,配置里需要两件事:先把旧签名排除掉,再用签名插件对整个产物签名。排除部分长这样:

<filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> <exclude>META-INF/*.EC</exclude> </excludes> </filter> </filters>

不排除的后果是 shade 过程中可能因为同名签名文件冲突而报错,或者保留下来一堆对不上的旧摘要。排除之后产物变成完全无签名状态,必须接上签名步骤:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jarsigner-plugin</artifactId> <version>3.1.0</version> <executions> <execution> <id>sign-artifact</id> <phase>package</phase> <goals><goal>sign</goal></goals> </execution> </executions> <configuration> <keystore>${project.basedir}/build/build-sign-ks.p12</keystore> <storetype>PKCS12</storetype> <storepass>${env.BUILD_KS_PASS}</storepass> <alias>build-signer</alias> <keypass>${env.BUILD_KS_PASS}</keypass> <arguments> <argument>-digestalg</argument><argument>SHA-256</argument> <argument>-sigalg</argument><argument>SHA256withRSA</argument> </arguments> </configuration> </plugin>

关键点是<phase>package</phase>要跟 shade 的执行阶段一致,而且执行顺序要在 shade 之后。Maven 里同一个 phase 下多个插件的顺序按声明顺序走,所以签名插件的声明位置要放在 shade 后面。这一点特别容易忽略,配置写对了但顺序错了,签的是没有 shade 的那个原始 jar,上线照样报错。

Gradle 场景稍微麻烦一点,官方的signing插件主要是给发布到仓库用的,对已存在的 jar 重新签名需要写一段自定义任务去调jarsigner,或者用shadowJar配一个doLast里执行ant.signjar。不管用哪种方式,思路都一样:先剥离,后签名,顺序不能反,并且要确保签的是最终产物。

注意:如果你们的 CI 里有多阶段构建,比如先打 jar 再打镜像,请确认签名发生在 jar 打包阶段,而不是镜像层。镜像层的操作不会改变 jar 内容,但如果镜像构建过程会解压再压缩,签名依然可能失效。

5. 常见问题与排查速查表

5.1 报错现象对照速查

不同的现象组合指向不同的根因,先把这张对照表过一遍,能省不少试错时间。

现象最可能的根因首选排查动作
升级 JDK 后立刻报,代码没动jre/lib/ext 失效用-XshowSettings:properties看 ext.dirs
只有 fat jar 报,IDE 里不报重打包破坏签名jar tf查 META-INF 签名文件
换 BC 版本后依旧报错打包配置没改jarsigner -verify看签名状态
报错信息里 provider 名是自定义的继承了 BouncyCastleProvider查getClass()的实际来源
部分接口报、部分不报走了不同 provider搜代码里所有"BC"的用法
报ClassNotFoundException而非本异常jar 根本没加载上查 classpath 和依赖树

这张表的用法是从现象倒推,不要从理论正推。实践中我见过太多人先入为主认定"肯定是版本问题",然后花几小时换版本,实际上连签名状态都没看过一眼。先用表里的动作确认事实,再决定改哪里。

5.2 几个容易踩的坑

第一个坑是只看jarsigner -verify的最后一行。前面提过,输出里出现零星entry is not signed时,整体依然可能显示jar verified,但 JCE 的要求是全部条目都有有效签名,一个漏网都不行。我的做法是加一个grep -c "is not signed",计数不为零就当成失败处理。

第二个坑是签名之后又跑了别的打包步骤把结果覆盖了。典型链条是mvn package里 shade 生成了app.jar,然后另一个插件又基于 classes 目录重新打了一遍,把签好名的产物覆盖。排查方式是比对最终产物的修改时间和签名时间,或者直接对最终产物再验一次。构建链路复杂的时候,我会在签名任务的输出里打一行标记,方便确认它确实作用在了目标文件上。

第三个坑是以为Security.addProvider会立刻抛异常。前面说过它不会,所以当你看到启动日志干净、接口才报错时,不要怀疑日志级别或者异常被吞了。正确的排查动作是在启动阶段主动调一次 BC 提供的服务,比如拿一个 SM4 的 Cipher 实例,把问题提前暴露出来。我在关键服务里都会加这么一段"自检",上线前就能发现,而不是等业务流量打上来才炸。

第四个坑是重打包时只排除*.SF和*.RSA,漏了*.DSA和*.EC。老版本的 BC 可能用 DSA 签名,一些新版本用 EC 签名,漏掉一种就会在 shade 时报冲突。四类扩展名一起排除,省心。

还有一个不太算坑但值得说的点:重新签名后,jarsigner -verify会提示签名证书"不受信任",这是正常的,因为你用的是自签密钥。不要因为这个提示以为签名没成功。同理,-certs输出里出现警告不代表 JCE 会拒绝,JCE 只看签名是否自洽,不看证书信任链。这个区别搞清楚了,你就不会被误导。

5.3 JDK 与 BC 版本对照

最后把版本选择这事说清楚,免得在选型阶段反复纠结。

BC 构件命名含义建议使用场景
bcprov-jdk15on适配 JDK 1.5 及以上,老命名仅在维护极老工程时保留
bcprov-jdk15to18适配 JDK 1.5 到 1.8明确只在 JDK 8 上跑时可选
bcprov-jdk18on适配 JDK 1.8 及以上JDK 8/11/17/21 的默认选择
bcprov-lts8onJDK 8 起的长周期维护分支对稳定性要求高于新特性的场景

选版本的实操建议是:先确定你集群里最低的 JDK 版本,然后挑一个比这个 JDK 发布时间稍晚的 BC 稳定版。不要直接上最新的那个大版本号,新版本可能有 API 调整或者对 JDK 有更高要求,你的工程未必吃得消。升级 BC 跟升级 JDK 一样,最好一次只动一个变量,动完立刻用第 4.1 节的探针验证一遍,确认没有新的异常。

如果你正在做 JDK 8 到 17 的整体迁移,我建议把 BC 的调整和打包方式的调整放在同一次变更里完成,而不是分两次。原因很简单:这两件事耦合在一起,分开做的话第一次调完可能仍然报错,你会误以为方向错了,来回折腾的成本远超一次做完。变更完之后,把"BC 从哪个 jar 加载"这个信息固化到健康检查或者启动日志里,以后升级 JDK 时一眼就能看出有没有问题。

我个人在几次类似迁移里最深的体会是:这个报错表面上属于"依赖问题",实际上考的是你对构建产物形态的掌控力。同样是new BouncyCastleProvider(),在 IDE 里跑、在普通 jar 里跑、在 fat jar 里跑、在容器里跑,加载路径完全不一样,而 JCE 只认其中一个。所以与其记解决方案,不如养成一个习惯——任何涉及加密 provider 的工程,都先确认它的CodeSource指向哪里,把那行打印留在启动自检里。多做这一步,后面能少掉很多次深夜排查。另外提醒一句,签名用的密钥别用测试环境的那把去签生产产物,虽然这个场景下证书信任不影响 JCE 校验,但把环境的密钥边界划清楚总是对的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询