ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JDK 8 升 17 后 JCE 认证 BC Provider 失败排查

JDK 8 升 17 后 JCE 认证 BC Provider 失败排查 前几天把一个跑了很多年的老系统从 JDK 8 挪到 JDK 17编译零报错、单元测试全绿、打包体积还小了一圈眼看就要收工结果服务一起来就直接甩脸java.lang.SecurityException: JCE cannot authenticate the provider BC。这个报错很闷它不告诉你是哪个环节出的问题也不告诉你是谁把签名搞丢了只丢下一句我不能认证 BC 这个 provider就完事。JDK 版本升级、SecurityException、JCE、provider BC 这几个关键词凑在一起基本可以断定问题出在 BouncyCastle 这个加密库和 JCE 的代码签名校验机制上。这篇文章想干的事很具体把这条报错的成因彻底拆开讲清楚 JDK 升级后为什么偏偏是现在炸给出五种典型根因对应的可复现修复路径再把从 JDK 8 迁到 17 的完整实操流程走一遍。如果你正好在做 JDK 版本升级、依赖瘦身、多版本共存的环境切换或者单纯想知道为什么本地 IDE 跑得好好的、打成包一部署就挂那下面的内容应该能省你几个小时。文中涉及的工具版本、路径和配置都以我在实际项目里跑通的那套为准不同 JDK 小版本可能有细微差异遇到对不上的地方以你手上的环境为准。1. 先搞清楚这个异常到底在抱怨什么1.1 JCE 的代码签名校验验的不是有没有签名这么简单很多人第一次看到这个报错第一反应是BC 的 jar 是不是没签名然后去下载页面翻半天发现官方包明明是签过名的。这里的机制比有没有签名复杂一层。JCEJava Cryptography Extension在 JDK 1.4 之后虽然并入了 JDK 本体但它的代码签名校验规则一直保留着。原因是历史上加密强度有出口管制JCE 属于受限组件所以只有被特定代码签名证书签发过的 provider jar才允许提供加密服务。JDK 内部维护了一套信任锚用来判断某个 provider 的 jar 签名是否可信。当一个 Provider 试图通过Cipher、KeyAgreement、KeyGenerator、Mac这几类服务对外提供算法时JDK 会去检查这个 Provider 类是从哪里加载进来的、它的CodeSource里挂没挂证书、证书链能不能链到受信任的签名机构。三个条件缺一不可jar 本身带签名块、签名内容与 jar 内容完全一致、证书链能被 JDK 认可。任何一条不满足就会抛出JCE cannot authenticate the provider BC。注意报错文本里只有 provider 的名字BC没有任何细节所以排查时必须自己往回找证据。BouncyCastle 官方发布的 jar 是签过名的你能在包里的META-INF目录下看到.SF和.RSA这类签名文件那就是签名块。所以问题几乎从来不是BC 没签名而是签名在你手里被弄丢了或者JDK 换版本后不认这个签名了。1.2 JDK 8 到 JDK 17/21校验逻辑有三处关键变化为什么同样的代码、同样的依赖在 JDK 8 上安稳跑了五年一升到 17 就翻车因为校验逻辑本身变了而且不止一处。第一处变化最致命JDK 9 之后JceSecurity获取证书的方式从自己去读 jar 文件改成了从类的CodeSource里取证书。JDK 8 时期的实现更宽松它会尝试打开 provider 所在的 jar 去读取签名信息而 JDK 9 之后实现变成从当前已加载的Class的ProtectionDomain里拿CodeSource再调用getCertificates()。这意味着只要类的加载方式不是标准的URLClassLoader从普通 jar URL 加载证书很可能直接是null验证当场失败。fat jar、内嵌 jar、自定义类加载器、模块路径加载全都踩在这条线上。第二处变化是扩展机制被移除。JDK 8 时代有个流传很广的做法把bcprov的 jar 直接丢进$JAVA_HOME/jre/lib/ext/然后在java.security里静态注册 provider全机器所有 Java 应用都能用。JDK 9 之后 ext 目录机制被取消这条路彻底断了。老项目升级时如果还留着这个习惯或者java.security里还挂着静态注册项就会出现配置看着对、运行时找不到类或者签名验证失败的组合症状。第三处变化是禁用算法清单变严。jdk.jar.disabledAlgorithms这个属性专门管 jar 签名用的算法和密钥长度JDK 8u171 之后就已经开始收紧JDK 17 更加严格。老版本 BC 的 jar 签名用的是什么算法、密钥多长直接决定它在 JDK 17 上能不能过关。你可以用下面这条命令看当前 JDK 的实际策略# JDK 9 grep -E jdk.jar.disabledAlgorithms|jdk.certpath.disabledAlgorithms \ $JAVA_HOME/conf/security/java.security # JDK 8 grep -E jdk.jar.disabledAlgorithms|jdk.certpath.disabledAlgorithms \ $JAVA_HOME/jre/lib/security/java.security如果输出里出现MD5、SHA1、RSA keySize 1024这类条目而手上的 BC jar 恰好是用这些算法签的报错几乎是必然的。这也是为什么升级 BC 版本经常是最高效的解法——新版本的签名算法跟着升级了自然躲过了禁用清单。2. 定位根因五类场景与三分钟自查法2.1 按打包方式和加载路径对号入座在动手改任何配置之前先花几分钟确认自己的场景。这个报错的根因高度集中在五种情况里判断维度其实只有一个BC 的 class 到底是从哪儿被加载进来的。下面这张表是我自己排查时整理的对照表按症状直接找病因。现象特征大概率根因典型触发场景本地 IDE 正常打成 fat jar 后报错重打包导致签名块丢失或被替换Maven Shade、Gradle Shadow启动时报错日志里先出现Invalid signature file digest为消除上述错误手动删了.SF/.RSA文件同样是重打包依赖里同时存在多个 BC 版本依赖冲突解析到了未签名或老版本 jar传递依赖未做统一管理报错发生在启动早期、栈里有ProviderConfig配置文件中静态注册了 providerjava.security被手工改过使用 Spring Boot 可执行 jar 部署内嵌 jar 加载CodeSource证书为nullLaunchedURLClassLoader使用 jlink 生成的运行时镜像模块路径加载签名信息不可见jlink、jpackage只用 BC 的摘要/ASN.1/PEM 工具类本不该注册 Provider误注册了初始化代码顺手加了addProvider这张表里最容易被忽略的是最后一行。BC 不只提供加密服务还有大量纯工具类比如 ASN.1 编解码、PEM 解析、证书构造。这些用法根本不需要把BouncyCastleProvider注册进Security一旦注册就会凭空引入一次签名校验白白多一个失败点。2.2 一段代码看清 CodeSource 里的证书判断根因最快的办法是把BC 的类从哪儿来、有没有证书直接打印出来。下面这段代码不到十行能把关键信息全部暴露import java.security.CodeSource; import java.security.Provider; import java.security.Security; import java.util.Arrays; public class ProviderCertCheck { public static void main(String[] args) throws Exception { Class? clazz Class.forName(org.bouncycastle.jce.provider.BouncyCastleProvider); Provider bc (Provider) clazz.getDeclaredConstructor().newInstance(); CodeSource cs clazz.getProtectionDomain().getCodeSource(); System.out.println(loaded from (cs null ? null : cs.getLocation())); System.out.println(certificates (cs null ? null : Arrays.toString(cs.getCertificates()))); Security.addProvider(bc); System.out.println(registered bc.getName() / bc.getVersionStr()); } }按你实际的启动方式把它跑起来比如java -cp target/app.jar:lib/bcprov-jdk18on-1.78.1.jar ProviderCertCheck看输出就行三种结果对应三条路certificates null而loaded from是个正常的file:路径——说明这个 jar 文件本身就没有签名块或者被重新打包过签名块被删了。往打包配置上查。certificates null而loaded from是jar:file:/app.jar!/BOOT-INF/lib/...这种内嵌形态——说明是类加载方式的问题签名即使存在也取不到。往部署形态上查需要把依赖外置。certificates有值但依然报错——说明签名读取没问题问题在签名本身不被信任也就是算法被禁用或证书链不受认可。往 BC 版本和 JDK 的策略文件上查。配合两个命令行工具基本能把结论钉死。第一个是确认 jar 签名是否完好jarsigner -verify -verbose -certs lib/bcprov-jdk18on-1.78.1.jar | head -30输出jar verified才算通过。如果提示jar is unsigned那这个 jar 就是被人动过的如果提示签名块里引用的文件缺失或摘要不匹配说明 jar 被打开重压过。第二个是看签名块的构成unzip -l lib/bcprov-jdk18on-1.78.1.jar | grep -i META-INF/BC正常会看到若干对.SF和.RSA文件。如果这里空空如也那前面的一切分析都可以收束成一句话签名块没了。3. 对症下药五类根因的修复路径3.1 场景一Fat Jar 重打包把签名搞丢了这是最常见的一类而且它经常是连环坑。第一环你用 Shade 或 Shadow 把 BC 打进了 fat jar运行时报Invalid signature file digest for Manifest main attributes。第二环你在网上搜到解法往插件配置里加了一段排除META-INF/*.SF、*.RSA、*.DSA的过滤规则那条错误果然消失了。第三环服务启动到加密模块JCE cannot authenticate the provider BC出现了。原因很简单签名是对 jar 全部内容做摘要后加密得到的你删掉签名块等于承认这个 jar 无签名再加上重打包改变了包的物理结构即使保留签名块也无法通过校验。所以**删签名文件和注册 BC Provider这两个需求天生互斥**不能同时满足。Maven 里那段看起来解决了问题的配置长得大概是这个样子plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId configuration filters filter artifactorg.bouncycastle:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters /configuration /plugin我可以明确告诉你这段配置只能让你从第一条错误跳到第二条错误解决不了根本问题。真正可行的思路只有两条把 BC 从 fat jar 里拿出来单独作为外部依赖或者干脆不注册 Provider见 3.5。前者更通用具体做法是把 BC 相关 jar 放进一个独立的lib/目录用清单文件的Class-Path引用或者部署时用-cp显式拼上java -cp app.jar:lib/* com.example.Main另外提醒一点绝对不要对 BC 做 package relocation。Shade 的重定位功能会重写 class 字节码里的包名引用这会直接改变 jar 内容等于亲手作废签名。有些项目为了规避依赖冲突把org.bouncycastle改成shaded.org.bouncycastle在纯工具类场景下还行一旦涉及 JCE 服务必挂。3.2 场景二BC 版本太老撞上 JDK 17 的禁用算法清单如果你确认 jar 的签名块完好、jarsigner -verify也通过但 JDK 17 依然不认那多半卡在算法策略上。老版本 BC 的 jar 签名用的算法年代久远在 JDK 17 的jdk.jar.disabledAlgorithms面前过不了关。这里有个命名上的坑必须先讲清楚因为它坑过很多人artifact 名字里的数字是编译基线不是只能跑在这个版本上。bcprov-jdk15on表示支持 JDK 1.5 及以上bcprov-jdk18on表示支持 JDK 1.8 及以上。所以把项目升到 JDK 17 之后bcprov-jdk18on是完全正确的选择不存在jdk18on 是给 JDK 18 用的、17 不能用这回事。处理方式很直接把依赖统一升级到较新的 1.78.x 或更高版本并且确认bcprov、bcpkix、bcutil三个 artifact 版本号完全一致。版本不一致会带来更诡异的运行时行为比如某个类从 A 版本加载、某个类从 B 版本加载报出来的错误信息完全对不上真实原因。dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78.1/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcpkix-jdk18on/artifactId version1.78.1/version /dependency改完依赖第一件事是排查冲突确认没有别的组件把老版本又拽回来mvn dependency:tree -Dincludesorg.bouncycastle输出里如果同时出现两个版本那就是没治干净用dependencyManagement强制锁版本。这里有个实操心得升级 BC 的时候不要只改bcprov很多人只换了核心包忘了bcpkix和bcutil结果运行到证书处理逻辑时才炸排查成本翻倍。3.3 场景三类加载器隔离签名取不到再来看第二类高频场景本地 IDE 一切正常打成可执行 jar 一部署就报错。这个现象非常有辨识度因为 IDE 里的 classpath 通常是一堆普通 jar 文件用标准URLClassLoader从file:URL 加载证书取得到而可执行 jar 里的依赖是内嵌的加载方式完全不同。以 Spring Boot 为例可执行 jar 里的依赖放在BOOT-INF/lib/下运行时由它自己的类加载器从jar:file:/app.jar!/BOOT-INF/lib/bcprov-xxx.jar!/这种嵌套 URL 加载。这个 URL 不是标准的 jar 连接证书信息取不到CodeSource.getCertificates()返回null于是校验失败。这是一个纯粹的部署形态问题跟你用哪个 BC 版本无关。OSGi 容器、部分自定义热部署框架、把依赖塞进lib-provided的场景逻辑都一样。解法是把加密相关的依赖从可执行 jar 里彻底剥离出去。做法有几个层次从简到繁第一种用 Spring Boot 的外置依赖加载能力把 jar 放到可执行包外面的lib/目录通过属性指定加载路径java -Dloader.path./lib -cp app.jar org.springframework.boot.loader.launch.PropertiesLauncher第二种干脆不用可执行 jar改用薄包加依赖目录的部署方式启动脚本里显式拼 classpathjava -cp app.jar:lib/* com.example.Application第三种容器化部署时把依赖挂载到镜像的独立目录容器启动命令里指定 classpath。三种方式本质相同都是让 BC 的类以标准file:URL 被加载。注意把 BC 从可执行包里挪出去之后一定要用前面那段ProviderCertCheck再跑一遍确认loaded from里出现的是真实的文件路径而不是jar:file:...!/BOOT-INF/...形态否则等于白改。3.4 场景四模块路径加载与运行时镜像如果项目已经上了 Java 模块化或者用jlink做过裁剪过的运行时镜像还有一类更隐蔽的情况。当你把 BC 的 jar 放到 module path 上它会被当成自动模块加载模块的CodeSource结构跟 classpath 场景不同签名信息同样可能取不到。排查方法是先用--module-path和--class-path的区分来确认加载位置。如果确认走的是模块路径最省事的办法是把 BC 相关 jar 挪回 classpath也就是用-cp而不是--module-path来引入它。加密库这种第三方组件在模块化体系里本来就容易遇到麻烦让它老老实实待在 classpath 上是成本最低的选择。jlink生成的运行时镜像还有个更麻烦的点镜像里没有源代码目录结构签名信息在裁剪过程中不会保留。我的建议是不要把需要 JCE 认证的 Provider 打进运行时镜像镜像只装 JDK 自身需要的模块业务依赖在lib/里外挂。这样做还顺带解决了镜像体积和依赖更新的问题一举两得。3.5 场景五只用了 BC 的算法却顺手注册了 Provider最后一类根因说起来有点冤枉代码根本不需要注册 Provider但初始化的时候顺手写了Security.addProvider(new BouncyCastleProvider())于是凭空引入一次校验。关键的知识点是JDK 只对特定的服务类型做 JCE 签名校验。从我观察到的行为来看触发校验的主要是Cipher、KeyAgreement、KeyGenerator、Mac这几类加密服务而MessageDigest、Signature、KeyFactory、CertificateFactory这些普通 JCA 服务通常不会要求 JCE 认证不同 JDK 小版本实现细节可能有差异以你手上的源码为准。这意味着如果你用 BC 只是为了做 SM3 摘要、ASN.1 结构编解码、PEM 文件读写、证书解析完全可以绕开 Provider 注册直接使用对应的轻量 API// 不需要 addProvider直接用轻量 API import org.bouncycastle.crypto.digests.SM3Digest; SM3Digest digest new SM3Digest(); byte[] data hello.getBytes(java.nio.charset.StandardCharsets.UTF_8); digest.update(data, 0, data.length); byte[] out new byte[digest.getDigestSize()]; digest.doFinal(out, 0);只有确实需要走 JDK 标准加密接口的场景比如Cipher.getInstance(SM4/ECB/PKCS7Padding, BC)才必须注册 Provider也才必须解决签名问题。这个区分能帮你卸掉一大半不必要的工作量。4. 完整实操把一个 JDK 8 老项目迁到 JDK 174.1 依赖梳理是第一优先级升级 JDK 从来不是改个环境变量就完事依赖梳理必须放在最前面。我一般的顺序是先确认实际运行的是哪个 JDK再梳理所有跟加密相关的依赖最后统一版本。确认运行版本这一步看着傻但真的能救命。多版本 JDK 共存的机器上环境变量、IDE 配置、构建工具配置、启动脚本里可能写着四个不同的版本号。用这几条命令把事实钉死java -version echo $JAVA_HOME # Windows 下用 echo %JAVA_HOME% mvn -version然后梳理依赖mvn dependency:tree -Dincludesorg.bouncycastle把输出里所有 BC 相关的坐标抄下来统一到同一个版本。这里面还有个很容易踩的坑有些组件自带加密实现的传递依赖比如某些签名、验签、国密相关的工具库它们可能把bcprov的某个老版本当作 transitive dependency 带进来。如果不做统一管理升级主依赖也没用冲突解析依然可能选中老版本。统一版本用dependencyManagement最干净dependencyManagement dependencies dependency groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk18on/artifactId version1.78.1/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcpkix-jdk18on/artifactId version1.78.1/version /dependency dependency groupIdorg.bouncycastle/groupId artifactIdbcutil-jdk18on/artifactId version1.78.1/version /dependency /dependencies /dependencyManagement4.2 打包配置改造这一步是能不能彻底解决问题的关键。核心原则只有一条让 BC 的 jar 以原始形态、通过文件路径被加载。Maven 项目如果用 assembly 做分发把布局从单文件 fat jar 改成带lib目录的形式plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-assembly-plugin/artifactId configuration descriptorRefs descriptorRefjar-with-dependencies/descriptorRef /descriptorRefs !-- 关键不要把依赖塞进主 jar -- /configuration /plugin说实话jar-with-dependencies也是把依赖解压合并进同一个 jar同样会丢签名这个描述符不适合本场景。更适合的是自定义 assembly 描述符或者直接用一个简单的复制任务把依赖拷到lib/plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId executions execution idcopy-dependencies/id phasepackage/phase goals goalcopy-dependencies/goal /goals configuration outputDirectory${project.build.directory}/lib/outputDirectory /configuration /execution /executions /plugin启动脚本相应改成把lib目录加进 classpath#!/bin/bash APP_HOME$(cd $(dirname $0) pwd) java -Xms512m -Xmx2g \ -cp $APP_HOME/app.jar:$APP_HOME/lib/* \ com.example.BootstrapGradle 项目思路相同用copy任务或者Sync任务把runtimeClasspath里的依赖拷到build/lib再用启动脚本拼 classpath。这里有个小细节值得注意Windows 下 classpath 的分隔符是分号而不是冒号写启动脚本时最好按平台分支处理或者统一用-classpath文件参数的方式省得换环境就翻车。4.3 上线前的三步验证改完配置别急着提交按下面三步走一遍能挡掉绝大多数回归。第一步验签名jarsigner -verify lib/bcprov-jdk18on-1.78.1.jar必须输出jar verified。第二步验加载路径java -cp app.jar:lib/* ProviderCertCheck必须看到loaded from是file:开头的真实路径certificates不是null。第三步跑算法冒烟专门触发 JCE 校验路径import java.nio.charset.StandardCharsets; import java.security.Security; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import org.bouncycastle.jce.provider.BouncyCastleProvider; public class Smoke { public static void main(String[] args) throws Exception { Security.addProvider(new BouncyCastleProvider()); Cipher cipher Cipher.getInstance(SM4/ECB/PKCS5Padding, BC); byte[] key new byte[16]; cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, SM4)); byte[] out cipher.doFinal(smoke-test.getBytes(StandardCharsets.UTF_8)); System.out.println(cipher ok, output length out.length); } }这一步走的是Cipher服务一定会触发 JCE 校验。能打出cipher ok才算真的过关。顺便说一句这段冒烟代码建议直接进 CI 的启动检查步骤以后再动依赖或打包方式时能第一时间发现问题。5. 常见问题速查与踩坑清单5.1 报错信息与根因速查表实际排查时经常出现报错文本一样、根因完全不同的情况下面这张表按报错文本归类可以直接对照。报错文本位置与上下文根因处理方向JCE cannot authenticate the provider BC栈里有ProviderConfig、JceSecurity证书为null或签名链不受信任查加载路径与签名块Invalid signature file digest for Manifest main attributes启动时校验 fat jar重打包后签名与内容不匹配不要合并 BC 进 fat jarNoSuchAlgorithmException: Cannot find any provider supporting SM4在认证失败之后出现前者失败导致 Provider 未生效先修认证问题NoClassDefFoundError: org/bouncycastle/...启动早期ext 目录机制失效或依赖缺失改成显式 classpath 依赖NoSuchProviderException: BCgetInstance(..., BC)调用处Provider 未注册或注册失败检查注册代码与时机有一类报错特别容易误判认证失败之后紧接着出现找不到算法的异常很多人会一头扎进算法名写错了的方向。其实根本不是Provider 因为认证失败被剔除自然找不到它提供的任何算法。看栈的顺序第一个异常才是真凶。5.2 几条文档里不会写的实操心得第一优先怀疑打包方式而不是 BC 版本。我遇到的同类报错里打包和加载路径问题占了绝大多数版本问题反而是少数。因为前者是签名信息根本没被读到后者是读到了但不认可后者至少还能通过jarsigner看到端倪。第二判断问题先看环境差异不急着改代码。本地正常、线上报错最大的差异往往在 classpath 的组织方式上。先比对两者的实际启动命令比翻代码快得多。第三多版本 JDK 共存的机器上把版本号写在启动脚本里别依赖环境变量。JDK 环境变量配置失败或者指向意外版本是 JDK 升级过程中最常见也最难察觉的一个坑——你以为是代码问题实际上跑的根本不是你想的那个 JDK。第四升级 JDK 时顺手把安全策略文件也对比一遍。老项目里java.security经常被手工改过比如加过静态 provider 注册、调整过禁用算法清单。升级后这些改动要么丢失要么和新版本默认值冲突。我的做法是把新旧两个文件都导出来做一次 diff逐条确认每个差异项是否还需要保留。第五别用未签名或者来源不明的 jar 替换。有些老帖子里流传的做法是删掉签名文件再重打一个包或者从某些地方拿到被重签过的 jar。这么做短期内可能让报错消失但它同时意味着你放弃了对这个加密库完整性的校验风险远大于收益。5.3 升级 JDK 之前应该做的检查清单把这几件事做完再动手升级能省掉后面一大半的排查时间。统计项目里所有加密相关的依赖特别是 BC 系列和基于它封装的工具库记录每个的版本号。用jarsigner -verify逐个确认这些 jar 的签名状态把不通过的单独标出来。确认打包方式判断是否存在把加密库合并进 fat jar 的情况以及是否有条件改成外置依赖。检查java.security里是否存在手工添加的 provider 注册项以及是否有其他地方引用了已废弃的扩展目录。检查代码里Security.addProvider的调用位置判断每一个是否真的必要能去掉的就去掉。确认部署环境的 JDK 版本与构建环境一致包括容器基础镜像里的版本。准备一段覆盖实际使用算法的冒烟测试能独立运行、快速失败。这套清单的本质是把所有可能影响签名校验的环节都提前暴露出来而不是等上线后从日志里往外扒。我个人在实际操作中的体会是JCE cannot authenticate the provider BC这个报错看着专业化程度很高实际排查起来反而比很多业务逻辑问题简单因为它只有五个明确的失败点而且每个失败点都有对应的验证手段。真正的麻烦在于很多人在第一步就选错了方向把时间花在翻版本号和改算法名上而问题其实一直躺在打包配置里。等你用ProviderCertCheck那段代码把loaded from和certificates打印出来答案基本就自己浮出来了。
返回列表