简介:面向需要在 Java 项目中实现国密算法的开发者,这份资源围绕 SM2 公钥加解密与 SM3 摘要计算展开,既覆盖字符串处理,也提供面向文件的加解密实现,适合接口参数保护、敏感数据落盘、文件传输校验等场景,对刚接触国密改造的初中级开发者尤为友好。压缩包共 5 个文件,以 4 个 Java 源码和 1 个 jar 依赖为主:源码包含加解密工具封装与密钥对象定义,jar 补齐 Bouncy Castle 相关能力;整体约 4.89MB,导入后无需再额外寻找依赖即可编译调试。目前已有 428 人学习下载,具备一定参考基础。读者可从中了解 SM2 密钥生成与加解密调用方式、SM3 摘要计算流程,以及文件分块读写时如何结合国密算法处理,同时参考工具类分层与异常处理思路,减少自行摸索成本,便于快速迁移到实际业务项目中。
1. 一个 zip 包解决的事:SM2 加密、SM3 摘要,和那条最常断的编码链
做国密改造的人往往把它想得很重,以为要换掉整套安全体系。真实情况常常是:接口里几个敏感字段要改成 SM2 加解密,落库的校验值要换成 SM3 摘要,附件上传后要能算出一个可对比的指纹,而手上真正缺的,只是一份能跑通的实现和几个知道边界在哪的封装。这个 zip 包里放的就是这套东西——SM2 非对称加解密、SM3 摘要计算,覆盖字符与文件两种入参形态,并附带 jar 包,省掉自己拼依赖那一步。它适合正在做后端接口字段加密、报文签名、附件完整性校验的从业者直接落地;也适合已经被“密文解不开、中文变问号、大文件跑崩”折腾过一轮,想找一份能拿来对照排查的参考实现的人。
2. SM3 摘要先立住:从字符指纹到文件指纹
2.1 为什么落地顺序通常是先 SM3 后 SM2
改造排期里,SM3 一般排在前面。原因不玄学:它是无密钥的确定性函数,输入一样输出就一样,不涉及密钥分发、不涉及双方约定,替换掉原来的 MD5 或 SHA-256 之后,调用方几乎感知不到变化。SM2 不一样,它牵扯密钥对怎么产生、公钥怎么给到对方、密文按什么顺序排布、双方用不用同一个用户标识,任何一处没对齐,表现都是“解出来是乱码”,排查成本高一个量级。
SM3 的结构值得花几分钟搞清楚,因为后面所有坑都和它相关。它输出固定 256 位,也就是 32 字节、64 个十六进制字符。内部按 512 位分组处理消息,先做填充:补一个 1,补若干 0,最后 64 位放原始消息的比特长度,保证总长是 512 的整数倍。压缩函数 64 轮,每轮用不同的常量,消息扩展把 16 个字扩成 68 个。这套结构和 SHA-256 家族长得很像,但常量、初始向量、扩展方式都不同,所以两者的输出值完全不通用——把 SHA-256 改个类名当 SM3 用,是最典型的自欺欺人。
还有一点常被忽略:SM3 是 Merkle-Damgård 结构,存在长度扩展特性,所以做口令存储、做 HMAC 场景时不能直接SM3(salt + password)就完事,规范做法是 HMAC-SM3(BouncyCastle 里用HMac配SM3Digest即可)。如果只是做数据完整性校验、做幂等键、做文件指纹,单次摘要完全够用,不必过度设计。
2.2 字符摘要:编码、加盐与十六进制输出
下面这段是最小可用版本,直接依赖 jar 包里的 BouncyCastle 类,不需要注册 JCA Provider。
import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.util.encoders.Hex; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; public final class Sm3Chars { /** 单次摘要:入参必须先钉死字符集,UTF-8 是跨语言互操作的默认选择 */ public static String digest(String text) { byte[] in = text.getBytes(StandardCharsets.UTF_8); SM3Digest d = new SM3Digest(); d.update(in, 0, in.length); byte[] out = new byte[d.getDigestSize()]; // 固定 32 字节 d.doFinal(out, 0); return Hex.toHexString(out); // 64 个十六进制字符,小写 } /** 加盐摘要:盐拼在明文前面,拼接顺序必须写进对接协议 */ public static String digestWithSalt(String text, String salt) { return digest(salt + text); } /** 摘要比对走恒定时间比较,别用 equals 逐字节短路 */ public static boolean safeEquals(String a, String b) { return MessageDigest.isEqual( a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); } }三个参数必须在一开始就定死,否则后面全是返工。
第一是字符集。text.getBytes()不带参数时用的是平台默认编码,本地 Windows 跑得好好的,一上 Linux 容器摘要就变了,这种问题排查起来极其难受。统一写StandardCharsets.UTF_8,别图省事。
第二是拼接顺序和分隔符。盐在前还是在后、中间加不加分隔符,两端必须一模一样。我一般会在协议文档里写死成salt + ":" + text,加个冒号是为了避免("ab","c")和("a","bc")撞出同一个输入。
第三是输出格式。Hex.toHexString给的是小写,而不少老系统、不少前端库默认要大写,还有一部分系统用 Base64。这三种表达的是同一个 32 字节数组,但字符串比对时就是不等。对接前先问一句“你们要哪种”,比事后对日志强。
至于盐本身,跟着记录一起存明文就行,它的作用是让相同的明文产生不同的摘要,不是保密。想再稳一点就用 HMAC-SM3,把盐当密钥用。
2.3 文件摘要:流式分块与内存上限
文件摘要的坑几乎全部集中在“怎么读”上。Files.readAllBytes写起来最舒服,几百 KB 的图片也没事,但只要有人传一个 2GB 的附件上来,堆就直接被顶穿。正确姿势是流式读取、边读边喂。
import org.bouncycastle.crypto.digests.SM3Digest; import org.bouncycastle.util.encoders.Hex; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; public final class Sm3File { private static final int BUF_SIZE = 8192; // 8KB 足够,与磁盘块大小接近 public static String digestFile(Path path) throws IOException { SM3Digest d = new SM3Digest(); byte[] buf = new byte[BUF_SIZE]; try (InputStream in = Files.newInputStream(path)) { // 必须关闭 int n; while ((n = in.read(buf)) > 0) { d.update(buf, 0, n); // 只喂实际读到的字节数 } } byte[] out = new byte[d.getDigestSize()]; d.doFinal(out, 0); return Hex.toHexString(out); } }几个细节值得说明。
BUF_SIZE取 8KB 还是 64KB,对最终的摘要值没有任何影响,SM3 是流式的,喂多少次都一样,只影响系统调用次数。8KB 到 64KB 之间随便挑,我习惯 8KB,和常见文件系统的块大小接近。
read返回 -1 表示读到末尾,但循环条件写成> 0更稳,因为某些 InputStream 实现可能返回 0。update的第三个参数必须是实际读到的n,不能写buf.length——缓冲区最后一轮通常是半满的,写满长度会把上一次的残留字节也算进去,摘要必错,而且这种错误在文件大小恰好是 8192 整数倍时不会出现,测试时很容易漏掉。
还有一点:文件摘要如果用于秒传、去重这类场景,光有摘要不够,还要把文件大小一起带上做联合判断,否则理论上存在不同文件同摘要的可能(虽然概率极低,但工程上多存一个 long 不费事)。
3. SM2 加解密:密钥对、密文排布与模式选择
3.1 密文的三种排布:C1C3C2、C1C2C3 与多出来的那个字节
SM2 的密文不是一个整体,它由三段拼成,而这三段的顺序在不同实现里有两种排法,这就是互操作翻车的第一大来源。
| 段名 | 内容 | 长度 |
|---|---|---|
| C1 | 椭圆曲线点 (x, y) | 64 字节(不含 0x04 前缀) |
| C2 | 明文与密钥流异或后的数据 | 与明文等长 |
| C3 | 明文的 SM3 摘要,用于解密后校验 | 32 字节 |
排布顺序上,早期草案用 C1C2C3,现行标准推荐 C1C3C2,BouncyCastle 的SM2Engine把模式做成了枚举,默认是 C1C3C2。两个系统一个用旧一个用新,密文长度完全一样,但解出来必然是乱码或直接抛异常,因为 C3 的位置被当成了密文数据。
由此可以推出一条极好用的排查公式:密文总长应该等于明文长度加 96 字节。如果你拿到的密文比这个数多 1 字节,那多半是某个实现在 C1 前面加了0x04标识字节(未压缩点前缀)。BouncyCastle 的processBlock输出不含这个字节,而部分封装库会带上,两边做互操作时要么统一补、要么统一去,代码里最好做成一个开关。
另外,SM2 单次加密的明文长度理论上没有硬上限,但实践上不建议一次塞几百 KB 进去。KDF 过程会额外产出一份和明文等长的密钥流,密文又是一份,峰值内存是明文的三倍左右。文件加解密必须分块,原因在这里。
3.2 字符加解密:从生成密钥对到十六进制落地
下面这段用轻量 API,不依赖 JCA Provider 注册,jar 包在 classpath 里就能跑。
import org.bouncycastle.asn1.gm.GMNamedCurves; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.engines.SM2Engine; import org.bouncycastle.crypto.generators.ECKeyPairGenerator; import org.bouncycastle.crypto.params.*; import org.bouncycastle.math.ec.ECPoint; import org.bouncycastle.util.BigIntegers; import org.bouncycastle.util.encoders.Hex; import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; public final class Sm2Chars { private static final X9ECParameters X9 = GMNamedCurves.getByName("sm2p256v1"); private static final ECDomainParameters DOMAIN = new ECDomainParameters(X9.getCurve(), X9.getG(), X9.getN(), X9.getH()); private static final SecureRandom RND = new SecureRandom(); /** 返回 [公钥Hex, 私钥Hex],公钥 64 字节,私钥固定 32 字节 */ public static String[] generateKeyPair() { ECKeyPairGenerator gen = new ECKeyPairGenerator(); gen.init(new ECKeyGenerationParameters(DOMAIN, RND)); AsymmetricCipherKeyPair kp = gen.generateKeyPair(); ECPublicKeyParameters pub = (ECPublicKeyParameters) kp.getPublic(); ECPrivateKeyParameters pri = (ECPrivateKeyParameters) kp.getPrivate(); // getEncoded(false) 前面带 0x04,去掉后正好 64 字节 byte[] pubBytes = pub.getQ().getEncoded(false); String pubHex = Hex.toHexString(pubBytes, 1, pubBytes.length - 1); // BigInteger 直接转字节数组会丢前导零,必须补到 32 字节 String priHex = Hex.toHexString(BigIntegers.asUnsignedByteArray(32, pri.getD())); return new String[]{pubHex, priHex}; } public static String encrypt(String plain, String pubHex) { // 解码点的时候要把 0x04 补回去 ECPoint q = DOMAIN.getCurve().decodePoint(Hex.decode("04" + pubHex)); ECPublicKeyParameters pub = new ECPublicKeyParameters(q, DOMAIN); SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); engine.init(true, new ParametersWithRandom(pub, RND)); byte[] in = plain.getBytes(StandardCharsets.UTF_8); try { byte[] cipher = engine.processBlock(in, 0, in.length); // 长度 = in.length + 96 return Hex.toHexString(cipher); } catch (InvalidCipherTextException e) { throw new IllegalStateException("SM2 加密失败", e); } } public static String decrypt(String cipherHex, String priHex) { byte[] d = Hex.decode(priHex); ECPrivateKeyParameters pri = new ECPrivateKeyParameters(new BigInteger(1, d), DOMAIN); SM2Engine engine = new SM2Engine(SM2Engine.Mode.C1C3C2); // 模式必须与加密侧一致 engine.init(false, pri); byte[] cipher = Hex.decode(cipherHex); try { byte[] plain = engine.processBlock(cipher, 0, cipher.length); return new String(plain, StandardCharsets.UTF_8); } catch (InvalidCipherTextException e) { // 到这一步基本只有三种可能:模式不对、私钥不对、密文被截断 throw new IllegalArgumentException("SM2 解密失败,先查模式与密钥", e); } } }SM2Engine不是线程安全的,每次调用 new 一个,别做静态缓存。SecureRandom可以共享,它是线程安全的。公钥用 64 字节的裸 x||y 表达、私钥固定 32 字节,是工程上最省事的约定,因为定长意味着可以定长入库、定长截取,不用额外存长度字段。
私钥那段特别要留意:pri.getD()是个 BigInteger,如果它对应的字节数组高位是 0,toByteArray()出来只有 31 字节,跟别的系统对不上;用BigIntegers.asUnsignedByteArray(32, d)强制补到 32 字节就能消除这种抖动。这类偶发问题最消耗耐心,因为大部分密钥正常,只有约 1/256 的概率会踩到。
3.3 文件加解密:分块策略与密钥长度边界
SM2 直接加密大文件是选型错误,但在“包里只给了 SM2 和 SM3”的前提下,你至少要把分块写对,让它不至于崩。
块大小建议 4KB 到 64KB 之间。太小则每块都要做一次椭圆曲线点运算和 KDF,CPU 全花在固定开销上;太大则前面说的三倍内存问题会找上门。我一般取 32KB。
更大的问题是文件格式:每块密文长度等于明文块长加 96 字节,中间块等长,最后一块变长。解密方必须知道每块的边界,否则没法切。两种做法:定长块 + 文件头记录块大小,或者尾部存一张块长表。前者简单,我倾向前者。
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 4 字节 | 固定标识,防止把别的文件喂进解密器 |
| version | 1 字节 | 格式版本,将来换算法不至于全部作废 |
| mode | 1 字节 | 0 表示 C1C3C2,1 表示 C1C2C3 |
| chunkSize | 4 字节 | 明文块大小,解密方按它切 |
| plainSize | 8 字节 | 原始明文总长,用于校验末块 |
| body | 变长 | 逐块密文顺序拼接 |
写解密器时先读这 18 字节头,校验 magic,再按chunkSize + 96循环切块,最后一块按plainSize截断。有了plainSize,即使传输中途被截断也能立刻报错,而不是解出一份残缺文件还以为成功。
这里必须说一句边界:SM2 加密 MB 级文件的耗时是秒级甚至更久,因为它每一块都要做点乘。业界标准做法是数字信封——随机生成一个对称密钥,用对称算法加密文件,再用 SM2 只加密那把密钥。如果 jar 包里没有对称算法,这条路径需要自己补,别硬拿 SM2 去扛整个文件,那不是它能干的活。
4. jar 包落地:依赖、封装与工程集成
4.1 把 jar 挂进构建:三种方式与它们各自的代价
拿到 zip 解压后,第一件事不是写代码,是看 jar 里到底是什么。用unzip -l列一下包结构,或者jar tf xxx.jar | head -50,重点看包名:如果是org/bouncycastle/...开头,说明它就是把官方库打了个包,那你完全可以改为直接引官方坐标,版本升级、漏洞修复都方便;如果是某个自定义包名,说明外面还封了一层,那就得看它的 API 是怎么设计的,别绕过封装直接调底层。
引入方式有三种,代价不同。
第一种是丢进src/main/resources/lib或项目根目录的lib,用 system scope 引。
mvn install:install-file \ -Dfile=./lib/gm-crypto.jar \ -DgroupId=com.example.gm \ -DartifactId=gm-crypto \ -Dversion=1.0.0 \ -Dpackaging=jar \ -DgeneratePom=true这条命令把本地 jar 装进本地仓库(~/.m2/repository),之后 pom 里正常写依赖即可。-DgeneratePom=true会自动生成一个最小 pom,省得你手写。代价是每个开发者、每台构建机都要执行一次,CI 上尤其容易漏,漏了就是Could not resolve dependencies。
第二种是传到团队私服,一次上传全员可用,这是团队场景的正解。第三种是 system scope,<scope>system</scope>加<systemPath>,看着最省事,但打 fat jar 时经常不会被包含进去,运行时才报ClassNotFoundException,我在生产环境见过不止一次,不推荐。
如果确认 jar 就是 BouncyCastle,直接引官方坐标更干净:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>${bouncycastle.version}</version> </dependency>JDK 8 及以上用bcprov-jdk18on,更老的 JDK 用bcprov-jdk15to18。版本号不要凭感觉写,要么跟 jar 包里的META-INF/MANIFEST.MF对齐,要么用mvn dependency:tree看现有工程已经引了哪个版本,统一到那一个上去——同时存在两个版本的 bcprov 是后面所有NoSuchMethodError的根源。
4.2 封装成工具类:接口设计与异常口径
原始 API 用起来啰嗦,参数类型是 BC 自己的AsymmetricKeyParameter,业务代码里到处出现这些东西很难看。做一层薄封装,把 Hex 字符串作为唯一的对外边界。
public interface GmCrypto { String[] generateKeyPair(); String encrypt(String plain, String pubHex); String decrypt(String cipherHex, String priHex); String sm3(String text); String sm3File(Path path) throws IOException; }接口定这么窄是有意为之:对外只暴露字符串,内部怎么转字节、怎么补零、用什么模式,全部收在实现里。将来要换模式或者换库,改一个实现类就行,业务侧不动。
异常口径要提前想清楚。BC 抛的是受检的InvalidCipherTextException,业务代码里不该被迫写 try-catch,所以统一转换成非受检的CryptoException,并且带上可排查的信息。但注意,异常信息里不能带明文、不能带私钥,日志脱敏这条线要守住——解密失败的日志打出密文前 16 个字符用于定位就够了。
还有一点:不要在工具类里用静态字段缓存SM2Engine或SM3Digest实例。这两个类都是有内部状态的,多线程共用一个实例,表现是偶发的解密失败或者摘要值错乱,而且很难复现,典型的玄学问题。每次 new,GC 压力可以忽略。
4.3 与既有系统的边界:字段长度、编码与并发
上线前最容易漏的是数据库字段长度。算一笔账:100 个中文字符,UTF-8 下是 300 字节,SM2 密文是 300 + 96 = 396 字节,转 Base64 后是 528 个字符,转 Hex 是 792 个字符。原来VARCHAR(255)的列,改造后直接溢出,而且是只有长内容才溢出,测试数据短的时候完全看不出来。
| 明文形态 | UTF-8 字节 | SM2 密文字节 | Base64 字符 | Hex 字符 |
|---|---|---|---|---|
| 10 个英文 | 10 | 106 | 144 | 212 |
| 50 个中文 | 150 | 246 | 328 | 492 |
| 200 个中文 | 600 | 696 | 928 | 1392 |
列宽按最坏情况留一倍余量,或者干脆用 TEXT 类型。存二进制也可以,但跨语言、跨库导出时会遇到编码问题,我一般选 Base64。
并发方面,加解密是 CPU 密集操作,SM2 尤其重。接口 QPS 高的场景,别在请求线程里做整文件的 SM2 分块加密,扔到独立线程池或者干脆异步化。压测时重点看两件事:GC 频率(有没有因为一次性读大文件导致 Full GC)和 P99 延迟(SM2 的耗时波动比对称算法大得多)。
5. 避坑与排查:五条踩出来的记录
5.1 密钥长度差一个字节:偶发失败的真凶
现象:本地自测全过,线上跑了几天开始零星报解密失败,把失败的密文和私钥拿出来单独跑,有时能复现有时不能。
原因:私钥是 BigInteger 转字节数组得到的,高位为 0 时长度只有 31 字节。绝大多数密钥高位非零,所以 255/256 的情况下一切正常;一旦碰上高位为零的密钥,转出来的字节数组就短一截,用它构造ECPrivateKeyParameters时数值本身没错,但如果中间经过了一次 Hex 存储再还原、或者跟别的系统做长度对齐,就会错位。
解决:所有私钥出口统一走BigIntegers.asUnsignedByteArray(32, d),公钥统一用getEncoded(false)去掉首字节后的 64 字节。入库前加一条断言:私钥 Hex 长度必须等于 64,公钥 Hex 长度必须等于 128,不符合直接抛,别让它流到线上。
5.2 密文解不开:先算长度,再看模式
现象:A 系统加密,B 系统解密,抛InvalidCipherTextException,或者不抛异常但解出来是乱码。
原因:三种可能,按概率排序是 C1C3C2 与 C1C2C3 不一致、密文带不带0x04前缀不一致、密钥根本不是一对。
解决:第一步算长度,密文长度减去明文长度(如果知道明文长度的话)应该正好是 96;差 1 就是有0x04。第二步把模式做成配置项而不是硬编码,对接时先问对方用哪个,能省掉半天联调。第三步用同一对密钥做一次本地加密解密闭环,排除密钥本身的问题。这个顺序别颠倒,先查密钥往往是在浪费时间。
5.3 中文变问号:字符集不是可选项
现象:本地跑通,部署到另一台机器摘要对不上;或者解密出来的中文全是问号。
原因:某处用了getBytes()或new String(bytes)的无参重载,取的是 JVM 默认字符集。容器镜像的基础环境、启动参数里的-Dfile.encoding,任何一处不同都会导致结果不同。
解决:全链路显式指定StandardCharsets.UTF_8,包括摘要入参、加密入参、解密出参、数据库连接串的characterEncoding、以及 HTTP 响应头。用 IDE 全局搜索getBytes()和new String(的无参调用,逐个改掉,这是最快的一次性清理办法。
5.4 大文件跑崩:三份副本同时躺在内存里
现象:几十 KB 的附件一切正常,几百 MB 的时候 OOM 或者 GC 抖动到接口超时。
原因:Files.readAllBytes读一份,SM2 加密过程 KDF 产出的密钥流一份,密文一份,峰值内存接近明文的三倍;如果后面再套一层 Base64,还要再乘 1.33。
解决:改成流式分块,块大小 32KB,边读边加密边写输出流,全程只有几份块级缓冲。用Files.newOutputStream直接落盘,不要先拼成一个大byte[]再写。改完之后内存占用和文件大小基本脱钩。
5.5 NoSuchAlgorithmException:Provider 没注册,或者被顶掉了
现象:Signature.getInstance("SM3withSM2")抛NoSuchAlgorithmException,或者报NoSuchProviderException: BC。
原因:JCA 路径(getInstance系列)依赖 Provider 注册,轻量 API 路径不需要。如果用的是 JCA,必须执行过Security.addProvider(new BouncyCastleProvider())。另一种情况是注册了,但工程里同时存在两个版本的 bcprov,老版本被类加载器先加载,里面没有 SM2 相关实现。
解决:mvn dependency:tree -Dincludes=org.bouncycastle看有没有重复引入,有就排掉旧的,统一到一个版本。注册动作放在应用启动的最早期,别放在某个懒加载的静态块里。实在不想折腾 Provider,就统一走轻量 API,SM2Engine、SM3Digest这些类不依赖注册。
6. 进阶:把“能跑”变成“可验证”
代码能编译、能跑通,和这套东西可信,是两回事。我习惯在集成前先做两件验证。
第一件是用标准测试向量卡住 SM3。国密标准文档里给了示例,对字符串abc做 SM3,正确结果是:
// 期望值:66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0 String got = Sm3Chars.digest("abc"); System.out.println(got.equals("66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"));注意这里必须显式指定 UTF-8。向量对不上,说明实现有问题或者编码没对齐,后面所有摘要校验都没有意义。这一步跑通,SM3 这条链就可以放心用了。
第二件是给 SM2 做闭环加互操作测试。闭环很简单:同一个进程里生成密钥、加密、解密、比对原文;再篡改密文里的一个字节,确认解密会抛异常而不是返回错误明文——C3 段的摘要校验就是干这个的,如果改动密文后还能解出东西,说明你用的实现把校验跳过了,这是个严重问题。
签名验签也一样要做,而且比加解密更容易踩坑,因为多了一个隐式参数:用户标识。SM2 签名在计算摘要时会把用户标识拼进去,标准默认值是1234567812345678,很多实现不显式设置就用这个默认值。如果一方设了、一方没设,验签必然失败,而报错信息通常只有一句“验签不通过”,毫无指向性。
import org.bouncycastle.crypto.params.ParametersWithID; import org.bouncycastle.crypto.params.ParametersWithRandom; import org.bouncycastle.crypto.signers.SM2Signer; byte[] userId = "1234567812345678".getBytes(StandardCharsets.UTF_8); SM2Signer signer = new SM2Signer(); signer.init(true, new ParametersWithID(new ParametersWithRandom(pri, RND), userId)); signer.update(msg, 0, msg.length); byte[] sig = signer.generateSignature(); SM2Signer verifier = new SM2Signer(); verifier.init(false, new ParametersWithID(pub, userId)); // 同一个 userId verifier.update(msg, 0, msg.length); boolean ok = verifier.verifySignature(sig);自测清单我一般按这张表走一遍再交付:
| 检查项 | 方法 | 通过标准 |
|---|---|---|
| SM3 正确性 | 标准向量abc | 输出与公布值一致 |
| SM3 编码 | 中文串在 UTF-8 / GBK 下各跑一次 | 两次结果不同,证明编码生效 |
| SM2 闭环 | 自生成密钥加密解密 | 原文完全一致 |
| SM2 篡改检测 | 翻转密文中段一个字节 | 抛异常,不返回明文 |
| 模式互操作 | 与对方系统互发密文 | 双向都能解 |
| 签名默认 ID | 不设 userId 与显式设为默认值 | 结果一致 |
顺带说一句选型边界:这套东西适合字段级加密、报文摘要、文件指纹、签名验签,不适合拿 SM2 去加密大文件——那是数字信封的活。如果后续要扩,方向是对称算法加 SM2 加密密钥,而不是把 SM2 的分块调得更细。
早些年我在一个项目里图省事,跳过向量验证直接联调,结果对了两天日志才发现是对方把十六进制转成了大写,那一刻的后悔药是后来所有项目我都强制先跑标准向量再动业务代码。包里那几个 jar 和示例类,按上面的顺序先跑 SM3 向量、再跑 SM2 闭环,基本就能接进现有工程了。希望帮到你。
本文还有配套的精品资源,点击获取