ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SM2/SM3国密加解密实战:Java jar包、字符文件与摘要校验避坑

SM2/SM3国密加解密实战:Java jar包、字符文件与摘要校验避坑

简介:面向需要落地国密算法的 Java 开发者与项目团队,这份资料聚焦 SM2、SM3 在字符串与文件两类对象上的加解密实现,适合在数据安全改造、接口报文保护、敏感文件传输等场景中参考复用。压缩包共 5 个文件,由 4 个 Java 源码与 1 个依赖 jar 组成,整体约 4.89MB;源码侧重工具类与密钥封装,jar 则补齐 BouncyCastle 相关依赖,下载后可直接结合项目调试。已有 428 人学习,说明其对初次接触国密或需要快速验证方案的读者具有一定参考价值。读者可从中获得字符串加解密、文件加解密的基础实现思路,以及密钥处理、工具方法拆分和依赖配置的示例,便于对照自身业务做二次封装与联调排错。

1. 一个 jar 包解决国密加解密:sm2 sm3 字符文件加解密资源到底装了什么

前阵子有个做系统对接的朋友丢过来一个压缩包,说对面要求配置文件、导出文本、字符型数据文件全部用 sm2 sm3 国密算法处理,他手上只有一个 JDK 和一堆看不懂的 jar。拆开看,这份 sm2sm3 国密字符文件加解密含 jar 包的资源,本质是一套能直接跑起来的 Java 示例工程:SM2 非对称加解密负责保护内容,SM3 摘要负责校验完整性,依赖的加密库 jar 一起打包放进来,省掉了去仓库里翻版本、对坐标、试兼容的那一圈。它不是框架,也不是中间件,就是一份能照着改、照着抄的最小可用实现。

适合谁:手上有对接任务、需要在两三天内把加解密逻辑跑通的后端同学;被 "C1C3C2" "04 开头公钥" "SM3 摘要对不上" 折磨过、想找一份能对拍的参考实现的人;以及想搞清楚 jar 包里到底哪个类在干活、以后再换库也不慌的熟手。下面按 "资源里是什么 → 密钥和密文怎么摆 → 字符和文件怎么加 → 摘要怎么校验 → 哪里会翻车" 的顺序拆。

2. 先分清 sm2 和 sm3 的分工:一个是锁,一个是封条

拿到这种包,最常见的误用是把两个算法当成同一类东西互换着用。SM2 是基于椭圆曲线的非对称算法,有公钥私钥,能做加密、解密、签名、验签;SM3 是密码杂凑算法,输出固定 256 位摘要,单向不可逆。前者解决 "别人看不到",后者解决 "别人改没改"。选型上其实没有太多纠结空间:需要还原明文的场景(配置文件、文本数据)走 SM2;只需要比对一致性的场景(文件下发、传输校验、日志防篡改)走 SM3。两个一起用,就是先 SM3 算摘要附在包里,再 SM2 加密正文,接收方解密后重算摘要比对。

2.1 曲线参数和密钥长度决定了密文长什么样

SM2 用的是 256 位素域曲线 sm2p256v1,这套参数不是随便定的,密钥长度、点编码格式、密文拼接顺序全都由它派生。工程上只要记住几个数字,排错时能省一半时间。

对象长度说明
私钥 d32 字节(256 bit)十六进制是 64 个字符,不足要左补 0
公钥 Q65 字节未压缩点格式04 || X(32) || Y(32)
公钥 Q(压缩)33 字节02/03 || X(32),部分对接方会用
SM2 密文97 + 明文长度C1(65) + C3(32) + C2(n),C1C3C2 顺序
SM3 摘要32 字节(256 bit)十六进制 64 个字符
SM2 签名值64 字节(裸 r||s)或 70~72 字节(DER)两种格式互转是高频坑

