☰
国密SM2/SM3文件加解密实战:从编码到jar交付的避坑指南
2026/10/15 1:25:21 网站建设 项目流程

简介:这份资源面向Java后端开发、安全合规与国密改造相关技术人员,聚焦国密算法在实际业务中的落地问题。包内提供基于SM2与SM3的完整实现,既能对普通字符串进行加密解密,也能直接处理文件,覆盖密钥生成、签名验签、摘要计算等常见场景,适合需要将国密能力接入现有Java项目的开发者参考。资源共5个文件,以4个Java源码文件与1个jar依赖包为主,源码按工具类拆分,便于按需调用与二次封装,jar包为运行所需加密库,下载后无需额外配置即可编译使用,压缩包整体约4.89MB。目前已有427人学习下载。借助其中的工具类与依赖,读者可快速理解国密加解密的调用方式,掌握字符串与文件两类数据的处理差异,并在此基础上完成接口加密、敏感数据保护等改造,减少自行摸索算法细节与依赖冲突的时间成本。

1. 国密 SM2/SM3 文件加解密:为什么你的 jar 包一换机器就解不开

做过政务、金融或国企对接的开发者,大概率都遇到过这样的需求:一份字符文件要在两端之间传输,对方明确要求用国密算法,SM2 做非对称加解密、SM3 做摘要校验,最后交付物里还得带一个能直接跑的 jar 包。听起来不难,但真正动手时,坑往往不在算法本身,而在编码、填充模式、密钥格式和 jar 依赖这几件事上。我见过太多案例:本地跑得好好的,换一台机器就报解密失败,或者中文文件解出来全是乱码。这篇笔记就围绕「SM2/SM3 国密字符文件加解密 + jar 包交付」这个完整链路,把选型理由、代码实现、参数设置和踩坑记录一次讲透。适合正在做国密改造的后端工程师、需要交付独立工具包的开发者,以及被 BouncyCastle 依赖版本折腾过的人。

2. 先把 SM2 和 SM3 的分工理清楚:谁加密、谁校验、谁不该混用

2.1 SM2 是非对称加密,SM3 是摘要算法,职责完全不同

很多人第一次接触国密,会把 SM2 和 SM3 当成「两个可以互相替代的加密算法」,这是最常见的认知偏差。SM2 是基于椭圆曲线的非对称算法,有公钥和私钥,能做加密解密,也能做签名验签;SM3 是密码杂凑算法,输出 256 位摘要,本质上是不可逆的,只能用来做完整性校验,不能用来还原原文。

所以在一个文件加解密场景里,合理的分工是:用 SM2 的公钥加密文件内容(或者加密一个对称密钥),用 SM3 对原文或密文做摘要,接收方解密后再算一次 SM3,比对摘要是否一致,确认文件没被篡改。如果你拿 SM3 去「解密」,那方向就错了,它根本没有解密这个概念。

常见做法是:小文件直接用 SM2 加密整个内容;大文件先用 SM2 加密一个随机对称密钥,再用对称算法加密文件体,SM3 负责校验。标题里说的是「字符文件」,通常体积不大,直接 SM2 加密内容就够了,但摘要校验这一步不能省。

2.2 为什么字符文件比二进制文件更容易翻车

字符文件的核心问题是编码。一个 UTF-8 的中文文件,读成字节数组再加密,解密后如果按 GBK 去还原,必然乱码。更隐蔽的是换行符:Windows 下是\r\n,Linux 下是\n,如果加解密两端对换行做了不同处理,SM3 摘要就会对不上,但你会以为是密钥错了。

我的习惯是:加解密全程只操作byte[],绝不在中间环节做new String()转换。只有最后输出文件时,才按明确的字符集写回。这样能避开绝大多数编码玄学。

2.3 依赖选型:BouncyCastle 是绕不开的,但版本要锁死

Java 原生 JCE 对国密支持不完整,实际项目里基本都靠 BouncyCastle。关键点在于:BC 的bcprov-jdk15on和bcprov-jdk18on在国密 API 上有差异,不同小版本对 SM2 的默认参数处理也不完全一致。如果你交付的 jar 包里嵌了一个版本,对方环境里又有一个旧版本,类加载顺序一乱,就会出现「本地能跑、对方报 NoSuchAlgorithmException」。

我一般会在 pom 里显式锁定一个版本,并且用 shade 插件把依赖打进 jar,避免运行时被外部 classpath 污染。下面是一个最小可用的依赖配置:

<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>1.78.1</version> <!-- 锁定版本,避免运行时被外部旧版覆盖 --> </dependency>

参数说明:bcprov-jdk18on适配 JDK 1.8 及以上,1.78.1 对 SM2/SM3 的 API 比较稳定。不要用provided作用域,否则打出来的 jar 在对方机器上会缺类。

3. 用 Java 跑通 SM2 加解密的最小实现:密钥、编码、填充三个参数

3.1 生成 SM2 密钥对并正确保存

