4. SM2 加密与解密实现归纳总结

4.1 加密流程:这个椭圆曲线怎么玩的?

SM2 加密本质上是混合加密——公钥只用来加密一个临时对称密钥,真正数据用对称算法(SM4)加密。你想想看,直接拿椭圆曲线算数据,性能扛不住,所以它走了 ECIES 这条路。

加密端拿到接收方公钥 PB,先产生临时密钥对 (dk, R),其中 R = dk * G。然后计算共享点 S = dk * PB。S 的 x 坐标就是关键,用它派生出对称密钥和 MAC 密钥。我习惯把这步叫做「密钥协商 + 封装」。

加密核心步骤(我简化一下):
  1. 生成随机数 dk ∈ [1, n-1]
  2. 计算椭圆曲线点 R = dk * G
  3. 计算共享点 S = dk * PB,若 S=O 则重试
  4. 从 S.x 派生对称密钥 K(比如 KDF)
  5. 用 K 加密明文 M 得到密文 C2
  6. 计算杂凑值 C3 = SM3(一些标识 + S.x + M + S.y)
  7. 输出 C = C1 || C3 || C2

这里 C1 是临时公钥 R 的字节表示(通常 04 || x || y,共 65 字节)。C3 是 32 字节的 SM3 摘要。C2 是对称加密的密文。说起来简单,但实际编码时你会遇到不少坑,咱们后边慢慢聊。

小提示:SM2 标准里加密用的 KDF 就是 SM3 迭代输出,不是传统 AES 的派生方式。我第一次实现时直接拿 SHA-256 代替,结果解密死活对不上,折腾了两天才发现。

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' 比对。不一致?说明要么密钥错了,要么数据被篡改。

避坑指南:我曾经在解密时忘记先校验 C1 是否在椭圆曲线上。标准要求检查 R 是否满足曲线方程且不是无穷远点。漏掉这一步,攻击者可以构造畸形点让解密端算出固定共享点,绕过密钥保护。这属于安全漏洞,一定要加!
加密 vs 解密步骤对比
阶段加密方解密方
输入明文 M + 接收方公钥 PB密文 C + 自己私钥 dB
临时密钥生成 dk, 计算 R无(使用收到的 C1)
共享点S = dk*PBS = dB*C1
密钥派生KDF(S.x) → KKDF(S.x) → K
对称操作加密 M → C2解密 C2 → M'
摘要计算SM3(S.x+M+S.y) → C3SM3(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 结构,不兼容。

个人经验:我写过一次跨平台对接,Android 端用 Java SDK 加密,C 端需要解密。两边对 C1 长度理解不同:Java 端输出 65 字节(含 04 前缀),C 端却认为首字节必须是 04 但长度可以省略?结果解码偏移了 1 字节,所有解密都失败。后来统一成 x||y 共 64 字节+显式加回 04 才解决。

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 图帮你理清加密和解密的对称关系。我把核心节点和箭头画出来,你就知道哪步最关键了。

SM2 加密与解密完整流程 加密方 解密方 生成 dk R = dk*G S = dk*PB KDF(S.x) 加密 M→C2 C3=SM3(…) 解析 C1/C3/C2 校验 C1 在曲线上 S = dB*C1 KDF(S.x) 解密 C2→M' 对比 C3' 与 C3 公钥 PB 事先共享 输出 C = C1||C3||C2 注意:共享点 S 计算是核心,两边必须一致

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 加密 = 临时密钥 + 共享点 + KDF + 对称加密 + 杂凑校验。解密就是它的逆过程,但多了点验证和摘要比对。只要共享点一致,剩下就是对称算法和杂凑函数的事。

好了,这章我把 SM2 加密解密的骨架给你画清楚了。代码实现时建议直接用成熟的国密库(BC、GMSSL、CSRNG 等),别自己造轮子。真的想抠细节,拿标准文档《GB/T 32918.4-2016》对着看,字字珠玑。