java版本:1.8.0_202
bouncycastle包版本:
org.bouncycastle bcprov-jdk15on1.66
maven打包插件配置:
org.apache.maven.plugins maven-shade-plugin2.4.1 package shade *****.****.***.****** *:* module-info.class META-INF/*.SF META-INF/*.DSA META-INF/*.RSA
项目是使用netty提供http服务,数据传输中需要进行国密SM系列算法做加密,所以用到bouncycastle的一些加密方法。
项目在idea中启动是正常的,但是打包后放到服务器上运行就会报JCE cannot authenticate the provider BC,直译过来就是java密码扩展组件无法认证拓展提供者BC
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中的文件被别人篡改
这种处理方式是最简单粗暴的,也是网上推荐最多的做法,操作方法如下:
原理就是让jvm启动的时候通过jdk自带的ExtClassloader(拓展类加载器) 将bc包看作系统包加载进来。jvm会将bc包中的类看作是jdk自带的,就如同使用java.lang所在的jar包一样,我们无需特殊处理即可直接使用bc包中的类。此时bc包的结构也不会被破坏(因为是从java_home中将jar包完整加载进来的),签名信息还在bc包中,自然就可以通过JCE对provider所在包的签名校验了。
但这种方式太过于粗暴,我们在开发环境自然可以这么搞,但是在生产环境我们不应该或不推荐这样做,原因如下:
由于上述原因,我个人并不推荐这样做,但这种做法并不是不可行。如果你的环境支持这样的操作,那可以这样解决。
处理方式就是使用jarsigner命令对我们使用maven-shade-plugin或maven-assembly-plugin插件打出来的jar包进行签名,然后再次启动jar包。
这样JCE在校验provider所在包的签名时会取到我们对jar包的签名。由于我们是自己签名的合法签名,JCE自然就会通过了校验使我们的项目顺利运行。
注意: 这种处理方式是我们拥有了合法公认的CA颁发PKI体系证书后才能这么做,使用keytools生成的自签名证书是无法做到的,因为JCE也会对签名者的证书进行合法性校验(至于如何校验不涉及本文主题,在此不再赘述)。当JCE发现我们的证书不是合法证书时,即使签名正确,他也不会让我们项目运行,会报签名的证书不合法错误!
这是最标准的做法,我们无需改动代码和做过多的额外操作,完全符合java的安全规范。缺点就是我们需要获取合法的CA颁发的证书,这需要付出一些额外的费用,且费用还不是很低。
既然问题的原因是我们使用maven-shade-plugin等插件解压jar破坏了bc包的签名,那么我们使用不解压的打包插件就可以了,比如spring-boot-maven-plugin。这种插件会将我们依赖的jar原封不动的打包到jar里,然后使用插件自己额外添加的启动器类去启动我们的启动类。然后使用插件自己封装的类加载器去加载我们依赖的jar包,其类加载器会自动将依赖的jar完整的加载到jvm中供JCE验签。
这是种实现方式比较优雅,但会使我们最终打出的包略大一些,毕竟要添加额外的类加载器、启动器以及一些插件必须的其他的封装类到jar包中。
既然是由于JCE无法正确找到provider的class对象的签名造成的,那么我们只要将加载到内存的class对象的源码属性指向正确地签名地址即可。
这是本人最终采取的方式,具体操作流程比较复杂,详细步骤如下:
1. 将我们依赖的被签名的jar放到工程resouce目录下的lib目录下(lib目录是自定义的,需要手动创建,创建其他目录也可以),没有被签名的依赖的jar不需要放入
2. 在pom文件中添加被签名jar的依赖,在本次中就是bc包,打包配置不变。以下是部分pom配置,大家需要请自行修改
org.bouncycastle bcprov-jdk15on1.66 org.apache.maven.plugins maven-shade-plugin2.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 { EnumerationurlEnumeration = 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 MapENTRY_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的方式启动
我们还可以使用maven-dependency-plugin等类似打包插件,将我们以来的jar打包到jar包外,然后再menifest.MF中指定额外的classpath属性,这样也可以使我们的工程顺利运行。只是在部署的时候需要将jar和依赖的classpath目录一同部署。
我们在遇到JCE验签错误时,使用以上五种方式均可解决。最简单直接地是方案1,适合我们在生产环境可以灵活操作jdk的情况。最符合规范的是方案2,适合我们已经有合法CA颁发的证书的情况。方案3和方案5适合我们在对工程定制化要求不高的情况下使用,允许我们的项目中出现一些额外的依赖。如果需要高度定制化工程则需要用方案4的方式,自定义我们加载的class对象,有时甚至需要定制化我们的classloader实现一些特殊的要求。
通过这次问题的解决我对JVM的类加载机制有了更深一步的了解,对于类加载机制也有很多东西可以分享,在此不再展开说明,详细分析会在以后的博客中进行更新。大家有兴趣可以在下方评论区留言,我会及时回复更新,对于文中不正确的地方欢迎大家指正。