4. SM2 加密与解密实现归纳总结
4.1 加密流程:这个椭圆曲线怎么玩的?
SM2 加密本质上是混合加密——公钥只用来加密一个临时对称密钥,真正数据用对称算法(SM4)加密。你想想看,直接拿椭圆曲线算数据,性能扛不住,所以它走了 ECIES 这条路。
加密端拿到接收方公钥 PB,先产生临时密钥对 (dk, R),其中 R = dk * G。然后计算共享点 S = dk * PB。S 的 x 坐标就是关键,用它派生出对称密钥和 MAC 密钥。我习惯把这步叫做「密钥协商 + 封装」。
- 生成随机数 dk ∈ [1, n-1]
- 计算椭圆曲线点 R = dk * G
- 计算共享点 S = dk * PB,若 S=O 则重试
- 从 S.x 派生对称密钥 K(比如 KDF)
- 用 K 加密明文 M 得到密文 C2
- 计算杂凑值 C3 = SM3(一些标识 + S.x + M + S.y)
- 输出 C = C1 || C3 || C2
这里 C1 是临时公钥 R 的字节表示(通常 04 || x || y,共 65 字节)。C3 是 32 字节的 SM3 摘要。C2 是对称加密的密文。说起来简单,但实际编码时你会遇到不少坑,咱们后边慢慢聊。
4.2 解密流程:对侧密钥恢复
解密端看到密文 C,先拆出前 65 字节为 C1,接着 32 字节为 C3,剩下的就是 C2。然后用自己的私钥 dB 计算共享点 S = dB * C1。你想想看,因为 C1 = R = dk*G,所以 dB*R = dB*dk*G = dk*PB,和加密端共享点一样。
拿到 S.x 后用同样的 KDF 导出对称密钥,解出明文 M',再重新计算 C3' 比对。不一致?说明要么密钥错了,要么数据被篡改。
| 阶段 | 加密方 | 解密方 |
|---|---|---|
| 输入 | 明文 M + 接收方公钥 PB | 密文 C + 自己私钥 dB |
| 临时密钥 | 生成 dk, 计算 R | 无(使用收到的 C1) |
| 共享点 | S = dk*PB | S = dB*C1 |
| 密钥派生 | KDF(S.x) → K | KDF(S.x) → K |
| 对称操作 | 加密 M → C2 | 解密 C2 → M' |
| 摘要计算 | SM3(S.x+M+S.y) → C3 | SM3(S.x+M'+S.y) → C3' |
| 输出/校验 | C = C1||C3||C2 | 对比 C3' 与收到的 C3 |
4.3 密钥格式与编码:别在 bytes 上翻车
SM2 公钥一般用 PEM 或 ASN.1 打包。私钥又分原始私钥(32 字节)和 PKCS#8 格式。我建议你在 Java 里直接用 Bouncy Castle 的 ECPrivateKey 和 ECPublicKey 包装,别手动拼字节,容易出错。
密文格式各 SDK 不统一。有的把 C1 用 DER 编码,有的直接拼 04||x||y。你要对接时一定先看对方标准。举个例子,华为安全组件里密文就是纯字节拼接,而某些国密支持库套了 ASN.1 结构,不兼容。
4.4 代码示例:纯 Java 实现片段(Bouncy Castle)
// 加密:使用 BC 引擎
SM2Engine engine = new SM2Engine("SM2");
engine.init(true, new ParametersWithRandom(publicKey, new SecureRandom()));
byte[] ciphertext = engine.processBlock(plaintext, 0, plaintext.length);
// 解密
SM2Engine engineDec = new SM2Engine("SM2");
engineDec.init(false, privateKey);
byte[] recovered = engineDec.processBlock(ciphertext, 0, ciphertext.length);
注意 BC 的 SM2Engine 默认使用 C1C3C2 顺序,与国标一致。但有的库用 C1C2C3,你一定要检验后再用。另外引擎内部的 KDF 和 SM3 杂凑顺序都有讲究,别自己重写,用官方 API 最稳妥。
4.5 整体流程视图
下面这张 SVG 图帮你理清加密和解密的对称关系。我把核心节点和箭头画出来,你就知道哪步最关键了。
4.6 避坑总结
- 点校验不能省:解密时
SM2Engine内部其实做了,但如果你自己解析 C1 要手动调用ECCurve.validatePoint。 - 字节序问题:SM2 采用大端序(Big Endian),和 Intel 平台一致,但某些嵌入式小端 CPU 需要转换。我就遇到过 STM32 上报的密文跟 Java 算的不一样,查了半天发现是字节序没统一。
- KDF 参数:标准派生用 SM3 迭代,迭代次数与所需密钥长度有关。别想当然地用 SHA-256 代替,否则解密方算出来的 K 不同。
- 临时密钥重用:每次加密必须新生成一个 dk。如果复用同一个临时密钥,攻击者可以通过两次加密的关系推导出私钥。这属于基本安全原则,但我见过有人为了性能缓存临时密钥,赶紧打回去改。
- 公钥压缩:SM2 支持 04 非压缩和 02/03 压缩格式。如果你对接的系统用了压缩公钥,记得先解压缩再使用。我曾在某政务平台接口中收到 33 字节公钥,当时没处理压缩点,直接报曲线点不在域上,浪费了半天。
好了,这章我把 SM2 加密解密的骨架给你画清楚了。代码实现时建议直接用成熟的国密库(BC、GMSSL、CSRNG 等),别自己造轮子。真的想抠细节,拿标准文档《GB/T 32918.4-2016》对着看,字字珠玑。