密文长度这个公式特别值得记:密文长度 = 97 + 明文长度。如果解出来长度对不上,基本可以直接判定是公钥前缀 04 被吞了、或者 C1 被当成了 64 字节处理。另外注意,SM2 单次能加密的数据量理论上有上限,实际工程里超过几 KB 就开始明显变慢,几十 KB 以上建议改成 "SM2 加密一个随机对称密钥 + 对称算法加密正文 + SM3 校验" 的组合,SM2 只负责保护那把短密钥。这份资源里没有对称算法,所以它的定位很明确:字符型小数据和中小文件的加解密,别硬扛几百 MB 的包。

2.2 jar 包依赖和工程接入:先看 lib 目录再谈代码

压缩包解压后先别急着打开 IDE,先看目录。典型的布局是lib/放依赖 jar,src/放示例代码,可能还有一个README或者pom.xml。lib/里最核心的是 BC 库(BouncyCastle),文件名一般是bcprov-jdk18on-*.jar或bcprov-jdk15to18-*.jar,前者对应 JDK 8 以上并做了模块化适配,后者是给老 JDK 用的兼容包。有些包还会带上commons-codec(Base64/Hex 转换)或者hutool-all(封装好的 SmUtil 工具类)。看清楚这几个文件名,后面写 Maven 坐标时版本号要跟它对齐,别自己拍脑袋填一个。

用命令行快速确认 jar 里到底有没有 SM2 相关类,比在 IDE 里点半天快:

# 进入解压后的 lib 目录,确认 jar 里是否包含 SM2/SM3 实现类 cd lib jar tf bcprov-jdk18on-*.jar | grep -i -E "SM2Engine|SM3Digest|GMNamedCurves" # 期望至少看到: # org/bouncycastle/crypto/engines/SM2Engine.class # org/bouncycastle/crypto/digests/SM3Digest.class # org/bouncycastle/asn1/gm/GMNamedCurves.class

如果这三行一个都没出来,说明这个 jar 是裁剪版或者根本不是 BC,后面的代码一行都跑不通,先换包再说。确认之后按 Maven 工程接入,坐标写成和 lib 里 jar 同名同版本:

<!-- pom.xml 片段:版本号必须与 lib/ 下实际 jar 文件名保持一致 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>${bc.version}</version> <!-- 版本值取自 lib 目录下的 jar 名,不要随手填 --> </dependency>

用轻量 API(org.bouncycastle.crypto.*包)时不需要注册 Provider,直接 new 对象就能用;用 JCE 那套(KeyPairGenerator.getInstance("EC", "BC"))才需要Security.addProvider(new BouncyCastleProvider())。示例代码如果两种混着写,先统一成轻量 API,能避开 JDK 自带 Provider 抢占、JDK 17 模块化访问限制一大堆问题。注册 Provider 时还有个小细节:addProvider是追加到末尾,insertProviderAt(provider, 1)才是插到最前面,如果环境里有别的 Provider 也实现了 EC,不插队就可能拿到错误的实现。

3. 字符与文件加密落地:密钥对、密文格式和分块边界

资源里最值钱的部分其实是 "怎么把一段字符串变成一段能贴进配置文件的密文",这里面藏着编码、拼接、Base64 三层转换,任何一层错位,对面就解不开。这一章按生成密钥、加密字符串、加密文件的顺序走一遍,每一步给出可直接抄的代码和参数说明。

3.1 密钥对生成与公私钥的两种序列化方式

生成密钥对本身很简单,坑全在输出格式上。私钥 d 要保证是 32 字节定长,公钥要保证带04前缀,这两个保证不了,跨系统传输必炸。下面这段用轻量 API 生成并输出十六进制和 Base64 两种形式:

