Ethereum源码阅读笔记-accounts(2)-分层确定性钱包

转载请注明出处:www.huamo.online
字节杭州 求贤若渴:

  1. https://job.toutiao.com/s/JXTdQaH
  2. https://job.toutiao.com/s/JXTMWW3
  3. https://job.toutiao.com/s/JXT1tpC
  4. https://job.toutiao.com/s/JXTdu6h

分层确定性钱包(HD Wallets)

在使用钱包,以及看以太坊代码时,都会碰到m/44'/60'/0'/0这样奇怪的字符串,以及派生路径(DerivationPath)这样的术语,决定搞个明白,随后发现这些奇怪的东西都与一个名词有关:分层确定性钱包

分层确定性钱包的概念最初来源于bitcoin,出自BIP-0032。其英文叫做Hierarchical Deterministic Wallets,简称为HD Wallets一个分层确定性钱包可以部分或全部共享给不同的系统,每一部分都拥有或不拥有花费数字货币的能力。

动机

比特币早期的钱包是非确定性的,使用随机生成的私钥。中本聪提出了每个交易使用一个地址的概念,为了避免每次交易完后都需要备份钱包(为了备份私钥),早期的钱包会默认预先生成100个私钥放在私钥池里以供使用,但这只是延缓了备份的间隔,需要备份的私钥数量并没有减少,依然不方便管理。另外,早期的钱包并没有打算在多个系统上同时共享使用。他们支持通过钱包的加密功能隐藏私钥而且不共享密码,但这样“阉割过”的钱包也失去了生成公钥的能力。

而确定性钱包不需要频繁的备份。椭圆曲线数学允许在不暴露私钥的情况下计算出公钥内容。这就可以支持一些场景,比如一个网上商店可以让它的网络服务器,在不接触相应私钥(花费收到的资金时需要用到私钥)的情况下,为每笔订单或每个客户生成新的地址(公钥哈希)。

一般来说,确定性钱包通常只包含一条由秘钥对构成的链。只有一条链就意味着共享一个钱包时,要么就全共享,要么就什么都不共享。然而,在有些场景下,人们可能只需要其中一些秘钥来共享和回复。在上一段提到的网店例子中,网络服务器并不需要访问卖家钱包中的所有公钥,并且它只需要访问那些接受顾客付款的地址,而不用去访问例如卖家要花钱时生成的地址。分层确定性钱包基于多个秘钥对链来支持选择性共享,这些秘钥对链由同一个根生成。

至此,可以看到,钱包分为非确定性和确定性。所谓非确定性即钱包随机生成多个私钥,而确定性钱包是由一个根(种子)生成多个秘钥对。而分层确定性钱包可以通过一个根生成多个秘钥对的链(即构成了树),来支持选择性共享。

从下文的学习中再来读这一段,发现确定性钱包的主要应用场景就是:在不需要暴露私钥的情况下,只靠父公钥就能生成大量地址,而这些地址都能靠主私钥获取绝对控制权。

规范:密钥生成

约定

