勤学苦链
首发于勤学苦链
[block #20] 详解比特币地址 #1:P2SH地址与multisig

[block #20] 详解比特币地址 #1:P2SH地址与multisig

本文延续《[block #14] 详解比特币地址 #0:P2PKH地址和WIF》,技术性偏强,目标读者是程序员。


上一篇介绍了首字符是“1”的P2PKH(Pay-to-Public-Key-Hash)地址。它由一个公钥的哈希构成。任何人都可以向它转入资金,但只有掌握对应的私钥才能转出资金。转出时需要出示交易的私钥签名和公钥。秘钥本身并不透露。


P2PKH的上锁逻辑直接写入交易的输出,脚本形式是:

DUP HASH160 PUSHDATA(目标公钥哈希) EQUALVERIFY CHECKSIG

PUSHDATA中包含了20字节的公钥哈希(公钥→SHA256→RIPEMD160)。


对应的解锁脚本是:

PUSHDATA(签名) PUSHDATA(公钥)

第一个PUSHDATA包含用DER编码的椭圆曲线签名。长度通常是71、72或73字节,也有小概率会更短。第二个PUSHDATA包含的则是公钥。压缩形式的公钥长度33字节,非压缩形式长65字节。为了减少交易大小和交易费,通常都采用压缩公钥。


解锁脚本的验证步骤如下所示。方括号表示虚拟机的堆栈状态,栈顶在尾部:

   // 首先运行解锁脚本
0: [] // 初始状态:空堆栈
1: [签名] // PUSHDATA 入栈签名
2: [签名, 公钥] // PUSHDATA 入栈公钥
   // 复制堆栈后运行上锁脚本
3: [签名, 公钥, 公钥] // DUP 复制栈顶公钥
4: [签名, 公钥, 公钥哈希] // HASH160 取出栈顶公钥,压入它的哈希
5: [签名, 公钥, 公钥哈希, 目标公钥哈希] // PUSHDATA 入栈目标公钥哈希
6: [签名, 公钥] // EQUALVERIFY 取出栈顶两个哈希,确认它们等值,否则验证失败
7: [TRUE] // CHECKSIG 取出栈顶的公钥和签名,验证交易,入栈检查结果
   // 栈顶为真值则验证成功


由此可见,P2PKH缺乏灵活性,无法支持复杂的需求,比如允许三个人中任意两个动用资金之类。为了丰富比特币的功能,BIP-16改进方案在2012年引入了首字符是“3”的P2SH(Pay-to-Script-Hash)地址。它常被谬称为多重签名(multi-signature或multisig)地址,因为multisig都以这种形式实现,但P2SH还能用于其他加密方式。今年八月激活的隔离见证(SegWit)也常以P2SH包裹,做到向后兼容。


P2SH的灵活性来自于它巧妙的设计——交易的输出并不像P2PKH那样直接保存上锁脚本,而是记录20字节的脚本哈希,形式是这样的:

HASH160 PUSHDATA(目标脚本哈希) EQUAL

光根据脚本哈希,外人无从推断出解锁的逻辑。等我们转账时才需要出示与该哈希对应的原始脚本,并且保证脚本的运行结果为真。


P2SH最常用于M-of-N多重签名,也就是在预定的N个公钥中,给出M个相应的签名就能通过验证(1 ≤ M ≤ N)。我们来看个2-of-3 multisig的具体例子。


先生成三组私钥和压缩形式的公钥。这个过程通常由三个人私下完成,各自保管自己的私钥:

私钥A:
L2iQ55QcE6otmfUZPLYU2Qf5SP41HhCtPEKu2v3r16vHZ19MT7Sn
公钥A:
020FA7BED1B89DF218A2ED2C94EBBF872A7BDA0F48D231EB8CB6F16B87D9BB5211

私钥B:
L37gNgdvmLJuLNre3EASs2ETkEvrUn8AShviJTD2TfNaqWiEgwRT
公钥B:
02D7E287092457F2BEA226CD7537C5EE99AF50CCA923795A2EA65CF249F783C5D1

私钥C:
Kwd8QPXS1LZAGmqJDvb5emRtEPJuVjzXX2FRUZrYERMTt5uz58Vr
公钥C:
02E8B48F3C0A7C452792FA96CDCF2FC6A23298F4D6512BD8AA9A25210B66A1D450


用这三个公钥就能组合成2-of-3上锁脚本:

2 PUSHDATA(公钥A) PUSHDATA(公钥B) PUSHDATA(公钥C) 3 CHECKMULTISIG

其中的2和3分别表示向堆栈压入数字2和3的操作。CHECKMULTISIG用于检查堆栈上的两个签名是否对应三个公钥中的两个。


以上脚本编译后的结果共计105字节,十六进制表示如下:

5221020fa7bed1b89df218a2ed2c94ebbf872a7bda0f48d231eb8cb6f16b87d9
bb52112102d7e287092457f2bea226cd7537c5ee99af50cca923795a2ea65cf2
49f783c5d12102e8b48f3c0a7c452792fa96cdcf2fc6a23298f4d6512bd8aa9a
25210b66a1d45053ae


对它先求SHA256,得到32字节的哈希:

6feec0468378f4a1f0c8dbccfd41f6041eaf0185d22d029d2dea4603ed8c94bf


再对以上结果做RIPEMD160,得到20字节的哈希:

c19ba54b40598eab41f636b4b5c3fe6493dddd64


这就是P2SH上锁脚本中PUSHDATA里的目标脚本哈希。加上固定前缀0x05后作Base58Check编码便得到了最终的、首字符是“3”的P2SH地址:

3KLio22Epg8nrXfzFBuoHWwxFPCkpFzYFo


转账时需要提供两个签名和原始脚本,解锁形式如下:

0 PUSHDATA(签名D) PUSHDATA(签名E) PUSHDATA(脚本)


解锁过程比P2PKH稍复杂一些:

   // 首先运行解锁脚本
0: [] // 初始状态:空堆栈
1: [0] // 0 被压入栈顶
2: [0, 签名D] // PUSHDATA 入栈第一个签名
3: [0, 签名D, 签名E] // PUSHDATA 入栈第二个签名
4: [0, 签名D, 签名E, 脚本] // PUSHDATA 入栈完整脚本
   // 复制堆栈后运行上锁脚本
5: [0, 签名D, 签名E, 脚本哈希] // HASH160 取出栈顶脚本,压入它的哈希
6: [0, 签名D, 签名E, 脚本哈希, 目标脚本哈希] // PUSHDATA 入栈目标脚本哈希
7: [0, 签名D, 签名E, TRUE] // EQUAL 取出栈顶两个哈希,判断是否相等,压入检测结果
   // 取出栈顶结果,如果非真,则验证失败,否则运行原始脚本
8: [0, 签名D, 签名E] // 准备运行原始脚本
9: [0, 签名D, 签名E, 2] // 2 被压入栈顶
10: [0, 签名D, 签名E, 2, 公钥A] // PUSHDATA 入栈第一个公钥
11: [0, 签名D, 签名E, 2, 公钥A, 公钥B] // PUSHDATA 入栈第二个公钥
12: [0, 签名D, 签名E, 2, 公钥A, 公钥B, 公钥C] // PUSHDATA 入栈第三个公钥
13: [0, 签名D, 签名E, 2, 公钥A, 公钥B, 公钥C, 3] // 3 被压入栈顶
14: [0, 签名D, 签名E, 2, 公钥A, 公钥B, 公钥C] // CHECKMULTISIG 先取出公钥数N(3)
15: [0, 签名D, 签名E, 2] // 接着取出N(3)个公钥
16: [0, 签名D, 签名E] // 再取出签名数M(2)
17: [TRUE] // 接着取出M(2)个签名和数字0,根据交易验证签名,压入检查结果
    // 栈顶为真则验证成功


细心的读者会发现最早入栈的数字零完全冗余,无助于签名验证。这是因为CHECKMULTISIG的代码实现存在一个bug,调用时会从堆栈上多取出一个数字。取出后它被立刻丢去,并不用于之后的运算。这个数具体是什么值无关紧要,可堆栈上不能缺失它,不然无法正常执行CHECKMULTISIG。为了保证共识机制的向后兼容,每个解锁脚本就多压入一个零,而CHECKMULTISIG实现代码里的错误只能年复一年地遗留下来,不通过硬分叉再也无法修复。


比特币的多重签名如此直截了当都出了off-by-one错误,以太坊那样复杂许多的系统中自然有更多雷区,一不小心就爆出Parity多重签名那样的大bug(参阅《[block #18] 用一美元冻结一个亿》)。我们都应当以此为戒,视复杂性为安全性的大敌

编辑于 2017-11-12

文章被以下专栏收录

    勤学苦链(Chain-Shackle-Link)是一个偏重加密货币、智能合约等区块链技术主题的专栏