SM2 vs ECDSA/X25519:椭圆曲线的国产方案到底怎么样

0 阅读32分钟

SM2 vs ECDSA/X25519:椭圆曲线的国产方案到底怎么样

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 短形式曲线 y2=x3+ax+by^2 = x^3 + ax + b,由国家密码管理局选定参数。模数 pp 和阶 nn 都是 256-bit 素数,cofactor h=1h = 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=p3a = p - 3,这和 P-256 一样,是为了优化 Jacobian 坐标下的点加运算(后面会详细讲)。

P-256 / secp256r1

NIST 在 1999 年选定的曲线,同样是 Weierstrass 短形式。模数 p=22562224+2192+2961p = 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

bb 的值来自 SHA-1(seed) 的输出,seed 为 c49d3608 86e70493 6a6678e1 139d26b7 819f7e90。NIST 声称这是 "nothing up my sleeve" 数——用哈希输出证明参数不是后门。但 SHA-1 的输入本身没有解释来源,这一点后来引发了巨大争议。

Curve25519

Daniel Bernstein 在 2006 年发表的曲线,使用 Montgomery 形式 By2=x3+Ax2+xBy^2 = x^3 + Ax^2 + x,其中 A=486662A = 486662B=1B = 1。素数 p=225519p = 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=8h = 8 意味着曲线的完整群阶是 8n8n,密钥交换时需要将输入点乘以 8(或 clamp 私钥的低 3 位为 0)来避免小子群攻击。这在 X25519 的协议设计中已经内置处理。

参数对比

