4. SM2 公钥加密算法:加密、解密与密文膨胀
公钥加密,说白了就是“你锁我开”。你把公钥公开,谁都能往里塞消息。但只有你手里的私钥,才能把锁打开。SM2 的加密流程,我刚开始看的时候觉得挺绕,后来自己动手实现了一遍,才发现它设计得很精巧。
4.1 加密流程:三步走
SM2 加密不是直接拿公钥去加密数据。它用的是“混合加密”的思路。你想想看,非对称加密算起来太慢了,要是加密一个大文件,CPU 得烧起来。所以 SM2 的做法是:
- 生成一个临时密钥对,用对方的公钥加密临时私钥。
- 用临时公钥派生出一个对称密钥,加密实际数据。
- 把加密后的临时私钥、加密后的数据、以及一些辅助信息打包发送。
具体流程,我习惯用这张图来理解:
这里有个细节,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 后:
- 从 C1 中恢复出临时公钥 R。
- 用自己的私钥 d_B 计算共享点
S = [d_B]R。 - 用 KDF 派生对称密钥 t。
- 用 t 异或 C2 得到明文 M。
- 重新计算杂凑值,跟 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 批量加密:共享临时公钥
如果你要加密多条消息给同一个接收方,可以复用同一个临时密钥对。具体做法:
- 生成一个临时密钥对 (r, R),只发送一次 C1。
- 后续每条消息,用同一个 r 派生不同的对称密钥(通过加入消息序号或时间戳)。
- 接收方缓存 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 次尝试就能伪造一条合法密文。这跟没加密差不多。
优化优先级总结:
- 首选:压缩 C1(零成本,省 32 字节)
- 次选:混合加密(SM2 + AES,适合大量数据)
- 谨慎:批量加密(适合内网,注意前向安全)
- 禁止:截断 C3(不要为了性能牺牲安全)