一文搞懂分层确定性钱包(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: "",
},
},
},
};
mnemonic
如何能生成许多账户地址?path: "m/44'/60'/0'/0"
都是什么意思?为啥数字右上角还有小撇号?如何自定义配置?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 进制字符,哪一个更方便备份和恢复钱包,一目了然。
// 由种子生成主账户私钥
masterKey, _ := bip32.NewMasterKey(seed)
上面这一行代码就用到了 BIP-32 标准。BIP-32 提出了分层确定性钱包(HD Wallet)
的标准,它允许从单个种子(Seed)生成一系列相关的密钥对,包括一个主账户密钥和无限多个子账户密钥,不同的子账户之间具有层次关系,形成以主账户为根结点的树形结构。
下面用一张图来概括这个树形结构
如图所示,BIP-39 和 BIP-32 各自的内容分别在两个虚线框内。依据这个树形结构图,我们来拆解下 BIP-32 这部分的分层确定性钱包(HD Wallet)
,有趣的是你会发现这个命名真的是没有一点废话,把名字拆分为 3 个词,每个词都对应一个性质:
分层
: 因为是树形结构,每一层都有一个序号(从 0 开始),主账户密钥 masterKey 序号是 0,以此类推,这个就叫做索引号(32 位)确定性
: 当通过单向哈希函数派生子密钥的时候,因为既想要随机,又希望同一个父密钥每次生成的子密钥都相同,于是,引入了链码来保证确定性,使得每次生成子密钥都是由父密钥
+父链码
+索引号
三个一起派生子密钥。钱包
: 对应着密钥(私钥+公钥)
这一部分重点记住 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)
还记得本文开头提出的那个问题吗?"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/0
,m/1'/0
, m/0'/1/2/3
等等任何路径,都是正确的账户,但是,这些都和钱包应用不再兼容了,且安全性不可知,你需要自己维护钱包的安全,因此,建议我们都统一遵循行业标准(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 上面的地址进行比较,可以验证这个实现。注意不要用真实账户来做验证,防止泄露!
现在,我们比较一下上一篇的非确定性钱包
和本篇的分层确定性钱包
各有什么特点,以便于你开始做一个钱包时,做出符合心意的决策。
钱包 | 特点 |
---|---|
非确定性钱包 | 1.隐私增强,因为不同地址之间是独立生成的,无关联性; <br> 2.地址独立,一个地址的私钥泄露,不影响其他地址的安全性; <br> 3.不方便备份,需要备份所有地址的私钥(keystore 文件) |
分层确定性钱包 | 1.方便的备份,只需要备份助记词即可; <br> 2.易于管理,尤其是需要大量地址的场景;<br> 3.隐私性不够,所有地址之间都有某种关联性 |
另外,分层确定性钱包(HD Wallet)还有 2 大优势:
树状结构可以表达特殊含义,比如将不同分支的子钱包分配给不同部门、子公司等
子公钥和子私钥解耦,因为父公钥可以派生很多子公钥在开放的服务器上接收资产,没有任何私钥。与此同时,在另一台安全的服务器派生子私钥控制资产。换一种说法,就是 HD 钱包可以产生无限数量的公钥地址,在应用程序中负责收钱,因为没有私钥不能花钱。在另一个更安全的服务器上,生成私钥来花钱。由此,就可以创建非常安全的公钥地址。
至此,以太坊上的 2 种钱包已经讲完了。后面我会补充一些细节知识,比如 keccak256 的小八卦等。如果你在看这个系列文章时,遇到什么问题,欢迎留言告诉我,我会尽我所能解决你的问题,并改进我的文章内容。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!