基于MetaMask 理解钱包工作原理
钱包保管的是密钥,不是币。一句助记词按 BIP-32/39/44 派生出全部账户,vault 用 PBKDF2 加 AES-GCM 锁在本地,页面拿到的 window.ethereum 只是一根通往后台的管子 —— 以及 EIP-6963 为什么要换掉抢占 window.ethereum 的老做法。
把 MetaMask 从浏览器里卸载掉,币会不会没?
不会。链上没有「MetaMask 里的钱」这回事 —— 以太坊的状态里只有「地址 0xabc… 余额多少」这样一条记录,它不认识你用什么软件。插件在本地存的只有一份加密过的助记词,卸载掉的是那份密文。换一台电脑把助记词填回去,同一批地址会被重新算出来,余额一分不少。
所以「MetaMask 保管你的资产」这个说法是反的:它保管的是密钥,资产在链上。 这个区别决定了下面所有的设计。一个钱包真正要解决的问题只有三个:怎么从一句话生出无数个账户、怎么把这句话锁在本地、以及怎么在不交出这句话的前提下替网页签名。
一句助记词决定了你所有的账户
那 12 个单词不是「密码」,是一段熵的可读编码。BIP-39 规定:128 位随机数附上 4 位校验和,一共 132 位,每 11 位查一次 2048 词的词表,正好切成 12 个词。词数和熵的关系是 词数 = (熵 + 熵/32) / 11,所以 24 个词对应 256 位。
这 12 个词接下来要变成一颗种子。BIP-39 的做法是 PBKDF2-HMAC-SHA512,迭代 2048 次,把助记词本身当口令,盐是字符串 "mnemonic" 拼上你设的那个可选密码短语,输出 64 字节。注意这里的迭代次数低得反常 —— 2048 次几乎不构成任何暴力破解的障碍,因为 BIP-39 假设的是「助记词本身已经有 128 位熵」,这一步防的不是猜词,只是拉长单次尝试的时间。
种子交给 BIP-32,得到一棵可以无限往下长的密钥树。BIP-44 再规定这棵树该怎么走:
m / 44' / 60' / 0' / 0 / 0 │ │ │ │ └── address_index:这是第几个地址 │ │ │ └── change:0 是外部地址,1 是找零(以太坊上不用) │ │ └── account':账户编号 │ └── coin_type':60 就是以太坊 └── purpose':固定 44,表示按 BIP-44 走MetaMask 的 HD keyring 把默认路径写成前四段 m/44'/60'/0'/0,派生第 n 个账户时在后面接上 n。所以你在界面里点「创建账户」,插件并没有生成新的随机数,只是把路径末尾的序号加一,再算一次。
这件事的后果值得单独说一句:加账户不需要重新备份。 助记词是这棵树的根,第 100 个账户和第 1 个账户来自同一句话,恢复时按同样的路径重算就能全部找回来。反过来,这也意味着助记词是单点 —— 泄露一次,所有账户一起没。
代码在 MetaMask/accounts 仓库的 packages/keyring-eth-hd/src/hd-keyring.ts,类叫 HdKeyring,生成助记词的公开方法是 generateRandomMnemonic(),底层用的是 @metamask/scure-bip39。
BIP-39 那 4 位校验和只够发现错误,不能纠正。抄错一个词,插件会告诉你这句话不合法,但它没法告诉你错在第几个词。
vault 是一段密文,解密密钥每次从密码现算
助记词落到磁盘上时是一段密文,MetaMask 管它叫 vault。做这件事的是 @metamask/browser-passworder,用的是浏览器的 WebCrypto(SubtleCrypto),不是 Node 的 crypto —— 插件跑在浏览器里,没有 Node 那套 API。
流程只有两步:PBKDF2 从你的密码派生出一把 256 位的密钥,AES-GCM 用这把密钥加密。存下来的是一个 JSON 字符串:
{ "data": "…base64,密文…", "iv": "…base64,16 字节随机初始向量…", "salt": "…base64,32 字节随机盐…", "keyMetadata": { "algorithm": "PBKDF2", "params": { "iterations": 600000 } }}三个字段各有各的不能少的理由。盐让两个用同样密码的人算出不同的密钥,顺带废掉预先算好的彩虹表。IV 必须原样存下来,因为解密时要用同一个 IV —— 这是最容易写错的地方,解密时现生成一个随机 IV 是解不出任何东西的。keyMetadata 记着这份 vault 是用多少次迭代加密的,有了它,迭代次数才能在不让所有老用户重设密码的前提下往上调。
迭代次数这个数字很关键。库里留着两个常量:老的 OLD_DERIVATION_PARAMS 是 10,000 次,库的当前默认值是 900,000 次,而扩展在构造加密器时自己传的是 600,000。这个数是唯一挡住离线爆破的东西 —— 拿到 vault 密文的人可以在自己机器上无限次猜密码,没有任何服务端来限速或者封号。10,000 次在今天的显卡面前几乎不算数。
AES-GCM 还带一个认证标签。vault 被改过一个字节,解密会直接失败,而不是解出一堆垃圾再被上层当成合法数据用。
密码本身不存在任何地方。这就是自托管的字面含义:没有后门,也没有「找回密码」。
页面里的 window.ethereum 只是一根管子
网页拿到的那个 window.ethereum,和私钥之间隔着两道进程边界。搞清楚这条链路上每一段在干什么,比记住任何 API 都重要,因为整个安全模型就写在这个分工里。
inpage.js跑在页面的 JavaScript 上下文里,和 DApp 的代码共享同一个 window。它建一条WindowPostMessageStream,然后调@metamask/providers的initializeProvider,window.ethereum就是这一步装上去的。contentscript.js是内容脚本,两头接线:一头是上面那条 window 消息流,另一头是通往扩展后台的 port。它不定义任何 provider 类,只做管道。- 后台才有私钥。交易进到未确认列表、弹确认页、用户点同意之后拿账户私钥签名,全在这一侧。这部分现在是独立的
@metamask/transaction-controller包,入口方法是addTransaction。
window.ethereum 那个对象的类是 MetaMaskInpageProvider,在 MetaMask/providers 仓库的 src/MetaMaskInpageProvider.ts。它的继承链是 MetaMaskInpageProvider → AbstractStreamProvider → BaseProvider → SafeEventEmitter。真正发请求的 _rpcRequest 在 BaseProvider 上,而它只对 eth_accounts 和 eth_requestAccounts 做了特殊处理(拿到结果后顺手更新一下缓存的账户列表),其余所有方法一律原样交给 JSON-RPC 引擎,通过那条流送去后台。
这里没有任何「如果是发交易就弹个窗」的分支,而且不能有。页面上下文里的代码,DApp 全都读得到也改得了 —— 把「要不要弹确认框」的判断放在那儿,等于让被审查的人自己决定要不要接受审查。确认这件事必须发生在网页碰不到的地方。
对外的接口形状由 EIP-1193 规定,核心就一个方法:
if (!window.ethereum) throw new Error('没有装钱包插件');
// 会弹出授权页;用户拒绝时抛出 code 4001const accounts = await window.ethereum.request({ method: 'eth_requestAccounts',});console.log(accounts[0]);
window.ethereum.on('accountsChanged', (accounts) => { /* 可能是空数组,表示断开 */ });window.ethereum.on('chainChanged', (chainId) => window.location.reload());EIP-1193 定义的事件一共五个:connect、disconnect、chainChanged、accountsChanged、message。老教程里的 networkChanged 不在其中,它在 @metamask/providers v16.0.0(2024-03-12)里被删掉了,替代品是 chainChanged。同一个版本还删掉了三个属性:ethereum.chainId、ethereum.networkVersion、ethereum.selectedAddress。它们不是「不推荐用」,是真的没有了 —— 现在拿链 ID 走 net_version 或 eth_chainId,拿当前账户走 eth_accounts。
ethers 之类的库并不替换这套东西,只是在外面包一层:v6 里把 window.ethereum 交给 BrowserProvider,就能用和连自建节点时一样的接口读链上数据,具体写法在 Node.js,Ethers.js:解析以太坊区块链数据那篇。
多个钱包抢一个全局变量,EIP-6963 换了个开法
window.ethereum 是个普通的全局变量,装两个钱包插件,两个都往上写,最后加载的那个赢。用户装了三个钱包却只能连上其中一个,而且是哪一个还不确定 —— 这个问题拖了很多年。
EIP-6963 的解法是不再抢那个变量,改用事件互相发现。它已经是 Final 状态:
const wallets = new Map();
window.addEventListener('eip6963:announceProvider', (event) => { const { info, provider } = event.detail; wallets.set(info.rdns, { info, provider }); // rdns 是反向域名,全局唯一});
window.dispatchEvent(new Event('eip6963:requestProvider'));
// wallets 里现在是所有在场的钱包,让用户自己挑一个两个事件方向相反:钱包在自己加载完时主动发一次 eip6963:announceProvider,页面则用 eip6963:requestProvider 再问一遍。两边都会发,所以谁先加载都无所谓,这是它能取代抢全局变量的关键。随 provider 一起送过来的 info 有四个字段:uuid(本次会话的随机标识)、name(显示名)、icon(图标,data URI)、rdns(反向域名,用来稳定地区分钱包)。
MetaMask 在 @metamask/providers v13.1.0(2023-10-11)加上了这套支持。它没有删掉 window.ethereum,两条路并存 —— 老 DApp 照旧能用,只是仍然会碰上抢变量的老问题。
这套设计的代价
vault 的安全性等于密码的强度,再乘以迭代次数。 密文躺在磁盘上,攻击者拿走它之后是完全离线的爆破,没有速率限制、没有告警、没人能吊销。600,000 次 PBKDF2 把每次尝试的成本抬高了,但抬不过一个六位数字的密码。而且早年创建、之后没改过密码的 vault,可能仍停在 10,000 次那档。
确认页只能替你挡住它看得懂的东西。 钱包能把「转 0.1 ETH 给某地址」显示清楚,但一笔调用陌生合约的交易,界面上能给的信息终究受限于它对那份 calldata 的解析能力。签名钓鱼针对的就是这段空白 —— 用户点「确认」时未必知道自己批准了什么。
助记词是单点,而且不可撤销。 私钥没有「改一下」这个操作。怀疑助记词泄露了,唯一的办法是新建一个钱包,再把所有资产逐笔转过去,每一笔都要付 gas,NFT 和各种合约里的仓位还得一个个手动搬。
EIP-6963 只解决了「连哪个钱包」。 它让多个插件能共存,但连上之后的权限模型一点没变:一个 DApp 拿到 provider 之后能发起哪些请求、能看到你哪些账户,仍然由钱包自己那套确认流程管,标准在这一层什么都没规定。
这篇归在链上基础设施下。同一主题里最近的另外两篇是 Node.js,Ethers.js:解析以太坊区块链数据和 TheGraph 二:subgraph 四大关键定义数据。