SM2 的密钥对生成不复杂,但保存格式有讲究。公钥是 64 字节(X 和 Y 各 32 字节),通常前面加一个04标识未压缩点;私钥是 32 字节的大整数。如果你直接把getEncoded()的结果 Base64 存下来,换一个库去解析可能就对不上。

import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.*; import java.util.Base64; public class Sm2KeyGen { public static void main(String[] args) throws Exception { Security.addProvider(new BouncyCastleProvider()); KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC"); // 指定 SM2 曲线,不能省略,否则默认曲线不是国密 kpg.initialize(new org.bouncycastle.jce.spec.ECNamedCurveGenParameterSpec("sm2p256v1")); KeyPair kp = kpg.generateKeyPair(); String pub = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); String pri = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()); System.out.println("PUB=" + pub); System.out.println("PRI=" + pri); } }

逻辑说明:这里用的是标准 JCE 接口配合 BC Provider,sm2p256v1是国密推荐的椭圆曲线参数。参数说明:ECNamedCurveGenParameterSpec必须显式指定曲线名,如果只写kpg.initialize(256),生成的可能是 secp256r1,那就不是 SM2 了,加密出来的东西对方解不开。

3.2 SM2 加密:C1C3C2 和 C1C2C3 的顺序坑

SM2 加密结果由三部分组成:C1 是椭圆曲线点,C3 是摘要,C2 是密文。历史上存在两种排列顺序,旧标准用 C1C2C3,新标准用 C1C3C2。BC 库默认输出 C1C3C2,但很多老系统对接时要求 C1C2C3。如果你不确认对方要哪种,解密必然失败。

import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey; import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.params.ParametersWithRandom; public class Sm2Encrypt { public static byte[] encrypt(byte[] data, BCECPublicKey publicKey) { ECPublicKeyParameters param = new ECPublicKeyParameters( publicKey.getQ(), // 公钥点 publicKey.getParameters()); SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); // 显式指定顺序 engine.init(true, new ParametersWithRandom(param, new SecureRandom())); try { return engine.processBlock(data, 0, data.length); } catch (Exception e) { throw new RuntimeException("SM2 加密失败", e); } } }

逻辑说明:SM2Engine.Mode.C1C3C2是显式声明输出顺序,不要依赖默认值。参数说明:ParametersWithRandom保证每次加密的随机数不同,同一个明文两次加密结果不一样,这是正常且必要的。如果对方系统只认 C1C2C3,把 Mode 改成C1C2C3即可,但两端必须一致。

3.3 SM3 摘要:加盐和编码要提前约定

SM3 本身很简单,但「加不加盐」「盐怎么拼」是接口约定问题。我一般把盐拼在原文前面,并且盐也用 UTF-8 字节,避免字符串拼接时的编码差异。

import org.bouncycastle.crypto.digests.SM3Digest; public class Sm3Util { public static byte[] digest(byte[] data, String salt) { SM3Digest digest = new SM3Digest(); if (salt != null) { byte[] saltBytes = salt.getBytes(java.nio.charset.StandardCharsets.UTF_8); digest.update(saltBytes, 0, saltBytes.length); // 盐在前 } digest.update(data, 0, data.length); byte[] out = new byte[32]; // SM3 固定 256 位 digest.doFinal(out, 0); return out; } }

逻辑说明:SM3Digest是流式接口,update 的顺序决定最终摘要。参数说明:输出固定 32 字节,转十六进制字符串时用%02x补齐,不要用Integer.toHexString,否则前导零会丢,导致两端摘要字符串长度不一致。

3.4 文件读写:全程 byte[],最后一步才转字符串

import java.nio.file.*; public class FileCrypto { public static byte[] readFile(String path) throws Exception { return Files.readAllBytes(Paths.get(path)); // 直接读字节,不做字符转换 } public static void writeFile(String path, byte[] data) throws Exception { Files.write(Paths.get(path), data, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } }

逻辑说明:Files.readAllBytes和Files.write是二进制安全的,不会插入任何平台相关的换行转换。参数说明:TRUNCATE_EXISTING保证覆盖写,避免旧内容残留导致文件变长。如果输出的是 Base64 文本,再单独做一次编码,不要和文件读写混在一起。

4. 把依赖和代码打成可交付 jar:shade 配置与入口类设计

4.1 为什么普通 jar 到对方机器上会缺类

mvn package默认打出来的 jar 只包含你自己的 class,BouncyCastle 是外部依赖,不会进去。对方如果没配 classpath,一运行就NoClassDefFoundError。解决办法是用 maven-shade-plugin 打成 fat jar,把所有依赖合并进去。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.crypto.Main</mainClass> </transformer> </transformers> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> </configuration> </execution> </executions> </plugin>

逻辑说明:ManifestResourceTransformer指定入口类,filters排除签名文件,否则合并后的 jar 会因为签名不匹配报SecurityException。参数说明:createDependencyReducedPom设为 false 可以避免生成多余的 pom 文件。

4.2 入口类设计:命令行参数怎么定

交付给别人的 jar,最好用命令行参数驱动,而不是改代码。我一般设计成四个参数:模式(enc/dec)、输入文件、输出文件、密钥文件。

public class Main { public static void main(String[] args) throws Exception { // args[0]=enc/dec, args[1]=输入, args[2]=输出, args[3]=密钥 String mode = args[0]; String in = args[1]; String out = args[2]; String keyFile = args[3]; if ("enc".equals(mode)) { // 读取公钥,加密,写 SM3 摘要到同名 .sm3 文件 } else if ("dec".equals(mode)) { // 读取私钥,解密,校验 SM3 } else { System.out.println("用法: java -jar crypto.jar enc|dec 输入 输出 密钥文件"); } } }

逻辑说明:参数顺序固定,方便写脚本调用。参数说明:密钥文件里存 Base64 字符串,公钥和私钥分开文件,不要混在一个文件里靠行号区分,容易读错。

4.3 运行验证:一条命令确认 jar 可用

java -jar crypto.jar enc plain.txt plain.enc pub.key java -jar crypto.jar dec plain.enc plain.dec pri.key diff plain.txt plain.dec && echo "一致"

逻辑说明:加密后再解密,用 diff 比对原文和还原结果。参数说明:如果 diff 无输出说明完全一致;如果有差异,先看是不是换行符问题,再看 SM3 摘要是否匹配。

5. 避坑与排查:国密文件加解密最常见的 5 个翻车现场

5.1 解密报 InvalidCipherTextException

现象:加密正常,解密时抛InvalidCipherTextException或mac check failed。原因:密文的 C1C3C2 顺序和对方不一致,或者密文在传输中被 Base64 解码后多了换行。解决:先确认两端 Mode 一致,再把密文用 Base64 编码后传输,解码时用Base64.getMimeDecoder()容忍换行,或者干脆去掉所有空白字符再解码。

5.2 中文解密后乱码

现象:英文正常,中文变问号或方块。原因:加密前用String.getBytes()没指定字符集,走了平台默认编码。解决:全程StandardCharsets.UTF_8,读写文件都用字节流,最后展示时才转字符串。

5.3 SM3 摘要对不上但内容看起来一样

现象:解密出来的文本肉眼看着一样,SM3 比对失败。原因:换行符差异,Windows 的\r\n和 Linux 的\n在字节层面不同。解决:在摘要计算前统一换行符,或者约定传输时用二进制模式,禁止 FTP 的 ASCII 模式。

5.4 换机器后 NoSuchAlgorithmException: EC

现象:本地正常,对方环境报找不到 EC 算法。原因:对方 JRE 没装 BC Provider,或者 jar 里的 BC 版本和对方 classpath 里的冲突。解决:用 fat jar 把 BC 打进去,并在代码里显式Security.addProvider(new BouncyCastleProvider()),不要依赖外部配置。

5.5 大文件加密内存溢出

现象:加密几十兆的文件时 OOM。原因:SM2 加密本身不适合大文件,一次性读入内存再加密,堆撑不住。解决:超过 1MB 的文件改用混合加密,SM2 只加密一个随机 AES 密钥,文件体用 AES 流式处理,SM3 分块更新摘要。

6. 进阶技巧:用混合加密和分块摘要处理大文件

前面说的都是小字符文件,但实际项目里迟早会遇到大文件。SM2 直接加密大文件有两个硬伤:一是性能差,椭圆曲线运算比对称加密慢几个数量级;二是内存占用高,processBlock需要完整数据。我的做法是混合加密:随机生成一个 16 字节的 AES 密钥,用 SM2 公钥加密这个密钥,文件体用 AES-CTR 模式流式加解密,SM3 分块更新。

// 混合加密核心逻辑示意 byte[] aesKey = new byte[16]; new SecureRandom().nextBytes(aesKey); byte[] encryptedKey = Sm2Encrypt.encrypt(aesKey, publicKey); // SM2 只加密密钥 // 文件体用 AES-CTR 流式处理,SM3 同步 update SM3Digest digest = new SM3Digest(); try (InputStream in = Files.newInputStream(Paths.get(src)); OutputStream out = Files.newOutputStream(Paths.get(dst))) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) > 0) { digest.update(buf, 0, len); // 分块更新摘要 byte[] enc = aesCtr(buf, 0, len, aesKey); out.write(enc); } }

逻辑说明:SM2 只负责保护 AES 密钥,文件体走对称加密,性能提升明显。参数说明:AES-CTR 不需要填充,适合流式;缓冲区 8192 字节是经验值,太小会增加 IO 次数,太大收益递减。SM3 分块 update 的结果和一次性 update 完全一致,不用担心摘要对不上。

验证方法:加密后记录 SM3 摘要,解密后重新计算,比对一致才算成功。我一般会在输出文件旁边生成一个.sm3文件存十六进制摘要,方便对方校验。

最后说个血泪经验:国密对接最怕的不是算法难,而是「约定不清」。C1C3C2 还是 C1C2C3、盐拼前面还是后面、换行怎么处理,这些必须在动手前和对方白纸黑字确认,否则调三天都调不通。我现在养成的习惯是,先写一个最小 demo 和对方互发一组测试向量,确认无误再写正式代码。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询