Api Design Practices
0x00
工作内容里,签名验签模块在重构后出现了一个偶现 bug,个别账号签名的数据验签失败。
重构的时候单元测试覆盖过功能测试(私钥-公钥-签名-验签)是可以的,修改测试循环大量次数跑后,发现生成1万次随机私钥,会有60-80次会出现验签失败的问题。
打印出来后出错的 case 后,发现共有的特点是,32 bytes 的私钥里,出错的都是第一个 byte 为 0 (hex: 0x00) 的私钥。一个随机账号出错的概率大概就是 \(\frac{1}{256}\) 。
所以原因大概率就是前置0 在存储使用的过程中丢失了的原因。进一步定位代码找到了出错的地方。
先描述一下相关接口方法的使用。
0x01
签名验签使用的是 openssl
的库来实现其数学计算过程,其中私钥 32 bytes = 256 bit 是通过 BigNumber
(简写为 BN
) 来保存的。在 BN
对象和其可读形式(无论是 hex 编码还是 base64编码)的转换过程中,第一步都要把 BN
转换成对应的 bytes 数据。
openssl 在 BN
相关的接口里也提供了对应的转换方式:
方法传入两个参数,第一个是入参 BN *
,第二个是出参 u8 *
,返回值表示转换的长度。
非常的 C 风格,经验丰富的 coder 也会马上注意到,出参 to 指针指向的结果对象,其内存是由调用者来管理的,也就是说在调用之前我就需要明确申请好足够的空间来存放结果。但是我怎么知道 BN * in
会转换出多少 bytes 呢?
对此 openssl 还提供了另外一个方法:
传入 BN *
, 返回其对应的 bytes 长度。
0x02
所以使用方法大致如下:
过程应该很简单清晰,获取长度 - 申请内存 - 调用转换方法。
但是应该怎么处理 BN_bn2bin
方法的返回值呢?
0x03
这个返回的整数值,在某些类似场合下(比如读写文件)其含义或许是:实际写入的字节数。但是在这里,它一定是 BN
的实际 bytes 长度。其实现也就是如此:
返回值 n
也和我们调用 BN_num_bytes
获取长度得到的结果必定是一样的。那我们判断返回值 if (BN_bn2bin(bn, binary_result) != len)
的意义是什么?
想要调用 BN_bn2bin
,必定要先调用 BN_num_bytes
,那 BN_bn2bin
的返回值我在使用前已经知道了。
所以实践上可能还仅剩的意义大概是:在 debug 里多判断一次断言,release 下可以直接放过这个返回值了:
0x04
上面解释后也可以看出 BN_bn2bin
设计初衷就是一个通用的转换方式,所以按照上述的封装方式一定会得到一个通用的序列化方法。
而对于私钥这种定长(32 bytes)的大整数,不可避免地出现有前置0的情况,比如 值为 0x00ff..ffff
,经过上面的操作后得到的 binary_result
的长度是 31,如果在存储的时候没有考虑到这种情况,复制的时候就可能整体左移了一 bytes,变成了 0xffff..ff00
私钥的数值放大了256倍,都不是同一把私钥,自然无法验签了。这也就是最初遇到的 bug 的原因了。
所以最好是把通用序列化和定长序列化区分开来。额外封装出能够自动补齐0的接口来。这里就不再展开了。
0x05
允许对内存的精确掌控也必然要求使用者足够仔细和周全。对于开发一个提供给他人使用的库来说,或许可以做的更好。例如这个例子里,通用的序列化方法和定长的序列化方式都可以提供出来,意义很弱的长度返回值也可以用现代语言里的容器取代。
接口层面可能如下(随手写的举个例子,返回值 Vec<u8>
仅做表示意义):
0x06
原因相关但是跑题的感想:💩⛰ 代码重构起来,要么彻底了解前后背景设计原因,要么做好准备定位分析奇奇怪怪的问题。