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 密钥生成
- 选一条安全曲线(SM2 曲线参数由 GB/T 32918 指定)。
- 用真随机数生成器输出 32 字节私钥 d(范围 1 ~ n-1,n 是曲线阶)。
- 计算公钥 P = d * G(标量乘,G 为基点)。
- 公钥输出格式:未压缩(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 签名验签
- 计算 Z = Hash(ID || 曲线参数 || 公钥)。
- 拼接 Z + 消息,得到消息摘要 e。
- 生成随机数 k,计算点 (x1, y1) = k * G,r = (e + x1) % n。
- 计算 s = ( (1+d)⁻¹ * (k - r*d) ) % n。
- 签名输出 (r, s)。
验签则用公钥重建点并检查 r 与 e 的关系。为什么这么复杂?为了防重放和伪造。从原理上看,它比 ECDSA 多了一步 Z 值计算,等于绑定了用户身份。
记住重点:签名前务必计算 Z 值。我之前有个同事忘了把 ID 传给验签方,导致签名永远不通过。最后发现双方 Z 值不一致——底层参数 ID 默认是空字符串,但文档没写清。
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 实现吃得透透的。