2023 到 2024 年,我在北京大学重庆大数据研究院做区块链。这条链要进政务和运营商的试点,合规要求明确:签名用 SM2,对称加密用 SM4,基于标识的方案用 SM9,哈希统一用 SM3。它的 EVM 继承自以太坊,预编译合约建立在 secp256k1、SHA-256 和 alt_bn128 配对曲线上。我的工作是用 GmSSL 把椭圆曲线相关的预编译合约在国密曲线上重做一遍,再为国密算法新增一组预编译合约。

这看起来像一次机械替换:换曲线,换哈希,接口不动。实际上一部分确实可以这样搬过去,另一部分不能。这篇按这个分法来写。

示例 —— 重做了什么,新增了什么
在国密曲线上重做    点加、标量乘                     →  SM2 的 256 位素域曲线
                    配对检查(乘积是否为 1)         →  SM9 的 BN 曲线
新增                SM2 签名验证、SM3 哈希、SM4 加密 / 解密、
                    SM9 签名验证、SM9 配对(返回配对值本身,SM9 验签要用)
底层                GmSSL(C):每个原语只有一份实现

一、为什么是预编译合约

原则上这些都能用 Solidity 写。没有人这么做,理由和以太坊当年一样:一次椭圆曲线运算在 EVM 字节码里要花上百万 gas,配对检查则完全不可行。预编译合约是挂在固定地址上的原生代码,有自己的 gas 公式;EVM 像调用普通合约一样调用它,由验证节点以原生速度执行。对我来说更重要的是第二个理由:Solidity 库意味着一份由我们自己写、没有经过外部检验的 SM2 新实现;预编译合约则让链上每个合约都调用同一份实现——GmSSL 的实现,它对照过标准公布的测试向量,也被很多项目用过。

二、能照搬的部分:曲线运算和编码

点加和标量乘在 SM2 的 256 位素域曲线上重新实现;配对检查搬到 SM9 用的曲线上,即一条 256 位的 Barreto–Naehrig 曲线,用 R-ate 配对。输入输出的编码沿用以太坊:坐标是定宽 32 字节大端整数,无穷远点全零,畸形输入直接失败而不做猜测。这样原本给 0x06 到 0x08 编码调用的工具可以照原样继续用。

同样照搬的还有一条:算术之前先验证点。calldata 里的每一个点,在传给 GmSSL 之前都要验证它在曲线上;配对第二个群里的点还要验证它在正确的子群里。对不在曲线上的点做配对,就是经典的无效曲线攻击;预编译合约尤其不能省这项检查,因为链上每个合约都会调用它。

三、照搬不了的部分:ecrecover

以太坊的第一个预编译合约 ecrecover,输入哈希和签名,返回签名者的地址;Solidity 里很多身份验证模式建立在它上面。我原以为把 secp256k1 换成 SM2、接口原样保留就行。问题出在 SM2 签名之前先哈希了什么:

示例 —— SM2 签名前的两次哈希(GB/T 32918)
Z_A  =  SM3( ENTL_A ‖ ID_A ‖ a ‖ b ‖ x_G ‖ y_G ‖ x_A ‖ y_A )
e    =  SM3( Z_A ‖ M )

签名者自己的公钥 (x_A, y_A) 和标识 ID_A 都进了摘要。从签名恢复公钥在数学上仍然可行:拿到 e 之后由 r 反推出 x1,再由 t = r + s 解出 P_A = t⁻¹(R − [s]G),和 secp256k1 一样只需要一个 recovery id 来区分候选点。但这样恢复出来的公钥和 ID 之间没有任何绑定:调用方传进来的 e 是不是用这把公钥、这个 ID 算出来的,链上无从核对。要核对,就得把 M 和 ID 也传进来重算一遍 e,而这已经比直接验证更贵。所以这个预编译合约做成了验证而不是恢复:输入公钥、签名者标识、消息和签名,返回真或假。原本靠恢复来推导身份的合约都得改写:先把公钥传进去,验证通过后再从公钥推导地址。

标识字符串是第二个问题。大多数实现默认使用标准给出的值 1234567812345678,而且不作说明;这个预编译合约把 ID 作为显式输入并写明默认值,因为用一个 ID 做出的签名,换一个 ID 验证就会失败,而这种失败和伪造签名看起来没有区别。

四、留在链下的部分:签名

SM2 和 SM9 签名都要取一个随机数。如果把签名做成预编译合约,每个验证节点会对同一笔交易算出不同的签名,共识就无法达成。换成 RFC 6979 那种确定性的 nonce 也解决不了问题:签名要用私钥,而私钥一旦进了 calldata 就永远留在账本上。所以没有做任何签名预编译合约:验证、哈希、点运算和配对在链上执行,签名留在私钥所在的地方。

SM4 是边界情况。密钥和 IV 都由调用方提供时,同一种分组模式下的加解密是确定性的,可以做成预编译合约,但文档必须写明:任何放进 calldata 的东西都永远留在账本上,密钥也不例外。链上的 SM4 是给已经在链下管理好密钥的协议用的基础组件,不是隐私功能。如果一个预编译合约让人误以为它是隐私功能,这本身就是一个安全隐患。

五、gas 重新测量

定价过低的预编译合约就是一个拒绝服务原语:一笔交易花很少的 gas,却占用验证节点大量时间。以太坊的配对预编译合约从一开始(EIP-197)就定成一个基础费用加每对费用;后来实现变快了,EIP-1108 又把两个数字调低。我沿用了同样的结构和同样的方法。每个操作都用 GmSSL 在验证节点的硬件上做基准测试,再相对于现有的 gas 表定价:SM2 签名验证是一个基础费用加按消息长度计的哈希费用,SM3 和 SM4 按输入长度线性计费,SM9 配对是基础费用加每对费用。最终的数字我记不准确,这里不引用。方法是:先测量,按最坏情况定价,库或硬件变了就重新测量。

六、C 边界上的检查

GmSSL 是 C 写的,虚拟机不是,所以每一次预编译调用都要跨一道外部函数边界,而这道边界在共识引擎里。边界上的规则很简单:输入先检查长度,再复制进定长缓冲区;库里的任何错误都不允许变成进程崩溃;每一种失败——畸形的点、长度不对的密钥——都变成预编译合约失败:耗尽 gas,什么也不返回,和以太坊曲线预编译合约(EIP-196、EIP-197)的规定一致。解码器全部做过模糊测试,因为如果一段 calldata 就能让验证节点崩溃,任何有钱包的人都能让整条链停下来。

示例 —— 每个预编译合约都要过的测试
已知答案      GB/T 标准里 SM2、SM3、SM4、SM9 自带的测试向量
差分          同样的输入走一遍 GmSSL 命令行,输出逐字节一致
反例          曲线外的点、错误子群的点、被截断的输入、错误的 ID  →  失败,绝不返回错误的"真"
模糊          对每个解码器灌随机 calldata,不崩溃,不挂起

代价

几点体会

一个标准很少能和它替换的 API 一一对应;接口应该按数学本身来设计,而不是沿用旧的函数名,ecrecover 就是一个例子。任何需要私钥或随机数的操作都应该留在链下,不管放到链上有多方便。共识引擎里的外部函数边界就是安全边界:放在那里的检查,决定一笔坏交易能不能让节点停下来。