4. SM2迁移实战:把OpenSSL的ECDSA签名换成SM2签名

好,这一章咱们直接动手干活。前几章讲了SM2的数学原理和国密标准,今天就来硬的——把OpenSSL里现成的ECDSA签名流程,一步步换成SM2签名。你可能会问:直接调OpenSSL的EVP_PKEY_CTX不行吗?嗯,理论上能调,但真正的坑在细节里。我习惯把这类迁移拆成三步:改参数、换算法、调解析。下面咱们一个个说。

ECDSA → SM2 签名迁移核心逻辑 ECDSA 密钥生成 参数:NID_X9_62_prime256v1 签名:ECDSA_sig / DER 替换为 SM2 密钥 参数:NID_sm2 签名:SM2_sig / ASN1 SM2 密钥生成 曲线+杂凑预计算 签出 (r,s) 含 Z 值 关键差异点 ② 签名输出格式:DER vs 原始 (r,s) ③ 验签需重新计算 Z 值 ① 曲线参数不同(sm2 vs prime256v1) ④ 杂凑方式需带用户标识

4.1 替换前你要知道的差异

说实话,ECDSA和SM2在签名算法层面非常像——都是椭圆曲线数字签名,都输出(r, s)。但细节上完全不同。我刚开始做迁移时,以为把NID_X9_62_prime256v1改成NID_sm2就完事了,结果验签一直失败。后来才发现SM2不仅要改曲线,还要改杂凑计算方式。

维度ECDSASM2
曲线参数secp256r1 / P-256国密SM2推荐曲线(p、a、b、G、n固定)
杂凑输入仅原始消息mm + Z值(用户标识+公钥+曲线参数摘要)
签名格式DER编码的 ECDSA-Sig-ValueASN.1编码的 SM2Signature(r || s共64字节)
验签过程直接验签先计算Z值,再验签

核心变化一句话:SM2的杂凑步骤把「用户身份」绑进了签名。你这个公钥是谁的,就得用谁的用户标识算一遍Z值。别偷懒,我之前偷懒跳过Z值计算,结果测了一个月没发现问题——直到上线前夕被合作方发现签名验证不通,我那个星期差点住在机房。

4.2 关键步骤:从ECDSA到SM2的替换路线

好,咱们直接看代码层面的替换。我习惯用OpenSSL 1.1.1+的EVP_PKEY API做,因为它统一了ECDSA和SM2的接口。如果你还在用老API,嗯,老实说,我建议你先升级。

步骤1:创建密钥上下文

原来是这么写的:

EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL);

改SM2后变成:

EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_SM2, NULL);

注意,这里的EVP_PKEY_SM2是OpenSSL 1.1.1才加的。我见过有同事在1.0.2上硬写,结果链接失败,升级完直接能用。所以如果你还在用1.0.2,老老实实升到1.1.1+,别再挣扎了。

步骤2:设置曲线参数

ECDSA时代:

EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, NID_X9_62_prime256v1);

SM2时代:

EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx, NID_sm2);

这里有个小坑:NID_sm2在OpenSSL里默认不带椭圆曲线参数,它只是告诉库「我要用SM2标准曲线」。但你必须确保底层曲线已注册。有些定制版OpenSSL可能没编译SM2曲线进去,我曾在某国产芯片SDK里遇到过,配了NID_sm2后返回「curve not supported」。解决办法是手动加载SM2曲线对象,或者在编译时加-DOPENSSL_SM2。

步骤3:签名前的杂凑(Z值计算)

ECDSA直接对消息做SHA256即可。SM2却要先算Z值:

SM2_get_z_digest(group, pub_key, user_id, user_id_len, digest);

然后才是:

EVP_DigestUpdate(ctx, digest + 0, 32);  // Z值作为杂凑的一部分
EVP_DigestUpdate(ctx, message, msg_len); // 原始消息

我个人建议把Z值计算封装成一个工具函数,因为它在签名和验签两端都要调用。别问为什么我这么建议——我当年就是签名时算了Z值,验签时忘了重新算,结果死磕了两天。

