SM2 做到了一件 NIST 标准没做到的事——用同一条曲线,同时定义签名、加密和密钥交换三套算法。
NIST 的路线是”一件事配一个标准”:签名用 ECDSA(FIPS 186-4),密钥交换用 ECDH(SP 800-56A),非对称加密?自己拿 ECDH 结果去包一层 AES 吧。而 Daniel Bernstein 那边更极端——Curve25519 只做密钥交换(X25519),签名另起一条等价曲线 Ed25519,加密?不在设计目标里。
SM2 的 GM/T 0003 标准分四部分,同一条 256-bit 素域曲线贯穿始终:Part 2 签名、Part 3 密钥交换、Part 4 加密。这种”三合一”设计到底是深思熟虑还是过度捆绑?要回答这个问题,我们得从最底层的曲线参数开始看。
快速选型指南:如果你只是想知道该用哪个—— - 合规场景(等保/密评/金融)→ SM2,没得选 - 纯国际互联网(GitHub、AWS)→ X25519/Ed25519 - 新项目,需要密码敏捷性 → 先用 X25519,预留 SM2 接口 - 详细对比请继续往下看。
一、三条曲线的基本参数
椭圆曲线密码的安全性取决于底层数学结构。这三条曲线虽然都提供约 128-bit 的安全级别,但设计哲学截然不同。
SM2 曲线
SM2 使用 256-bit 素域上的 Weierstrass 短形式曲线 \(y^2 = x^3 + ax + b\),由国家密码管理局选定参数。模数 \(p\) 和阶 \(n\) 都是 256-bit 素数,cofactor \(h = 1\)(意味着曲线上所有点都在主群中,不需要额外处理小子群攻击)。
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
n = FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF
7203DF6B 21C6052B 53BBF409 39D54123
注意 \(a = p - 3\),这和 P-256 一样,是为了优化 Jacobian 坐标下的点加运算(后面会详细讲)。
P-256 / secp256r1
NIST 在 1999 年选定的曲线,同样是 Weierstrass 短形式。模数 \(p = 2^{256} - 2^{224} + 2^{192} + 2^{96} - 1\),是一个具有特殊结构的 Solinas 素数,可以用几次移位和加法完成模约简,效率极高。
p = FFFFFFFF 00000001 00000000 00000000
00000000 FFFFFFFF FFFFFFFF FFFFFFFF
a = FFFFFFFF 00000001 00000000 00000000
00000000 FFFFFFFF FFFFFFFF FFFFFFFC (即 p - 3)
b = 5AC635D8 AA3A93E7 B3EBBD55 769886BC
651D06B0 CC53B0F6 3BCE3C3E 27D2604B
n = FFFFFFFF 00000000 FFFFFFFF FFFFFFFF
BCE6FAAD A7179E84 F3B9CAC2 FC632551
\(b\) 的值来自
SHA-1(seed) 的输出,seed 为
c49d3608 86e70493 6a6678e1 139d26b7 819f7e90。NIST
声称这是 “nothing up my sleeve”
数——用哈希输出证明参数不是后门。但 SHA-1
的输入本身没有解释来源,这一点后来引发了巨大争议。
Curve25519
Daniel Bernstein 在 2006 年发表的曲线,使用 Montgomery 形式 \(By^2 = x^3 + Ax^2 + x\),其中 \(A = 486662\),\(B = 1\)。素数 \(p = 2^{255} - 19\) 极其简洁,模约简只需一次乘法和加法。
p = 2^255 - 19
= 7FFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFED
A = 486662
n = 2^252 + 27742317777372353535851937790883648493
h = 8 (cofactor)
cofactor \(h = 8\) 意味着曲线的完整群阶是 \(8n\),密钥交换时需要将输入点乘以 8(或 clamp 私钥的低 3 位为 0)来避免小子群攻击。这在 X25519 的协议设计中已经内置处理。
参数对比
| 特性 | SM2 | P-256 | Curve25519 |
|---|---|---|---|
| 曲线形式 | Weierstrass \(y^2 = x^3 + ax + b\) | Weierstrass \(y^2 = x^3 + ax + b\) | Montgomery \(y^2 = x^3 + Ax^2 + x\) |
| 域素数 \(p\) | 256-bit(通用素数) | 256-bit(Solinas 素数) | \(2^{255}-19\)(Mersenne-like) |
| 阶 \(n\) | 256-bit 素数 | 256-bit 素数 | \(\approx 2^{252}\) |
| cofactor \(h\) | 1 | 1 | 8 |
| \(a\) 值 | \(p - 3\) | \(p - 3\) | N/A(Montgomery \(A = 486662\)) |
| 模约简效率 | 通用 Montgomery 乘法 | 快速 Solinas 约简 | 极快(一次 × 38 + add) |
| 参数来源 | 国家密码管理局选定 | SHA-1(seed),seed 来源不明 | 最小满足安全条件的 \(A\) |
| 签名 | ✅ SM2 签名 | ✅ ECDSA | ✅ Ed25519(扭曲 Edwards 等价形式) |
| 加密 | ✅ SM2 加密 | ❌(需 ECIES 组合方案) | ❌(非设计目标) |
| 密钥交换 | ✅ SM2 密钥交换 | ✅ ECDH | ✅ X25519 |
二、签名算法对比
签名是椭圆曲线最广泛的应用场景。三条曲线对应三种不同的签名方案,核心差异在于随机数处理和消息预处理。
SM2 签名:Z 值是个好设计
SM2 签名最独特的地方是引入了 Z 值(也叫预处理哈希值)。在对消息签名之前,先计算一个 Z 值:
// SM2 Z 值的计算
// Z = SM3(ENTL || ID || a || b || xG || yG || xA || yA)
//
// ENTL: 用户 ID 的比特长度(2 字节)
// ID: 用户标识(默认 "1234567812345678")
// a, b: 曲线参数
// xG, yG: 基点坐标
// xA, yA: 签名者公钥坐标
void sm2_compute_z(uint8_t z[32],
const uint8_t *id, uint16_t id_len,
const sm2_point *pub_key)
{
sm3_ctx ctx;
sm3_init(&ctx);
// ENTL = id_len * 8,大端序 2 字节
uint16_t entl = id_len * 8;
uint8_t entl_bytes[2] = { entl >> 8, entl & 0xFF };
sm3_update(&ctx, entl_bytes, 2);
// 用户 ID
sm3_update(&ctx, id, id_len);
// 曲线参数 a, b 和基点 G
sm3_update(&ctx, SM2_CURVE_A, 32);
sm3_update(&ctx, SM2_CURVE_B, 32);
sm3_update(&ctx, SM2_BASE_X, 32);
sm3_update(&ctx, SM2_BASE_Y, 32);
// 签名者公钥
sm3_update(&ctx, pub_key->x, 32);
sm3_update(&ctx, pub_key->y, 32);
sm3_final(&ctx, z);
}然后实际签名的输入是 \(e = \text{SM3}(Z \| M)\),而不是直接对消息 \(M\) 哈希。Z 值把用户身份和曲线参数绑定进了签名过程,这意味着:
- 防止公钥替换攻击:攻击者不能把 Alice 的签名”移植”到 Bob 的公钥上
- 隐式确认曲线参数:即使两个不同实现使用了不同的曲线参数(比如测试环境的弱曲线),签名也无法跨环境伪造
- 域分离(domain separation):不同用户 ID 产生不同 Z 值,天然隔离
关于 SM3 哈希的内部结构,可以参考 SM3 vs SHA-256 的压缩函数对比。
SM2 签名的核心计算:
// SM2 签名 (r, s) 的计算过程
// 输入: 私钥 dA, 消息摘要 e, 随机数 k
// 输出: 签名 (r, s)
int sm2_sign(const bignum *dA, const uint8_t *digest,
bignum *r, bignum *s)
{
bignum k, x1, tmp;
sm2_point kG;
do {
// 1. 生成随机数 k ∈ [1, n-1]
bn_rand_range(&k, &SM2_N);
// 2. 计算 [k]G = (x1, y1)
sm2_point_mul(&kG, &k, &SM2_G);
bn_from_bytes(&x1, kG.x, 32);
// 3. r = (e + x1) mod n
bn_add(r, &x1, (const bignum *)digest);
bn_mod(r, r, &SM2_N);
// 检查 r = 0 或 r + k = n 则重新生成 k
} while (bn_is_zero(r) || bn_eq_sum(r, &k, &SM2_N));
// 4. s = ((1 + dA)^(-1) * (k - r * dA)) mod n
bn_add_word(&tmp, dA, 1); // tmp = 1 + dA
bn_mod_inv(&tmp, &tmp, &SM2_N); // tmp = (1 + dA)^(-1)
bn_mul(s, r, dA); // s = r * dA
bn_sub(s, &k, s); // s = k - r * dA
bn_mul(s, s, &tmp); // s = (1 + dA)^(-1) * (k - r * dA)
bn_mod(s, s, &SM2_N);
// 清除敏感中间值
bn_clear(&k);
bn_clear(&tmp);
return !bn_is_zero(s); // s = 0 需重新签名
}注意第 3 步:SM2 的 \(r = (e + x_1) \bmod n\),而 ECDSA 是 \(r = x_1 \bmod n\)。这个差异看起来微小,但它使得 SM2 签名的安全性证明更自然——\(r\) 同时依赖消息和随机点,信息”混合”更充分。
ECDSA:PlayStation 3 的教训
ECDSA 的签名计算:
// ECDSA 签名
// r = x1 mod n (仅依赖随机点,不含消息)
// s = k^(-1) * (e + r * dA) mod n
int ecdsa_sign(const bignum *dA, const bignum *e,
bignum *r, bignum *s)
{
bignum k, x1, k_inv;
ec_point kG;
// 1. 随机数 k
bn_rand_range(&k, &SECP256R1_N);
// 2. [k]G = (x1, y1)
ec_point_mul(&kG, &k, &SECP256R1_G);
bn_from_bytes(&x1, kG.x, 32);
// 3. r = x1 mod n(注意:不包含消息 e)
bn_mod(r, &x1, &SECP256R1_N);
// 4. s = k^(-1) * (e + r * dA) mod n
bn_mod_inv(&k_inv, &k, &SECP256R1_N);
bn_mul(s, r, dA);
bn_add(s, s, e);
bn_mul(s, s, &k_inv);
bn_mod(s, s, &SECP256R1_N);
bn_clear(&k);
bn_clear(&k_inv);
return 1;
}ECDSA 对随机数 \(k\) 的质量要求极高。2010 年的 PlayStation 3 事件就是血的教训:索尼在所有 PS3 固件签名中使用了固定的 \(k\) 值。由于 ECDSA 中 \(s = k^{-1}(e + r \cdot d_A) \bmod n\),如果两次签名使用相同的 \(k\),则 \(r\) 值相同,攻击者可以通过两个签名联立方程直接算出私钥 \(d_A\):
\[k = \frac{e_1 - e_2}{s_1 - s_2} \bmod n, \quad d_A = \frac{s_1 k - e_1}{r} \bmod n\]
SM2 同样依赖随机数 \(k\),也面临相同风险。区别在于 SM2 的 Z 值机制提供了一层额外的域分离,但并不能替代安全的随机数生成。
EdDSA (Ed25519):确定性签名
Ed25519 的革命性设计在于完全消除了随机数依赖:
// Ed25519 签名(简化)
// r = SHA-512(prefix || M) 的低 256 位 ← 确定性!
// R = [r]G
// S = (r + SHA-512(R || A || M) * a) mod l
void ed25519_sign(const uint8_t priv[32], const uint8_t *msg,
size_t msg_len, uint8_t sig[64])
{
// 从私钥派生 prefix(a 的 SHA-512 后半部分)
uint8_t h[64];
sha512(priv, 32, h);
// 确定性 nonce:r = SHA-512(h[32..63] || msg)
// 不需要任何外部随机数!
uint8_t nonce[64];
sha512_two_part(h + 32, 32, msg, msg_len, nonce);
bignum r;
bn_from_bytes_le(&r, nonce, 64);
bn_mod(&r, &r, &ED25519_L);
// R = [r]G
ed25519_point R;
ed25519_scalar_mul(&R, &r, &ED25519_G);
// S = (r + H(R || A || M) * a) mod l
// ...
}这是一个根本性的改进:没有随机数就不可能有随机数重用问题。Ed25519 的签名完全由私钥和消息决定,同一消息永远产生同一签名。
签名方案对比
| 特性 | SM2 签名 | ECDSA | Ed25519 |
|---|---|---|---|
| 消息预处理 | \(e = \text{SM3}(Z \| M)\) | \(e = \text{SHA-256}(M)\) | 直接使用 \(M\) |
| 随机数 \(k\) | 需要 | 需要 | 不需要(确定性) |
| \(r\) 的计算 | \(r = (e + x_1) \bmod n\) | \(r = x_1 \bmod n\) | \(R = [r]G\)(点本身) |
| 域分离 | Z 值(含用户 ID) | 无内置机制 | 无内置机制 |
| k 重用风险 | 泄露私钥 | 泄露私钥 | 不存在 |
| 签名可锻造性 | \((r, s)\) 有唯一规范形式 | \((r, n-s)\) 也是有效签名 | 规范化编码 |
| 哈希算法 | SM3(256-bit) | SHA-256 | SHA-512 |
三、加密和密钥交换
SM2 加密:自带非对称加密
SM2 加密(GM/T 0003.4)类似 ECIES(Elliptic Curve Integrated Encryption Scheme),但不需要外部对称加密算法——KDF 和消息异或直接内置:
// SM2 加密流程
// 输入: 明文 M, 接收方公钥 PB
// 输出: 密文 C = C1 || C3 || C2
int sm2_encrypt(const uint8_t *msg, size_t msg_len,
const sm2_point *pub_key,
uint8_t *cipher, size_t *cipher_len)
{
bignum k;
sm2_point C1, S, kPB;
// 1. 生成随机数 k
bn_rand_range(&k, &SM2_N);
// 2. C1 = [k]G(临时公钥,类似 ECDH 的临时密钥)
sm2_point_mul(&C1, &k, &SM2_G);
// 3. [k]PB = 共享秘密点
sm2_point_mul(&kPB, &k, pub_key);
// 4. 用 KDF 派生密钥流
// t = KDF(x2 || y2, klen)
// KDF 内部使用 SM3 迭代哈希
uint8_t kdf_out[msg_len];
sm2_kdf(kPB.x, kPB.y, msg_len, kdf_out);
// 5. C2 = M ⊕ t(密钥流异或明文)
for (size_t i = 0; i < msg_len; i++)
cipher[65 + 32 + i] = msg[i] ^ kdf_out[i];
// 6. C3 = SM3(x2 || M || y2)(完整性校验)
sm3_ctx ctx;
sm3_init(&ctx);
sm3_update(&ctx, kPB.x, 32);
sm3_update(&ctx, msg, msg_len); // 注意:是明文,不是密文
sm3_update(&ctx, kPB.y, 32);
sm3_final(&ctx, cipher + 65);
// 输出: C1(65 bytes) || C3(32 bytes) || C2(msg_len bytes)
encode_point(&C1, cipher); // C1 = 04 || x1 || y1
*cipher_len = 65 + 32 + msg_len;
bn_clear(&k);
return 1;
}注意第 6 步:C3 的计算用的是明文 M 而不是密文 C2。这意味着 SM2 加密是 Encrypt-then-MAC 的变体,但 MAC 的输入是明文。这种设计在学术上被认为不如 Encrypt-then-MAC(密文上计算 MAC)安全,因为解密时必须先解密才能验证完整性。
ECDH + 对称加密:组合方案
P-256 和 Curve25519 本身不定义加密算法。如果需要非对称加密,通常使用 ECIES 组合:
// ECIES 加密(使用 P-256 / X25519)
// 1. 临时密钥对: (k, kG)
// 2. 共享秘密: S = [k]PB(对方公钥)
// 3. 密钥派生: (enc_key, mac_key) = HKDF(S)
// 4. 加密: C = AES-GCM(enc_key, M)
// 5. 输出: kG || C组合方案的优势是每个组件可以独立替换:AES-GCM 可以换成 ChaCha20-Poly1305,HKDF 可以换成 BLAKE2,曲线可以从 P-256 换成 X25519。
X25519 密钥交换 vs SM2 密钥交换
X25519 的密钥交换简洁到极致。在 不到 500 行 C 实现 TLS 1.3 握手 中我们详细拆解了 X25519 在 ECDHE 中的应用,核心就是一次标量乘法:
// X25519 密钥交换
// Alice: a = random(), A = X25519(a, 9) // 9 是基点的 x 坐标
// Bob: b = random(), B = X25519(b, 9)
// 共享秘密: S = X25519(a, B) = X25519(b, A) = [ab]G 的 x 坐标SM2 密钥交换(GM/T 0003.3)则复杂得多,引入了确认步骤和可选的密钥确认哈希:
// SM2 密钥交换(简化)
// 双方各自生成临时密钥对,再通过一个混合公式计算共享秘密。
//
// 核心公式(以 A 方为例):
// x̄A = 2^w + (xA & (2^w - 1)) // w = ⌈log2(n)/2⌉ - 1
// tA = (dA + x̄A · rA) mod n // dA = 长期私钥, rA = 临时私钥
// V = [h · tA](PB + [x̄B]RB) // h = cofactor, PB = 对方长期公钥
// // RB = 对方临时公钥
// 共享秘密 = KDF(xV || yV || ZA || ZB, klen)
void sm2_key_exchange(/* ... 大量参数 ... */)
{
// 步骤比 X25519 多得多:
// 1. 计算 x̄ = 2^w + (x & mask)
// 2. 计算 t = (d + x̄ * r) mod n
// 3. 点乘: V = [h * t](PB + [x̄B]RB) ← 两次点乘
// 4. KDF 派生密钥,输入包含双方 Z 值
// 5. 可选:计算密钥确认哈希 S1, SA
}SM2 密钥交换的复杂性来自它试图在协议层面提供更多安全保证(如身份绑定、密钥确认),而 X25519 选择把这些交给上层协议(如 TLS 1.3 的 Finished 消息)。
“三合一”设计的利弊
| 维度 | SM2 三合一 | ECDSA + ECDH + ECIES 分离 |
|---|---|---|
| 标准数量 | 一个标准覆盖全部 | 至少三个标准 |
| 实现复杂度 | 一条曲线,三套算法 | 每个算法可以选最优曲线 |
| 灵活性 | 曲线绑定,无法单独替换 | 签名用 Ed25519 + 交换用 X25519 |
| 参数管理 | 系统只需一套曲线参数 | 可能需要管理多条曲线 |
| 安全分析 | 一条曲线的安全性影响全部功能 | 风险隔离 |
| 量子迁移 | 一次迁移替换全部 | 需要逐个迁移 |
四、标量乘法实现拆解
椭圆曲线的所有操作最终都归结为一个核心运算:标量乘法——给定点 \(P\) 和整数 \(k\),计算 \([k]P = P + P + \cdots + P\)(\(k\) 次)。这是性能瓶颈,也是侧信道攻击的主要目标。
Double-and-Add:最朴素的算法
标量乘法的基础算法类似于整数的快速幂:
// 从高位到低位的 double-and-add
// 时间复杂度: O(log k) 次点加和点倍
ec_point scalar_mul_naive(const bignum *k, const ec_point *P)
{
ec_point R = POINT_AT_INFINITY;
// 从最高位向最低位扫描 k 的每一位
for (int i = bn_bit_length(k) - 1; i >= 0; i--) {
R = point_double(R); // 每一步都做 double
if (bn_test_bit(k, i)) {
R = point_add(R, *P); // 当前位为 1 时做 add
}
// 当前位为 0 时只做 double —— 侧信道泄漏!
}
return R;
}问题显而易见:if 分支导致 bit=0 和
bit=1
的执行路径不同。通过功耗分析或缓存时序攻击,攻击者可以逐位恢复标量
\(k\)——而 \(k\) 通常就是私钥。
SM2 曲线的 Jacobian 坐标实现
SM2 和 P-256 都使用 Weierstrass 曲线,标准实现使用 Jacobian 坐标 \((X, Y, Z)\) 表示仿射点 \((x, y) = (X/Z^2, Y/Z^3)\),避免昂贵的模逆运算。
参考 simple_gmsm 的实现,SM2 的点倍运算(point doubling)在 Jacobian 坐标下:
// Jacobian 坐标点倍: 2P = (X3, Y3, Z3)
// 当 a = p - 3 时的优化公式(SM2 和 P-256 都适用)
//
// 输入: P = (X1, Y1, Z1)
// 输出: 2P = (X3, Y3, Z3)
void sm2_point_double_jacobian(
bignum *X3, bignum *Y3, bignum *Z3,
const bignum *X1, const bignum *Y1, const bignum *Z1)
{
bignum S, M, T, tmp;
// S = 4 * X1 * Y1^2
bn_sqr(&tmp, Y1); // tmp = Y1^2
bn_mul(&S, X1, &tmp); // S = X1 * Y1^2
bn_lshift(&S, &S, 2); // S = 4 * X1 * Y1^2
bn_mod(&S, &S, &SM2_P);
// M = 3 * X1^2 + a * Z1^4
// 当 a = p - 3 时:
// M = 3 * (X1 - Z1^2)(X1 + Z1^2) ← 关键优化!
// 省去了一次乘法,因为 a * Z1^4 = -3 * Z1^4
// = 3 * X1^2 - 3 * Z1^4
// = 3 * (X1^2 - Z1^4)
// = 3 * (X1 - Z1^2)(X1 + Z1^2)
bignum Z1_sq;
bn_sqr(&Z1_sq, Z1); // Z1^2
bn_mod(&Z1_sq, &Z1_sq, &SM2_P);
bignum diff, sum;
bn_sub(&diff, X1, &Z1_sq); // X1 - Z1^2
bn_add(&sum, X1, &Z1_sq); // X1 + Z1^2
bn_mul(&M, &diff, &sum); // (X1 - Z1^2)(X1 + Z1^2)
bn_mul_word(&M, &M, 3); // 3 * (X1 - Z1^2)(X1 + Z1^2)
bn_mod(&M, &M, &SM2_P);
// X3 = M^2 - 2 * S
bn_sqr(X3, &M);
bn_sub(X3, X3, &S);
bn_sub(X3, X3, &S);
bn_mod(X3, X3, &SM2_P);
// Y3 = M * (S - X3) - 8 * Y1^4
bn_sub(&T, &S, X3);
bn_mul(Y3, &M, &T);
bn_sqr(&tmp, &tmp); // tmp = Y1^4 (tmp 之前是 Y1^2)
bn_lshift(&tmp, &tmp, 3); // 8 * Y1^4
bn_sub(Y3, Y3, &tmp);
bn_mod(Y3, Y3, &SM2_P);
// Z3 = 2 * Y1 * Z1
bn_mul(Z3, Y1, Z1);
bn_lshift(Z3, Z3, 1);
bn_mod(Z3, Z3, &SM2_P);
bn_clear(&S); bn_clear(&M); bn_clear(&T);
bn_clear(&tmp); bn_clear(&Z1_sq);
}\(a = p - 3\) 的优化是关键:把 \(3X_1^2 + aZ_1^4\) 变成 \(3(X_1 - Z_1^2)(X_1 + Z_1^2)\),省去一次完整的 256-bit 乘法。SM2 和 P-256 都选择 \(a = p - 3\) 正是为了这个优化。
Curve25519 的 Montgomery Ladder
Montgomery 形式的曲线有一个优美的性质:可以用只含 x 坐标的公式完成标量乘法,并且天然具有常量时间特性。
// Montgomery Ladder: X25519 标量乘法
// 核心性质: 每一步都执行完全相同的操作,无论 bit 是 0 还是 1
//
// 参考 RFC 7748 的伪代码
void x25519_scalar_mul(uint8_t result[32],
const uint8_t scalar[32],
const uint8_t point[32])
{
// u 坐标(只需要 x 坐标!)
fe25519 u;
fe25519_frombytes(&u, point);
// 两个工作点: (x_2, z_2) 和 (x_3, z_3)
fe25519 x_2, z_2, x_3, z_3;
fe25519_one(&x_2); // x_2 = 1 (即无穷远点)
fe25519_zero(&z_2); // z_2 = 0
fe25519_copy(&x_3, &u); // x_3 = u (输入点)
fe25519_one(&z_3); // z_3 = 1
int swap = 0;
// 从高位到低位扫描标量的每一位
for (int pos = 254; pos >= 0; pos--) {
int bit = (scalar[pos >> 3] >> (pos & 7)) & 1;
// 条件交换:不用 if/else,用位运算
// swap ^= bit 记录累积交换状态
swap ^= bit;
fe25519_cswap(&x_2, &x_3, swap);
fe25519_cswap(&z_2, &z_3, swap);
swap = bit;
// 以下操作对 bit=0 和 bit=1 完全相同
fe25519 A, B, C, D, E, AA, BB, DA, CB;
fe25519_add(&A, &x_2, &z_2);
fe25519_sq(&AA, &A);
fe25519_sub(&B, &x_2, &z_2);
fe25519_sq(&BB, &B);
fe25519_sub(&E, &AA, &BB); // E = AA - BB
fe25519_add(&C, &x_3, &z_3);
fe25519_sub(&D, &x_3, &z_3);
fe25519_mul(&DA, &D, &A);
fe25519_mul(&CB, &C, &B);
// x_3 = (DA + CB)^2
fe25519 sum, diff;
fe25519_add(&sum, &DA, &CB);
fe25519_sq(&x_3, &sum);
// z_3 = u * (DA - CB)^2
fe25519_sub(&diff, &DA, &CB);
fe25519_sq(&z_3, &diff);
fe25519_mul(&z_3, &z_3, &u);
// x_2 = AA * BB
fe25519_mul(&x_2, &AA, &BB);
// z_2 = E * (AA + a24 * E)
// a24 = (A - 2) / 4 = 121665 for Curve25519
fe25519 a24E;
fe25519_mul_121665(&a24E, &E);
fe25519_add(&a24E, &a24E, &AA);
fe25519_mul(&z_2, &E, &a24E);
}
// 最后一次条件交换
fe25519_cswap(&x_2, &x_3, swap);
fe25519_cswap(&z_2, &z_3, swap);
// 结果 = x_2 * z_2^(-1) (唯一的模逆)
fe25519 z_inv;
fe25519_inv(&z_inv, &z_2);
fe25519_mul(&x_2, &x_2, &z_inv);
fe25519_tobytes(result, &x_2);
}Montgomery Ladder 的精妙之处在于
cswap——条件交换通过位运算实现,不依赖分支指令。每一轮循环体的计算量完全相同,不管标量的当前
bit 是 0 还是 1。这是 Curve25519
被认为”天然抗侧信道”的核心原因。
窗口法优化
朴素的逐位扫描每 bit 需要一次 double,当 bit=1 时还需要一次 add。窗口法(windowed method)通过预计算减少 add 次数:
// 固定窗口法 (w=4) 标量乘法
// 预计算: T[i] = [i]P, i = 0, 1, ..., 15
// 将 k 每 4 位一组,每组只做一次查表 + add
void scalar_mul_windowed(ec_point *R, const bignum *k,
const ec_point *P)
{
// 预计算表(16 个点)
ec_point table[16];
table[0] = POINT_AT_INFINITY;
table[1] = *P;
for (int i = 2; i < 16; i++)
point_add(&table[i], &table[i-1], P);
*R = POINT_AT_INFINITY;
int bits = bn_bit_length(k);
// 从高位到低位,每次处理 4 位
for (int i = (bits - 1) / 4 * 4; i >= 0; i -= 4) {
// 4 次 double
for (int j = 0; j < 4; j++)
point_double(R, R);
// 取当前窗口的 4 位值
int idx = bn_get_window(k, i, 4);
// 查表 + add(需要常量时间查表以防侧信道)
ec_point selected;
ct_select(&selected, table, 16, idx); // 常量时间查表
point_add(R, R, &selected);
}
}对于 Weierstrass 曲线(SM2 /
P-256),窗口法是主流优化手段。但 ct_select
必须用常量时间实现(遍历所有表项做条件拷贝),否则查表操作本身就会泄漏窗口值。
标量乘法实现对比
| 特性 | SM2 / P-256(Weierstrass) | Curve25519(Montgomery) |
|---|---|---|
| 坐标系统 | Jacobian \((X, Y, Z)\) | 仅 \(x\) 坐标 \((X, Z)\) |
| 基础算法 | Double-and-Add + 窗口法 | Montgomery Ladder |
| 每 bit 操作 | 1 double + (0或1) add | 1 double + 1 add(固定) |
| 天然常量时间 | ❌ 需要额外保护 | ✅ ladder 结构天然恒定 |
| 点加公式 | 需要 \(y\) 坐标 | 不需要 \(y\) 坐标 |
| 预计算优化 | 窗口法效果好 | 受限于 ladder 结构 |
| 模约简代价 | SM2 较大 / P-256 较小 | 极小(\(2^{255}-19\)) |
| 需要最终模逆 | 是(Jacobian → 仿射) | 是(\(X/Z\)) |
五、安全性和侧信道
SM2 曲线参数的安全论证
SM2 曲线参数由国家密码管理局选定,但并未公开详细的参数生成过程。从数学角度看,SM2 曲线满足所有已知的安全条件:
- 阶为素数(\(h = 1\)):免疫小子群攻击
- 抗 MOV 攻击:嵌入度(embedding degree)足够大
- 抗异常曲线攻击:\(\#E(\mathbb{F}_p) \neq p\)
- 复乘判别式足够大:抗 CM 方法攻击
但”安全”和”可信”是两件事。参数的选择过程不透明意味着无法排除参数中存在理论上未知的结构性弱点。这并不是说 SM2 不安全——而是这种信任模型需要依赖对国家密码管理局的信任。
P-256 的 “Nothing Up My Sleeve” 争议
NIST 的 P-256 曲线参数 \(b\) 来自
SHA-1(seed),但 seed 的来源从未解释。2013 年
Snowden 泄露事件揭示 NSA 在 Dual EC DRBG
随机数生成器中植入了后门,虽然 P-256 曲线本身与 Dual EC DRBG
无关,但这彻底瓦解了密码学界对 NIST 曲线选择过程的信任。
具体的担忧是:如果 NSA
先选定了一个具有某种未知结构弱点的曲线,再反向构造 seed 使得
SHA-1(seed) 恰好产生该曲线的 \(b\) 值——那么 “nothing up my
sleeve” 论证就是一个精心的障眼法。
目前没有人证明 P-256 存在后门,但也没有人能证明它没有。这种不可证伪的状态让密码学界非常不安,也是 Curve25519 迅速获得采用的重要原因。
Curve25519 的设计透明性
Daniel Bernstein 选择 Curve25519 参数的过程完全透明:
- 素数 \(p = 2^{255} - 19\):最接近 \(2^{255}\) 的素数,模运算效率最高
- \(A = 486662\):满足安全条件的最小正整数 \(A\)
- cofactor \(h = 8\):Montgomery 形式下的数学必然结果
每个参数都有明确的、可验证的选择理由,不存在任何隐藏的自由度。这种”rigid” 参数选择被 SafeCurves 项目推广为最佳实践。
侧信道防护难度
| 攻击类型 | SM2 / P-256 | Curve25519 |
|---|---|---|
| 简单功耗分析(SPA) | double-and-add 分支泄漏标量 bit | Montgomery Ladder 天然恒定 |
| 差分功耗分析(DPA) | 窗口法查表可被探测 | 只有一个查表操作(\(a_{24}\) 常量) |
| 缓存时序攻击 | 预计算表引入缓存依赖 | 无查表操作 |
| 电磁辐射分析(EMA) | 需要随机化投影坐标 | Ladder + cswap 大幅降低风险 |
| 故障注入 | 需要点验证(在曲线上?) | cofactor clamp 提供部分保护 |
Weierstrass 曲线要达到 Curve25519 同等级别的侧信道防护,需要额外工程投入:
// Weierstrass 曲线的侧信道防护措施
// 1. 随机化投影坐标(防 DPA)
void randomize_projective(ec_point *P) {
bignum lambda;
bn_rand(&lambda, 256);
bn_mul(&P->X, &P->X, &lambda);
bn_sqr(&lambda, &lambda);
bn_mul(&P->Y, &P->Y, &lambda); // 实际要乘 lambda^3
// ...
}
// 2. 标量随机分割(防 SPA)
// k = k1 + k2, 其中 k1 随机
void scalar_blind(bignum *k1, bignum *k2,
const bignum *k, const bignum *n) {
bn_rand(k1, 128);
bn_sub(k2, k, k1);
bn_mod(k2, k2, n);
}
// 3. 常量时间条件选择(替代 if/else)
void ct_select(ec_point *out, const ec_point *table,
int table_size, int index) {
// 遍历所有表项,用位掩码选择
memset(out, 0, sizeof(*out));
for (int i = 0; i < table_size; i++) {
uint64_t mask = ct_eq(i, index); // i == index ? 0xFFFF... : 0
ct_cmov(out, &table[i], mask);
}
}这些保护措施在 Curve25519 的 Montgomery Ladder 中是不需要的,因为算法结构本身就保证了常量时间和常量操作。
侧信道防护
上面的表格已经列出了攻击类型和对策,但工程落地时的”到底该怎么做”往往比”知道有风险”更关键。这里展开讲几个具体要点。
常量时间标量乘法
SM2/P-256 的 Weierstrass 曲线没有 Montgomery Ladder 的天然保护,必须在软件层面保证常量时间。核心原则:所有依赖私钥/随机数比特的操作,执行路径必须相同。
具体要求: - 禁止
if (bit) point_add()——用条件移动(cmov)替代分支
- 窗口法查表必须遍历整个预计算表,用位掩码选择目标项 -
大数乘法/模约简中禁止提前退出的短路优化 - 坐标转换(Jacobian
→ 仿射)中的模逆要用常量时间的费马小定理法或 Bernstein-Yang
算法
随机数 k 的生成质量
SM2 签名中 \(k\) 的安全性怎么强调都不为过。PlayStation 3 事件(k 重用)是经典反面教材,但即使 \(k\) 不完全重复,只要存在偏差(某些比特总是 0 或 1),Lattice Attack(格攻击)就可以从几百个签名中恢复私钥。
工程建议: 1. 优先使用 RFC 6979 风格的确定性
k:从私钥和消息派生 \(k =
\text{HMAC-DRBG}(d_A, e)\),完全消除随机源依赖。GM/T
0003 标准没有强制要求确定性 k,但也没有禁止——请务必使用。 2.
如果必须用随机数,确保来源是 /dev/urandom
或硬件 TRNG,绝对不要用 rand()
或时间戳。 3. 生成 k
后做模偏差修正:k = k mod (n-1) + 1,确保均匀分布在
[1, n-1]。
Montgomery Ladder vs Double-and-Add
对 SM2/P-256 来说,虽然不能直接用 Curve25519 的 Montgomery Ladder(曲线形式不对),但可以用 co-Z 坐标系的 Joye Ladder 或 完整公式(complete addition formula) 来实现类似的常量时间效果:
- Joye Ladder:类似 Montgomery Ladder 的思路,但适用于 Weierstrass 曲线。每一步做一次 doubling 和一次 addition,不依赖标量 bit 分支。
- Complete addition formula:Renes-Costello-Batina 在 2016 年提出的统一点加公式,点加和点倍用同一个公式,天然消除分支差异。代价是每次操作多几次域乘法。
OpenSSL / GmSSL 实现建议
| 库 | SM2 侧信道防护现状 | 建议 |
|---|---|---|
| OpenSSL 3.x | 使用 EC_GROUP_new_by_curve_name(NID_sm2) +
EC_KEY_set_flags(EC_FLAG_COFACTOR_ECDH);标量乘法走
ec_scalar_mul_ladder(Ladder
实现),有基本常量时间保护 |
确保编译时 OPENSSL_NO_EC2M 未影响 SM2
路径;生产环境开启 OPENSSL_ia32cap 检查 |
| GmSSL 3.x | 原生 SM2 实现,标量乘法使用窗口法 +
bn_ct_select(常量时间选择) |
检查 sm2_sign 是否使用了确定性 k;建议开启
-DENABLE_SM2_BLINDING 编译选项 |
| 铜锁 Tongsuo | 继承 OpenSSL EC 实现,额外优化了 SM2 点乘路径 | 推荐用于生产环境;注意
enable-ec_nistp_64_gcc_128 开关对 SM2
无效(P-256 专用) |
Rust libsm / smcrypto |
社区实现,侧信道防护参差不齐 | 审计标量乘法是否使用常量时间操作;不建议直接用于高安全场景 |
底线原则:如果你的代码里有
if (bit == 1) point_add(...)这种结构——无论是自己写的还是库里的——请立即修复。这是最容易被侧信道攻击利用的模式。
生态互操作性
SM2 的”三合一”设计很完整,但生态支持是另一回事。下面这张表总结了 SM2 在主流密码库/语言中的支持现状:
| 库 / 语言 | SM2 签名/验签 | SM2 密钥交换 | SM2 加密 | 成熟度 |
|---|---|---|---|---|
| OpenSSL 3.x | ✅ 通过 EVP 接口 | ✅ ECDH(需配置 NID_sm2) | ❌ 无原生支持 | ⭐⭐⭐⭐ 生产可用 |
| BoringSSL | ❌ | ❌ | ❌ | — 无计划支持 |
| Go crypto | ❌ 标准库无 | ❌ | ❌ | ⭐ 需第三方库
tjfoc/gmsm |
| Rust ring | ❌ | ❌ | ❌ | — 无计划支持 |
| Rust rustls | ❌ | ❌ | ❌ | — 依赖 ring,同上 |
| Java BouncyCastle | ✅ SM2Signer |
✅
SM2KeyExchange |
✅ SM2Engine |
⭐⭐⭐⭐ 最完整的非 C 实现 |
| Python cryptography | ✅ (3.x+ 通过 OpenSSL 后端) | ❌ 无直接 API | ❌ | ⭐⭐ 签名可用,其余需手写 |
| Node.js crypto | ❌ 原生不支持 | ❌ | ❌ | ⭐ 需 sm-crypto
npm 包 |
| GmSSL | ✅ 原生 | ✅ 原生 | ✅ 原生 | ⭐⭐⭐⭐ 纯国密首选 |
| 铜锁 Tongsuo | ✅ 原生 | ✅ 原生 | ✅ 原生 | ⭐⭐⭐⭐⭐ 生产级 OpenSSL 兼容 |
几个关键观察:
- BoringSSL 和 ring 完全不支持 SM2——这意味着 Chrome、Cloudflare 的 TLS 栈、Rust 生态的主流 TLS 库都无法原生使用 SM2。这是国密 TLS 互联网推广的最大障碍。
- Java 的 BouncyCastle 是非 C 生态中最完整的 SM2 实现——签名、密钥交换、加密三合一全部支持。如果你的项目是 Java/Kotlin,BouncyCastle 是最省心的选择。
- Go 标准库不支持 SM2——但
github.com/tjfoc/gmsm和github.com/emmansun/gmsm两个社区库提供了较完整的支持。生产使用前需要做安全审计。 - “通过 OpenSSL 后端支持”的库(如 Python cryptography)只能使用 SM2 签名——因为 OpenSSL 3.x 本身的 SM2 加密和密钥交换支持有限。
性能对比
以下数据基于 64-bit x86 平台(带 ADX/BMI2 指令)的典型实现,单位:微秒/操作:
| 操作 | SM2(通用实现) | P-256(优化实现) | X25519 / Ed25519 |
|---|---|---|---|
| 密钥生成 | ~120 μs | ~50 μs | ~45 μs |
| 签名 | ~130 μs | ~60 μs | ~55 μs |
| 验签 | ~300 μs | ~150 μs | ~120 μs |
| 密钥交换 | ~200 μs | ~80 μs | ~50 μs |
| 模约简 | 通用算法 | Solinas 快速约简 | 一次乘法 + 加法 |
SM2 的性能劣势主要来自两方面:
- 模数没有特殊结构:P-256 的 Solinas 素数和 Curve25519 的 \(2^{255}-19\) 都允许极高效的模约简,SM2 的通用素数只能用 Montgomery 乘法
- 缺乏主流硬件优化:Intel/AMD 的密码学指令集没有专门针对 SM2 优化,而 P-256 和 X25519 在 OpenSSL 中有手写汇编实现
不过在国产硬件平台(如飞腾、鲲鹏)上,部分芯片提供了 SM2 专用加速指令,性能差距会显著缩小。
六、生态和选型
SM2 的国内生态
SM2 在中国的金融、政务和电信领域已经是强制要求:
- 金融支付:PBOC 3.0 银行卡规范要求 SM2 签名
- 数字证书:国密 SSL 证书(GM/T 0024)使用 SM2 密钥对
- 电子政务:电子签章、电子发票等系统强制国密算法
- VPN:IPSec VPN 的国密版本使用 SM2 密钥交换
国密 TLS(TLCP,GM/T 0024-2014)在 TLS 1.1 的基础上引入了 SM2/SM3/SM4 密码套件,但在协议设计上远不如 TLS 1.3 精简。这是一个值得关注的演进方向。
主流国密实现:
- GmSSL:北大开源项目,API 兼容 OpenSSL
- Tongsuo(铜锁):蚂蚁集团开源的 OpenSSL fork,支持国密和 TLS 1.3
- simple_gmsm:纯 C 教学实现,代码量小,适合理解算法原理
ECDSA / X25519 的全球生态
ECDSA 和 X25519 是全球互联网的基础设施:
- TLS 1.3:X25519 是首选密钥交换算法,ECDSA(P-256) 是最常见的证书签名算法
- SSH:Ed25519
已成为推荐的密钥类型(
ssh-keygen -t ed25519) - 区块链:Bitcoin 和 Ethereum 使用 secp256k1(P-256 的”表亲”)
- 信号协议:Signal / WhatsApp 端到端加密使用 X25519
从硬件支持来看,Intel 的 MULX/ADCX/ADOX 指令对 P-256 和 X25519 的优化已经非常成熟,ARM 的 NEON 指令集同样有高度优化的实现。
量子计算威胁下的未来
无论 SM2、P-256 还是 Curve25519,在量子计算面前都同样脆弱——Shor 算法可以在多项式时间内解决椭圆曲线离散对数问题。后量子密码学(PQC)是所有椭圆曲线方案的共同宿命。
目前的迁移策略是混合模式:在同一握手中同时使用经典算法(如 X25519)和后量子算法(如 ML-KEM / Kyber),任一算法未被攻破即保持安全。中国也在 SM9(标识密码)和格基密码方面有自己的研究路线。
关于后量子密码的详细讨论,参考 后量子密码学与抗量子算法。
选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 中国金融/政务系统 | SM2 | 合规要求 |
| 面向全球的互联网服务 | X25519 + Ed25519 | 生态最完善,侧信道防护最好 |
| 需要非对称加密 | SM2 加密 或 ECIES(X25519) | SM2 有标准化方案,ECIES 灵活性更好 |
| 跨境业务 | 双算法支持 | 同时支持国密和国际算法 |
| 新项目起步 | X25519/Ed25519 + 密码敏捷性 | 预留算法替换接口,为 PQC 迁移做准备 |
写在最后
SM2 是一个工程上完整、数学上安全的方案。它的”三合一”设计在合规场景下减少了选择焦虑,Z 值机制在协议层面提供了 ECDSA 没有的域分离。但它的生态相比 ECDSA/X25519 仍然薄弱,性能在通用平台上也有差距,参数选择过程的不透明是一个持续的信任成本。
从纯技术角度,Curve25519 系列是目前最优雅的设计:Montgomery Ladder 天然抗侧信道,参数选择完全透明,\(2^{255}-19\) 的模约简快到极致。这也是为什么 TLS 1.3、Signal、WireGuard 等现代协议都以它为首选。
但密码学从来不只是数学和代码——标准、生态和政策同样重要。在可见的未来,SM2 和 ECDSA/X25519 将在各自的领域长期共存,直到量子计算把它们一起送入历史。在那之前,写好常量时间的标量乘法实现,可能比争论哪条曲线更优更有实际意义。