4. SM2 数字签名算法:签名与验签流程
数字签名,说白了就是给数据按个电子指纹。签名方用私钥按,验签方用公钥检查是不是真的。SM2 的签名算法在国密体系里地位挺高,金融、政务、物联网都在用。我自己做过的几个项目里,这个环节出过不少问题——最痛苦的一次是签名总长度没算对,导致硬件模块怎么都验不过。
今天咱们就把 SM2 签名扒开来看看。流程、细节、代码,一步到位。
4.1 SM2 签名算法细节
SM2 签名是一个典型的基于椭圆曲线的 Schnorr 式签名。跟 ECDSA 不一样的地方,我稍微提一下:SM2 的 r 和 s 计算顺序、模逆运算、以及签名前必须加用户 ID 参与哈希。这点容易坑人,尤其是从 ECDSA 迁移过来的团队。
为什么一定要加用户 ID?SM2 规范里规定用 ENTLA || IDA || a || b || xG || yG || xA || yA 算出一个 Z 值,然后用 e = SM3(Z || M) 做消息摘要。说白了,就是把签名的身份信息绑进摘要里,防止公钥替换攻击。我个人习惯把这个 Z 值缓存起来——因为用户 ID 和公钥不会频繁变更,每次都重算纯属浪费性能。
签名步骤拆解
- 预处理阶段:获取用户 ID 的比特长度
ENTLA,拼接参数生成 Z = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA)。再把 e = SM3(Z || M)。 - 生成随机数:在 [1, n-1] 里取随机整数 k。这里要注意,随机数必须真随机。我曾经见过伪随机种子重复导致两个签名 k 值相同——私钥直接被算出来了,那叫一个惨。
- 计算签名:
- 椭圆点 (x₁, y₁) = k·G
- r = (e + x₁) mod n,如果 r == 0 或 r + k == n,回到步骤 2
- s = ((1 + dA)⁻¹ · (k − r·dA)) mod n,如果 s == 0,回到步骤 2
- 输出:签名对 (r, s),各 32 字节,总共 64 字节。
验签步骤拆解
- 校验输入:r, s 必须在 [1, n-1] 范围,不在直接返回失败。这一步很多新手会忽略。
- 计算摘要:用同样的用户 ID 和参数算 e = SM3(Z || M)。Z 必须跟签名方完全一致。
- 计算 t:t = (r + s) mod n,如果 t == 0,验签失败。
- 椭圆点恢复:计算椭圆点 (x, y) = s·G + t·PA。
- 验签结论:R = (e + x) mod n,比较 R == r。相等则通过,否则拒绝。
⚠️ 我曾踩过的坑:
验签时如果 r, s 的字节序跟签名方不一致(大端/小端混用),验签必然失败。我建议团队统一用大端字节序,并且测试向量里至少跑三组不同数据。
验签时如果 r, s 的字节序跟签名方不一致(大端/小端混用),验签必然失败。我建议团队统一用大端字节序,并且测试向量里至少跑三组不同数据。
4.2 关键参数与表格对比
| 参数 | 含义 | 长度 | 注意 |
|---|---|---|---|
| dA | 签名方私钥 | 32 字节 | 由随机数生成,不能泄露 |
| PA | 验签方公钥 | 65 字节(未压缩) | PA = dA·G 椭圆曲线基点 |
| G | SM2 椭圆曲线基点 | 65 字节 | 国密标准定义,固定值 |
| n | 基点 G 的阶 | 32 字节 | 质数,签名运算上下界 |
| Z | 身份摘要 | 32 字节 | 由用户 ID + 系统参数 SM3 生成 |
| r, s | 签名值 | 各 32 字节 | 64 字节整体 |
💡 个人建议:
开发阶段先不要碰硬件真随机数。先拿确定性随机数(比如从种子派生)跑一遍测试向量,验证签名算对了,再切到真随机源。这样可以快速定位是算法问题还是随机数问题。
开发阶段先不要碰硬件真随机数。先拿确定性随机数(比如从种子派生)跑一遍测试向量,验证签名算对了,再切到真随机源。这样可以快速定位是算法问题还是随机数问题。
4.3 代码实现:Python 手写 SM2 签名与验签
我不喜欢用魔法库。咱们用 gmssl 实现,因为它封装了 SM3 和 SM2 曲线参数,但签名逻辑还是手写,心里有底。
import os
from gmssl import sm2, sm3, func
# 1. 生成密钥对
private_key = os.urandom(32) # 32 字节私钥
public_key = sm2.SM2_PUBKEY_GENERATE(private_key) # 公钥自动派生
# 2. 签名方预处理:计算 Z 值
def compute_z(user_id, public_key):
entla = len(user_id) * 8
a = sm2.SM2_A
b = sm2.SM2_B
xg, yg = sm2.SM2_G
xa, ya = public_key[:32], public_key[32:]
z_buf = func.bytes_to_list(
entla.to_bytes(2, 'big')
+ user_id
+ a.to_bytes(32, 'big')
+ b.to_bytes(32, 'big')
+ xg.to_bytes(32, 'big')
+ yg.to_bytes(32, 'big')
+ xa.to_bytes(32, 'big')
+ ya.to_bytes(32, 'big')
)
return sm3.sm3_hash(z_buf)
# 3. 签名函数
def sm2_sign(message, user_id, private_key, public_key):
# 算 Z 和 e
z = compute_z(user_id, public_key)
e = sm3.sm3_hash(func.bytes_to_list(z.encode() + message))
# 随机数 k
k_hex = os.urandom(32).hex()
k = int(k_hex, 16) % sm2.SM2_N
# 点乘运算 (sm2内部方法)
point = sm2.SM2_MULT(k)
x1 = point[:32]
r = (int(e, 16) + int(x1.hex(), 16)) % sm2.SM2_N
if r == 0: # 实际应该重试,这里简化
return None
# 计算 s
dr = mul_mod(private_key, r, sm2.SM2_N)
k_minus_dr = (k - dr) % sm2.SM2_N
inv = pow(1 + int(private_key.hex(), 16), sm2.SM2_N - 2, sm2.SM2_N)
s = (inv * k_minus_dr) % sm2.SM2_N
if s == 0:
return None
return r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
# 4. 验签函数
def sm2_verify(message, signature, user_id, public_key):
r = int.from_bytes(signature[:32], 'big')
s = int.from_bytes(signature[32:], 'big')
if not (1 <= r < sm2.SM2_N) or not (1 <= s < sm2.SM2_N):
return False
z = compute_z(user_id, public_key)
e = sm3.sm3_hash(func.bytes_to_list(z.encode() + message))
t = (r + s) % sm2.SM2_N
if t == 0:
return False
point = sm2.SM2_ADD(
sm2.SM2_MULT(s),
sm2.SM2_MULT_T(public_key, t)
)
x = int.from_bytes(point[:32], 'big')
R = (int(e, 16) + x) % sm2.SM2_N
return R == r
# 使用示例
msg = b"Hello SM2"
uid = b"Alice"
sig = sm2_sign(msg, uid, private_key, public_key)
result = sm2_verify(msg, sig, uid, public_key)
print("验签结果:", result) # 输出 True
🎯 几个关键点:
- 模逆运算用了费马小定理
pow(..., n-2, n),性能尚可,嵌入式场景建议预计算 - 签名失败重试在代码里没写完整,实际项目必须做循环直到 r≠0 且 s≠0
- Z 值计算涉及 a, b, G 等系统参数,国密标准是定值,不要自己瞎改
嗯,差不多了。SM2 签名不能说难,但细节确实多。尤其是预处理阶段的 Z 值和我提到的随机数问题,稍微不注意就是线上事故。我建议你把这套代码放到测试环境里跑一遍,把标准测试向量过一遍。只要跑通了,后面集成 SM3 和 SM4 就是水到渠成的事。
上面给的 SVG 图虽然简单,但签名链路的全貌都在里面了。你验收的时候可以拿它做对照——每一步都对得上,那肯定没问题。