Web3专题(三) 2种钱包之分层确定性钱包(HD Wallet,BIP32,BIP39,BIP44)

一文搞懂分层确定性钱包(HD Wallet)

本文介绍以太坊的第 2 种钱包,即分层确定性钱包(HD Wallet)。如果你还不知道第 1 种钱包是什么,请转身观看Web3 专题(二) 2 种钱包之非确定性钱包(keystore管理私钥)

首先,我们从一个实际开发场景开始,请看下面一段hardhat配置代码(仅仅看下就行,不用看懂。如果你不知道hardhat也不影响阅读):

module.exports = {
  networks: {
    sepolia: {
      url: "...",
      accounts: {
        mnemonic: "test test test test test test test test test test test junk",
        path: "m/44'/60'/0'/0",
        initialIndex: 0,
        count: 20,
        passphrase: "",
      },
    },
  },
};
  1. mnemonic 如何能生成许多账户地址?
  2. path: "m/44'/60'/0'/0" 都是什么意思?为啥数字右上角还有小撇号?如何自定义配置?
  3. passphrase 是什么?有什么用?和 metamask 的登录密码一样吗?

如果你对这些问题都了然于胸,完全清楚、明白,那你可以跳过这篇文章了。如果还有不明白的,看完本文,相信你都会找到答案。

接下来用 Go 语言实现一个 HD 钱包,边写代码边讲理论,一步步把 HD 钱包的知识理解到位,并能应用于实际开发。

你需要导入的包

go get github.com/ethereum/go-ethereum/crypto
go get github.com/tyler-smith/go-bip32
go get github.com/tyler-smith/go-bip39

助记词

本文涉及到 3 个比特币改进提案 —— BIP-32、BIP-39、BIP-44。以太坊和许多加密钱包都应用这个标准。
在这一小节,先讲 BIP-39 标准,下一小节会详细讲 BIP-32 标准。

BIP-39 提出了助记词的标准,助记词是一组随机生成的易于记忆的单词。

为了解决 BIP-32 中种子(Seed)难于记忆和不方便备份的问题,于是乎,在 BIP-32 标准提出后,又提出了 BIP-39 的标准。BIP-39 主要包含 2 个功能:由熵源生成助记词, 由助记词生成种子(Seed)

// 由熵源生成助记词
// @参数 128 => 12个单词
// @参数 256 => 24个单词
entropy, _ := bip39.NewEntropy(128)
mnemonic, _ := bip39.NewMnemonic(entropy)
fmt.Println("助记词:", mnemonic)

// 由助记词生成种子(Seed)
seed := bip39.NewSeed(mnemonic, "salt")

生成 seed 时,第二个参数salt是什么?这是一个可选参数:盐值。有 2 个目的,一是增加暴力破解的难度,二是保护种子(seed),即使助记词被盗,种子也是安全的。如果设置了salt,虽然多了一层保护,但是一旦忘记,就永久丢失了钱包,实际应用中要综合考虑,只能与精心规划的备份和恢复过程结合使用。

这个盐值也叫密码口令(passphrase),还记得本文开头的 hardhat 配置项吗?那个passphrase就是这个盐值,和 metamask 的登录密码完全不一样,它们是两个东西。

执行以上代码,输出如下:

助记词(12 words): bundle crush peasant stay gift inmate immense amazing sunset april pattern canvas
Seed(512 bits): 92f7065bffdbbdd109......b03015d29ffd14d82f8d1
注意:你执行代码生成的和这个不一样,但长度都一样,这个过程是随机的。

可以看到,助记词是 12 个单词,Seed 是 512 位,即 128 个 16 进制字符,哪一个更方便备份和恢复钱包,一目了然。

分层确定性钱包(HD Wallet)的基本原理

// 由种子生成主账户私钥
masterKey, _ := bip32.NewMasterKey(seed)

上面这一行代码就用到了 BIP-32 标准。BIP-32 提出了分层确定性钱包(HD Wallet)的标准,它允许从单个种子(Seed)生成一系列相关的密钥对,包括一个主账户密钥和无限多个子账户密钥,不同的子账户之间具有层次关系,形成以主账户为根结点的树形结构。

下面用一张图来概括这个树形结构

HD-Wallet原理

如图所示,BIP-39 和 BIP-32 各自的内容分别在两个虚线框内。依据这个树形结构图,我们来拆解下 BIP-32 这部分的分层确定性钱包(HD Wallet),有趣的是你会发现这个命名真的是没有一点废话,把名字拆分为 3 个词,每个词都对应一个性质:

  1. 分层: 因为是树形结构,每一层都有一个序号(从 0 开始),主账户密钥 masterKey 序号是 0,以此类推,这个就叫做索引号(32 位)
  2. 确定性: 当通过单向哈希函数派生子密钥的时候,因为既想要随机,又希望同一个父密钥每次生成的子密钥都相同,于是,引入了链码来保证确定性,使得每次生成子密钥都是由父密钥+父链码+索引号三个一起派生子密钥。
  3. 钱包: 对应着密钥(私钥+公钥)

