SM2 签名 ZA 与 Entail 陷阱:80% 国密开发者都在犯的致命错误
title: "SM2 签名 ZA 与 Entail 陷阱:80% 国密开发者都在犯的致命错误" slug: "sm2-sign-za-entail-trap" excerpt: "SM2 签名的 ZA 计算涉及 ENT_L、标识 ID、椭圆曲线参数等多个字段,任何一步出错都会导致签名在密评中被判定为不合规。本文从源码级剖析 ZA 的计算过程,分析 8 个常见陷阱,并给出完整的修复方案和验证工具。" category: gm tags: - SM2 - ZA - GM/T 0003 - 国密 - 签名
前言
如果你在国密项目中做过 SM2 签名,你大概率遇到过这样的报错:
90% 的原因出在 ZA 计算——也就是 GM/T 0003.2-2012 规定的签名预处理步骤。signature verification failedinvalid signature formatZA hash mismatch
ZA 是什么?为什么每次签名前都要先算一遍?哪些地方最容易踩坑?本文从标准原文出发,结合生产实战,给出完整解答。
阅读前提:你需要理解基本的 SM2 签名流程(哈希→ZA 拼接→签名),但对 ZA 内部的各个字段可能不太清楚。
一、ZA 是什么,为什么要算它
1.1 标准原文
GM/T 0003.2-2012 第 5.1 节定义 SM2 签名算法:
输入:消息 M,发送方 A 的私钥 dA
输出:签名对 (r, s)
步骤:
1. 计算 ZA = SM3(ZA')
2. 计算 e = SM3(ENTL_A || ZA || M)
3. 生成随机数 k ∈ [1, n-1]
4. 计算 (x1, y1) = [k]G
5. r = (e + x1) mod n
6. s = ((1 + dA)^(-1) * (k - r * dA)) mod n
7. 返回 (r, s)第 1 步的 ZA' 就是 ZA 的原始数据,ZA 是其 SM3 哈希值。
1.2 ZA 的设计意图
ZA 解决的核心问题是:防止重放攻击。
如果没有 ZA,攻击者可以截取合法的签名 (r, s),将其应用到另一条消息上。加入 ZA 后:
- ZA 包含了发送方的身份标识(ID)
- ZA 还包含了椭圆曲线参数(a, b, xG, yG)
- 这意味着签名不仅绑定消息,还绑定了签名者的身份和使用的曲线
二、ZA 的结构拆解
2.1 ZA 的完整构成
ZA = SM3(ZA')
其中 ZA' = ENTL_A || ID_A || a || b || xG || yG || xA || yA每个字段的含义:
| 字段 | 长度 | 说明 |
|---|---|---|
| ENTL_A | 2 字节 | ID 的长度(比特数) |
| ID_A | 可变 | 发送方标识,默认 A53A(ASCII 的"A") |
| a | 32 字节 | 椭圆曲线方程 y² = x³ + ax + b 中的 a |
| b | 32 字节 | 椭圆曲线方程中的 b |
| xG | 32 字节 | 基点 G 的 x 坐标 |
| yG | 32 字节 | 基点 G 的 y 坐标 |
| xA | 32 字节 | 发送方公钥的 x 坐标 |
| yA | 32 字节 | 发送方公钥的 y 坐标 |
2.2 字段字节顺序
所有整数型字段(a、b、xG、yG、xA、yA)均为 大端序(Big-Endian),不足 32 字节时前面补零。
例如 GM/T 0003.5-2012 定义的 SM2 曲线参数:
a = 0xFFFCFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC
b = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
p = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
n = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
xG = 0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7
yG = 0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A02.3 ENTL_A 的计算
# ENTL_A = len(ID_A) in bits
id_bytes = b"A53A" # 默认标识,ASCII
entl_a = len(id_bytes) * 8 # 4 * 8 = 32 = 0x0020
# 占 2 字节大端序
entl_a_bytes = entl_a.to_bytes(2, 'big') # b'\x00\x20'⚠️ 常见错误:将 ENTL_A 写成字节数(4)而非比特数(32)。
三、8 个常见陷阱与修复方案
陷阱 1:Entail 拼写错误
很多人把 Entail 写成 Entail、Ental、Entailment 等。实际上 GM/T 0003.2-2012 标准中的术语是 ENT_L(Entry Length,标识长度),但在代码中通常直接称为 ENT_L 或 entl。
代码中的实际名称:
# gmssl 库中:
from gmssl import sm2
# 签名时自动处理 ZA,开发者无需手动计算
sig = cryptsm2.sign_with_sm3(data, key)
# cryptography 库中:
# 不支持 SM2,无法直接签名陷阱 2:ID 编码错误
GM/T 0003.2-2012 规定默认 ID 为 A53A(十六进制),对应 ASCII 字符 "A"。
# ✅ 正确:使用 A53A
ID_A = bytes.fromhex('A53A')
# ❌ 错误:直接使用 ASCII "A"
ID_A = b"A" # 长度相同但含义不同注意:bytes.fromhex('A53A') 和 b"A" 虽然长度都是 1 字节,但 字节值不同:
| 写法 | 字节值 | 说明 |
|---|---|---|
bytes.fromhex('A53A') | 0xA5, 0x3A | 2 字节,标准默认值 |
b"A" | 0x41 | 1 字节,ASCII A |
b"A" 代替 bytes.fromhex('A53A'),导致 ZA 计算错误。陷阱 3:密钥格式错误
gmssl 库要求私钥为 64 字符(32 字节)的十六进制字符串:
# ✅ 正确:64 字符 hex string
private_key = "6d8e5c3a2b1f4e7d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d"
# ❌ 错误:缺少前导零
private_key = "6d8e5c3a2b1f4e7d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e" # 62字符修复方案:
def normalize_sm2_key(key: str) -> str:
"""确保私钥为 64 字符 hex 字符串"""
key = key.replace("0x", "").replace(" ", "")
if len(key) < 64:
key = key.zfill(64)
return key[:64]陷阱 4:gmssl CryptSM2 初始化错误
gmssl 3.2.x 的 CryptSM2 必须同时传入私钥和公钥:
# ❌ 错误:只传私钥
cryptsm2 = sm2.CryptSM2(private_key)
# ✅ 正确:同时传入私钥和公钥
cryptsm2 = sm2.CryptSM2(private_key, public_key)公钥格式:65 字节(0x04 + x + y),即 130 字符 hex 字符串。
陷阱 5:手动 SM3 哈希后签名
这是最高频的错误:
# ❌ 错误:手动先做 SM3 哈希
from gmssl import sm3, func
data_hash = sm3.sm3_hash(func.bytes_to_list(message))
sig = cryptsm2.sign(data_hash, k) # 再次哈希!
# ✅ 正确:使用 sign_with_sm3
sig = cryptsm2.sign_with_sm3(message, k)原因:sign() 方法内部会再次对输入做 SM3 哈希。如果你传入的已经是哈希值,相当于做了双重哈希,验证必然失败。
陷阱 6:k 值复用攻击
# ❌ 危险:固定 k 值
k = "0000000000000000000000000000000000000000000000000000000000000001"
sig = cryptsm2.sign_with_sm3(message, k)
# 攻击者获取两条相同 k 值的签名后,可推导私钥:
# s1 = (k - r1*dA) / (1+dA) mod n
# s2 = (k - r2*dA) / (1+dA) mod n
# 两式相减可解出 dA修复:每次签名使用密码学安全的随机数:
import os
k = os.urandom(32).hex()
sig = cryptsm2.sign_with_sm3(message, k)陷阱 7:公钥格式混淆
SM2 公钥有两种格式:
| 格式 | 长度 | 说明 |
|---|---|---|
| 压缩格式 | 33 字节 | 0x02/0x03 + x 坐标 |
| 非压缩格式 | 65 字节 | 0x04 + x + y |
# ❌ 错误:传入压缩公钥
public_key_compressed = "02" + x_coord.hex() # 65字符,缺少y
# ✅ 正确:使用完整公钥
public_key_uncompressed = "04" + x_coord.hex() + y_coord.hex() # 130字符陷阱 8:gmssl 与 cryptography 库混用
cryptography 库不支持 SM2 签名,但很多开发者误以为支持:
# ❌ 错误:cryptography 不支持 SM2
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
# 这段代码会报 AttributeError 或 ImportError正确做法:
# ✅ 使用 gmssl 进行 SM2 签名
from gmssl import sm2, func
# ✅ 或使用 Tongsuo/BabaSSL 的 Python 绑定
# pip install tongsuo # 需厂商 SDK四、完整的 ZA 计算验证工具
以下是一个完整的 ZA 计算验证脚本,可用于排查签名问题:
#!/usr/bin/env python3
"""SM2 ZA 计算验证工具"""
from gmssl import sm2, sm3, func
from gmssl.func import bytes_to_list
# SM2 曲线参数(GM/T 0003.5-2012)
SM2_A = 0xFFFCFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC
SM2_B = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
SM2_P = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
SM2_N = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
SM2_GX = 0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7
SM2_GY = 0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0
def compute_za(user_id: bytes, sender_pk_x: bytes = None, sender_pk_y: bytes = None) -> bytes:
"""按照 GM/T 0003.2-2012 计算 ZA
Args:
user_id: 发送方标识 ID(如 bytes.fromhex('A53A'))
sender_pk_x: 发送方公钥 x 坐标(32 字节),可选
sender_pk_y: 发送方公钥 y 坐标(32 字节),可选
Returns:
ZA 哈希值(64 字符 hex 字符串)
"""
# ENTL_A:ID 长度的比特数,2 字节大端序(⚠️ 是比特数,不是字节数!)
entl = (len(user_id) * 8).to_bytes(2, 'big')
# a, b:曲线参数,32 字节大端序
a = SM2_A.to_bytes(32, 'big')
b = SM2_B.to_bytes(32, 'big')
# G 点坐标
gx = SM2_GX.to_bytes(32, 'big')
gy = SM2_GY.to_bytes(32, 'big')
# 拼接 ZA'
za_prime = entl + user_id + a + b + gx + gy
# 如果提供了发送方公钥,追加 xA || yA
if sender_pk_x and sender_pk_y:
za_prime = za_prime + sender_pk_x + sender_pk_y
print(f"ZA' ({len(za_prime)} bytes): {za_prime.hex()}")
# SM3 哈希
za = sm3.sm3_hash(bytes_to_list(za_prime))
print(f"ZA (hex): {za}")
return za
def test_za():
"""测试默认 ID 的 ZA 计算"""
# 默认 ID:A53A(十六进制)
default_id = bytes.fromhex('A53A')
za = compute_za(default_id)
print(f"\n最终 ZA: {za}")
return za
if __name__ == '__main__':
test_za()运行结果(默认 ID A53A,无发送方公钥):
ZA' (134 bytes): 0010a53afffcfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc28e9fa9e9d9f5e344d5a9e4bcf6509a7f39789f515ab8f92ddbcbd414d940e9332c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589334c74c7bc3736a2f4f6779c59bdcee36b692153d0a9877cc62a474002df32e52139f0a0
ZA (hex): f413f66b21e5a47e074f52968d9fc9b087e11608f2970fb03b7dc54bcb13d3d0五、生产环境中的最佳实践
5.1 使用标准 API,不要手动计算
from gmssl import sm2
# ✅ 推荐:使用 sign_with_sm3,库内部自动处理 ZA
cryptsm2 = sm2.CryptSM2(private_key, public_key)
signature = cryptsm2.sign_with_sm3(message, None) # k 由库自动生成
# ✅ 验签同样使用标准 API
is_valid = cryptsm2.verify(signature, message)5.2 密钥管理的三条红线
import os
import secrets
# 红线 1:私钥必须使用密码学安全的随机数生成
private_key = secrets.token_hex(32) # 32 字节 = 64 字符 hex
# 红线 2:不要硬编码私钥
# 红线 3:密钥存储必须加密
# 使用 HSM 或 KMIP 管理私钥5.3 签名前的预检查清单
def pre_sign_check(private_key: str, public_key: str, message: bytes) -> bool:
"""签名前预检查"""
# 1. 检查私钥长度
if len(private_key) != 64:
raise ValueError(f"私钥长度错误:{len(private_key)},应为 64")
# 2. 检查公钥长度(非压缩格式)
if len(public_key) != 130:
raise ValueError(f"公钥长度错误:{len(public_key)},应为 130")
# 3. 检查公钥前缀
if not public_key.startswith("04"):
raise ValueError("公钥格式错误:缺少 0x04 前缀")
# 4. 检查消息非空
if not message:
raise ValueError("消息不能为空")
return True六、密评中的 ZA 检查要点
根据 GM/T 0034-2014 和密评指南,ZA 相关的检查项:
| 检查项 | 标准要求 | 常见问题 |
|---|---|---|
| ZA 计算 | 符合 GM/T 0003.2 | 手动计算错误 |
| ID 编码 | 默认 A53A 或协商值 | 使用错误的 ID |
| 曲线参数 | GM/T 0003.5-2012 | 使用错误的 a/b 值 |
| 签名格式 | R+S 64 字节或 DER 编码 | 格式不一致 |
| 随机数 k | 密码学安全随机 | 固定 k 值或弱随机数 |
- ZA 计算错误:手动拼接 ZA 时字段顺序或字节序错误
- 双重哈希:先用 SM3 哈希消息,再传给 sign(),导致签名验证失败
- k 值重用:使用固定或弱随机数生成 k,存在私钥推导风险
| 陷阱 | 问题 | 修复方案 |
|---|---|---|
| ENTL 单位错误 | 用字节数而非比特数 | entl = len(id_bytes) * 8 |
| 双重哈希 | 先 SM3 再 sign() | 用 sign_with_sm3() |
| k 值固定 | 可推导私钥 | 用 os.urandom(32) |
| 公钥格式 | 压缩/非压缩混淆 | 用 0x04 前缀的 65 字节 |
总结
SM2 签名的 ZA 计算看似简单,实则是国密工程中最容易出错的环节之一。本文总结了 8 个常见陷阱,核心教训:
- 优先使用库函数:
sign_with_sm3()自动处理 ZA,不要手动计算 - 注意字节顺序:所有整数字段使用大端序,不足补零
- 密钥格式检查:私钥 64 字符,公钥 130 字符(非压缩格式)
- 随机数安全:k 值必须使用密码学安全的随机数生成器