学区块链做的笔记Day4,大部分内容来自《精通以太坊》。

钱包技术概述

抽象来说,钱包主要是一个用户界面。钱包控制对以太币的访问、管理私钥和地址、跟踪账户余额、创建并签名交易。

从更聚焦的角度而言,钱包这个词特指用来存储和管理用户密钥的系统。

一个针对以太坊钱包的常见误解是认为钱包中包含以太币或者代币。实际上,钱包中只保存了密钥。以太币和其他各种代币都保存在以太坊区块链上。用户使用钱包中保存的密钥来签名交易,进而控制属于他们的以太币或代币。简单的说,以太坊钱包就是一个钥匙圈。

钱包主要分两类,取决于它所保存的密钥之间是否存在关联。

  1. 非确定性钱包
    其中保存的每一个私钥都是通过不同的随机数相互独立生成的。私钥之间没有任何关联。这类钱包被称为JBOK(Just a Bunch Of Keys)钱包
  2. 确定性钱包
    其中所有的密钥都是从一个主密钥衍生而来的,这个主密钥就是种子密钥。这类钱包中所有的密钥之间都存在关联关系,如果获得了“种子密钥”,则可以重新生成所有密钥。确定性钱包有多种密钥派生方法。最常用的派生方法是使用一个类似树形的结构,我们称之为层级式确定性(hierarchical deterministic)钱包,或者简称为HD钱包

为了让确定性钱包在对抗数据丢失事件(比如手机失窃)时,稍微安全一点,我们一般会将种子编码为一个英文词列表(当然也可以使用其他语言),你可以将其抄写下来并在意外发生时使用。这样的列表就是钱包助记词。当然,如果有人得到了这些助记词,他们也可以重建你的确定性钱包,进而控制你的以太币和智能合约。因此,妥善保存助记词!

非确定性(随机)钱包

很多以太坊客户端软件中的钱包(包括geth)使用json格式的keystore文件来保存单独(随机生成)的私钥。这个文件使用额外的密码来加密,以确保安全。JSON文件的内容如下:(这里我是从Nethermind的keystore文件夹里找来的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"version": 3,
"id": "0f0c8c44-aefc-41df-9d77-22afe614ffe1",
"address": "cd082b2a894cf90ebab3e4c271b99cfaa02444cf",
"crypto": {
"ciphertext": "4f3dbc3d613e8a64ac5027c279015e05a89f15b0046c2a1d902708b4f749b379",
"cipherparams": {
"iv": "453b85984c61d9595fb8e5cf837a4e64"
},
"cipher": "aes-128-ctr",
"kdf": "scrypt",
"kdfparams": {
"dklen": 32,
"salt": "9531ed6b8db7b4b4264f84d4ce17100a34f951b86497a4cbfb45829bfaa191f4",
"n": 262144,
"r": 8,
"p": 1
},
"mac": "dab5a32e88e2a0da9b060c501dc69b1268ed0337599fa52e36d5ed96158ebca2"
}
}

keystore的格式遵循密钥导出函数(KDF),这也被称为增强式密码算法,用于防止对文件的密码进行暴力、字典或彩虹表攻击。简单地说,JSON文件中的私钥并不是被密码直接加密的,相反,密码会通过连续的哈希运算进行扩展(然后再进行加密运算)。哈希计算重复262144轮,这个参数可以在JSON的crypto.kdfparams.n参数中看到。任何企图暴力破解密码的尝试,都必须经过262144轮的哈希计算,这会极大地降低攻击的速度,在密码长度和复杂度足够的情况下,暴力猜测密码的攻击不再有效(密码本身复杂度x262144轮哈希,计算量会非常大)

有多种软件程序库可以读取和写入keystore格式,列入JavaScript库keythereum

ethereumjs/keythereum: Create, import and export Ethereum keys (github.com)

确定性(种子密钥)钱包