步骤4:签名与输出处理

ECDSA:

ECDSA_sign(0, dgst, 32, sig, &siglen, eckey);

SM2:

SM2_sign(NID_sm3, dgst, 32, sig, &siglen, eckey);

注意SM2签名输出是64字节的(r, s)纯二进制,而ECDSA输出是DER编码。这一点很容易被忽视——如果你下游代码硬性要求DER格式,就需要手动把(r, s)包装成ASN.1序列。我做过的项目里,有80%的互操作问题都是格式不匹配造成的。

我的习惯做法:迁移初期先打印sig的十六进制对比,确认长度和结构。ECDSA的DER通常在70-72字节,SM2的原始签名固定64字节。一眼就能看出差异。

4.3 常见错误与避坑指南

下面这几个坑我几乎每个迁移项目都见过,咱们一个个过。

  • 错误一:Z值计算遗漏——最经典的坑。签名时算了一次,验签时忘了重新算。我曾经在一个产品里信誓旦旦说「我们做了SM2签名」,结果验签用的还是ECDSA的裸消息杂凑,导致自签名自验签通过,但跟别人家互验直接崩。
  • 错误二:用户标识长度不统一——SM2标准里用户标识默认是"1234567812345678"(16字节),但有些实现用空字符串。你想想看,双方用的用户标识不一样,Z值肯定对不上。我建议在项目文档里明确写死用户标识值,并在上线前做双向验证。
  • 错误三:签名时带了签名参数——ECDSA签名时可以设置ec_param_enc等参数,但SM2禁止额外编码。如果你把ECDSA的参数设定习惯带过来,会导致签名数据被错误解析。记得把EVP_PKEY_CTX_set_signature_md设置的摘要模式改为EVP_sm3()而不是SHA256。
  • 错误四:忽略OpenSSL版本差异——同样一个NID_sm2,在1.1.1a和1.1.1k上的行为可能不同(特别是某些社区补丁打的有问题)。我习惯在代码里加一行版本断言:if (OPENSSL_VERSION_NUMBER < 0x1010100fL) ...,低于1.1.1的直接报错。

⚠️ 特别警告:SM2的签名结果(r, s)会泄露椭圆曲线的部分信息吗?理论上不会,但实际实现中如果随机数k不够随机,就会重复r值。我在一次压力测试中发现,单线程连续签名1000次,r值出现了两次完全一样——因为底层随机数种子被重复初始化。解决办法:调用前务必RAND_poll()一次,或者使用硬件真随机数。

4.4 一张表总结迁移清单

我把整个替换过程浓缩成下面这张检查清单,你做完一个勾一个,保证不漏。

步骤操作常见错误
1. 密钥生成EVP_PKEY_CTX_new_id(EVP_PKEY_SM2)用了旧EVP_PKEY_EC,未升级OpenSSL
2. 曲线设定NID_sm2 + 检查曲线是否注册曲线未编译进库,返回错误
3. 用户标识统一写死(建议16字节)双方标识不一致
4. Z值计算SM2_get_z_digest 签名&验签各一次漏算Z值
5. 摘要算法EVP_sm3() 替代 SHA256用了SHA256,不匹配SM3
6. 签名输出64字节二进制 (r,s)误当DER解析,长度不对
7. 验签输入重新计算Z值 + 原始消息直接传签名原文,未带Z值

最后说一句心里话:迁移SM2签名并不难,难的是把你的代码逻辑从ECDSA的惯性中拉出来。Z值、用户标识、SM3——这三个改变会渗透到签名、验签、密钥交换、证书签发等各个环节。
我个人习惯的做法:先写一个纯SM2的单测(自签名自验签),过了再跟标准实现(如GmSSL或参考实现)做互操作测试。这一步千万别省,你省了,将来省不了。

嗯,这一章就到这里。你的SVG图里那几个红色节点和绿色节点,就是每一步要动的地方。只要按清单走,基本不会翻车。真遇到妖孽问题,欢迎来群里问,我大概率遇到过。