这一部分重点记住 HD Wallet的所有账户都是由密钥(公钥和私钥), 链码, 索引号(32 位)三个部分组成的即可。

当派生子密钥的时候,单独的私钥是不行的,必须是私钥和链码一起才能派生对应索引的子私钥,因此私钥和链码一起也叫做扩展私钥(xprv9tyUQV64JT...),因为是可扩展的。同样的,公钥和链码一起叫做扩展公钥(xpub67xpozcx8p...)。下面我们用上面的主账户扩展私钥masterKey来派生子账户看看:

// 由主账户私钥生成子账户私钥
// @参数 索引号
childKey1, _ := masterKey.NewChildKey(1)
childKey2, _ := masterKey.NewChildKey(2)

我们派生了 2 个子密钥,这 2 个子密钥是不是如我上面所说的三个部分组成的呢?下面的代码是NewChildKey的内部实现,省略了大部分代码,只看密钥的数据结构。

func (key *Key) NewChildKey(childIdx uint32) (*Key, error) {
    // ...
    intermediary, err := key.getIntermediary(childIdx) // 这个方法内部通过 `父密钥`+`父链码`+`索引号`派生了子密钥
    // ...
    childKey := &Key{
        ChildNumber: uint32Bytes(childIdx), // 索引号 32位
        ChainCode:   intermediary[32:],     // 链码
        Depth:       key.Depth + 1,
        IsPrivate:   key.IsPrivate,
    }

    // ...

    childKey.Key = addPrivateKeys(intermediary[:32], key.Key) // 子密钥

    return childKey, nil
}

看了代码,又得出了新的信息:派生出的字节数组 左边 32 字节是密钥,右边 32 字节是链码。

除了通过扩展私钥派生,还可以通过扩展公钥派生出子公钥。不过需要注意:公钥只能派生子公钥,无法派生子私钥。我们实现这个逻辑进行验证一下:

// 用主账户公钥 派生 子账户公钥(没有私钥)
publicKey := masterKey.PublicKey()
PubKeyToChild, _ := publicKey.NewChildKey(1)

// 用主账户私钥 派生 子账户私钥,再生成子账户公钥
key1, _ := masterKey.NewChildKey(1)
masterKeyToChild := key1.PublicKey()

fmt.Println(bytes.Equal(masterKeyToChild.Key, PubKeyToChild.Key))

执行一下,就知道这段代码返回true,由此引出我们下面要讲的 2 种派生:

1. 扩展公钥(公钥 + 链码) ==> 子公钥, 子私钥另外由父私钥派生出。

2. 扩展私钥(私钥 + 链码) ==> 子私钥 ==> 子公钥

存在第 1 种派生的好处是:可以用父公钥在开放的服务器上派生很多子公钥接收资产,因为没有私钥,所以只能接收不能花费,很安全。与此同时,在另一台安全的服务器派生子私钥来控制资产,这样就做到了 子公钥和子私钥解耦

然而,在享用这种好处的同时,也会有风险,比如子私钥泄露,那攻击者会利用子私钥与父链码来推断父私钥,或者所有姊妹账户。于是乎,就出现了强化派生(hardened derivation)强化派生就是限制了父公钥派生子公钥的能力,只能使用第 2 种派生,即父私钥 ==> 子私钥 ==> 子公钥

HD Wallet 规定:索引号在 0 和 2^31–1(0x0 to 0x7FFFFFFF)之间的只用于常规派生。索引号在 2^31 和 2^32– 1(0x80000000 to 0xFFFFFFFF)之间的只用于强化派生。

PS: 在表示中,强化派生密钥右上角有一个小撇号,如:索引号为 0x80000000 就表示为 0'

我们再看一下派生子密钥的源码,这个和上面的NewChildKey是同一个方法,刚才省略了这部分内容。你会看到,一进来方法,就会判断 childIdx 是否在强化派生的索引范围,当强化派生时,不允许公钥派生。

func (key *Key) NewChildKey(childIdx uint32) (*Key, error) {
    // FirstHardenedChild 是一个常量: uint32(0x80000000)
    if !key.IsPrivate && childIdx >= FirstHardenedChild {
        return nil, ErrHardnedChildPublicKey
    }

    // ...
}

从公钥生成地址,这个比较简单,直接给出代码。如果不理解压缩公钥与解压缩公钥,请阅读我前面的文章Web3 专题(一) 助记词和生成私钥、公钥、地址的基本原理

