4. SM2 公钥加密算法:加密、解密与密文膨胀

公钥加密,说白了就是“你锁我开”。你把公钥公开,谁都能往里塞消息。但只有你手里的私钥,才能把锁打开。SM2 的加密流程,我刚开始看的时候觉得挺绕,后来自己动手实现了一遍,才发现它设计得很精巧。

4.1 加密流程:三步走

SM2 加密不是直接拿公钥去加密数据。它用的是“混合加密”的思路。你想想看,非对称加密算起来太慢了,要是加密一个大文件,CPU 得烧起来。所以 SM2 的做法是:

  1. 生成一个临时密钥对,用对方的公钥加密临时私钥。
  2. 用临时公钥派生出一个对称密钥,加密实际数据。
  3. 把加密后的临时私钥、加密后的数据、以及一些辅助信息打包发送。

具体流程,我习惯用这张图来理解:

SM2 公钥加密流程 发送方 A ① 生成临时密钥对 (r, R) ② KDF 派生对称密钥 t ③ 对称加密明文 M → C2 ④ 计算杂凑值 C3 接收方 B ⑤ 用私钥解密得到 r ⑥ 派生对称密钥 t ⑦ 对称解密得到 M ⑧ 验证杂凑值 C3 公钥 P_B C1 = R (临时公钥) C2 (密文) + C3 (杂凑) 最终密文 = C1 || C3 || C2

这里有个细节,C1 是临时公钥 R 的字节串,不是直接传椭圆曲线点。SM2 规范里规定,C1 要压缩编码,长度固定为 65 字节(未压缩)或 33 字节(压缩)。我个人习惯用未压缩格式,兼容性更好,虽然多了 32 字节。

核心公式:

  • 临时密钥:r = random(1, n-1),R = [r]G
  • 共享点:S = [r]P_B,其中 P_B 是接收方公钥
  • 对称密钥:t = KDF(S_x || S_y, klen)
  • 密文:C2 = M ⊕ t
  • 杂凑:C3 = Hash(S_x || M || S_y)

4.2 解密流程:逆向操作

解密就是加密的逆过程。接收方拿到密文 C1 || C3 || C2 后:

  1. 从 C1 中恢复出临时公钥 R。
  2. 用自己的私钥 d_B 计算共享点 S = [d_B]R。
  3. 用 KDF 派生对称密钥 t。
  4. 用 t 异或 C2 得到明文 M。
  5. 重新计算杂凑值,跟 C3 比对。不一致就返回错误。

嗯,这里要注意。我在项目中遇到过一个问题:解密时如果 C1 解析失败,一定要返回统一的错误码,不能告诉攻击者“C1 格式错误”还是“杂凑不匹配”。否则攻击者可以通过错误信息逐步推断出私钥信息。这是侧信道攻击的经典入口。

安全警告:

  • 解密失败时,不要区分“C1 无效”和“C3 不匹配”。
  • 所有失败场景返回同一个错误码。
  • 我曾经见过一个实现,在 C1 解析失败时打印了调试日志,结果被侧信道攻击利用。血的教训。

4.3 密文膨胀问题:为什么 SM2 加密比明文长?

这是 SM2 加密最让人头疼的地方。你想想看,加密 1 字节的数据,出来的密文有多长?

组成部分 长度(字节) 说明
C1(未压缩) 65 临时公钥的 x||y 坐标,各 32 字节 + 1 字节前缀
C3 32 SM3 杂凑值
C2 len(M) 对称加密后的密文,长度等于明文
总计 97 + len(M) 至少 97 字节的固定开销

也就是说,哪怕你只加密一个字节“hello”,密文也有 102 字节。膨胀率接近 100 倍。这就是密文膨胀问题。

为什么会这样?因为 SM2 每次加密都要生成一个新的临时密钥对,这个临时公钥必须随密文一起发送。接收方没有它,就无法计算共享点。这是非对称加密的固有代价。

4.4 优化思路:怎么把膨胀降下来?

我在做物联网项目时,设备每次上报数据只有几十字节,但 SM2 加密后硬生生多了 97 字节。对于 NB-IoT 这种窄带通信,这简直是灾难。后来我摸索出几条优化路径:

4.4.1 使用压缩格式的 C1

SM2 支持椭圆曲线点的压缩表示。未压缩的 C1 是 65 字节,压缩后只有 33 字节。一下子省了 32 字节。

// 未压缩格式:04 || x || y
// 压缩格式:02 || x  (y 为偶数时)
//           03 || x  (y 为奇数时)

// 解压时需要计算 y = ±sqrt(x³ + ax + b)
// 这个计算大约多花 0.1ms,但省了 32 字节,值!

我的建议:如果带宽是瓶颈,优先用压缩格式。如果 CPU 是瓶颈,用未压缩格式。我在一个车联网项目里,设备上报频率高,带宽有限,压缩格式帮了大忙。

4.4.2 批量加密:共享临时公钥

如果你要加密多条消息给同一个接收方,可以复用同一个临时密钥对。具体做法:

  1. 生成一个临时密钥对 (r, R),只发送一次 C1。
  2. 后续每条消息,用同一个 r 派生不同的对称密钥(通过加入消息序号或时间戳)。
  3. 接收方缓存 R,后续解密时直接使用。

这样,第一条消息还是 97 + len(M),后续每条消息只有 32 + len(M)(只需要 C3 和 C2)。

注意:复用临时密钥对会降低前向安全性。如果私钥泄露,所有历史消息都能被解密。我一般只在可信内网环境用这个优化,公网场景慎用。

4.4.3 混合加密:对称密钥缓存

另一种思路:用 SM2 只加密一个对称密钥(比如 AES-256 的密钥),后续用这个对称密钥加密大量数据。这就是 TLS 的做法。

方案 首次开销 后续每条消息开销 安全性
纯 SM2 加密 97 + len(M) 97 + len(M) 高(每次新密钥)
批量加密(复用 R) 97 + len(M) 32 + len(M) 中(前向安全弱)
混合加密(SM2 + AES) 97 + 16(AES 密钥) 16 + len(M)(AES 密文) 高(AES 密钥定期更换)

我个人最推荐第三种。在项目中,我通常用 SM2 加密一个 32 字节的 AES-256 密钥,然后用 AES-GCM 模式加密实际数据。这样既享受了 SM2 的密钥分发安全性,又避免了每次加密的膨胀问题。

4.4.4 减少 C3 长度(不推荐)

有些实现为了省带宽,把 SM3 杂凑截断到 16 字节甚至 8 字节。嗯,我强烈不建议这么做。SM3 输出 32 字节是有道理的,截断后碰撞概率上升,攻击者有可能伪造密文。我曾经审计过一个项目,他们把 C3 截到 4 字节,结果理论上 2^16 次尝试就能伪造一条合法密文。这跟没加密差不多。

优化优先级总结:

  1. 首选:压缩 C1(零成本,省 32 字节)
  2. 次选:混合加密(SM2 + AES,适合大量数据)
  3. 谨慎:批量加密(适合内网,注意前向安全)
  4. 禁止:截断 C3(不要为了性能牺牲安全)