3. SM2 非对称加密算法:从椭圆曲线到工程落地

SM2 算法,说白了就是咱们国家自己的椭圆曲线密码体系。它跟 ECDSA、ECDH 这些国际算法在数学原理上同源,但参数是自己定的,安全性有独立保障。我在做政务系统对接时,经常遇到客户指定必须用 SM2,那时候我就意识到——这玩意儿不是备选,是刚需。

3.1 SM2 椭圆曲线原理

椭圆曲线不是椭圆,你想想看,它的方程长这样:

y² = x³ + ax + b (mod p)

其中 4a³ + 27b² ≠ 0。这个曲线上的点加上一个无穷远点,构成一个阿贝尔群。SM2 用的是素数域上的椭圆曲线,参数由国标 GM/T 0003-2012 定义。

我个人习惯把椭圆曲线理解成一个「单向陷门函数」的载体。给定基点 G 和私钥 d,计算公钥 P = dG 很容易;但反过来,从 P 和 G 求 d,那就是椭圆曲线离散对数问题(ECDLP),目前没有多项式时间解法。

SM2 推荐曲线参数(256 位素数域)

  • 素数 p:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFF
  • 系数 a:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFC
  • 系数 b:28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93
  • 基点 G:压缩形式 02 32C4AE2C 1F198119 5F990446 6A39C994 8FE30BBF F2660BE1 715A4589 334C74C7
  • 阶 n:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123

为什么选 256 位?因为 256 位椭圆曲线的安全强度相当于 3072 位 RSA。我在项目中做过对比,SM2 的密钥生成速度比 RSA 快一个数量级,这对高并发场景太重要了。

3.2 SM2 密钥对生成

生成密钥对其实就三步:

  1. 选私钥:在 [1, n-1] 范围内随机选一个整数 d
  2. 算公钥:计算 P = dG,G 是基点
  3. 输出:私钥 d(256 位),公钥 P(通常用未压缩格式,65 字节)

嗯,这里要注意——随机数质量直接决定密钥安全。我曾经见过一个项目用 rand() 生成私钥,结果每次重启服务密钥都一样,那跟没加密有啥区别?

// SM2 密钥对生成(伪代码)
function generateSM2KeyPair():
    d = randomBigInt(1, n-1)       // 私钥
    P = scalarMultiply(d, G)       // 公钥
    return (d, P)

工程建议:生产环境务必使用硬件随机数生成器或操作系统提供的 CSPRNG(如 /dev/urandom)。Java 用 SecureRandom,Go 用 crypto/rand,别自己造轮子。

3.3 SM2 加密与解密流程

SM2 加密不是直接用公钥加密数据,而是用「混合加密」的思路。说白了就是:用 SM2 交换一个临时密钥,再用 SM4 加密实际数据。但国标里定义的是纯 SM2 加密流程,我给你们拆解一下。

加密流程

  1. 生成随机数 k ∈ [1, n-1]
  2. 计算椭圆曲线点 C1 = kG = (x1, y1)
  3. 计算点 S = hP,h 是余因子(SM2 中 h=1)
  4. 计算点 kP = (x2, y2)
  5. 推导密钥:t = KDF(x2 || y2, klen),KDF 是密钥派生函数
  6. 加密:C2 = M ⊕ t
  7. 计算哈希:C3 = Hash(x2 || M || y2)
  8. 输出密文:C = C1 || C2 || C3

