简介:这份资源面向需要在 Java 项目中落地国密算法加密能力的开发者,尤其是希望同时处理字符串与文件加解密、又不想额外折腾依赖配置的中初级后端与安全方向工程师。包内共 5 个文件,以 4 个 Java 源码文件与 1 个 jar 依赖包为主:源码分别承担文件读写工具、SM2 加解密、SM3 摘要计算以及密钥对象封装等职责,jar 包则提供 BouncyCastle 相关密码学支撑,压缩包整体约 4.89MB,结构精简、导入即可编译运行。目前已有 428 人学习下载,说明其在国密改造、接口数据保护、敏感文件传输等场景中具有一定参考价值。读者可从中获得一套可直接复用的 SM2、SM3 调用范例,理解公私钥的生成与保存方式,掌握对文本内容做加密解密、对文件做流式加解密的具体写法,并借助摘要算法完成完整性校验,省去自行查阅国密规范与调试依赖版本的时间,适合作为项目内国密模块的起步模板。
1. 拿到一个国密加解密压缩包,先别急着解压
有个做行业应用的朋友上周找我,说对接方甩过来一句话:「数据要用 SM2 加密、SM3 做校验」,然后就没下文了,密钥格式、密文排列、编码方式一概没说。他手上就一个压缩包,里面是 sm2、sm3 的字符与文件加解密工具,还带了可以直接跑的 jar 包。这类资源解决的其实就是这种「标准存在、落地细节全靠猜」的处境:SM2 负责非对称加密和签名,SM3 负责摘要与完整性校验,两者合起来覆盖了国密场景里最常见的两件事——把敏感内容藏起来、把内容有没有被改过验出来。适合两类人看:一是 Java 项目里被要求补国密合规的开发者,二是需要离线批量处理文件、做摘要归档的运维同学。它纯软件实现,不依赖密码机,密钥编码、密文顺序这些最容易翻车的地方,源码里都摊开了。
2. 工程结构:压缩包拆开后每一层在干什么
先把包解开看整体,不要一上来就找 main 方法。这类国密工具包的结构通常非常规整:一个放依赖的 lib 目录、一个放源码的 src 目录、根上一个已经打好可直接执行的 jar,外加一份说明和几组示例密钥。理清这个分层,后面调 API 才不会到处乱翻。
2.1 目录分层:lib、src、jar 三段式
我见过的同类包基本都是这个形态,只是文件名会有出入:
sm2sm3-crypto/ ├── lib/ │ ├── bcprov-jdk18on-*.jar # 国密算法真正的实现来源 │ └── bcprov-util-*.jar # 部分包会额外拆一个工具包 ├── src/ │ └── com/example/crypto/ │ ├── Sm2Util.java # 密钥生成、加密、解密、签名、验签 │ ├── Sm3Util.java # 摘要、HMAC、十六进制转换 │ ├── FileCryptUtil.java # 文件级加解密与摘要 │ └── Main.java # 命令行入口,参数解析在这里 ├── sm2sm3.jar # 打好的可执行包 ├── keypair.example # 示例密钥,注意这是演示用的 └── README.md这个分层对应的能力边界很清楚:lib里的 jar 决定了你能用什么算法、什么曲线;src里的四个类决定了你调用方不方便;根上的 fat jar 决定了能不能不装 JDK 依赖管理工具直接跑。很多人卡住不是因为算法不会,而是把lib里的 jar 漏拷了,运行时抛NoSuchAlgorithmException: SM2,回头去改代码,改一天也改不出来。
提示:
keypair.example只用来验证流程通不通,真上线前必须自己重新生成,示例密钥等于把门钥匙贴在门上。
2.2 依赖与环境:绕不开的 BouncyCastle
标准 JDK 自带的是 RSA、EC、SHA 这一套,SM2 和 SM3 不在里面。市面上 Java 侧的国密实现,绝大多数底层是 BouncyCastle,区别只在于:有的包把 BC 的 jar 一起塞进lib,有的直接打在 fat jar 里,有的只写了 Maven 坐标让你自己拉。拿到这个包,第一步应该是确认 BC 到底在哪。
Maven 项目里常见的引入方式是这样:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>与 lib 目录中 jar 保持一致的版本</version> </dependency>判断版本是否一致的土办法很直接:看lib下那个 bcprov jar 的文件名,版本号对不上就统一成它。BC 各版本之间的 API 有过变动,尤其 SM2 的密文默认排列在早期版本和后期版本之间是反的,混用两个版本会出现「加密正常、解密报错」这种极其难查的现象。
环境层面只有两个硬要求:JDK 8 及以上(SM2 用到了java.security.KeyPairGenerator的 EC 扩展,JDK 7 会很别扭),以及把 BC Provider 注册进去。注册有两种写法,一种改java.security配置文件全局生效,一种在代码里Security.addProvider(new BouncyCastleProvider())局部生效。我一般用后者,原因是部署到别人的容器里时,改配置文件往往没权限。
2.3 先跑通再读代码:三条命令验证
在拆源码之前,先花五分钟确认这个包在你机器上是活的。常见做法是准备三个最小验证:加密再解密、摘要对比、文件加解密。
# 1. 确认 jar 能启动,看它支持哪些子命令 java -jar sm2sm3.jar --help # 2. 字符加解密走一遍,注意把示例公钥换成包里给的 java -jar sm2sm3.jar sm2-encrypt --text "hello-sm2" --pubkey keypair.example # 3. 文件摘要,验证 SM3 输出长度是不是 64 位十六进制 java -jar sm2sm3.jar sm3 --file ./test.binSM3 的输出固定 256 位,转成十六进制就是 64 个字符,这是一条特别好用的自检线:如果你拿到 32 个字符,那多半是拿 MD5 冒充或者截断了;如果长度对但和对方系统对不上,问题就在输入字节上,比如换行符被readLine吃掉了。先跑通这三条,再回头读Sm2Util和Sm3Util,你会发现自己看代码时心里有底,知道每一段最终要落到哪个命令上。
3. SM2 核心链路:密钥、加解密、签名验签三段拆解
SM2 是基于 256 位素数域椭圆曲线的公钥算法,和 RSA 最大的区别是密钥短、运算快,但参数格式上的讲究多得多。这一章按调用顺序走一遍:先生成密钥对,再拿公钥加密、私钥解密,最后是签名和验签。每一段代码后面我会说清楚参数该传什么、错了会怎样。
3.1 密钥对生成:32 字节私钥与 65 字节公钥
密钥是整个链路的地基,格式错了后面全白搭。SM2 的私钥是一个 32 字节的大整数,公钥是曲线上的一个点,由 x、y 两个 32 字节坐标拼成,前面加一个04前缀表示「未压缩点」,所以是 65 字节。记住这两个长度,排查问题时能省掉一大半时间。
import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.*; import java.security.spec.ECGenParameterSpec; public class Sm2KeyGen { static { // 只需注册一次,重复注册不会报错但没必要 if (Security.getProvider("BC") == null) { Security.addProvider(new BouncyCastleProvider()); } } /** * 生成 SM2 密钥对 * 曲线名必须是 sm2p256v1,写成 secp256r1 生成的是另一条曲线,密文互不兼容 */ public static KeyPair generate() throws Exception { KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC"); kpg.initialize(new ECGenParameterSpec("sm2p256v1"), new SecureRandom()); return kpg.generateKeyPair(); } public static void main(String[] args) throws Exception { KeyPair kp = generate(); // 私钥取 D 值,固定 32 字节 byte[] priv = ((java.security.interfaces.ECPrivateKey) kp.getPrivate()).getS().toByteArray(); // 公钥取编码点,未压缩格式 65 字节,首字节 0x04 byte[] pub = kp.getPublic().getEncoded(); System.out.println("priv len = " + priv.length); System.out.println("pub len = " + pub.length); } }这段代码里有两个参数值得盯住。sm2p256v1是国家标准推荐的曲线,如果你手贱写成secp256r1(也就是 NIST P-256),代码不会报错,密钥照样生成,但对方用标准 SM2 解你的密文时会直接失败——两条曲线的基点不同,点乘结果自然对不上。new SecureRandom()也别省,用new Random()生成的私钥在密码学意义上等于明文。
另外注意,kp.getPublic().getEncoded()拿到的是 X.509 SubjectPublicKeyInfo 结构,里面除了 65 字节点数据还包了算法标识头,长度通常在 91 字节左右。真正要传给对方的往往只是那 65 字节的裸点,中间需要一次剥离。这个剥离动作不做,对方解析时会报Invalid point coordinates。反过来,私钥的 PKCS#8 编码也一样带壳,所以工具包里通常会有getRawPrivateKey/getRawPublicKey这类方法专门处理。
3.2 公钥加密、私钥解密:C1C3C2 的顺序问题
SM2 加密后的密文由三部分组成:C1 是椭圆曲线上的一个点(65 字节),C3 是 32 字节的摘要,C2 是和明文等长的密文数据。这三段的排列顺序就是国密落地里翻车率最高的地方。国家标准推荐 C1C3C2,但早期实现和部分老系统用的是 C1C2C3,两者外观上一模一样,解密时才会告诉你「解密失败」。
import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.*; public class Sm2Crypto { /** * 公钥加密 * @param plain 明文,建议先转 UTF-8 字节再传 * @param pubKey 65 字节未压缩公钥点 * @param mode C1C3C2 或 C1C2C3,必须和对端一致 */ public static byte[] encrypt(byte[] plain, byte[] pubKey, SM2Engine.Mode mode) throws Exception { ECPublicKeyParameters key = Sm2KeyUtil.toPublicKey(pubKey); // 内部把 65 字节转成 BC 参数对象 SM2Engine engine = new SM2Engine(mode); engine.init(true, new ParametersWithRandom(key, new SecureRandom())); return engine.processBlock(plain, 0, plain.length); } /** * 私钥解密 * @param cipher 密文,C1 部分必须带 04 前缀 * @param privKey 32 字节私钥 D 值 */ public static byte[] decrypt(byte[] cipher, byte[] privKey, SM2Engine.Mode mode) throws Exception { ECPrivateKeyParameters key = Sm2KeyUtil.toPrivateKey(privKey); SM2Engine engine = new SM2Engine(mode); engine.init(false, key); return engine.processBlock(cipher, 0, cipher.length); } }init的第一个参数true表示加密方向,false表示解密方向,这个布尔值写反了不会编译报错,只会在运行时输出一堆乱码或者抛异常。ParametersWithRandom是加密时必须包的,因为 SM2 加密过程中要生成临时密钥 k,这个 k 必须随机,不包的话 BC 会用默认随机源,安全性打折。
密文长度也可以用来反推:明文 10 字节,密文大约是 65 + 32 + 10 = 107 字节。如果你拿到一个密文长度正好等于明文长度,那说明对方压根没加密,可能只是 Base64 了一下。这个估算在做接口联调时特别好用,能快速排除「到底谁没按规范来」。
3.3 签名与验签:DER 编码和 r||s 裸签名之别
加密解决机密性,签名解决的是「这条消息确实是你发的、没被改过」。SM2 签名用的是 SM3 做摘要,算法标识一般写SM3withSM2。和加密一样,这里也有格式分歧:BC 默认输出 ASN.1 DER 编码的签名(一个 SEQUENCE 里包 r 和 s),而很多国内系统和硬件设备要求的是r||s各 32 字节拼接的 64 字节裸签名。
import org.bouncycastle.crypto.signers.SM2Signer; public class Sm2Sign { /** * 签名 * @param data 待签数据,业务上常把参数按字典序拼接后签名 * @param privKey 32 字节私钥 * @param userId 用户标识,国标默认 "1234567812345678",对端不传时用这个默认值 * @param der 是否输出 DER 编码;false 则输出 64 字节 r||s */ public static byte[] sign(byte[] data, byte[] privKey, byte[] userId, boolean der) throws Exception { ECPrivateKeyParameters key = Sm2KeyUtil.toPrivateKey(privKey); SM2Signer signer = new SM2Signer(); signer.init(true, new ParametersWithID( new ParametersWithRandom(key, new SecureRandom()), userId)); signer.update(data, 0, data.length); return signer.generateSignature(); } }userId是 SM2 特有的东西,官方叫用户标识,参与摘要计算。默认值1234567812345678是标准里给的空标识替代值。我遇到过最隐蔽的一次联调问题就是:双方算法实现都对,但一方传了自己的 userId,另一方用默认值,结果签名永远是验不过。抓包看两边的数据完全一致,最后是在代码里打断点才发现的。所以对接文档里如果没写 userId,一定要主动问一句。
DER 和裸签名的转换,BC 提供了StandardDSAEncoding和PlainDSAEncoding两个工具类,互相转就行。验签方拿到签名后,第一件事应该是看长度:64 字节基本可以判断是裸签名,70 到 72 字节大概率是 DER。
4. SM3 与文件加解密:从摘要到流式分块
字符处理的量级通常是几百字节,怎么写都行;文件动辄几百兆,处理方式就得换一套思路。这一章先说 SM3 的字符摘要,再讲文件加解密怎么分块,最后说大文件场景下必须避开的坑。
4.1 SM3 摘要与 HMAC-SM3:输入字节决定结果
SM3 是密码杂凑算法,输出 256 位,作用等价于 SHA-256,用于完整性校验。它的 API 极其简单,但「简单」恰恰意味着出错的地方都在输入侧:同一个字符串,编码不同,摘要就不同;多一个空格,摘要就全变。
import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.util.encoders.Hex; public class Sm3Util { /** * 计算 SM3 摘要 * @param data 原始字节,字符串必须先指定编码转字节 * @return 32 字节摘要 */ public static byte[] digest(byte[] data) { SM3Digest digest = new SM3Digest(); digest.update(data, 0, data.length); byte[] out = new byte[digest.getDigestSize()]; // 固定 32 digest.doFinal(out, 0); return out; } public static String digestHex(String text) { // 编码不写死,是中文摘要对不上的头号原因 return Hex.toHexString(digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } }getDigestSize()返回 32,这个值不要硬编码成 16——那是 MD5 的习惯。当你要做「带密钥的摘要」,用的是 HMAC-SM3,而不是把密钥拼在明文前面再算摘要。后者看起来结果正常,实际上存在长度扩展攻击的风险,规范里明确不推荐。
还有一点常被忽略:SM3 本身不防篡改来源,只防内容变化。如果只做摘要不做签名,攻击者可以同时改内容和摘要,校验照样通过。所以正确的组合是 SM2 签名加 SM3 摘要,或者直接用 SM2 签名(内部已经包含 SM3)。
4.2 文件加解密:分块读、分块算、分块写
文件加解密不能照抄字符那套。原因很直接:SM2 单次加密的明文长度理论上没有硬上限,但实际实现里对超长数据会非常慢,而且密文膨胀明显。业界的常规做法是混合加密——随机生成一个对称密钥,用对称算法加密文件内容,再用 SM2 把这个对称密钥加密后附在文件头。
/** * 文件加密:SM4 加密内容 + SM2 加密会话密钥 * 文件结构: [2字节 keyLen][SM2 加密后的 SM4 key][SM4 密文...] */ public static void encryptFile(File in, File out, byte[] sm2PubKey) throws Exception { byte[] sessionKey = new byte[16]; new SecureRandom().nextBytes(sessionKey); // 每次加密都换,绝不复用 byte[] wrappedKey = Sm2Crypto.encrypt(sessionKey, sm2PubKey, SM2Engine.Mode.C1C3C2); try (InputStream is = new BufferedInputStream(new FileInputStream(in)); OutputStream os = new BufferedOutputStream(new FileOutputStream(out))) { os.write((wrappedKey.length >> 8) & 0xFF); // 写密钥长度,高字节在前 os.write(wrappedKey.length & 0xFF); os.write(wrappedKey); Cipher cipher = Cipher.getInstance("SM4/CTR/NoPadding", "BC"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sessionKey, "SM4"), new IvParameterSpec(new byte[16])); byte[] buf = new byte[8192]; int n; while ((n = is.read(buf)) != -1) { os.write(cipher.update(buf, 0, n)); } os.write(cipher.doFinal()); } }这里用 8KB 缓冲区是有讲究的:太小系统调用频繁,太大会占内存,8KB 到 64KB 都是常见取值。模式选 CTR 是因为它不需要填充,密文长度严格等于明文长度,文件尾不会多出几个字节导致对方解析错位;如果用 ECB 或 CBC,就必须处理 padding,解密时还要告诉对方 padding 规则,多一个沟通成本。
文件头那 2 字节的长度字段是自描述的,解密方先读 2 字节知道 SM2 密文多长,再读对应长度解出会话密钥,剩下的全是 SM4 密文。这种自描述结构比「约定固定长度」友好得多,因为 SM2 密文长度会随公钥编码方式变化。
4.3 别用 readAllBytes:内存和中断的代价
新手最容易写的是Files.readAllBytes(path)然后再加密。本机测一个 10MB 文件没问题,扔到生产上处理 2GB 的日志归档,直接 OOM。更麻烦的是这种方式没有中间状态,进程被杀就全废,得从头再来。
流式处理除了省内存,还有一个隐性好处:可以在循环里埋进度回调和校验点。比如每处理 64MB 记录一次偏移量,配合随机访问就能实现断点续传。这段逻辑不复杂,但需要在读循环里预留位置,等出问题再改架构就来不及了。
文件摘要同理,要用SM3Digest的update逐块喂,不要一次性读全。另外注意读文件时不要做任何字符集转换,二进制文件一旦经过new String(bytes)再转回来,内容就被破坏了。我见过有人为了「方便日志打印」把中间结果转成字符串,结果加解密出来的文件打不开——这个坑非常经典。
5. 排查手册:国密联调里最常见的五个翻车现场
国密这块的问题有个共同特征:代码不报错、结果不对,或者报的错跟真实原因完全无关。下面五条是我和周围人踩过的,按出现频率排。
5.1 解密报错或乱码:先怀疑 C1C3C2 顺序
现象:加密方说加密成功了,解密方一调就抛invalid cipher text或者解出来是乱码,双方代码看起来都对。
原因:密文三段排列不一致。C1C3C2 和 C1C2C3 生成的密文长度完全相同,肉眼和长度都判断不出来,只有真正解密时才会暴露。
解决:把顺序当成接口协议的一部分写进对接文档。联调时最快的验证办法是拿同一份明文、同一对密钥,用两种模式各加密一次,让对方两种都试。能解通的那个就是对的。BC 里改顺序只需要换new SM2Engine(SM2Engine.Mode.C1C3C2)这一个参数。
5.2 中文摘要对不上:编码没统一
现象:英文内容摘要完全一致,一换成中文就怎么都对不上,双方都坚称自己用的是 SM3。
原因:String.getBytes()不传编码时用的是平台默认字符集,Windows 上常是 GBK,Linux 容器里常是 UTF-8,同一段中文转出来的字节数组不一样,摘要自然不一样。
解决:所有getBytes()强制写StandardCharsets.UTF_8,并且在对接文档里明确写「UTF-8,无 BOM」。顺便检查一下读取配置文件、读请求体的地方,国密工具类里因为编码翻车的比例,比算法本身用错的比例高得多。
5.3 密钥解析失败:带壳的和裸的不是一回事
现象:InvalidKeySpecException、Invalid point coordinates,或者密钥长度校验过不了。
原因:私钥有 PKCS#8(带算法标识头)和裸 32 字节 D 值两种形态,公钥有 X.509(带壳)和 65 字节裸点两种形态。对方给的是带壳的,你按裸的解析,长度对不上直接抛异常。
解决:先看长度再决定解析方式。私钥 32 字节按裸的来,超过 100 字节基本是 PKCS#8;公钥 65 字节且首字节是04是裸点,91 字节左右是 X.509。写工具方法时两种都支持,入口处用长度分支,比让调用方自己判断靠谱。
5.4 Provider 找不到:jar 冲突或没注册
现象:NoSuchAlgorithmException: SM2 KeyPairGenerator not available。
原因:三种可能——BC 的 jar 没进 classpath、Security.addProvider没执行、项目里已经有一个老版本 BC 被类加载器优先加载了。
解决:启动时打印Security.getProviders()看 BC 在不在,再看BouncyCastleProvider.class.getPackage().getImplementationVersion()确认版本。如果项目依赖了别的库(比如某些模板引擎、某些 SDK)间接引入了低版本 BC,用mvn dependency:tree找出来排除掉,全项目统一成一个版本。
5.5 大文件处理失败:内存、超时、文件句柄
现象:小文件正常,大文件 OOM 或者处理到一半卡死;批量处理几千个文件后报「too many open files」。
原因:一次性读全文件,或者忘记关流。流没关在 Windows 上表现为文件删不掉,在 Linux 上是句柄泄漏,跑久了就崩。
解决:全部改成 try-with-resources 的流式写法,读缓冲 8KB 起步。批量任务里加一个计数器,每处理 N 个文件打印一次进度和耗时,出问题时能立刻定位是卡在哪个文件上。这些看着是工程琐事,但在国密场景里,出问题的往往不是算法,而是这些边角。
注意:上面五条里有四条跟算法本身无关。排查顺序建议是「格式 → 编码 → 依赖」,最后才怀疑算法实现,这样能省下大量时间。
6. 二次开发:把国密工具接进自己的业务系统
这套东西如果只是拿来跑个 demo,价值有限;真正有用的是把它拆成两层:底层保留通用加解密能力,上层按业务场景封装。
我的习惯是先做一次能力收敛。把Sm2Util、Sm3Util、FileCryptUtil里的方法按「输入输出类型」重新归一遍,字符类方法统一收成String → String(内部固定 UTF-8),字节类方法收成byte[] → byte[],文件类方法是File + File。收敛之后,业务代码里就不该再出现getBytes()、Hex.toHexString()这些转换动作,全部下沉到工具层。这一步做完,编码类的问题基本绝迹。
第二层是配置化。密钥不要写在代码里,也不要硬编码在配置文件里,理想做法是启动时从密钥管理服务拉,本地只留一个引用标识。退一步至少做到:私钥文件不进代码仓库,配置文件里放路径不放内容。同时加一个启动自检——生成密钥对、加密一段固定字符串、解密比对,三秒内完成,失败就直接启动失败,别让一个配置错误的应用跑起来接流量。
/** * 启动自检:宁可启动失败,也不要带着错的密钥对外服务 */ public boolean selfCheck() { try { KeyPair kp = Sm2KeyGen.generate(); byte[] raw = Sm2KeyUtil.getRawPublicKey(kp.getPublic()); byte[] priv = Sm2KeyUtil.getRawPrivateKey(kp.getPrivate()); String probe = "crypto-selfcheck"; byte[] cipher = Sm2Crypto.encrypt( probe.getBytes(StandardCharsets.UTF_8), raw, SM2Engine.Mode.C1C3C2); byte[] plain = Sm2Crypto.decrypt(cipher, priv, SM2Engine.Mode.C1C3C2); return probe.equals(new String(plain, StandardCharsets.UTF_8)); } catch (Exception e) { log.error("crypto self check failed", e); return false; } }第三层是兼容。对接方不可能全都规范,所以加解密方法最好把模式、编码、签名格式做成参数,默认值给标准的,遇上不标准的老系统传参覆盖。下面这张表是我做接口适配时必问的几个点,可以直接拿去当联调清单用:
| 需要确认的点 | 常见取值 | 不一致的后果 |
|---|---|---|
| 密文排列 | C1C3C2 / C1C2C3 | 解密直接失败 |
| 公钥格式 | 65 字节裸点 / X.509 | 解析抛异常 |
| 私钥格式 | 32 字节 D 值 / PKCS#8 | 解析抛异常 |
| 签名格式 | 64 字节 r||s / DER | 验签失败 |
| 字符编码 | UTF-8 / GBK | 中文内容摘要对不上 |
| userId | 默认 16 字节 / 自定义 | 签名验不过 |
验证方式别只靠单测。单测覆盖的是自己和自己的一致性,来回加密解密全绿,接上对方照样失败。真正有效的验证是拿对方给的固定测试向量跑一遍:对方给明文、给密钥、给期望密文,你算出来的和期望一致,才算通。这套测试向量要落到测试代码里长期保留,后面任何人改动工具类,跑一遍就知道有没有破坏兼容。
从那以后我每次接国密相关的活,都会在开工第一天先把那张确认表发给对方填,填完再写代码。有两次对方嫌麻烦说「按标准来就行」,结果联调时果然是 C1C2C3。希望帮到你。
本文还有配套的精品资源,点击获取