在本文其余部分中,我们假定是基于比特币中使用的公钥密码学,即ECC中用到的由secp256k1(http://www.secg.org/sec2-v2.pdf)定义的域和曲线参数。以下变量分别为:

  • 整数取模曲线的阶(这个阶记为n)

  • 曲线上点的坐标

  • 字节序列。

用两个坐标对的加法($+$)来表示椭圆曲线群的操作。连接($||$)操作是将一个字节序列加到另一个的尾部。

作为标准转换函数,我们假设:

  • $point(p)$:返回对secp256k1基点和整数$p$的EC点乘运算(重复EC群操作实现)得到的坐标对。

  • $ser_{32}(i)$:将一个32位无符号整型i序列化为一个4字节的序列,大端优先。

  • $ser_{256}(p)$:将整数p序列化为一个32字节序列,大端优先。

  • $$ser_P(P)$$:将点$$P = (x, y)$$序列化为一个字节序列,使用的方法为SEC1压缩格式:$$(0x02 \ or \ 0x03) || ser_{256}(x)$$,其中头部字节取决于省略的$y$坐标的奇偶性。

  • $parse_{256}(p)$:将一个32字节序列转化为一个256位整数,大端优先。

扩展的密钥

接下来,我们将会定义一个函数,用来从一个父密钥生成一系列的子密钥。为了防止结果仅依赖于父密钥本身,我们首先会用一个额外的256位调节数(entropy)同时扩展私钥和公钥。这个扩展用到的调节数,称为链码(chain code),对于相应的私钥和公钥是相同的,并且由32个字节构成。

我们将扩展后的私钥记为(k, c),其中k是原本普通的私钥,c是链码。将扩展后的公钥记为(K, c),其中K = point(k),并且c同样是链码。

每一个扩展后的密钥都有$2^{31}$个普通的子密钥,以及$2^{31}$个强化的子密钥。每一个子密钥都有一个索引序号。其中普通子密钥使用索引$0 \sim 2^{31}-1$,强化的子密钥使用索引$2^{31} \sim 2^{32} - 1$。为了方便强化子密钥的索引记法,数字$i_H$表示的就是$i + 2^{31}$。

子密钥生成函数(Child key derivation[CKD])

给定一个父扩展密钥和一个索引i,可以计算出对应的子扩展密钥。实现这个的算法取决于子密钥是否是强化密钥(或者说,是否$i \geqslant 2^{31}$),以及我们讨论的是私钥还是公钥

父私钥 $\longrightarrow$ 子私钥

函数$$CKDpriv((k_{par}, c_{par}), i) \rightarrow (k_i, c_i)$$基于父扩展私钥计算出一个子扩展私钥,以下是算法流程:

  • 检查是否$i \geqslant 2^{31}$(即子密钥是否是一个强化密钥)。

    • 如果是强化子私钥:令

      $$I = HMAC-SHA512(Key = c_{par}, \ Data = 0x00 || ser_{256}(k_{par}) || ser_{32}(i))$$

      (注意:0x00填充了私钥将其扩展为33字节)。

    • 如果是普通子私钥:令

      $$I = HMAC-SHA512(Key = c_{par}, \ Data = ser_{P}(point(k_{par})) || ser_{32}(i))$$

  • 将$I$分割为2个32字节序列,分别记为$I_L$和$I_R$

  • 返回的子私钥$$k_i$$为$$k_i = parse_{256}(I_L) + k_{par}(mod \ n)$$。

n为曲线中的一个常数,表示基点的阶

  • 返回的子链码$c_i$为$c_i = I_R$。

  • 在$parse_{256}(I_L) \geqslant n $或者$k_i = 0$的情况下,得到的密钥无效,此时应该为$i$处理下一个值。(这个概率低于$\frac{1}{2^{127}}$)

上面提到的HMAC-SHA512函数定义于RFC 4231

父公钥 $\longrightarrow$ 子公钥

函数$$CKD_{pub}((K_{par}, c_{par}), i) \rightarrow (K_{i}, c_{i})$$基于父扩展公钥计算出一个子扩展公钥。该函数只定义于非强化的子公钥。以下是算法流程:

  • 检查是否$i \geqslant 2^{31}$(即子密钥是否是一个强化密钥)。

    • 如果是强化子公钥:返回失败

    • 如果是普通子公钥:令

      $$I = HMAC-SHA512(Key = c_{par}, Data = ser_{P}(K_{par}) || ser_{32}(i))$$

  • 将$I$分割为2个32字节序列,分别记为$I_L$和$I_R$。

  • 返回的子公钥$$K_i$$为$$K_i = point(parse_{256}(I_L)) + K_{par}$$。

  • 返回的链码为$c_i = I_R$

  • 在$parse_{256}(I_L) \geqslant n$或者$K_i$是无穷远点的情况下,返回的密钥无效,此时应该为$i$处理下一个值。

父私钥 $\longrightarrow$ 子公钥

函数$N((k, c)) \rightarrow (K, c)$基于一个扩展私钥,计算出对应的扩展公钥(这个私钥是“阉割过”的版本,因为它无法对交易进行签名)。

  • 返回的公钥$K$为$K = point(k)$

  • 返回的链码$c$就是传入的链码$c$。

为了计算一个父私钥的子公钥,可以用以下2种方式:

  • $$N(CKD_{priv}((k_{par}, c_{par}), i))$$该方式总是可行的。

  • $$CKD_{pub}(N(k_{par}, c_{par}), i)$$,该方式只适用于生成非强化的子公钥。

两者等价意味着非强化的密钥很有实用意义:可以使用一个给定的父公钥生成一系列子公钥,而无需知道任何私钥。同时在强化密钥方面两者又是有区别的,通常不使用非强化密钥的原因是考虑到安全性,下面有更多这方面的信息。

父公钥 $\longrightarrow$ 子私钥

这是不可能实现的!

密钥树

下一步就是级联多个CKD结构来构造出一颗树。首先以一个根开始,这个根就是主扩展私钥m。通过对多个i值计算$$CKD_{priv}(m, i)$$,可以得到一系列的1级派生节点。这些节点的每一个也是一个扩展私钥,$$CKD_{priv}$$函数也同样适用于它们。

为了记法简单,我们将$$CKD_{priv}(CKD_{priv}(CKD_{priv}(m, 3_H),2),5)$$记为$$m/3_H/2/5$$。这种方式同样适用于公钥衍生,例如$$CKD_{pub}(CKD_{pub}(CKD_{pub}(M,3),2),5)$$记为$$M/3/2/5$$。这将会具有以下特性:

  • $$N(m/a/b/c) = N(m/a/b)/c = N(m/a)/b/c = N(m)/a/b/c = M/a/b/c$$(从数学上证明了,用父私钥生成子公钥,可以用父公钥生成子公钥代替,这样就可以不暴露父私钥)

  • $$N(m/a_H/b/c) = N(m/a_H/b)/c = N(m/a_H)/b/c$$。然而,再往后,$$N(m/a_H)$$不能等价于$$N(m)/a_H$$,因为这是不可能实现的:$$CKD_{pub}(N(m), i)$$只适用于非强化的子公钥。

密钥标识

扩展密钥可以用ECDSA的公钥K序列化之后的Hash160(先对序列化结果进行SHA256,然后再进行RIPEMD160)结果进行标识,忽略链码。这样完全对应于传统的比特币地址数据。但不建议用base58格式来表达该数据,因为这样它就可能会被解释为一个地址(但其实它只是个扩展密钥)。

这种标识符的前32位被称为密钥指纹。

序列化格式

扩展的私钥和公钥按照如下格式进行序列化:

  • 4字节:version字节(在比特币主网上,扩展公钥为0x0488B21E, 扩展私钥为0x0488ADE4; 在比特币测试网络上,扩展公钥为0x043587CF, 扩展私钥为0x04358394

  • 1字节:depth字节, 0x00表示主节点, 0x01表示一级衍生密钥, 以此类推下去。

  • 4字节:父密钥的指纹(从上节可知为父扩展密钥的前32位取出传到这里),如果是主密钥,则该字段为0x00000000

  • 4字节:孩子编号。即$$ser_{32}(i)$$,如果是主密钥,则该字段为0x00000000

  • 32字节:链码

  • 33字节:待扩展的公钥是$$ser_p{(K)}$$,而若是待扩展的私钥,则是$$0x00 || ser_{256}{(k)}$$

这78个字节的结构可以像其它比特币数据一样进行Base58编码:先加上32位的校验和(通过2次SHA256校验和得到),然后转化为Base58表述。结果就是编码后的字符串达到112个字符。由于version字节的不同,这种Base58表达的字符串,如果是主网,则以xprv或者xpub开头, 如果是测试网络,则以tprv或者tpub开头。

密钥指纹只是用来快速检测父亲和孩子节点的,软件必须愿意去处理冲突。在内部实现中,可以使用完整的160位标识符。

当导入一个序列化的扩展公钥时,实现必须验证公钥中的X坐标是否是曲线上的点,如果不是,则认为扩展公钥无效。

主私钥生成

主私钥不是直接生成的,而是通过一个种子得到的,主要是出于安全方面的考虑。

  • 从一个(P)RNG【(伪)随机数产生器】生成一个选定长度的种子序列S(128 ~ 512位, 推荐256位)

  • 计算 I = HMAC-SHA512(Key = "Bitcoin seed", Data = S)

  • 将I拆分为2个32字节序列:$$I_{L}, I_{R}$$

  • 将$parse_{256}{(I_L)}$作为主私钥, $I_R$作为主链码。

如果$I_L = 0 or I_L \geqslant n$,则认为主私钥无效

BIP-43

在BIP-32的算法基础上,BIP-43引入了Purpose域。

背景

由于BIP-32规范给予实现者太多的自由,很多实现都可以声称是BIP-32兼容的,但它们实际上可以采用不同的逻辑架构,最终导致这些钱包无法互操作,这有悖于BIP-32设计的初衷。

Purpose

提议BIP-32密钥树的第一层为purpose段,它定义了未来在该节点下方的结构的用处。

1
m / purpose' / *

撇号'表示使用了BIP-32的强化派生。

BIP-44

BIP-43提议的purpose模式基础上,BIP-44做了大幅扩展,提出了相当全面的层次结构。可以处理多种货币,多个账户,一个账户上的内部外部链,以及每个链上的数百万个地址。

路径层级

在BIP-32路径格式下,定义5个层级:

1
m / purpose' / coin_type' / account' / change / address_index

撇号'表示使用了BIP-32的强化派生。

每一层级都有一个特殊含义:

  • Purpose:依照BIP-43的建议,遵循BIP-44提案的子树结构的purpose44或者0x8000002C。该层使用强化派生。

  • Coin type:理论上来说一个主节点(种子)可以生成无限数量的独立加密货币,但是多种类型的加密货币共用相同的空间并不太好。该层级为每一种货币创建一个独立的子树,避免复用地址,也提高了隐私性。开发者可以为自己的货币申请一个未使用的常量数字代表这个货币。BIP-044已申请货币类型列表,从列表中可以看到以太币的代号是60或者0x8000003c。该层也使用强化派生。

  • Account:这一层将密钥空间区分为独立的用户ID,以保证钱包不会混淆不同账户的货币。用户可以像传统银行账户的方式来组织自己的资产:用于捐赠,用于储蓄,用于日常开销等。这个字段从数字0开始顺序递增,该数字用于作为BIP-32中的子密钥索引。当前一个账户没有任何交易历史的时候(意思是该账户的地址从未使用过),软件应该阻止创建一个新账户。还有一点就是,当导入外部来源的种子时,软件需要能够发现所有已经被使用的账户。下面有一个”账户发现”的算法描述。这一层也使用强化派生。

  • Change:即找零,常量0表示外部链,常量1表示内部链(即常说的找零地址)。外部链用于在钱包外可见的地址(例如收款)。内部链用于在外部不可见的地址,用于返回交易找零。这一层使用公共派生。

  • Index:地址由数字0开始顺序递增。用作BIP-32中的子密钥索引。该层使用公共派生。

账户发现

当从外部源导入主种子时,软件需要开始按照如下方式开始搜寻账户:

  1. 生成第一个账户节点(account = 0

  2. 生成这个账户的外部链节点

  3. 依照下述的间隔极限搜寻出外部链的地址。

  4. 如果在外部链上没有找到交易,停止搜寻,算法结束

  5. 如果找到一些交易,将account字段加1,再继续第一步。

如前所述,当前一个账户没有交易历史时,软件需要阻止创建新账户,所以该搜寻算法是凑效的。

算法是根据交易历史处理的,而不是账户余额,所以你可以有一个账户货币数量为0,算法依然会继续向后搜寻

地址间隔极限

当前间隔极限设为20,如果软件连续找到20个未使用的地址,它就认为在这个点上的后续地址都未被使用并停止搜寻地址链。只需要搜寻外部链,因为内部链只用来接收相关外部链转来的货币。

当用户尝试在外部链上超出间隔极限去创建一个新地址时,钱包软件需要给出警告。这样整个创建-发现账户地址的算法就形成了一个闭环。

参考资料

转载请注明出处:www.huamo.online