解密流程

  1. 从 C 中提取 C1,验证 C1 在曲线上
  2. 计算点 dC1 = (x2, y2)
  3. 推导密钥:t = KDF(x2 || y2, klen)
  4. 解密:M' = C2 ⊕ t
  5. 验证哈希:计算 Hash(x2 || M' || y2) 是否等于 C3
  6. 验证通过则输出 M',否则报错

避坑指南:我曾经在解密时忘记验证 C1 是否在曲线上,结果被攻击者注入无效点导致服务崩溃。一定要做点验证,这是安全底线。

3.4 SM2 签名与验签

SM2 签名用的是改进的 ECDSA 方案,但加入了 Z 值(用户标识的哈希),防止签名被跨场景复用。这个设计很巧妙——你想想看,如果签名不绑定用户身份,那 A 的签名被复制到 B 的上下文中也能通过验证,这不就乱套了?

签名流程

  1. 计算 Z = Hash(ENTLA || IDA || a || b || xG || yG || xP || yP)
  2. 计算 e = Hash(Z || M)
  3. 生成随机数 k ∈ [1, n-1]
  4. 计算椭圆曲线点 (x1, y1) = kG
  5. 计算 r = (e + x1) mod n,若 r=0 或 r+k=n 则重试
  6. 计算 s = ((1 + d)⁻¹ · (k - r·d)) mod n,若 s=0 则重试
  7. 输出签名 (r, s)

验签流程

  1. 验证 r, s ∈ [1, n-1]
  2. 计算 Z 和 e(同签名)
  3. 计算 t = (r + s) mod n,若 t=0 则验签失败
  4. 计算椭圆曲线点 (x1, y1) = sG + tP
  5. 计算 R = (e + x1) mod n
  6. 若 R = r 则验签通过,否则失败
// SM2 签名核心步骤(Go 风格伪代码)
func Sign(d *big.Int, M []byte) (r, s *big.Int) {
    Z := Hash(ENTLA || IDA || params)
    e := Hash(Z || M)
    for {
        k := randomInRange(1, n-1)
        x1, _ := scalarMultBase(k)
        r = modAdd(e, x1, n)
        if r == 0 || modAdd(r, k, n) == n { continue }
        s = modMul(modInv(modAdd(1, d, n), n), modSub(k, modMul(r, d, n), n), n)
        if s == 0 { continue }
        return
    }
}

性能小技巧:签名中的模逆运算 (1+d)⁻¹ 可以预计算,因为私钥 d 不变。我在一个支付系统中这样优化后,签名速度提升了 30%。

3.5 SM2 性能调优

SM2 的性能瓶颈主要在椭圆曲线点乘运算。我做过压测,单核每秒大概能跑 2000 次签名或 800 次解密。调优可以从这几个方向入手:

优化方向 具体方法 预期提升
点乘算法 使用 Montgomery 阶梯或窗口法(如 4-NAF) 30%-50%
域运算 使用 Barrett 约减或 Montgomery 模乘 20%-40%
预计算 预计算基点 G 的倍数点表 2-5 倍(签名场景)
并行化 多线程分摊签名/验签任务 接近线性
硬件加速 使用 CPU 的 AES-NI 或专用密码卡 10-100 倍

我个人最推荐的是「预计算 + 窗口法」组合。在签名场景中,因为基点 G 固定,可以提前算好 2⁴G、2⁸G 等值,运行时直接查表。我在一个 IoT 网关项目中,用这种方法把签名耗时从 2ms 降到了 0.4ms。

调优红线:优化不能牺牲安全性。比如不要为了速度而使用固定 k 值(会导致私钥泄露),不要跳过点验证,不要用不安全的随机数。安全第一,性能第二。

SM2 算法核心流程总览 密钥生成 私钥 d ∈ [1, n-1] 公钥 P = dG 加密 C1 = kG, kP = (x2,y2) C2 = M ⊕ KDF, C3 = Hash 解密 dC1 = (x2,y2) M' = C2 ⊕ KDF, 验 Hash 签名 Z = Hash(ENTLA || IDA || ...) r,s = f(e, d, k) 验签 验证 r,s ∈ [1, n-1] R = (e + x1) mod n ?= r 性能调优 窗口法 · 预计算 Montgomery · 并行化 底层数学支撑 椭圆曲线点加 · 点倍 · 模逆 · KDF · 哈希(SM3) ⚠️ 工程红线 随机数必须用 CSPRNG · 点验证不可跳过 · 私钥安全存储 · 防侧信道攻击

最后说一句,SM2 的调优没有银弹。你得先搞清楚业务场景——是签名多还是加密多?是服务端还是客户端?然后针对性优化。我在一个区块链项目中,发现验签是瓶颈,于是用批量验签算法把吞吐量提了 4 倍。嗯,这就是工程的艺术。