import org.bouncycastle.asn1.gm.GMNamedCurves; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.generators.ECKeyPairGenerator; import org.bouncycastle.crypto.params.*; import org.bouncycastle.util.encoders.Hex; import java.math.BigInteger; import java.security.SecureRandom; public class Sm2KeyGen { public static void main(String[] args) { // 1. 取 sm2p256v1 曲线参数,这是 SM2 的法定曲线,不能换 secp256r1 X9ECParameters x9 = GMNamedCurves.getByName("sm2p256v1"); ECDomainParameters domain = new ECDomainParameters( x9.getCurve(), x9.getG(), x9.getN(), x9.getH()); // 2. 用 SecureRandom 初始化生成器,别用 new Random() ECKeyPairGenerator gen = new ECKeyPairGenerator(); gen.init(new ECKeyGenerationParameters(domain, new SecureRandom())); AsymmetricCipherKeyPair kp = gen.generateKeyPair(); ECPrivateKeyParameters priv = (ECPrivateKeyParameters) kp.getPrivate(); ECPublicKeyParameters pub = (ECPublicKeyParameters) kp.getPublic(); // 3. 私钥定长 32 字节:BigInteger 转 byte[] 可能少位,用 %064x 强制补零 String privHex = String.format("%064x", new BigInteger(1, priv.getD().toByteArray())); // 4. 公钥用未压缩编码,getEncoded(false) 输出 04 || X || Y 共 65 字节 String pubHex = Hex.toHexString(pub.getQ().getEncoded(false)); System.out.println("PRIVATE=" + privHex); System.out.println("PUBLIC=" + pubHex); } }

逻辑说明:GMNamedCurves.getByName("sm2p256v1")是拿曲线的唯一正确入口,用SecNamedCurves拿secp256r1生成的密钥在数学上也能跑,但对接方用标准 SM2 实现时验签必然失败,这是最隐蔽的一类错误。私钥补零这一步看着琐碎,实际是血泪经验——BigInteger.toByteArray()返回的是补码表示,最高位为 1 时会多一个0x00字节变成 33 字节,最高位为 0 时又可能不足 32 字节,%064x一步到位解决两头问题。参数上唯一能改的是曲线名,但改了就退出 SM2 范畴了,没有任何调优空间。

公私钥序列化还有第二种形态:把priv.getD().toByteArray()和pub.getQ().getEncoded(false)塞进 PKCS#8 / X.509 结构,输出 PEM 文本。对接方如果给了.pem文件,就说明人家走的是这条路,你要用PrivateKeyFactory.createKey(...)和PublicKeyFactory.createKey(...)反序列化,别拿 PEM 文本直接当字节数组去加密,那是两层不同的东西。

3.2 字符加密:编码、C1C3C2 顺序与 Base64 落盘

字符串加密最容易翻车的地方是 "明文用什么编码变成字节数组"。UTF-8 是默认答案,但必须有明确约定,因为一个中文字符在 GBK 下 2 字节、UTF-8 下 3 字节,加密前不一致,解密后就是乱码,而乱码往往被误判成密钥错误。下面这段给出完整的字符串加密解密闭环:

import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.*; import org.bouncycastle.util.encoders.Base64; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; public class Sm2TextCodec { // 加密:明文 -> UTF-8 字节 -> SM2 密文 -> Base64 单行文本 public static String encrypt(String plain, ECPublicKeyParameters pub) throws Exception { byte[] data = plain.getBytes(StandardCharsets.UTF_8); // 模式必须显式指定,BC 不同版本默认值有差异 SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); engine.init(true, new ParametersWithRandom(pub, new SecureRandom())); byte[] cipher = engine.processBlock(data, 0, data.length); // 用 MIME 无换行版本,避免配置文件里出现折行 return Base64.toBase64String(cipher); } // 解密:Base64 -> 密文字节 -> SM2 解密 -> UTF-8 还原 public static String decrypt(String cipherText, ECPrivateKeyParameters priv) throws Exception { byte[] cipher = Base64.decode(cipherText.trim()); SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); engine.init(false, priv); byte[] plain = engine.processBlock(cipher, 0, cipher.length); return new String(plain, StandardCharsets.UTF_8); } }

逻辑说明:SM2Engine.Mode.C1C3C2是国密标准后来统一的顺序(C1 椭圆曲线点、C3 摘要、C2 密文),但早期不少实现用的是 C1C2C3,两边的密文长度完全一样,解密时才会报InvalidCipherTextException。所以模式一定要显式写,不能依赖默认值,BC 在版本演进中改过默认行为。Base64 编码用Base64.toBase64String而不是 JDK 的java.util.Base64.getMimeEncoder(),后者会每 76 字符插入换行,密文写进 properties 文件或者 JSON 字段时会把格式撑坏。

参数说明:明文字节数组长度理论上无上限,但经验值是单次加密控制在 1 KB 以内最稳,超过就考虑分段或者换对称加密;密文字节长度恒等于明文长度 + 97,可以用这个等式做入参校验——解密前先算一遍,长度不对直接返回参数错误,比抛异常更容易定位。落盘时建议在 Base64 字符串前加一个固定前缀标识算法和模式,比如SM2-C1C3C2:,将来换算法时有迁移余地。

3.3 文件加密:流式读取与分块加密的边界

字符型文件(CSV、TXT、配置文件)动辄几百 KB,直接整个读进内存再调processBlock有两个问题:一是大文件可能 OOM,二是 SM2 的解密耗时随长度线性上升,几百 KB 就要好几秒。示例工程里如果只给了字符串版本,实际用起来就得自己补分块逻辑。下面这套分块方案是我一般会用的:每块固定 512 字节明文,逐块加密,密文按 "4 字节长度头 + 密文块" 依次写入,解密时按长度头切分。

import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.params.*; import java.io.*; import java.security.SecureRandom; public class Sm2FileCodec { private static final int CHUNK = 512; // 每块明文 512 字节 public static void encryptFile(File in, File out, ECPublicKeyParameters pub) throws Exception { try (InputStream is = new BufferedInputStream(new FileInputStream(in)); DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(out)))) { byte[] buf = new byte[CHUNK]; int n; while ((n = is.read(buf)) > 0) { SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); engine.init(true, new ParametersWithRandom(pub, new SecureRandom())); byte[] cipher = engine.processBlock(buf, 0, n); dos.writeInt(cipher.length); // 4 字节大端长度头 dos.write(cipher); } // 用 -1 作为结束标记,避免最后一块刚好是 0 长度时无法判断 dos.writeInt(-1); } } public static void decryptFile(File in, File out, ECPrivateKeyParameters priv) throws Exception { try (DataInputStream dis = new DataInputStream(new BufferedInputStream(new FileInputStream(in))); OutputStream os = new BufferedOutputStream(new FileOutputStream(out))) { while (true) { int len = dis.readInt(); if (len == -1) break; byte[] cipher = new byte[len]; dis.readFully(cipher); SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); engine.init(false, priv); os.write(engine.processBlock(cipher, 0, cipher.length)); } } } }

逻辑说明:分块大小的选择是个权衡,块越小越安全(每次加密都用新的随机 k),但每块密文固定多出 97 字节开销,512 字节明文对应 609 字节密文,膨胀率约 19%;块越大膨胀率越低,但单块加密耗时上升。实测 512 到 2048 之间比较合适,看你的文件平均大小定。每块都要重新new SM2Engine并重新init,因为 SM2 加密内部会生成随机数 k,复用同一个 engine 实例在不同 BC 版本里行为不一致,宁可多几行代码。

参数说明:dos.writeInt(-1)这个结束标记不是可有可无的,如果文件长度恰好是 CHUNK 的整数倍,最后一块读完后再read()会返回 -1,但如果你靠 "读到流末尾" 判断结束,某些BufferedInputStream包装下会多读一次空块,导致解密结果尾部多出垃圾字节,下游解析 JSON 时直接报语法错误。另外,分块加密后的文件不能再按文本方式处理,存储时要标成二进制文件,别用文本编辑器打开另存,否则编码转换会把长度头彻底搞坏。

4. SM3 摘要与文件校验:签名和摘要不是替代关系

很多人以为有了 SM2 签名就不需要 SM3 了,这是把 "防篡改" 和 "身份认证" 混在一起。SM3 单独用,只能证明 "内容没变",任何人拿到明文都能重算摘要,所以它防的是传输误码和存储损坏;SM2 签名用私钥对摘要签名,公钥验签,防的是 "有人伪造内容并重算摘要"。两者叠加才是完整的。资源里给了 SM3 的基础实现,实际工程里它有三种用法,场景不同选择不同。

4.1 SM3 的三种用法:裸摘要、HMAC、SM2 签名

裸摘要最简单,计算快,适合大文件完整性校验;HMAC-SM3 在摘要里掺了一个共享密钥,适合双方有约定密钥但不想管理密钥对的场景;SM2 签名则是把 SM3 摘要用私钥签一遍,适合对外发布、需要第三方验证的场景。三种用法的代码都不长,但参数细节差别大:

import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.crypto.macs.HMac; import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.signers.SM2Signer; import org.bouncycastle.crypto.params.*; import org.bouncycastle.util.encoders.Hex; public class Sm3Usages { // 用法一:裸摘要,32 字节输出 public static String digest(byte[] data) { SM3Digest d = new SM3Digest(); d.update(data, 0, data.length); byte[] out = new byte[d.getDigestSize()]; // getDigestSize() 固定返回 32 d.doFinal(out, 0); return Hex.toHexString(out); } // 用法二:HMAC-SM3,密钥长度建议 32 字节 public static String hmac(byte[] key, byte[] data) { HMac mac = new HMac(new SM3Digest()); mac.init(new KeyParameter(key)); mac.update(data, 0, data.length); byte[] out = new byte[mac.getMacSize()]; mac.doFinal(out, 0); return Hex.toHexString(out); } // 用法三:SM2 签名,注意必须带用户 ID public static byte[] sign(byte[] data, ECPrivateKeyParameters priv, byte[] userId) throws Exception { SM2Signer signer = new SM2Signer(); // 默认 ID 是 "1234567812345678",对接方自定义时必须显式传入 ParametersWithID pid = new ParametersWithID( new ParametersWithRandom(priv, new java.security.SecureRandom()), userId); signer.init(true, pid); signer.update(data, 0, data.length); return signer.generateSignature(); // 返回 DER 编码,非裸 r||s } }

逻辑说明:SM2Signer返回的是 DER 编码的SEQUENCE { r INTEGER, s INTEGER },长度在 70 到 72 字节之间浮动,而不少对接方要求的是裸r || s各 32 字节共 64 字节。这两种格式不能直接互换,需要用org.bouncycastle.asn1.gm或者手动解析 DER 结构拆出 r 和 s 再左补零拼成 64 字节。签名时那个userId是最隐蔽的坑:SM2 签名在计算摘要前会把用户 ID、曲线参数、公钥一起拼进去算一个 Z 值,如果两边 ID 不一致(一边用默认值,一边传了自定义值),摘要就完全不同,验签必然失败,而报错信息通常只说 "验签不通过",根本看不出是 ID 的问题。

参数说明:HMAC 的密钥长度不强制,但短于 32 字节时安全性下降,一般做法是直接用 SM3 摘要出来的 32 字节当密钥;裸摘要对空输入也有输出(固定 32 字节),所以不能用摘要值是否为空来判断文件是否为空。签名用的随机数必须来自SecureRandom,用java.util.Random生成的随机数熵不足,理论上可被预测私钥,这类代码在代码扫描里基本是一票否决。

4.2 大文件分块摘要与断点校验

几百 MB 的文件不能一次readAllBytes,必须流式分块更新摘要对象。这里有个容易忽略的点:SM3Digest 的update是累积的,分块调用和一次性调用结果完全一致,所以分块大小不影响最终摘要值,只影响内存占用。下面这段是计算文件摘要并生成旁路校验文件的做法:

import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.util.encoders.Hex; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; public class Sm3FileCheck { public static String fileDigest(Path path) throws IOException { SM3Digest d = new SM3Digest(); byte[] buf = new byte[8192]; // 8KB 缓冲,机械盘和 SSD 都合适 try (InputStream is = Files.newInputStream(path)) { int n; while ((n = is.read(buf)) > 0) { d.update(buf, 0, n); } } byte[] out = new byte[d.getDigestSize()]; d.doFinal(out, 0); return Hex.toHexString(out); } // 生成 <文件名>.sm3 旁路文件,格式沿用常见约定:"摘要 文件名" public static void writeCheckFile(Path path) throws IOException { String hex = fileDigest(path); String line = hex + " " + path.getFileName().toString() + "\n"; Files.write(path.resolveSibling(path.getFileName() + ".sm3"), line.getBytes(StandardCharsets.UTF_8)); } }

逻辑说明:缓冲区 8 KB 是个通用值,再大对吞吐提升有限,反而增加单次分配压力;如果文件在网络上(比如从对象存储流式读取),缓冲区可以提到 64 KB 减少系统调用次数。生成的.sm3文件格式刻意跟常见校验文件保持一致——"摘要 + 两个空格 + 文件名",这样后续可以用现成的校验脚本处理,不用自己再写一遍比对逻辑。

参数说明:摘要值输出统一用小写十六进制,Hex.toHexString默认就是小写,如果对接方要求大写,在输出前toUpperCase(),但比对时一定要先统一大小写再比,见过太多次因为大小写不一致导致的 "校验失败"。另外,文件名里如果含空格或中文,写校验文件时要注意编码,统一用 UTF-8 读写,别依赖系统默认编码,Windows 上默认 GBK 会写出乱码文件名。

5. 踩坑记录:五个让人对着密文干瞪眼的排查现场

这一章是全篇最该反复看的部分。下面五条都是我在实际对接里真实撞过的,按 "现象 → 原因 → 解决" 记,遇到问题时直接对号入座,比从头读代码快得多。

5.1 解密报 InvalidCipherTextException,但密钥明明是对的

现象:加密端用 Java 跑通,解密端换成另一套实现(或者反过来),密文传过去就抛org.bouncycastle.crypto.InvalidCipherTextException: invalid cipher text,密钥、编码、长度都核对过没问题。

原因:九成是 C1C3C2 与 C1C2C3 的顺序不一致。这两种排列的密文总长度完全相同,肉眼和长度校验都发现不了,只有解密时才会在 C3 摘要比对那一步失败。老版本实现和一些第三方库默认还是 C1C2C3,而新标准统一成了 C1C3C2。

解决:先确认加密端用的模式,代码里搜SM2Engine.Mode,没写就是用了默认值,不同 BC 版本默认值可能不同。统一改成显式指定C1C3C2。如果对方改不了,就在自己这边做一次重排:把密文字节数组按 65、剩余长度减 97、32 切开,重新拼成C1 || C3 || C2再解,重排前先校验总长度不小于 97。

5.2 公钥少了一个字节,加密能过解密全乱

现象:公钥从配置文件里读出来是 64 字节的十六进制字符串,加密过程不报错,生成的密文也能传过去,但对方解出来永远是乱码或者解密失败。

原因:公钥丢了04前缀。标准未压缩点编码是 65 字节,第一位04表示 "未压缩",后面才是 X 和 Y 各 32 字节。有些系统在传输时把04当成了某种标识符给剥掉了,只剩 64 字节的 X||Y,而你这边用getEncoded(false)重建时又自动补上了04,结果补出来的点根本不在曲线上,加密时用的其实是一个错误的点。

解决:在密钥加载入口做一次强校验,长度必须是 65 且首字节必须是0x04,不满足就主动补04或者直接报错。补的时候要注意,不能简单0x04 + 原数组,得确认原来的 64 字节确实是 X||Y 顺序,有些实现输出的是 Y||X,顺序反了同样解不开,这种只能靠和对方对拍一组固定密钥的加密结果来确认。

5.3 明明内容一样,SM3 摘要就是不一致

现象:同一份文本文件,自己算的摘要和对方给的摘要对不上,逐字符 diff 看不出差异,文件大小也一样。

原因:换行符和 BOM。Windows 上编辑保存的文本是\r\n,Linux 上是\n,字节层面差一个0x0D,摘要当然不同。UTF-8 BOM 更隐蔽,文件开头多三个字节EF BB BF,文本编辑器不显示,cat出来也看不出来。

解决:计算摘要前统一做一次规范化,换行统一成\n,去掉开头 BOM,再转 UTF-8 字节。规范化逻辑要写进工具类,不能靠人工每次记得处理。校验文件本身也要用二进制模式读写,别用文本模式打开,否则读进来的时候又被系统自动转换一次换行。

5.4 换了 JDK 版本,代码编译不过或者运行时找不到类

现象:开发机上跑得好好的,部署到另一台机器就报NoClassDefFoundError或者ClassNotFoundException,类名指向 BC 的某个类;或者 JDK 17 上编译报模块访问错误。

原因:bcprov-jdk15to18和bcprov-jdk18on的适用 JDK 范围不同,混用就会出现类找不到。另外 JDK 9 之后模块化,如果工程用了jlink裁剪运行时,BC 的 jar 如果没被正确识别为自动模块,打包后类会被裁掉。

解决:先统一 jar,JDK 8 用jdk15to18,JDK 8 以上优先jdk18on,同一个工程里绝对不能同时存在两个版本的 BC。用 Maven 的话跑一次mvn dependency:tree | grep bcprov确认只有一条。用了jlink的话,在module-info.java里requires org.bouncycastle.provider;,或者干脆不用模块化,走 classpath 模式最省事。

5.5 几百 KB 的文件加密要等好几秒,大一点直接 OOM

现象:加密一个 500 KB 的文本文件耗时三四秒,加到 5 MB 直接抛OutOfMemoryError: Java heap space,日志里堆栈指向SM2Engine.processBlock。

原因:SM2 是非对称算法,每加密一个块都要做椭圆曲线点乘,计算量远大于对称算法,而且整文件读进内存的做法让堆压力随文件线性增长。拿它当 AES 用,方向就错了。

解决:小文件分块流式处理(参考 3.3 的实现),把单次加密的明文控制在 512 到 2048 字节;大文件改成混合加密,随机生成一个 16 字节密钥,用 SM2 加密这个密钥单独存一块,正文用对称算法加密,最后对密文整体算一次 SM3 附在文件尾。这样 SM2 只处理 16 字节,性能问题直接消失,安全性也不降级。

6. 进阶:跨端对拍、验收脚本和这份 jar 的能力边界

代码跑通只是第一步,真正决定这个资源能不能用在生产上的,是能不能跟另一套实现对上。我一般会在交付前做三件事:固定一组测试向量做跨端对拍、写一份可重复执行的验收脚本、把性能和安全边界测出来写进交付文档。这三件事做完,后面半年的对接扯皮能少一大半。

6.1 用固定向量做跨端对拍

对拍的核心是 "同一份输入,两边输出必须一致"。SM3 是确定性算法,好办;SM2 加密带随机数,每次密文都不同,所以对拍不能比密文,要比 "A 加密 B 解密" 的闭环。准备三组数据:一组纯 ASCII、一组含中文、一组含换行和特殊符号,每组都跑一遍加密-解密-摘要比对。SM2 签名则可以固定随机数做确定性验证,但生产代码里绝不能固定随机数,只在测试环境用。

跨语言对拍时优先用命令行工具做参照,避免两边都调同一个库导致 "错得一样"。常见的参照实现包括 OpenSSL 的 SM2/SM3 子命令、Python 的 gmssl 包、前端的 sm-crypto 库。对拍时把密钥、明文、密文、摘要全部以十六进制打印出来,逐字段比对,比看日志猜快十倍。签名对拍时记得把 userId 也打印出来,两边都要显式传同一个值。

6.2 一份能重复跑的验收脚本

验收脚本的价值在于 "下次换机器、换 JDK、换 jar 版本,跑一遍就知道有没有退化"。下面这份 bash 脚本把密钥生成、字符加解密、文件加解密、摘要校验串成一条线,任何一步失败就退出:

#!/usr/bin/env bash set -euo pipefail # 依赖:编译好的 classes 目录 + lib 下所有 jar CP="classes:$(ls lib/*.jar | tr '\n' ':')" WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT # 1. 生成密钥对,输出到文件 java -cp "$CP" Sm2KeyGen > "$WORK/key.txt" grep -q '^PRIVATE=[0-9a-f]\{64\}$' "$WORK/key.txt" || { echo "私钥长度异常"; exit 1; } grep -q '^PUBLIC=04[0-9a-f]\{128\}$' "$WORK/key.txt" || { echo "公钥前缀或长度异常"; exit 1; } # 2. 字符加解密闭环:中文 + 换行 + 特殊符号 printf '测试文本 line1\nline2 & <tag> 中文\n' > "$WORK/plain.txt" java -cp "$CP" Sm2TextCodec encrypt "$WORK/plain.txt" "$WORK/cipher.b64" java -cp "$CP" Sm2TextCodec decrypt "$WORK/cipher.b64" "$WORK/round.txt" cmp "$WORK/plain.txt" "$WORK/round.txt" || { echo "字符加解密不一致"; exit 1; } # 3. 文件加解密闭环:造一个 1MB 随机文件 head -c 1048576 /dev/urandom > "$WORK/big.bin" java -cp "$CP" Sm2FileCodec enc "$WORK/big.bin" "$WORK/big.enc" java -cp "$CP" Sm2FileCodec dec "$WORK/big.enc" "$WORK/big.dec" cmp "$WORK/big.bin" "$WORK/big.dec" || { echo "文件加解密不一致"; exit 1; } # 4. SM3 摘要:同一个文件算两次必须一致,改一个字节必须变 D1=$(java -cp "$CP" Sm3FileCheck "$WORK/big.bin") D2=$(java -cp "$CP" Sm3FileCheck "$WORK/big.bin") [ "$D1" = "$D2" ] || { echo "摘要非确定性"; exit 1; } printf 'x' >> "$WORK/big.bin" D3=$(java -cp "$CP" Sm3FileCheck "$WORK/big.bin") [ "$D1" != "$D3" ] || { echo "摘要无雪崩效应"; exit 1; } echo "ALL PASS"

脚本里的几个判断点值得说:公钥那行用正则卡死04开头加 128 个十六进制字符,直接把 5.2 那个坑挡在门外;文件加解密用 1 MB 随机数据,覆盖了分块边界和最后一块不满的情况;最后那个 "改一个字节摘要必须变" 的检查看着多余,但能挡住 "摘要函数被替换成恒等函数" 这类极端错误。整套跑下来一般 10 秒以内,适合塞进 CI 的冒烟阶段。

6.3 性能和安全边界,交付前必须量一遍

性能上用System.nanoTime()打点,分别测 100 次字符加密、10 次 1 MB 文件分块加密、10 次 1 MB 文件 SM3 摘要,取中位数而不是平均值,避免 JIT 预热阶段拖偏结果。经验数值上,SM2 单次加密(小数据)在普通服务器上大约几百微秒量级,SM3 摘要吞吐在几百 MB/s 量级,这两个数字决定了你的方案能扛多大的文件。如果业务方要求 "1 GB 文件也要走 SM2 加密",那不是调参能解决的,必须改成混合加密,这一点要提前说清楚,别等上线了才发现。

安全边界有几条硬线:私钥不能硬编码在代码里,不能明文写配置文件,最次也要从环境变量或者密钥管理服务里取;SecureRandom不能用固定种子,测试环境的确定性随机数要在打包时排除;分块加密的每块随机数必须独立,不能复用同一个 engine 实例;签名用的 userId 要和对接方书面确认,写进接口文档,别靠口头约定。还有一条容易被忽略——加密后的文件不要用文本方式传输,Base64 编码会膨胀 33%,几百 MB 的文件加密后变成几百 MB 的文本,传输层很容易超时,能走二进制就走二进制。

从那以后我每次接入新的加密资源包,第一件事不是读代码,而是先跑一遍上面那份验收脚本,把密钥长度、密文长度、摘要一致性这三个硬指标钉死,再去研究实现细节。因为加密这块的性质决定了——跑通了不代表对,可能只是两边错得一样;只有对拍过、量化过、写进脚本里能重复执行的东西,才敢往生产环境放。这套流程帮我挡掉过好几次 "开发环境一切正常、上生产全量解密失败" 的事故。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进