确定性钱包或基于种子密钥的钱包,包含了从同一个种子密钥(或者叫主密钥)所派生的私钥。种子密钥是一个随机生成的数字,它与其他的数据(例如索引号或“链码”)一起用于生成私钥。在确定性钱包中,种子密钥就可以用来恢复所有的派生密钥,因此在创建钱包时备份种子密钥就足以保护资金和智能合约的安全了。种子密钥也可以用来进行钱包的导入或者导出操作,一次性把钱包中的所有派生密钥在不同的钱包软件中迁移。

这种设计也使得种子密钥的安全变得极端重要,因为只要拥有种子密钥就可以获得整个钱包的访问权限。但从另一个角度来说,将安全性相关的注意力集中到一段数据上也可以被视为一个优点。

层级式确定性钱包(BIP-32/BIP-44)

目前,确定性钱包最高级的形式便是由比特币BIP-32标准定义的HD钱包。HD钱包可以保存用树状结合推导的多个密钥,比如一个私钥可以推导出一系列子密钥,每一个子密钥都可以推导出一系列孙子密钥,以此类推。

hd_wallet

相比随机性非确定性钱包,HD钱包由两个主要的好处:

  1. 树形结构可以用来表示组织结构的含义,列入一组密钥专门用来收款,另外一组专门用来付款。也可以把这样的结构跟公司组织架构对应,把树形结构的分支跟部门、区域、具体的只能或公司内部的账户分类相匹配。
  2. 第二个好处是HD钱包的用户可以创建一系列公钥,这个过程不需要访问对应的私钥。这样就允许HD钱包被用在相对不安全的服务器上,或者专门用于收款,这时候钱包中不需要保存可以操作以太币的私钥信息。_(没看懂)_

钱包的最佳实践

数字货币的钱包技术和标准正在逐步成熟,一些行业标准也正在推动钱包软件之间更广泛的交互性和易用性,安全性和灵活性。这些标准也允许钱包软件从单一的种子密钥为多种数字货币派生密钥,这些标准包括:

  • 基于BIP-39助记词标准
  • 基于BIP-32的层级式确定性钱包标准
  • 基于BIP-43的多用途层级式确定性钱包结构
  • 基于BIP-44的多币种和多账户钱包

助记词标准(BIP-39)BIP-39文档

请注意BIP-39只是助记词标准的一种实现。Electrum比特币钱包和一些较早期的钱包还有其他的实现方法,采用的助记词清单也不一样。

BIP-39定义了与种子密钥相对应的一组助记词,我们将在下面的步骤中一探究竟。为了清晰起见,我们把这些步骤分成两部分:第一部分包含6个步骤,第二部分包含3个步骤。

生成助记词

助记词由钱包根据BIP-39所定义的标准流程自动生成,钱包从随机源获取一个随机数,然后添加校验码,再把这个数字映射为一串英文单词:

  1. 创建一个128比特或256比特的密码学强度的随机数,我们姑且称之为S。
  2. 取出S的SHA-256哈希值的前(S的长度/32)比特,作为随机数S的校验值。
  3. 将上一步得到的校验值加到随机数S的末尾。
  4. 以11比特为单位,将随机数S与校验值的结合数分成多个组。
  5. 将每一个11比特的值都根据预先定义的字典映射为单词(这个字典包含2048个简单的英文单词,正好覆盖所有11比特的可能范围)。
  6. 保持初始的次序,得出的单词字符串即我们所需的助记词。
随机数(bits) 校验(bits) Entropy + checksum (bits) 助记词长度(words)
128 4 132 12
160 5 165 15
192 6 198 18
224 7 231 21
256 8 264 24

bip39-part1


这里我用python自己写了个生成助记词的程序。