// 解压缩公钥
pubKey1, _ := crypto.DecompressPubkey(PubKeyToChild.Key)
// 生成子账户地址
addr1 := crypto.PubkeyToAddress(*pubKey1)

分层确定性钱包(HD Wallet)的标准路径

还记得本文开头提出的那个问题吗?"m/44'/60'/0'/0" 都是什么意思?现在你知道了,右上角的小撇号代表强化派生,现在来讲讲其他部分。

BIP-44 确定了 HD 钱包的标准路径。由于 HD 钱包的树状结构,每一层有 40 亿个子密钥(20 亿个常规子密钥和 20 亿个强化子密钥),层数可以无限扩展,没有尽头。导致钱包里账户的路径可能性是无穷的,假设你想从 metamask 更换到另一个不同的钱包应用,就会存在兼容性问题。

于是乎,BIP-44 定义了标准,只要遵循了这个标准的钱包之间都是兼容的。好消息是,包括 metamask 在内的许多钱包,都遵循了这个标准。

BIP-44 标准的钱包路径: m / purpose' / coin_type' / account' / change / address_index

符号 意思
m 标记子账户都是由主私钥派生的
purpose' 标记是 BIP-44 标准,固定值 44'
coin_type' 标记币种,以太坊是 60' ,不同币种不一样,在这里查看:完整的币种类型
account' 标记账户类型,从 0' 开始,用于给账户分类
change 0 外部可见地址, 1 找零地址(外部不可见),通常是 0
address_index 地址索引

注意:为了保护主私钥安全,所有主私钥派生的第一级账户,都采用强化派生。 你当然可以使用m/0m/1'/0m/0'/1/2/3等等任何路径,都是正确的账户,但是,这些都和钱包应用不再兼容了,且安全性不可知,你需要自己维护钱包的安全,因此,建议我们都统一遵循行业标准(BIP-44)。

实现一个以太坊钱包(符合 BIP-44 标准的路径)

// 以太坊的币种类型是60
// FirstHardenedChild = uint32(0x80000000) 是一个常量
// 以路径(path: "m/44'/60'/0'/0/0")为例
key, _ := masterKey.NewChildKey(bip32.FirstHardenedChild + 44)  // 强化派生 对应 purpose'
key, _ = key.NewChildKey(bip32.FirstHardenedChild + uint32(60)) // 强化派生 对应 coin_type'
key, _ = key.NewChildKey(bip32.FirstHardenedChild + uint32(0))  // 强化派生 对应 account'
key, _ = key.NewChildKey(uint32(0)) // 常规派生 对应 change
key, _ = key.NewChildKey(uint32(0)) // 常规派生 对应 address_index

// 生成地址
pubKey, _ := crypto.DecompressPubkey(key.PublicKey().Key)
addr := crypto.PubkeyToAddress(*pubKey).Hex()

如果你从开始读到这里,相信这段代码你很容易理解。 现在,你可以输入你 metamask 的助记词,来执行这段代码,得到地址addr,和你 metamask 上面的地址进行比较,可以验证这个实现。注意不要用真实账户来做验证,防止泄露!

非确定性钱包 vs 确定性钱包

现在,我们比较一下上一篇的非确定性钱包和本篇的分层确定性钱包各有什么特点,以便于你开始做一个钱包时,做出符合心意的决策。

钱包 特点
非确定性钱包 1.隐私增强,因为不同地址之间是独立生成的,无关联性; <br> 2.地址独立,一个地址的私钥泄露,不影响其他地址的安全性; <br> 3.不方便备份,需要备份所有地址的私钥(keystore 文件)
分层确定性钱包 1.方便的备份,只需要备份助记词即可; <br> 2.易于管理,尤其是需要大量地址的场景;<br> 3.隐私性不够,所有地址之间都有某种关联性

另外,分层确定性钱包(HD Wallet)还有 2 大优势:

  1. 树状结构可以表达特殊含义,比如将不同分支的子钱包分配给不同部门、子公司等

  2. 子公钥和子私钥解耦,因为父公钥可以派生很多子公钥在开放的服务器上接收资产,没有任何私钥。与此同时,在另一台安全的服务器派生子私钥控制资产。换一种说法,就是 HD 钱包可以产生无限数量的公钥地址,在应用程序中负责收钱,因为没有私钥不能花钱。在另一个更安全的服务器上,生成私钥来花钱。由此,就可以创建非常安全的公钥地址。

结束语


至此,以太坊上的 2 种钱包已经讲完了。后面我会补充一些细节知识,比如 keccak256 的小八卦等。如果你在看这个系列文章时,遇到什么问题,欢迎留言告诉我,我会尽我所能解决你的问题,并改进我的文章内容。

点赞 1
收藏 3
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
认知那些事
认知那些事
0x2b62...95a0
人立于天地之间,必然有我们的出路。