Go语言国密SDK适配封装教程 - 第4节

SM2 非对称加密算法原理与 Go 实现

SM2 国密标准,说实话,是我做过最靠谱的椭圆曲线方案之一。2016 年我刚接触时还觉得它跟 ECDSA 差不多,后来在政务项目中踩过几次坑才摸清它的脾气。今天咱们就把 SM2 从原理到 Go 实现彻底撸一遍。

椭圆曲线密码学基础

SM2 基于椭圆曲线 (ECC),核心是一个有限域上的方程:y² = x³ + ax + b。嗯,这里有个关键——曲线参数是国家给定的,你不能自己乱选。为什么要这样?因为参数选不好会有安全漏洞。我曾在一个外包项目里见过工程师用随机参数生成曲线,结果私钥能被秒破解……

点加和倍点是 ECC 的基础操作:

  • 点加:两个曲线上的点做切线求得第三个交点,再取对称点。
  • 标量乘:私钥 d × 基点 G = 公钥 P,这个过程虽然简单但计算量不小。

反过来想:已知 P 和 G 求 d,那就是离散对数难题——目前无多项式解法。这也是 SM2 安全的根本。

SM2 算法特点

维度SM2 (256位)RSA (2048位)ECDSA (256位)
密钥长度32 字节256 字节32 字节
加密速度快(1.5× RSA)慢仅签名
签名结构Z + 消息摘要无身份标识无身份标识
安全强度128 位112 位128 位
国内适配国密强制部分场景不推荐

我个人的习惯是:但凡涉密项目直接上 SM2,不仅仅因为合规,更因为它的签名里带了一个 Z 值(用户身份标识的杂凑),能防住一些中间人攻击。

SM2 密钥生成

  1. 选一条安全曲线(SM2 曲线参数由 GB/T 32918 指定)。
  2. 用真随机数生成器输出 32 字节私钥 d(范围 1 ~ n-1,n 是曲线阶)。
  3. 计算公钥 P = d * G(标量乘,G 为基点)。
  4. 公钥输出格式:未压缩(04 || x || y)或压缩(02/03 || x)。

我曾经踩过的坑:用了 math/rand 代替 crypto/rand 生成私钥。结果密钥空间极度缩小,测试环境一切正常,上生产后没几天就被安全审计扫出「弱随机数」风险。从此以后我坚持只用 crypto/rand.Reader。

SM2 加密解密流程

加密不是直接拿公钥点乘明文——那样会出大问题。SM2 加密是「双点+对称加密」混合模式:

  • 发送方生成随机数 k,计算 C1 = k * G。
  • 计算共享点 S = k * P(接收方公钥)。
  • 从 S 的 x 坐标派生对称密钥 K(KDF)。
  • 用 K 加密明文得到密文 C2。
  • 计算 C3 = Hash(x2 || 消息 || y2),作为校验用。
  • 最终密文 = C1 || C3 || C2(顺序不能错)。

解密就是反过来:用自己的私钥 d 从 C1 算共享点 S,再派生出 K 解出 C2,最后验证 C3。

SM2 签名验签

  1. 计算 Z = Hash(ID || 曲线参数 || 公钥)。
  2. 拼接 Z + 消息,得到消息摘要 e。
  3. 生成随机数 k,计算点 (x1, y1) = k * G,r = (e + x1) % n。
  4. 计算 s = ( (1+d)⁻¹ * (k - r*d) ) % n。
  5. 签名输出 (r, s)。

验签则用公钥重建点并检查 r 与 e 的关系。为什么这么复杂?为了防重放和伪造。从原理上看,它比 ECDSA 多了一步 Z 值计算,等于绑定了用户身份。

记住重点:签名前务必计算 Z 值。我之前有个同事忘了把 ID 传给验签方,导致签名永远不通过。最后发现双方 Z 值不一致——底层参数 ID 默认是空字符串,但文档没写清。

SM2 加密解密流程 SM2 加密解密流程 发送方 (公钥 P) 随机数 k C1 = k*G S = k*P 派生 K (KDF) 加密 → C2 接收方 (私钥 d) 收到 C1 S' = d*C1 派生 K (同KDF) 解密 → 明文 C3 用于完整性校验 密文 = C1 || C3 || C2

