1. 问题描述
1.1. 出现的环境
java版本:1.8.0_202
bouncycastle包版本:
org.bouncycastle
bcprov-jdk15on
1.66
maven打包插件配置:
org.apache.maven.plugins
maven-shade-plugin
2.4.1
package
shade
*****.****.***.******
*:*
module-info.class
META-INF/*.SF
META-INF/*.DSA
META-INF/*.RSA
2.2. 报错项目描述
项目是使用netty提供http服务,数据传输中需要进行国密SM系列算法做加密,所以用到bouncycastle的一些加密方法。
项目在idea中启动是正常的,但是打包后放到服务器上运行就会报JCE cannot authenticate the provider BC,直译过来就是java密码扩展组件无法认证拓展提供者BC
2. 问题出现原因
2.1. 报错异常栈信息
Exception in thread "main" java.lang.SecurityException: JCE cannot authenticate the provider BC
at javax.crypto.Cipher.getInstance(Cipher.java:656)
at javax.crypto.Cipher.getInstance(Cipher.java:595)
at SM4Util.encrypt(SM4Util.java:21)
at TestMain.main(TestMain.java:12)
Caused by: java.util.jar.JarException: file:/XXX/XXX/XXX/XXXX has unsigned entries - assembly.xml //这里也有可能是别的异常,但都是JarVerifier.verifySingleJar(JarVerifier.java:502)这行附近报错,不同版本jdk行号可能不一致
at javax.crypto.JarVerifier.verifySingleJar(JarVerifier.java:502)
at javax.crypto.JarVerifier.verifyJars(JarVerifier.java:363)
at javax.crypto.JarVerifier.verify(JarVerifier.java:289)
at javax.crypto.JceSecurity.verifyProviderJar(JceSecurity.java:164)
at javax.crypto.JceSecurity.getVerificationResult(JceSecurity.java:190)
at javax.crypto.Cipher.getInstance(Cipher.java:652)
通过对异常打印进行分析,不难看出是JCE在校验BC包的签名时出现了问题
at javax.crypto.JarVerifier.verifySingleJar(JarVerifier.java:502)
tips:我们一般开发的jar包是不会进行签名的,但java可以使用jarsigner命令对jar包进行签名来为jar提供一种安全机制,防止我们的jar包被篡改、伪造等。目的是为了防止别有用心的人给别人的包中植入一些坏目的的代码,然后伪造他人来让别人引入,从而进行入侵他人的系统或电脑等危险操作。(但其实签名也是可以被破解的。。。),签名和验签原理也很简单,jarsigner先对jar包中的每个文件做摘要,然后将文件的摘要结果放到manifest.MF文件中,然后再对所有文件使用PKI体系里的私钥进行签名,再把签名结果文件和签名私钥对应的公钥一起打入到jar包中。当java进行验签时先验证私钥签名是否正确,这一步的作用是为了验证jar包提供者的身份是否正确,再校验jar包中每个文件的摘要值是否正确,这一步的目的是防止jar中的文件被别人篡改
问题分析
- BC包因为是开源通用的加密包,而加密操作又是很敏感的操作,为了提高jar包的安全性,BC官方对其jar进行了签名来防止他人篡改或伪造其代码。打开BC包不难看到其摘要和签名文件(摘要存储在menifest.MF中,签名存储在DSA和SF文件中)
- 当我们的非springboot项目对第三方jar包有依赖时会使用maven-shade-plugin或maven-assembly-plugin插件进行打包,上述两个插件的原理就是将我们所有依赖的jar都进行解压,然后再把依赖包中所有文件组合到一起形成一个新的项目结构再打成jar包。
- JCE在获取加密算法实例时必须要对provider(算法提供者)所在的jar包进行签名的校验。但是由于插件将BC包进行了解压后重新打包,导致BC包的provider出现在了我们打成的jar包中。所以JCE会对我们重新打成的整个jar包进行签名和摘要的校验。但是我们新打成的jar包没有使用jarsigner进行签名,因此JCE会报我们打成的jar包中有的文件没有被签名或我们的jar包签名不正确
3. 解决方法
3.1 方案1:将BC包添加到classpath下
这种处理方式是最简单粗暴的,也是网上推荐最多的做法,操作方法如下:
- 我们的代码无需修改,直接将bc包添加到java_home下的jrelibext目录下即可
- 再次启动我们的jar包即可顺利启动运行。
原理就是让jvm启动的时候通过jdk自带的ExtClassloader(拓展类加载器) 将bc包看作系统包加载进来。jvm会将bc包中的类看作是jdk自带的,就如同使用java.lang所在的jar包一样,我们无需特殊处理即可直接使用bc包中的类。此时bc包的结构也不会被破坏(因为是从java_home中将jar包完整加载进来的),签名信息还在bc包中,自然就可以通过JCE对provider所在包的签名校验了。
但这种方式太过于粗暴,我们在开发环境自然可以这么搞,但是在生产环境我们不应该或不推荐这样做,原因如下:
- 生产环境我们可能没有条件或不方便随意修改java_home下的内容,因为这会人为的引入额外安全风险。
- 当我们在java_home下引入了bc包以后,在这台机器上启动的其他不需要这个包的java服务也会加载这个包下的类,造成了内存额外的占用。这种不合理的内存占用会随着我们类似的操作越来越多(指为jdk添加越来越多的额外jar包)而拖累我们服务的性能。
- 当我们有两个服务同时依赖了bc包,但依赖的版本不一致,我们不能添加两个版本的bc到同一个java_home的目录下。因为这会造成我们一台机器启动两个服务时加载类冲突,jvm启动的时候并不知道它应该加载哪个版本的bc包。
由于上述原因,我个人并不推荐这样做,但这种做法并不是不可行。如果你的环境支持这样的操作,那可以这样解决。
3.2 方案2:对我们的jar进行签名
处理方式就是使用jarsigner命令对我们使用maven-shade-plugin或maven-assembly-plugin插件打出来的jar包进行签名,然后再次启动jar包。
这样JCE在校验provider所在包的签名时会取到我们对jar包的签名。由于我们是自己签名的合法签名,JCE自然就会通过了校验使我们的项目顺利运行。
注意: 这种处理方式是我们拥有了合法公认的CA颁发PKI体系证书后才能这么做,使用keytools生成的自签名证书是无法做到的,因为JCE也会对签名者的证书进行合法性校验(至于如何校验不涉及本文主题,在此不再赘述)。当JCE发现我们的证书不是合法证书时,即使签名正确,他也不会让我们项目运行,会报签名的证书不合法错误!
这是最标准的做法,我们无需改动代码和做过多的额外操作,完全符合java的安全规范。缺点就是我们需要获取合法的CA颁发的证书,这需要付出一些额外的费用,且费用还不是很低。
3.3 方案3:使用非解压的打包插件打包
既然问题的原因是我们使用maven-shade-plugin等插件解压jar破坏了bc包的签名,那么我们使用不解压的打包插件就可以了,比如spring-boot-maven-plugin。这种插件会将我们依赖的jar原封不动的打包到jar里,然后使用插件自己额外添加的启动器类去启动我们的启动类。然后使用插件自己封装的类加载器去加载我们依赖的jar包,其类加载器会自动将依赖的jar完整的加载到jvm中供JCE验签。
这是种实现方式比较优雅,但会使我们最终打出的包略大一些,毕竟要添加额外的类加载器、启动器以及一些插件必须的其他的封装类到jar包中。
3.4 方案4:使用代码修改加载的class对象的源码属性指向
既然是由于JCE无法正确找到provider的class对象的签名造成的,那么我们只要将加载到内存的class对象的源码属性指向正确地签名地址即可。
这是本人最终采取的方式,具体操作流程比较复杂,详细步骤如下:
1. 将我们依赖的被签名的jar放到工程resouce目录下的lib目录下(lib目录是自定义的,需要手动创建,创建其他目录也可以),没有被签名的依赖的jar不需要放入
2. 在pom文件中添加被签名jar的依赖,在本次中就是bc包,打包配置不变。以下是部分pom配置,大家需要请自行修改
org.bouncycastle
bcprov-jdk15on
1.66
org.apache.maven.plugins
maven-shade-plugin
2.4.1
package
shade
*****.****.***.******
*:*
module-info.class
META-INF/*.SF
META-INF/*.DSA
META-INF/*.RSA
3. 定义类去加载我们打包后lib目录下的jar,在内存中创建临时jar文件形成URL
public class LibLoadContext {
public static void initContext() {
loadAllLib();
CustomClassUrlConvert.changeClassURL();
}
/**
* 找到打包后jar包内lib目录下所有的jar的路径
*/
private static void loadAllLib() {
try {
Enumeration urlEnumeration = Thread.currentThread().getContextClassLoader().getResources("lib"); //如果你在步骤1.中创建了别的目录在,则将"lib"修改成你创建的目录即可(jar包中的根目录不需要加/)
while (urlEnumeration.hasMoreElements()) {
URL url = urlEnumeration.nextElement();
String protocol = url.getProtocol();
if ("jar".equalsIgnoreCase(protocol)) {
//转换为JarURLConnection
JarURLConnection connection = null;
try {
connection = (JarURLConnection) url.openConnection();
} catch (IOException e) {
throw new RuntimeException(e);
}
if (connection != null) {
JarFile jarFile = null;
try {
jarFile = connection.getJarFile();
} catch (IOException e) {
throw new RuntimeException(e);
}
if (jarFile != null) {
Enumeration jarEntryEnumeration = jarFile.entries();
while (jarEntryEnumeration.hasMoreElements()) {
/*entry的结果大概是这样:
org/
org/junit/
org/junit/rules/
org/junit/runners/*/
JarEntry entry = jarEntryEnumeration.nextElement();
String jarEntryName = entry.getName();
//获取lib下所有jar文件
if (jarEntryName.startsWith("lib/") && jarEntryName.endsWith(".jar")) {
doloadJar("/" + jarEntryName);
}
}
}
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 加载jar包内的jar中所有class文件,并在内存中形成临时jar文件,创建一个指向临时jar文件的URL对象
* @param jarPathInJar jar包内的jar的路径
*/
private static void doloadJar(String jarPathInJar) {
try {
//jarName:xxxx-xxxx.jar
String jarName = jarPathInJar.substring(jarPathInJar.lastIndexOf("/") + 1, jarPathInJar.length());
URL resource = LibLoadContext.class.getResource(jarPathInJar);
InputStream in = resource.openStream();
JarInputStream jis = new JarInputStream(in);
HashSet classPaths = new HashSet();
JarEntry jarEntry;
while ((jarEntry = jis.getNextJarEntry()) != null) {
classPaths.add(jarEntry.getName());
}
in.close();
jis.close();
File templateJarFile = File.createTempFile(jarName.substring(0, jarName.lastIndexOf('.')),".jar");
in = resource.openStream();
FileOutputStream fileOutputStream = new FileOutputStream(templateJarFile);
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
// 将读取的数据写入文件
fileOutputStream.write(buffer, 0, bytesRead);
}
in.close();
fileOutputStream.close();
URL url = new URL(resource, jarName, new URLStreamHandler() {
@Override
protected URLConnection openConnection(URL u) throws IOException {
URLConnection c = new JarURLConnection(u) {
@Override
public void connect() throws IOException {
resource.openConnection().connect();
}
@Override
public JarFile getJarFile() throws IOException {
JarFile jarFile = new JarFile(templateJarFile);
return jarFile;
}
};
return c;
}
});
//暂时在内存中缓存起来class文件和创建的临时URL之间的关系
CustomClassUrlConvert.loadAllClass(classPaths, url);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
4. 修改lib中的jar内的class文件对应的class对象的源码URL指向,将指向修改为创建的临时URL
public class CustomClassUrlConverter {
private static final Map ENTRY_MAP = new HashMap();
public CustomClassUrlConvert() {
}
/**
* 将lib目录下的jar包内的类文件路径与在内存中创建的临时jar文件的映射关系进行缓存
* 这里派出了version下的类文件路径,因为他们是给高版本jdk使用的,不适用于jdk1.8
* @param classPaths
* @param url
*/
public static void loadAllClass(Set classPaths, URL url) {
for (String classPath : classPaths) {
if (classPath.endsWith(".class") && !classPath.contains("versions/")) {
ENTRY_MAP.put(classPath, new ClassHolder(classPath, url));
}
}
}
/**
* 从类加载器中获取lib目录下jar包内所有的class文件的class对象。
* 逐个修改其class对象内的codesource指向,指向我们在内存中创建的临时jar文件
*/
public static void changeClassURL() {
for (Map.Entry entry : ENTRY_MAP.entrySet()) {
ClassHolder value = entry.getValue();
String className = value.getClassPath().replace('/', '.').replace(".class", "");
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
try {
Class> clazz = contextClassLoader.loadClass(className);
CodeSource codeSource = clazz.getProtectionDomain().getCodeSource();
java.lang.reflect.Field loca = codeSource.getClass().getDeclaredField("location");
loca.setAccessible(true);
loca.set(codeSource, value.getClassURL());
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
@NoArgsConstructor
@AllArgsConstructor
@Data
public static class ClassHolder {
private String classPath;
private URL classURL;
}
}
5. 将项目打包好运行即可,放在lib目录下的被签名的jar就可以按正常方式引入和使用了
这种方式其实是手动实现了方案3中插件的部分实现,这种方式实现比较复杂,但是可以使我们最终打包的代码比方式3中使用插件打包的代码小。jvm运行时也无需加载插件的类文件。使用这种方式我们也可以随时自定义jar包的加载逻辑,做出符合项目要求的加载方式。
按这种方式实现的项目是无法在idea里直接运行的,需要打包成jar,然后再idea里配置按jar的方式启动
3.5 方案5:使用其他maven打包插件
我们还可以使用maven-dependency-plugin等类似打包插件,将我们以来的jar打包到jar包外,然后再menifest.MF中指定额外的classpath属性,这样也可以使我们的工程顺利运行。只是在部署的时候需要将jar和依赖的classpath目录一同部署。
4. 总结
我们在遇到JCE验签错误时,使用以上五种方式均可解决。最简单直接地是方案1,适合我们在生产环境可以灵活操作jdk的情况。最符合规范的是方案2,适合我们已经有合法CA颁发的证书的情况。方案3和方案5适合我们在对工程定制化要求不高的情况下使用,允许我们的项目中出现一些额外的依赖。如果需要高度定制化工程则需要用方案4的方式,自定义我们加载的class对象,有时甚至需要定制化我们的classloader实现一些特殊的要求。
通过这次问题的解决我对JVM的类加载机制有了更深一步的了解,对于类加载机制也有很多东西可以分享,在此不再展开说明,详细分析会在以后的博客中进行更新。大家有兴趣可以在下方评论区留言,我会及时回复更新,对于文中不正确的地方欢迎大家指正。