28 日上午 9:00(美国太平洋时间 27 日下午 5:00),ArcBlock Technical Learning Series 第十期“深入浅出 HD 钱包基本原理”, 由 ArcBlock 资深前端工程师 王仕军 讲授。
# 分层确定性钱包 HD Wallet 剖析:设计和实现
你真的了解钱包么?
了解区块链、或者持有数字货币(比如比特币和以太坊)的同学可能都知道把数字货币存在自己的钱包,目前市面上的钱包应用非常多,有支持单链的、支持多链的,有手机 APP、有网页、有桌面客户端,还有浏览器插件如 MetaMask。绝大多数钱包应用在创建钱包时千叮咛万嘱咐让你做好备份的助记词是怎么回事儿?为什么这些钱包自称是 HD 钱包?为什么说泄露了助记词就丢失了所有的币?为什么用单个助记词可以产生、控制很多个账户?助记词究竟是怎么产生私钥的?安全性有没有保障?破解的难度究竟有多大?如果你有耐心读完本文,相信这些问题的答案都会了然于胸。废话少说,接下来我们就用层层递进、抽丝剥茧的方式去了解下分层确定性钱包的设计和实现。
重新认识钱包
分层确定性钱包常被简写成 HD Wallet,简写来源于 Hierarchical Deterministic Wallet,如果要彻底搞清楚什么是 Hierarchical Deterministic Wallet,从语言层面来看这个名词性短语:· Hierarchical 是形容词· Deterministic 是形容词· Wallet 是名词搞清楚每个词语在技术上的含义、设计动机,对于我们理解 HD 钱包非常有帮助。
钱包里面到底有没有币?
确切的说,任何区块链钱包里面都没有币,里面有的只是私钥、公钥对,可能有点反常识,但是事实确实如此。钱包可以包含任意数量的私钥、公钥对,其中私钥可以用来签名交易,从而把这个私钥能控制的币花出去,但是币本身是存在区块链大账本上的。把区块链钱包理解为钥匙串可能更形象些,因为钥匙并不是你的资产,而是控制资产的凭据。
在技术性的区块链文章里面,私钥、公钥、地址都是有特定的符号表示的,如下图:
· k,私钥,通常是随机产生,需要绝对保密的· K,公钥,由私钥通过椭圆曲线乘法运算而来,不可倒推出私钥,可以不保密· A,地址,由公钥通过单向哈希运算而来,不可倒推出公钥,是完全公开的
什么是确定性钱包?
比特币早期的钱包客户端 Satoshi Client 里面会自动随机生成 100 个私钥、公钥对,这些私钥之间完全没有关联,这种钱包也叫做随机钱包(Random Wallet)或者非确定性钱包(Non-Deterministic Wallet),钱包的备份和恢复必须针对每个私钥进行。
如果能随机产生一个种子,然后根据这个种子去生成一系列的私钥、公钥对,这样钱包的备份就会容易很多,因为只需要备份随机的种子就行了,这种根据随机种子按确定规则生成一系列钱包的方式就叫做种子钱包(Seeded Wallet)或确定性钱包(Deterministic Wallet),种子钱包在生成多个私钥时会用到序号作为参数,所以这种钱包也叫线性确定性钱包(Sequential Deterministic Wallet)。
种子钱包解决了备份的问题,但是还是不完美,没有办法把钱包的一部分共享出去给别人管理,但同时自己保有知情权、控制权。社区的智慧是无穷的,分层确定性钱包应运而生:
因为生成的钱包结构是有层次的,所以就被叫做 Hierarchical Deterministic Wallet:· 树状的钱包结构可以让钱包的组织方式更加灵活,或者赋予其现实世界的意义,比如可以用单个 HD 钱包来管理组织的所有资产;· 每个节点都会有私钥、公钥,也可以派生出更多的子节点;· 树状结构中的某个分支及其子树可以根据实际需要共享出去;· 备份和恢复只需要关心主节点;
分层确定性钱包的设计和实现
现如今 HD 钱包俨然已经成为事实上的行业标准,知道 HD 钱包的含义之后,我们来看看他的设计和实现。HD 钱包的想法最早出现在比特币社区,而比特币社区里面提出新功能、流程、改进建议都有标准化的流程,发起者需要用文档的形式把内容书面化,提交给社区去讨论、论证,这种文档就叫做 BIP(Bitcoin Improvement Proposal),比特币社区甚至连 BIP 本身该如何工作也写成了 BIP,定义 BIP 格式、工作流的的元 BIP 见此(https://github.com/bitcoin/bips/blob/master/bip-0002.mediawiki),而和 HD 钱包紧密关联的几个 BIP 如下:· BIP32: HD 钱包的核心提案,说明了自私钥生成方法以及树壮结构的构造方式;· BIP43: 为 HD 钱包子私钥派生路径增加有广泛共识的段;· BIP44: 确定支持多链 HD 钱包子私钥派生路径的标准格式;我们先来看 BIP32,其中定义了如下两个内容:· 根据父节点公(私)钥匙派生子节点公(私)钥的算法;· 将派生出来的钥匙对组织成树状结构的方法;在 BIP32 中根据父节点去派生子节点的方法被称作 Child Key Derivation Function,简称为 CKD,CKD 根据如下 3 个参数去生成子节点:· 父节点私钥或者公钥(Parent Private/Public Key)· 父节点链码(Parent Chain Code)· 子节点序号(Child Index)为了保证生成过程不可逆,CKD 会用到单向哈希函数 HMAC-SHA512,整个 HD Wallet 树里面的任何节点都可以有私钥、公钥,都具有如下的性质:· 各节点的私钥和随机生成的私钥并没有明显的区分· 节点私钥可以用来推导出节点公钥,进而推导出账户地址· 节点私钥可以用来签名交易· 至于节点之间的父子、兄弟关系在 HD 钱包之外完全是无感的
如何生成子私钥?
根据父节点私钥(Parent Private Key)生成子节点私钥(Child Private Key)的流程如下图:
· 根据父节点私钥和椭圆曲线乘法推导出父节点公钥(Parent Public Key);· 把父节点公钥、父节点链码、子节点序号作为参数求 HMAC-SHA512 得到 512 位输出;· 把步骤 2 的输出拆分为两个等长的 256 位串,分别标记为 L、R;· 把步骤 3 的输出 L 和父节点私钥做运算得到子节点私钥(Child Private Key);· 把步骤 3 的输出 R 当做子节点链码(Child Chain Code);子节点私钥、子节点链码可以作为输入传给 CKD,就可以生成孙节点,以及任意深度的节点。子私钥生成函数在 BIP32 中被标记为:
如何生成子公钥?
根据父节点公钥生成子节点公钥的流程如下图:
· 把父节点公钥、父节点链码、子节点序号作为参数求 HMAC-SHA512 得到 512 位输出;· 把步骤 1 的输出拆分为两个等长的 256 位串,分别标记为 L、R;· 把步骤 2 的输出 L 和父节点公钥做运算得到子节点公钥(Child Public Key);· 把步骤 2 的输出 R 当做子节点链码(Child Chain Code);子私钥生成函数在 BIP32 中被标记为:
节点私钥和子节点公钥的生成过程如果用 JS 代码实现,核心逻辑如下:
HDKey.prototype.deriveChild = function(index) { var indexBuffer = Buffer.allocUnsafe(4); indexBuffer.writeUInt32BE(index, 0); var data = Buffer.concat([this.publicKey, indexBuffer]); var I = crypto.createHmac('sha512', this.chainCode).update(data).digest(); var IL = I.slice(0, 32); var IR = I.slice(32); var hd = new HDKey(this.versions); if (this.privateKey) { hd.privateKey = secp256k1.privateKeyTweakAdd(this.privateKey, IL); } else { hd.publicKey = secp256k1.publicKeyTweakAdd(this.publicKey, IL, true); } hd.chainCode = IR; hd.index = index; return hd;};
如果你读到这里可能心里已经产生 N 多疑问:Chain Code 到底是什么东西?引入它有什么好处?每个父节点到底能生成多少个子节点呢?既然确定性钱包是从种子开始的,上面只是提到了从父节点开始生成子节点,怎么和种子关联上?请继续往下读。
为什么要有 Chain Code?
钱包安全的核心在私钥,而公钥则比较容易被找到,如果子节点生成过程只依赖父节点公钥和子节点序号,那么黑客拿到父节点公钥之后就能复原出所有子节点、孙节点的公钥,这样就会破坏隐私性,CKD 里面引入的 Chain Code 则是在整个子节点派生过程中引入确定的随机数,为 HD 钱包的隐私性增加了一重保障。
什么是 Extended Key?
因为在子节点生成过程中会同时用到父节点公钥和父节点链码,BIP32 里面约定把两者拼接再做特定结构编码产生的结果叫做 Extended Key,也叫做可扩展的钥匙,顾名思义就是根据 Extended Key 我们就可以开始派生子节点。父节点公钥、私钥和链码结合产生的 Extended Key 分别是:· Extended Private Key = Private Key + Chain Code, 标记为 xpriv,可用于派生子节点私钥和公钥· Extended Public Key = Public Key + Chain Code, 标记为 xpub,只能用于派生出子节点公钥因为从 Extended Key 可以解出父节点私钥、公钥和链码,可以说 Extended Key 代表了 HD 钱包中某个分支、子树的根或者起点,也正是因为这种特性,对 Extended Key 的数据保密要格外小心。
什么是 Master Key?
定义清楚 CKD 之后我们该从哪里开始生成节点呢?必须得有个主节点,主节点的生成有两种可能的方案:· 随机生成 512 位的随机数开始,拆分为两个 256 位的数字,分别作为主节点私钥和主节点链码,而后递归的生成子节点,虽然这种方式生成的随机数有 2^512 个,但是在生成主节点私钥的时候只用到了 256 位,实际上主节点私钥的可能取值就缩小到 2^256 个;· 随机的生成特定位数的随机数,位数越大越好,然后将该随机数进行 HMAC-SHA256 计算,得到 512 位的哈希,将其拆分为主节点私钥和链码,根据单项哈希函数的性质,只要随机数种子不同,得到的哈希值也会不同,私钥也会不同,这样生成 HD 钱包主节点的私钥就可以有更大的值域空间、更好的随机性;第 2 中方案的流程可以用下图来表示(其中主节点私钥在 BIP32 中被称为 Master Private Key,主节点链码被称为 Master Chain Code):
安全增强的 CKD 函数
因为区块链钱包里面保存的私钥能转移用户的资产,对安全性再怎么强调都不为过,对于上面的子节点私钥和公钥生成函数是否足够安全呢?我们设想下面的场景:
· 如果黑客知道了父节点的公钥和链码,那么他可以生成所有子节点、孙节点的公钥、地址,这样会严重破坏 HD 钱包的隐私性;· 如果黑客在上面的基础上知道了某个孙节点的私钥,那么所有重孙节点的私钥都能被推导出来,父节点的私钥也可能被推导出来,这样整个 HD 钱包就沦陷了;如果安全问题是没有办法彻底避免的,如何在某个子节点私钥泄露的时候把破坏性降到最低呢?这就需要对 CKD 函数稍作改进,在 BIP32 中称之为 安全增强的子私钥派生函数,记为 HCKD(Hardened Child Key Derivation),原来的 CKD 函数(Normal Child Key Derivation)和安全增强的 CKD 函数流程对比如下图:
安全增强的 CKD 函数产生的节点属性称呼也响应的发生变化:· 增强的子节点私钥:Hardened Child Private Key· 增强的子节点公钥:Hardened Child Public Key,只能根据增强的子节点私钥推导而来不同点在于,安全增强的 CKD 函数中子节点私钥的生成不再使用父节点公钥,而是直接使用父节点私钥,因为相比私钥而言公钥更容易被黑客截获,这样必须在有父节点私钥的情况下才能推导出子节点私钥,只靠父节点的公钥和链码不能推导出增强的子节点公钥。这样子节点之间的兄弟关系就不那么容易被获悉,而即使某个增强子节点私钥泄露,也不会影响到父节点。BIP32 约定 CKD 函数的节点序号取值范围在 0 ~ 2^31 之间,而 HCKD 的节点序号在 2^31 ~ 2^32 之间,这样每个节点就可以生成 2^32 个子节点。
节点派生路径标记
理论上 HD 钱包中任何节点的生成都会有路径,因为我们能找到从主节点到该节点的不同深度各节点的序号,这样我们就可以用统一的路径符号来标记每个节点,在做节点派生的时候也只需要声明路径即可。举几个常见派生过程和对应的派生路径:· CKDpriv(CKDpriv(CKDpriv(m,3),2),5) => m/3/2/5· CKDpriv(CKDpriv(CKDpriv(m,3H),2H),5H) => m/3'/2'/5'· CKDpub(CKDpub(CKDpub(m,0),0),0) => M/0/0/0其中 m 表示私钥,而 M 表示公钥。m/3/2/5 表示从主节点派生出来的第 4 个子节点的第 3 个孙节点的第 6 个重孙节点,派生过程中使用的是 CKD 函数,而 m/3'/2'/5' 则表示派生过程中使用的是 HCKD 函数。知道每个节点的派生路径之后,通过合并路径相同前缀的方法,不难得到如下的树状 HD 钱包节点结构图:
为什么需要 BIP44?
显然,BIP32 的在钱包安全性、易用性方面做了比较不错的平衡,但是不同的钱包应用开发者可以自定义自己的节点结构,这就很容易导致没有办法 100% 保证在使用了 HD 钱包 A 的用户能将自己的种子导入到 HD 钱包 B 中还能正常工作;也没有办法保证 HD 钱包能支持多个链的私钥管理。因为这个原因,比特币社区在 BIP32 的基础上提出了比较范的 BIP43 和比较具体的 BIP44,两者的目的在于就 HD 钱包子节点派生路径的模式、每段的含义上做出具体的规定,形成共识,事实上现如今的 HD 钱包都遵循了 BIP32 和 BIP44 的规定,也只有遵循了这两个规范的钱包应用才是大概率完全兼容的。春秋战国时期的中国不同小国的文字、马车轮距不同导致了较高的社会交易成本,秦始皇统一六国之后实施了“车同轨、书同文”的政策,BIP44 之于 BIP32 的作用和 “车同轨、书同文” 政策的效果非常类似,也正是他俩的结合才让 BIP 钱包成了事实上的行业标准。
BIP44 的内容相比 BIP32 就简单很多,里面规定了子节点派生路径的范式:m / purpose' / coin_type' / account' / chain / address_index示例如下:m/44'/60'/0'/0/0每个段的含义分别是:· CKD: m: 使用 CKDpriv, M 则表示使用 CKDPub· Purpose: 44' , hardened, 遵循哪个规范, 44 意味着 BIP44· Coin: 60', hardened, 60 指代以太坊, 完整的链代码(https://github.com/satoshilabs/slips/blob/master/slip-0044.md)· Account: 0' , hardened, 账户编号· Chain: 0 , 对于非比特币路径都是 0· Index: 0, 具体的账户节点
如何让钱包更加用户友好?
讲到这里,HD 钱包的基本原理已经都理清了,但是开篇提到的助记词是咋回事儿呢?
互联网发展了 20 多年,所有的互联网用户都熟悉了账户要输入密码的同时,生成、设置、记住密码对人来来说却变的很难,因为为了安全需要设置很复杂的密码,但是复杂的密码却不是那么容易记住。区块链钱包管理的私钥可以认为是随机生成的密码,有没有办法让这个密码变的更加用户友好呢?BIP39 提出的助记词机制就很好的解决了这个问题,让钱包私钥(对 HD 钱包来说就是种子)在安全性方面不打折扣,但是更容易识记。BIP39 主要描述了两个过程:· 根据随机数生成助记词的流程· 根据助记词推导 HD 钱包种子的流程
从随机数到助记词
有个广泛流传的误解说助记词是随机生成的,实际上助记词并不是随机生成的,而是随机生成的种子的一种呈现方式。助记词究竟是怎么生成的呢?整个过程如下图:
· 生成 128 位的随机数,这个随机数在 BIP29 中叫做熵(Entropy,简写为 ENT);· 对随机数做 SHA256,取前 4 位为校验码(Checksum);· 把步骤 1、2 中的结果拼接得到 132 位的结果,然后分割成 12 个长度为 11 位的串;· 将 12 个串转化为十进制数字去此表中查找对应的单词;· 把查找到的单词按顺序拼接起来构成助记词;整个过程的代码实现如下:
function generateMnemonic(strength, rng, wordlist) { strength = strength || 128; if (strength % 32 !== 0) throw new TypeError(INVALID_ENTROPY); rng = rng || randomBytes; return entropyToMnemonic(rng(strength / 8), wordlist);}function entropyToMnemonic(entropy, wordlist) { if (!Buffer.isBuffer(entropy)) entropy = Buffer.from(entropy, 'hex'); wordlist = wordlist || DEFAULT_WORDLIST; var entropyBits = bytesToBinary([].slice.call(entropy)); var checksumBits = deriveChecksumBits(entropy); var bits = entropyBits + checksumBits; var chunks = bits.match(/(.{1,11})/g); var words = chunks.map(function(binary) { var index = binaryToByte(binary); return wordlist[index]; }); return wordlist === JAPANESE_WORDLIST ? words.join('\u3000') : words.join(' ');}
在助记词生成过程中,不同长度的随机数所需要的校验码不同,最后产生的助记词长度不同,如下表:
BIP39 中目前支持多种语言的此表,每个词表的长度是 2^11 = 2048。· English· Japanese· Korean· Spanish· Chinese (Simplified)· Chinese (Traditional)· French· Italian可能有同学会问,表面看起来助记词就是随机排列的 12 词语,应该很容易暴力破解?下面我们以 12 长度的助记词为例分析下暴力破解的难度:· 可能的助记词数量 = 2048!/(2048 - 12)! = 5.27e+39· 每秒尝试 10000 次,每年能尝试的数量为 10000 * 60 * 60 * 24 * 364 = 3.15*e+11· 需要 1.67e+28 年才能穷举所有的助记词还不用说助记词的长度是可变的,每个助记词下面的钱包数量也是近乎无限的。暴力破解的难度不用多说。有了助记词之后,怎么生成 HD 钱包呢,HD 钱包的关键是种子,只要从助记词恢复出种子即可,整个过程如下图:
JS 代码实现如下:
function mnemonicToSeed(mnemonic, password) { var mnemonicBuffer = Buffer.from(unorm.nfkd(mnemonic), 'utf8'); var saltBuffer = Buffer.from(salt(unorm.nfkd(password)), 'utf8'); return pbkdf2(mnemonicBuffer, saltBuffer, 2048, 64, 'sha512');}
可以看到,从助记词到种子的过程中,加了两个机制来增加暴力破解的难度:· password 机制,这样助记词即使泄露,密码不对也无法拿到正确的种子,算是双保险;· pbkdf2 机制,比较弱的密码经过这个环节随机性会大大增强,也正是这个运算会增加暴力破解的计算量;
从助
记词到 HD 钱包
到这里,BIP32、BIP44、BIP39 的核心内容我们都理清楚了,从助记词生成 HD 钱包的流程如下图:
{width=100%}以以太坊为例的助记词 HD 钱包生成过程简化如下(这里用到了 bip39、hdkey、ethereumjs-util 等库):
const bip39 = require('bip39');const HDKey = require('hdkey');const EthUtil = require('ethereumjs-util');const mnemonic = bip39.generateMnemonic(128);const seed = bip39.mnemonicToSeed(mnemonic, '');const master = HDKey.fromMasterSeed(seed);const account = master.derive("m/44'/60'/0'");const addr = account.deriveChild(0).deriveChild(0);const pubKey = EthUtil.privateToPublic(addr.privateKey);const address = EthUtil.publicToAddress(pubKey).toString('hex');
钱包有了之后,用它去签名交易、再把交易广播出去就不在本文的讨论范畴之内,感兴趣的同学可以自行研究。读到这里相信你对于 HD 钱包的几个核心提案、安全隐患以及助记词的基本原理已经有比较不错的理解。