4. SM2迁移实战:把OpenSSL的ECDSA签名换成SM2签名
好,这一章咱们直接动手干活。前几章讲了SM2的数学原理和国密标准,今天就来硬的——把OpenSSL里现成的ECDSA签名流程,一步步换成SM2签名。你可能会问:直接调OpenSSL的EVP_PKEY_CTX不行吗?嗯,理论上能调,但真正的坑在细节里。我习惯把这类迁移拆成三步:改参数、换算法、调解析。下面咱们一个个说。
4.1 替换前你要知道的差异
说实话,ECDSA和SM2在签名算法层面非常像——都是椭圆曲线数字签名,都输出(r, s)。但细节上完全不同。我刚开始做迁移时,以为把NID_X9_62_prime256v1改成NID_sm2就完事了,结果验签一直失败。后来才发现SM2不仅要改曲线,还要改杂凑计算方式。
| 维度 | ECDSA | SM2 |
|---|---|---|
| 曲线参数 | secp256r1 / P-256 | 国密SM2推荐曲线(p、a、b、G、n固定) |
| 杂凑输入 | 仅原始消息m | m + Z值(用户标识+公钥+曲线参数摘要) |
| 签名格式 | DER编码的 ECDSA-Sig-Value | ASN.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图里那几个红色节点和绿色节点,就是每一步要动的地方。只要按清单走,基本不会翻车。真遇到妖孽问题,欢迎来群里问,我大概率遇到过。