特性SM2P-256Curve25519
曲线形式Weierstrass y2=x3+ax+by^2 = x^3 + ax + bWeierstrass y2=x3+ax+by^2 = x^3 + ax + bMontgomery y2=x3+Ax2+xy^2 = x^3 + Ax^2 + x
域素数 pp256-bit(通用素数)256-bit(Solinas 素数)2255192^{255}-19(Mersenne-like)
nn256-bit 素数256-bit 素数2252\approx 2^{252}
cofactor hh118
aap3p - 3p3p - 3N/A(Montgomery A=486662A = 486662
模约简效率通用 Montgomery 乘法快速 Solinas 约简极快(一次 × 38 + add
参数来源国家密码管理局选定SHA-1(seed),seed 来源不明最小满足安全条件的 AA
签名是 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=SM3(ZM)e = \text{SM3}(Z \| M),而不是直接对消息 MM 哈希。Z 值把用户身份和曲线参数绑定进了签名过程,这意味着:

  1. 防止公钥替换攻击:攻击者不能把 Alice 的签名"移植"到 Bob 的公钥上
  2. 隐式确认曲线参数:即使两个不同实现使用了不同的曲线参数(比如测试环境的弱曲线),签名也无法跨环境伪造
  3. 域分离(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+x1)modnr = (e + x_1) \bmod n,而 ECDSA 是 r=x1modnr = x_1 \bmod n。这个差异看起来微小,但它使得 SM2 签名的安全性证明更自然——rr 同时依赖消息和随机点,信息"混合"更充分。

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 对随机数 kk 的质量要求极高。2010 年的 PlayStation 3 事件就是血的教训:索尼在所有 PS3 固件签名中使用了固定的 kk 值。由于 ECDSA 中 s=k1(e+rdA)modns = k^{-1}(e + r \cdot d_A) \bmod n,如果两次签名使用相同的 kk,则 rr 值相同,攻击者可以通过两个签名联立方程直接算出私钥 dAd_A

k=e1e2s1s2modn,dA=s1ke1rmodnk = \frac{e_1 - e_2}{s_1 - s_2} \bmod n, \quad d_A = \frac{s_1 k - e_1}{r} \bmod n

SM2 同样依赖随机数 kk,也面临相同风险。区别在于 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 签名ECDSAEd25519
消息预处理e=SM3(ZM)e = \text{SM3}(Z \| M)e=SHA-256(M)e = \text{SHA-256}(M)直接使用 MM
随机数 kk需要需要不需要(确定性)
rr 的计算r=(e+x1)modnr = (e + x_1) \bmod nr=x1modnr = x_1 \bmod nR=[r]GR = [r]G(点本身)
域分离Z 值(含用户 ID)无内置机制无内置机制
k 重用风险泄露私钥泄露私钥不存在
签名可锻造性(r,s)(r, s) 有唯一规范形式(r,ns)(r, n-s) 也是有效签名规范化编码
哈希算法SM3(256-bit)SHA-256SHA-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
参数管理系统只需一套曲线参数可能需要管理多条曲线
安全分析一条曲线的安全性影响全部功能风险隔离
量子迁移一次迁移替换全部需要逐个迁移

四、标量乘法实现拆解

椭圆曲线的所有操作最终都归结为一个核心运算:标量乘法——给定点 PP 和整数 kk,计算 [k]P=P+P++P[k]P = P + P + \cdots + Pkk 次)。这是性能瓶颈,也是侧信道攻击的主要目标。

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 的执行路径不同。通过功耗分析或缓存时序攻击,攻击者可以逐位恢复标量 kk——而 kk 通常就是私钥。

SM2 曲线的 Jacobian 坐标实现

SM2 和 P-256 都使用 Weierstrass 曲线,标准实现使用 Jacobian 坐标 (X,Y,Z)(X, Y, Z) 表示仿射点 (x,y)=(X/Z2,Y/Z3)(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=p3a = p - 3 的优化是关键:把 3X12+aZ143X_1^2 + aZ_1^4 变成 3(X1Z12)(X1+Z12)3(X_1 - Z_1^2)(X_1 + Z_1^2),省去一次完整的 256-bit 乘法。SM2 和 P-256 都选择 a=p3a = 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, Y, Z)xx 坐标 (X,Z)(X, Z)
基础算法Double-and-Add + 窗口法Montgomery Ladder
每 bit 操作1 double + (0或1) add1 double + 1 add(固定)
天然常量时间否 需要额外保护是 ladder 结构天然恒定
点加公式需要 yy 坐标不需要 yy 坐标
预计算优化窗口法效果好受限于 ladder 结构
模约简代价SM2 较大 / P-256 较小极小(2255192^{255}-19
需要最终模逆是(Jacobian → 仿射)是(X/ZX/Z

五、安全性和侧信道

SM2 曲线参数的安全论证

SM2 曲线参数由国家密码管理局选定,但并未公开详细的参数生成过程。从数学角度看,SM2 曲线满足所有已知的安全条件:

  • 阶为素数h=1h = 1):免疫小子群攻击
  • 抗 MOV 攻击:嵌入度(embedding degree)足够大
  • 抗异常曲线攻击#E(Fp)p\#E(\mathbb{F}_p) \neq p
  • 复乘判别式足够大:抗 CM 方法攻击

但"安全"和"可信"是两件事。参数的选择过程不透明意味着无法排除参数中存在理论上未知的结构性弱点。这并不是说 SM2 不安全——而是这种信任模型需要依赖对国家密码管理局的信任。

P-256 的 "Nothing Up My Sleeve" 争议

NIST 的 P-256 曲线参数 bb 来自 SHA-1(seed),但 seed 的来源从未解释。2013 年 Snowden 泄露事件揭示 NSA 在 Dual EC DRBG 随机数生成器中植入了后门,虽然 P-256 曲线本身与 Dual EC DRBG 无关,但这彻底瓦解了密码学界对 NIST 曲线选择过程的信任。

具体的担忧是:如果 NSA 先选定了一个具有某种未知结构弱点的曲线,再反向构造 seed 使得 SHA-1(seed) 恰好产生该曲线的 bb 值——那么 "nothing up my sleeve" 论证就是一个精心的障眼法。

目前没有人证明 P-256 存在后门,但也没有人能证明它没有。这种不可证伪的状态让密码学界非常不安,也是 Curve25519 迅速获得采用的重要原因。

Curve25519 的设计透明性

Daniel Bernstein 选择 Curve25519 参数的过程完全透明:

  1. 素数 p=225519p = 2^{255} - 19最接近 22552^{255} 的素数,模运算效率最高
  2. A=486662A = 486662:满足安全条件的最小正整数 AA
  3. cofactor h=8h = 8:Montgomery 形式下的数学必然结果

每个参数都有明确的、可验证的选择理由,不存在任何隐藏的自由度。这种"rigid" 参数选择被 SafeCurves 项目推广为最佳实践。

侧信道防护难度

攻击类型SM2 / P-256Curve25519
简单功耗分析(SPA)double-and-add 分支泄漏标量 bitMontgomery Ladder 天然恒定
差分功耗分析(DPA)窗口法查表可被探测只有一个查表操作(a24a_{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 签名中 kk 的安全性怎么强调都不为过。PlayStation 3 事件(k 重用)是经典反面教材,但即使 kk 不完全重复,只要存在偏差(某些比特总是 0 或 1),Lattice Attack(格攻击)就可以从几百个签名中恢复私钥。

工程建议:

  1. 优先使用 RFC 6979 风格的确定性 k:从私钥和消息派生 k=HMAC-DRBG(dA,e)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)否:无原生支持4/5:生产可用
BoringSSL无计划支持
Go crypto否:标准库无1/5:需第三方库 tjfoc/gmsm
Rust ring无计划支持
Rust rustls依赖 ring,同上
Java BouncyCastle是:SM2Signer是:SM2KeyExchange是:SM2Engine4/5:最完整的非 C 实现
Python cryptography是:3.x+ 通过 OpenSSL 后端否:无直接 API2/5:签名可用,其余需手写
Node.js crypto否:原生不支持1/5:需 sm-crypto npm 包
GmSSL是:原生是:原生是:原生4/5:纯国密首选
铜锁 Tongsuo是:原生是:原生是:原生5/5:生产级 OpenSSL 兼容

几个关键观察:

  1. BoringSSL 和 ring 完全不支持 SM2——这意味着 Chrome、Cloudflare 的 TLS 栈、Rust 生态的主流 TLS 库都无法原生使用 SM2。这是国密 TLS 互联网推广的最大障碍。
  2. Java 的 BouncyCastle 是非 C 生态中最完整的 SM2 实现——签名、密钥交换、加密三合一全部支持。如果你的项目是 Java/Kotlin,BouncyCastle 是最省心的选择。
  3. Go 标准库不支持 SM2——但 github.com/tjfoc/gmsmgithub.com/emmansun/gmsm 两个社区库提供了较完整的支持。生产使用前需要做安全审计。
  4. "通过 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 的性能劣势主要来自两方面:

  1. 模数没有特殊结构:P-256 的 Solinas 素数和 Curve25519 的 2255192^{255}-19 都允许极高效的模约简,SM2 的通用素数只能用 Montgomery 乘法
  2. 缺乏主流硬件优化: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 天然抗侧信道,参数选择完全透明,2255192^{255}-19 的模约简快到极致。这也是为什么 TLS 1.3、Signal、WireGuard 等现代协议都以它为首选。

但密码学从来不只是数学和代码——标准、生态和政策同样重要。在可见的未来,SM2 和 ECDSA/X25519 将在各自的领域长期共存,直到量子计算把它们一起送入历史。在那之前,写好常量时间的标量乘法实现,可能比争论哪条曲线更优更有实际意义。