2023 到 2024 年在北京大学重庆大数据研究院,手上的任务是一条许可链:它的 EVM 必须跑在国密算法上,才能进政务和运营商的试点。这台虚拟机继承了以太坊的预编译合约,而那一套建立在 secp256k1、SHA-256 和 alt_bn128 配对曲线之上。合规要求的是 SM2 做签名、SM4 做对称加密、SM9 做基于标识的方案,底下统一用 SM3 做哈希。我用 GmSSL 把椭圆曲线相关的预编译合约在国密曲线上重做了一遍,又为国密算法本身新增了一组预编译合约。下面是真正要紧的几个决定。

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

一、为什么是预编译合约,而不是 Solidity 库

上面这些原则上都能用 Solidity 写。没有人这么做,理由和以太坊当年一样:一次椭圆曲线运算在 EVM 字节码里要花几百万 gas,配对检查则根本做不下来。预编译合约是挂在固定地址上的原生代码,有自己的 gas 公式;EVM 像调用普通合约一样调用它,验证节点以机器速度执行。对我来说更要紧的是第二个理由。Solidity 库意味着一份由我们自己写、没有任何人审计过的 SM2 新实现;预编译合约则让链上每一个合约都调用同一份实现——GmSSL 的实现,它被拿去对照标准公布的测试向量的次数,比我们那一份一辈子会被读的次数都多。

二、ecrecover 没有 SM2 的对应物

以太坊的第一个预编译合约 ecrecover,输入哈希和签名,返回签名者的地址;Solidity 里一半的身份验证模式建立在它上面。我原以为把 secp256k1 换成 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) 进了摘要。要验一个签名,你必须先知道它是谁的,所以“从签名里恢复公钥”是循环论证。于是这个预编译合约变成了验证而不是恢复:输入公钥、签名者标识、消息和签名,返回真或假。原本靠恢复来推导身份的合约都得改写:先把公钥传进去,验证通过后再从公钥推导地址。标识字符串是第二个坑。标准的默认值 1234567812345678 是大多数实现默认假定的;这个预编译合约把它作为显式输入并写明默认值,因为在一个 ID 下做出的签名换一个 ID 就会静默地失效,而这种失效看起来和伪造签名一模一样。

三、曲线预编译合约搬到国密曲线上

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

真正要紧的检查发生在算术之前。calldata 里到达的每一个点,在碰到 GmSSL 之前都要验证它在曲线上,对配对的第二个群还要验证它在正确的子群里。对一个不在曲线上的点做配对,就是经典的无效曲线攻击;而预编译合约是最不该把这扇门留着的地方,因为链上每一个合约都站在它后面。

四、只有确定性的操作才能上链

SM2 和 SM9 签名都要取一个随机数。把签名放进预编译合约,每个验证节点会对同一笔交易算出不同的签名,共识就裂开了。所以根本没有签名预编译合约:验证、哈希、点运算和配对在链上,签名留在私钥所在的地方。SM4 是边界情况。只要调用方提供密钥和 IV,加解密就是确定性的,可以做成预编译合约,但文档必须把不好听的话说明白:任何放进 calldata 的东西都永远留在账本上。链上的 SM4 调用是给那些已经在链下管理好密钥的协议用的积木,不是隐私功能;一个让人误以为它是隐私功能的预编译合约,本身就是一个隐患。

五、gas 跟着原生开销走

定价过低的预编译合约就是一个拒绝服务原语:一笔交易花很少的 gas,却烧掉验证节点大量时间。以太坊为此吃过亏,在 EIP-1108 里把配对预编译合约重新定价为一个基础费用加每对费用。我沿用了同样的形状。每个操作都用 GmSSL 在验证节点的硬件上做基准测试,再相对于现有的 gas 表定价:签名验证是常数,SM4 和哈希按输入长度线性计费,SM9 配对是基础费用加每对费用。最终的数字我不凭记忆引用。要紧的是方法:先测量,按最坏情况定价,库或硬件变了就重新测量。

六、C 语言边界就是安全边界

GmSSL 是 C 写的,虚拟机不是,所以每一次预编译调用都要跨一道外部函数边界。这道边界上的规矩严格而无趣。输入先检查长度,再复制进定长缓冲区。库里的任何错误都不允许变成进程崩溃;每一种失败——畸形的点、长度不对的密钥——都变成预编译合约失败:耗尽 gas,什么也不返回,这正是 EVM 的规定。解码器全部做过模糊测试,因为一个能被 calldata 弄崩的验证节点,意味着任何有钱包的人都能让整条链停下来。

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

代价

我带走的东西

一个标准很少能和它替换的 API 一一对应;接口要跟着数学走,而不是跟着旧函数名走,ecrecover 就是证明。任何取随机数的东西都留在链下,不管放到链上有多方便。共识引擎内部的一道外部函数边界,就是一道安全边界:住在那里的检查,才是让一笔坏交易停不掉任何一个节点的东西。