加密货币钱包助记词原理及Rust实现
本文概念以分层确定性钱包(hierarchical deterministic wallet/HD wallet)为例。
地址与密钥对
地址
在加密货币世界(比特币、以太坊等等),地址(一般以0x
打头,一段十六进制的字符串)是发送、接收和存储数字资产的唯一标识符。比如张三给李四转了一些加密货币,这笔交易在区块链上的表现即为0x1234
减少了1个BTC,0xabcd
增加了1个BTC,是不是有会计基础那味儿了?
那这个地址是怎么来的呢?可以自选一个粤B88888,不对,选一个0x8888
吗?答案是不可以。去中心化在某种程度上实现了平等——地址都是随机生成的。当然了,你也可以多生成几个,甚至几百个,选一个你自己喜欢的,还不用交6万块钱的指标。
新人肯定要问了,那我知道一个地址不就可以去操作它了吗?当然没那么简单了。难道我知道思聪的银行卡号,我就能直接取钱吗?
如果你是用Metamask或者Coinbase之类的钱包,你会发现,在生成新钱包地址,或者导入已有钱包地址的时候,会生成或需要提供一串叫作“助记词”的东西,比如apple ball cat dog ...
这样一串单词,同时还会生成一个0x
开头的地址。而且钱包还会提示你,一定要妥善保管好你的助记词,否则可能会造成财产损失。
光有地址是没有写的能力,或者说是没有发起交易的能力的。你只能查询某个地址的资产及交易记录,或者给其转入资产。如果需要主动发起交易的话,就必须要通过第一步,也就是导入助记词。(私钥也可以,下文会有介绍)。这个操作在传统web2世界里有点类似于登录,你必须输入密码登录你的银行账号才能给东南亚骗子转账。这里的助记词(或者私钥)就类似于密码。
不禁要问,助记词为什么那么重要?助记词和地址是什么关系呢?为什么发起交易需要助记词呢?
说到这里,就必须要提到另外一个概念,叫作密钥对。
密钥对
密钥对就是一个公钥和一个私钥组成的一对密钥,常被用在“非对称加密”技术中。如果你不太了解非对称加密,建议先学习一下对称加密及非对称加密再继续阅读。
区块链设计的巧妙之处就在于,你的地址实际上就是刚刚生成的密钥对中的公钥的哈希变换。公钥一般是私钥经过椭圆曲线加密得到的,私钥又是助记词经过一系列算法如PBKDF2和HMAC-SHA512转换得到的。所以整个流程就是助记词➡️私钥➡️公钥➡️地址。要注意,这个流程是不可逆的。换句话说,你知道我的地址,推不出我的公钥,也推不出我的私钥,更推不出我的助记词。所以我就敢大胆地把我的以太坊地址贴在这里:0xB029143abE1Cb60a69A8e4670Ed26949aE213Bc5
。当然了,我非常欢迎各位老爷们滑到最下方给我的地址转点Ethereum买咖啡喝。
发起交易需要钱包通过私钥来进行签发(sign)的操作,生成摘要并向链上发送交易信息及数字签名。得益于非对称加密的特性,链上的结点会验证这次交易的数字签名以判断是否是一次安全的、未被篡改的交易。如果一切正常,则此笔交易发生。
助记词(mnemonic)
上文中不难看到,“登陆”某个钱包,或者发起一笔交易,只要有私钥或者密钥对即可。那为什么又搞出一个助记词来呢?
其实道理很简单,就是人类不擅长去处理和机器太相关的事物。举个最简单的例子,登陆某个网站,实际上就是访问这个网站的IP地址。比如谷歌的IP地址是142.250.191.46
,把这串IP地址直接复制到浏览器里会发现进入的的确是谷歌的首页。但我连我取件码都记不住,你让我记那么长一串数字?因此才有了域名的概念。谷歌的域名google.com,在互联网上与142.250.191.46
可以简单认为是等价的。记google.com比记142.250.191.46
要简单太多了。
同样的,一个私钥一般长这样。
MHQCAQEEIEYgBlyQVsH7SpHUH7x4RErcckhu7ary/JjhP72Nk19EoAcGBSuBBAAKoUQDQgAE1MtHIxlGP5TARqBccrddNm1FnYH1Fp+onETz5KbXPSeG5FGwKMUXGfAmSZJq2gENULFewwymt+9bTXkjBZhh8A==
我觉得不会有人用copy paste以外的方法来输入它吧,甚至你用手抄都很可能抄错。那有没有什么“人类友好”的东西能像对应一个IP地址一样来对应一个私钥呢?助记词就是干这个的。
助记词原理及实现
上文提到生成或导入一个钱包的流程为助记词➡️私钥➡️公钥➡️地址。导入助记词实际上意味着我们已经随机生成了一套助记词。所以我们从讨论如何随机生成一套助记词开始,就可以把导入助记词也包括进来。值得一提的是,助记词的生成有很多种规范,比如BIP32,BIP39,BIP44,BIP84等等。就好比都是做咖啡,却有美式、拿铁、卡布奇诺等不同的制作规范一样。这里我们遵循比较常见的BIP39规范。
1. 生成熵(entropy)
高中物理和化学多次提到熵,就是混乱程度的意思。这里的熵可以理解为一段二进制乱码,128到256 bits,且为32的倍数。
大部分编程语言都有random方法来生成随机数。注意要保证生成的随机数是真随机的。比如javascript
的Math.random()
就是一个假随机数。这里我们用Rust实现一下,生成一段128 bits的乱码。
extern crate rand;
use self::rand::{thread_rng, RngCore};
fn gen_random_bytes(byte_length: usize) -> Vec<u8> {
let mut rng = thread_rng();
let mut bytes = vec![0u8; byte_length];
rng.fill_bytes(&mut bytes);
bytes
}
fn main() {
let bytes = gen_random_bytes(16); // 128 bits = 16 bytes
let bin = bytes.iter()
.map(|byte| format!("{:08b}", byte))
.collect::<String>();
println!("{}", bin);
}
// Bin 11001111011001110111011101110011001000100101010110010110111011001000101010100101011010100000110111111100010111000011101110011001
通过上述代码,我们得到一个128 bits的熵11001111011001110111011101110011001000100101010110010110111011001000101010100101011010100000110111111100010111000011101110011001
。
2. 由熵生成助记词
首先我们需要对刚刚得到的熵进行SHA256,会得到下面的十六进制的hash,同时将其转换成二进制。
use sha2::Digest;
fn sha256_first_byte(input: &[u8]) -> u8 {
sha2::Sha256::digest(input)[0]
}
// Bin 1101111001010100100001000101011100001011011001010100011101000010011100111101110100111000110111111001111000100000010110101110001011001001001110000110101110000111111010100010111101100111010010100001001111100110101101000011100000101001011110101010011000111101
// Hex 0xde5484570b65474273dd38df9e205ae2c9386b87ea2f674a13e6b438297aa63d
我们需要从这段hash里摘出来一些bits来加到刚刚生成的熵的最后,这个东西叫作校验和(checksum)。后面会介绍为什么要取这个校验和。一般的规范是,熵每32 bits,就从这段hash里开头摘1 bit出来。所以上面128 bits的熵,需要摘取开头的4 bits,就是1101
。
接着把这个校验和加到熵中,便得到了新的一串二进制数,熵+校验和,11001111011001110111011101110011001000100101010110010110111011001000101010100101011010100000110111111100010111000011101110011001 1101
。
因为我们刚刚加了4 bits,所以新二进制数的长度为132 bits。
然后我们把这132 bits的二进制数每11 bits分割,便能得到132 / 11 = 12
个小段,并将二进制转换为十进制。
11001111011 ➡️ 1659
00111011101 ➡️ 477
11011100110 ➡️ 1766
01000100101 ➡️ 549
01011001011 ➡️ 715
01110110010 ➡️ 946
00101010100 ➡️ 340
10101101010 ➡️ 1386
00001101111 ➡️ 111
11100010111 ➡️ 1815
00001110111 ➡️ 119
00110011101 ➡️ 413
BIP39提供了一个有2048个单词组成的字典,我们根据得到的十进制值对查询字典里对应的单词。这里要注意,字典的序号是从1开始的,而我们得到的十进制数是从0开始的,记得加1。
1659 + 1 = 1660 ➡️ sorry
477 + 1 = 478 ➡️ desert
1766 + 1 = 1767 ➡️ system
549 + 1 = 550 ➡️ dwarf
715 + 1 = 716 ➡️ floor
946 + 1 = 947 ➡️ iron
340 + 1 = 341 ➡️ clever
1386 + 1 = 1387 ➡️ pull
111 + 1 = 112 ➡️ assume
1815 + 1 = 1816 ➡️ title
119 + 1 = 120 ➡️ auction
413 + 1 = 414 ➡️ crisp
现在我们就得到了一套助记词sorry desert system dwarf floor iron clever pull assume title auction crisp
。
use zeroize::Zeroizing;
pub struct Mnemonic {
phrase: Zeroizing<String>,
entropy: Zeroizing<Vec<u8>>,
}
fn from_entropy_unchecked<E>(entropy: E) -> Mnemonic
where
E: Into<Vec<u8>>,
{
let entropy = Zeroizing::new(entropy.into());
let wordlist = wordlist;
let checksum_byte = sha256_first_byte(&entropy);
let phrase = Zeroizing::new(entropy
.iter()
.chain(Some(&checksum_byte))
.bits()
.map(|bits| wordlist.get_word(bits))
.join(" "));
Mnemonic {
phrase,
entropy,
}
}
上文提到,熵必须是32的倍数,所以每32 bits就会增加1 bit校验和。因此熵+校验和就是33的倍数。33的倍数肯定能被11整除,所以之后分割成的小段一定是等长的。又因为每个小段是11 bits,所以大小不会超过2 ** 11 = 2048
。而恰恰BIP39规范就正好提供了2048个单词。整套流程是严谨的。
校验和
现在回到前面的一个问题,为什么需要校验和呢?
有时候我们已经生成了一套助记词,想要导入到钱包时,就可能会出错。比如把上面的desert
记成了design
。由于助记词是可以逆向推导出序号的,所以有助记词的话,我们就能推导出熵+校验和。这里我们把desert
替换成design
逆向推导一下。
sorry ➡️ 1660 - 1 = 1559 ➡️ 11001111011
⬇️ 这里记错了单词
design ➡️ 479 - 1 = 478 ➡️ 00111011110
system ➡️ 1767 - 1 = 1766 ➡️ 11011100110
dwarf ➡️ 550 - 1 = 549 ➡️ 01000100101
floor ➡️ 716 - 1 = 715 ➡️ 01011001011
iron ➡️ 947 - 1 = 946 ➡️ 01110110010
clever ➡️ 341 - 1 = 340 ➡️ 00101010100
pull ➡️ 1387 - 1 = 1386 ➡️ 10101101010
assume ➡️ 112 - 1 = 111 ➡️ 00001101111
title ➡️ 1816 - 1 = 1815 ➡️ 11100010111
auction ➡️ 120 - 1 = 119 ➡️ 00001110111
crisp ➡️ 414 - 1 = 413 ➡️ 00110011101
这时得到的熵+校验和就是11001111011 00111011110 11011100110 01000100101 01011001011 01110110010 00101010100 10101101010 00001101111 11100010111 00001110111 00110011101
。所以熵就是11001111011001110111101101110011001000100101010110010110111011001000101010100101011010100000110111111100010111000011101110011001
,校验和就是1101
。还记得校验和是怎么来的吗?没错,对熵进行SHA256。计算后会得到一个hash0101101100000011011110110110011010100100110111011101110010010111101111111111001111000101000110010010100101111001100111000000000101001111011000010101101100110000010000100101010001010111101110110011010101000100001011011001000101010100010001100000110010010111
。可以发现前面4 bits是0101
,但是实际上的校验和是1101
,二者不等,所以这个助记词是有问题的。
除了防止记错以外,也可以防止碰撞攻击。如果所有助记词单词的组合和地址是一一对应的话,那么我随便改一个单词便能拿到其它地址的私钥,这显然是不合理的。通过校验和你就很难通过把助记词里的一些单词替换掉来窃取其它人的私钥。12位的助记词大概有2048 ** 12 = 5.4e+39
种组合。目前最强的民用芯片Intel i9的运算能力大概为每秒50亿次。用i9去碰撞所有可能的组合,需要2048 ** 12 / 5e+9 / 86400 / 365 = 3.5e+22
年。宇宙都灭亡了你害搁那算呢。
有了助记词之后,需要通过一套固定的加密算法,即PBKDF2 HMAC-SHA512
,将其变成种子(seed)。种子再经过固定算法HMAC-SHA512
,便可得到密钥对。这两步都有很多库可以实现,此处不再赘述。