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 签名之前先哈希了什么:
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,不崩溃,不挂起
代价
- 可移植性。依赖国密预编译合约的合约在公链以太坊上运行不了;假定 secp256k1 密钥和 Keccak 地址的钱包和库,要先改造才能和这条链交互。
- 配对的时间。SM9 验证是链上最贵的操作,远高于其他操作,它的 gas 价格也如实反映了这一点。
- 密钥托管。SM9 是基于标识的:密钥生成中心持有主密钥,能推导出任何人的私钥。这是方案本身的性质,不是实现的问题,但必须向部署它的人说明。
几点体会
一个标准很少能和它替换的 API 一一对应;接口应该按数学本身来设计,而不是沿用旧的函数名,ecrecover 就是一个例子。任何需要私钥或随机数的操作都应该留在链下,不管放到链上有多方便。共识引擎里的外部函数边界就是安全边界:放在那里的检查,决定一笔坏交易能不能让节点停下来。