助记词的列表来自github里BIP-39的助记词列表bips/bip-0039 at master · bitcoin/bips (github.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from Crypto.Random import get_random_bytes
import hashlib

# 读取助记词列表
def word_list(op):
if op == 'en':
filename = 'english.txt'
elif op == 'cn':
filename = 'chinese_simplified.txt'
else:
return 0
# 这里把下载的助记词txt文件直接放在同一个文件夹下
with open(filename, 'r') as file:
return file.read().split()

# 11位分割
def split_to_11bit_bytes(data):
return [int(data[i:i + 11], 2) for i in range(0, len(data), 11)]

def BIP39(l):
assert l % 32 == 0, "BIP39长度必须为32的倍数"
# 生成长度为l比特随机字节串
S_bytes = get_random_bytes(l//8)
S_bin = '{0:0>{1}b}'.format(int(S_bytes.hex(), 16), l)
# S的校验值
S_check_bin = format(int(hashlib.sha256(S_bytes).hexdigest(), 16), '0256b')[:l//32]
# 将校验值加到末尾
S_bytes_add_checksum_bin = S_bin + S_check_bin
# 将字节串分割并返回索引
w_index = split_to_11bit_bytes(S_bytes_add_checksum_bin)
# 获取助记词列表
wordlist = word_list('en')
res_word = [wordlist[i] for i in w_index]
print(res_word)

if __name__ == '__main__':
BIP39(128)

这个只是用作实验目的,真跑起来估计又慢bug又多。

这里可以用之后的例子来验证,后面例子随机数为

1
0c1e24e5917779d297e14d45f14e1a1a

生成出的助记词为

1
['army', 'van', 'defense', 'carry', 'jealous', 'true', 'garbage', 'claim', 'echo', 'media', 'make', 'crunch']

可以把上述代码中的S_bytes替换为

1
S_bytes = binascii.unhexlify('0c1e24e5917779d297e14d45f14e1a1a')

所以还能用生成助记词的库直接生成。

1
2
3
4
5
6
7
# pip install mnemonic
from mnemonic import Mnemonic

mnemonic = Mnemonic("english")
words = mnemonic.generate(strength=256)

print(words)

从助记词到种子密钥

助记词代表128比特或256比特的随机数。使用密钥扩展算法,例如PBKDF2,可以将这个随机数衍生成512比特长的种子,进而来构建确定性钱包和派生其他密钥。

密钥扩展算法需要两个参数:助记词和盐(salt)。加盐的目的是防止通过循环表格的方式来实现暴力破解。在BIP-39标准中,加盐还有另外一个目的:引入额外的密码来保护种子密钥

下面步骤7~9跟在上节所述的流程后面,推导出种子密钥:

  1. PBKDF2密钥扩展算法的第一个参数是步骤6中产生的助记词。
  2. PBKDF2密钥扩展算法的第二个参数是“盐”,盐的内容由用户提出,可以是一个可选的密码,并跟“mnemonic”组合在一起。
  3. PBKDF2针对助记词和盐进行2048轮哈希运算,使用的是HMAC-SHA512算法,产生一个512比特的数作为最终输出。这个数字就是种子密钥。

bip39-part2


这里本来是也想用python实现一下的的,但是试了很多方法出来的结果都和书上的结果不同,所以先跳过了。(完全不知道哪里出问题了)

错误的代码如下:

1
2
3
4
5
6
7
# 这里本来是想自己写PBKDF2函数的,但是书上讲的非常不清楚,看了眼网上其他教程,最终决定直接用别人造好的轮子了(开摆
# 然后按照书上的来,和书上的对不上,就先跳过了。
def PBKDF2(mnemonic_words, salt):
words = ' '.join(mnemonic_words).encode()
salt = ('0c1e24e5917779d297e14d45f14e1a1a' + salt).encode()
res = hashlib.pbkdf2_hmac("sha256", words, salt, 2048, dklen=512)
print(res.hex())

奥,mnemonic这个库还是可以用的,只是想用其他库自己写感觉还是有点难度

正确代码如下:

1
2
3
4
5
6
7
8
9
10
11
from mnemonic import Mnemonic
import binascii

mnemonic = Mnemonic('english')
S_bytes = binascii.unhexlify('0c1e24e5917779d297e14d45f14e1a1a')
words = mnemonic.to_mnemonic(S_bytes)
print(words)
# army van defense carry jealous true garbage claim echo media make crunch
seed = mnemonic.to_seed(words, passphrase='')
print(seed.hex())
# 5b56c417303faa3fcba7e57400e120a0ca83ec5a4fc9ffba757fbe63fbd77a89a1a3be4c67196f57c39a88b76373733891bfaba16ed27a813ceed498804c0570

使用助记词

BIP-39实现了针对多种编程语言的程序库。举例如下:

trezor/python-mnemonic: :snake: Mnemonic code for generating deterministic keys, BIP39 (github.com)

Consensys/eth-lightwallet: Lightweight JS Wallet for Node and the browser (github.com)

bip39 - npm (npmjs.com)

BIP39 - Mnemonic Code (iancoleman.io)(在线网站)

层级式确定性钱包标准(BIP-32)和路径定义标准(BIP-43/44)

大多数钱包都遵循BIP-32标准,这已经称为确定性钱包的事实性行业标准。

由很多基于不同编程语言且可以互相交互的BIP-32实现(软件库)。这些软件大部分是为比特币钱包设计的,它们使用不同的方式实现比特币地址,但所用的密钥推导实现是一样的,包括以太坊的兼容BIP-32的钱包也是如此。你可以使用为以太坊设计的钱包Consensys/eth-lightwallet: Lightweight JS Wallet for Node and the browser (github.com),或者使用比特币钱包但加入一个以太坊地址编码库。

也有作为独立网址BIP32 - JavaScript Deterministic WalletsBIP-32生成器,对测试和实验来说都是很有用的。

扩展的公钥和私钥

使用正确的数学运算,经过扩展的“父”密钥可以被用来推导出“子”密钥,并因此产生密钥和地址的树状层级结构。父密钥不一定位于树的顶端,但无论位于哪一层级,它们都可以被抽离出来。扩展一把密钥包括取得这把密钥本身以及附上一个独特的链码,链码是一段256比特的二进制字符串,与原密钥混合之后用来产生子密钥。

如果密钥是私钥,则扩展后就成为扩展私钥,用前缀xprv表示,扩展公钥则用xpub表示。

HD钱包的一个非常有用的特性就是可以从父公钥派生子公钥,而这个过程并不需要使用私钥。因此有两种方式可生成子公钥:既可以使用子私钥,也可以使用对应的父公钥。

因此,扩展的公钥可以用来生成HD钱包对应分支中所有的公钥。

(详细的说实话没咋看懂,以后遇到这种情况再说)

增强的子密钥生成过程

从扩展公钥派生出分支公钥这个功能非常有用,但也有潜在的风险。访问扩展公钥并不能获得对应子私钥的访问。然而,由于扩展公钥带有链码,如果子私钥被人获取或者泄露,那么结合链码,就可以派生出所有其他的子私钥。通过泄露的子私钥和父链码,可以推算出所有的子私钥。更糟糕的是,子私钥和父链码结合在一起,可以推算出对应的父私钥。

为了消除这个风险,HD钱包使用了另外一种派生算法,称为增强派生,这个算法打破了父公钥和子链码之间的关联。增强派生算法使用父私钥来生成子链码,而不是父公钥。这样就在父子两个密钥序列之间建立了防火墙,通过链码无法推算父私钥及兄弟私钥。

简单地说,如果你想借助扩展公钥方便地派生一组子公钥,同时避免暴露链码的风险,那么就应该采用增强派生算法,而不是常规算法。作为最佳实践,第一层的密钥总是采用增强派生算法得来的,这样就可以保证最重要的种子密钥的安全。

普通和增强情况下的索引码

我们希望能从给定的父密钥中推导出多个子密钥。为了管理这些子密钥,BIP-32使用了索引码。每一个索引码只需配合父密钥使用特定的子密钥派生方法,都可以产生一个不同的子密钥。在BIP-32的密钥推导函数中使用的索引码是一个32位的整数。为了方便地把通过普通派生方法和增强派生方法所产生的子密钥区别开来,索引码被分为两个范围:0和之间的索引码(从0x0到0x7FFFFFFF)用于普通派生算法之间的索引码(从0x80000000到0xFFFFFFFF)用于增强派生算法因此,如果索引码小于,那么这个子密钥就来自于普通派生算法,对应地,如果索引码等于或者大于,那么这个子密钥就是经过增强派生算法得来的。

为了让索引码更容易读取和显示,增强子密钥的索引码也是从零开始显示,但是带有一个符号。第一个来自增强派生算法的索引码(0x80000000)显示为0'。对应的第二个子密钥的索引码(0x80000001)显示为1'。以此类推,如果你看到HD钱包上显示的索引码是i',那么就意味着是

层级式确定性钱包的标识符(路径)

HD钱包中的密钥采用“路径”的命名规范进行标识,根据树形结构在每一层之间采用“/”分割。从主私钥派生出的私钥由m开头,而从主公钥派生出的公钥由M开头。因此,主私钥派生的第一个子私钥表示为m/0,主公钥派生的第一个子公钥表示为M/0。

HD path Key described
m/0 The first (0) child private key of the master private key (m)
m/0/0 The first grandchild private key of the first child (m/0)
m/0’/0 The first normal grandchild of the first hardened child (m/0’)
m/1/0 The first grandchild private key of the second child (m/1)
M/23/17/0/0 第24个主公钥的第18个孙子的第1个曾孙子的第1个玄孙子公钥
层级式确定钱包的树状结构

BIP-43提出,使用第一级增强扩展子密钥的索引码作为一个特殊的标识符,指定该分支的“用途”。基于BIP-43,一个HD钱包只应使用一个level-1层,这个索引码确定了剩余树的结构和命名空间,因此也确定了钱包的用途。例如,只使用m/i'/...分支的HD钱包意在标识一个具体的用途,这个用途被记录在索引码i中。

在次基础上扩展,BIP-44提出了一个支持多币种多账号结构的体系,该结构将“用途”索引码定位44'。所有遵循BIP-44标准的钱包都只是用树形结构的这一个m/44'/*分支。

BIP-44定义的结构中包含了5种预定义的树形层级:

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

第一层级purpose'总是设定为44'

第二层级coin-type'用来指定当前分支支持的币种,以此来实现HD钱包对多币种的支持——每一种类型的数字货币在第二层级的树状结构中都有一个自己的分支。货币的类型在一个名为SLIP0044的文档中定义。例如,以太坊是m/44'/60',以太坊经典是m/44'/61',比特币是m/44'/0'。所有货币的测试网都是m/44'/1'/

第三层级account',允许用户把他们的钱包分成多个逻辑上的子账户,用于记账或管理。例如,一个HD钱包可能包含两个以太坊“账户”:m/44'/60'/0'm/44'/60'/1'。每一个账户都是它对应分支的根。

由于BIP-44源自于比特币,它还包含了一个跟以太坊无关的“怪癖”:第四层change。HD钱包中的“找零”分为两层:第一层用来创建接收地址,第二层用来创建找零地址。在以太坊中,只有接收地址这一层被用到,因为以太坊中并不存在找零的概念。请注意,无论上层使用的是普通派生还是增强派生,这一层都使用普通派生算法。这样做的目的是允许这一层导出扩展公钥用于非安全的环境。可用地址就是从HD钱包第四层的密钥派生出来的,使得第五层成为address_index。例如,在一个以太坊钱包中,主账户的第三个用于收款的标识符对应的就是:m/44'/60'/0'/0/2

HD path Key described
M/44'/60'/0'/0/2 以太坊主账户的第三个接收公钥
M/44'/0'/3'/1/14 第四个比特币账户的第十五个找零地址公钥
m/44'/2'/0'/0/1 莱特币主账户的第二个私钥,用于签名交易