Go 语言实现

讲完了原理,咱们直接上手 Go。你可能会问:标准库 crypto 包有 SM2 吗?很遗憾,目前官方没有。不过社区有成熟的实现,比如 github.com/tjfoc/gmsm/sm2,国内用的最多。我一般在公司内网维护一个镜像,避免依赖外网。

密钥生成示例

package main

import (
    "crypto/rand"
    "github.com/tjfoc/gmsm/sm2"
)

func main() {
    priv, err := sm2.GenerateKey(rand.Reader)
    if err != nil {
        panic(err)
    }
    pub := &priv.PublicKey
    
    // 导出私钥(PEM格式)
    privPEM, _ := sm2.MarshalPrivateKey(priv, nil)
    // 导出公钥(未压缩)
    pubPEM, _ := sm2.MarshalPublicKey(pub)
    
    _ = privPEM
    _ = pubPEM
}

加密与解密

import (
    "crypto/rand"
    "github.com/tjfoc/gmsm/sm2"
    "github.com/tjfoc/gmsm/sm4"  // 对称算法由SM2内部调用
)

// 加密函数
func encrypt(pub *sm2.PublicKey, data []byte) ([]byte, error) {
    ciphertext, err := sm2.Encrypt(pub, data, rand.Reader)
    return ciphertext, err
}

// 解密函数
func decrypt(priv *sm2.PrivateKey, ciphertext []byte) ([]byte, error) {
    plaintext, err := sm2.Decrypt(priv, ciphertext)
    return plaintext, err
}

签名与验签

// 签名(结果 r,s 二元素)
func sign(priv *sm2.PrivateKey, msg []byte) (r, s []byte, err error) {
    r, s, err = sm2.Sign(priv, msg)
    return
}

// 验签
func verify(pub *sm2.PublicKey, msg, r, s []byte) bool {
    return sm2.Verify(pub, msg, r, s)
}

我自己的小技巧:生产环境中慎用默认 ID(空字符串)。务必在代码里显式设置 SM2 曲线的 ID 标识,比如 sm2.SetUID([]byte("1234567812345678")) —— 这样双方签名验签时的 Z 值才能保证一致。我在一次跨系统对接中吃过亏,因为对方用了不同的默认 ID。

避坑指南

  • 随机数质量:始终使用 crypto/rand.Reader,不要图快用 math/rand。
  • 公钥格式:加密接口默认使用未压缩公钥 (04+64字节)。如果你用压缩公钥,需要手动处理——有的库不自动转换。
  • 性能瓶颈:标量乘运算很吃 CPU。我在高并发场景下测试过,单核每秒约 5000 次加密。建议做预计算(如设置 sm2.Init() 提前生成窗口表)。
  • 跨语言互操作:C1 的编码顺序是 x || y (大端)。你如果跟 Java 或 C 语言对接,务必确认字节序一致。

基准测试参考

操作耗时 (μs)说明
密钥生成~1200包括标量乘和随机数
加密 (32字节)~1800两次标量乘 + KDF
解密~1300一次标量乘 + KDF
签名~1500一次标量乘 + 模逆
验签~2000两次标量乘

上面数据来自我的老旧笔记本 (i7-8650U),如果你在服务器上用更强的 CPU,性能还能翻倍。但不管怎样,SM2 相比 RSA 在同等安全强度下有 2~3 倍的速度优势,这也是我更喜欢它的原因。

警告:不要在生产环境直接拷贝网上的示例代码,尤其是那些忽略错误处理的。我见过一个线上事故——加密时忘记传随机数源,结果每次都生成相同的 C1,攻击者一看就知道是同一段明文。正确的做法是每个加密操作都传入一个独立的 io.Reader。

最后说句实在的:学 SM2 不能光看原理,一定要动手写代码并测试相互验签。我当年花了整整两个周末才把 P256 曲线和 SM2 的参数差异彻底搞清楚。你只要照着上面的例子跑一遍,再配合一个跨语言的验签测试,基本就能把 SM2 的 Go 实现吃得透透的。