基于MetaMask 理解钱包工作原理
MetaMask 是一款广受欢迎的以太坊钱包浏览器插件,它为用户提供了一种安全、便捷的方式来管理他们的数字资产并与去中心化应用程序(DApp)进行交互。本文将深入探讨 MetaMask 的源代码,分析其架构设计、关键模块和功能实现,以帮助读者更好地理解这个钱包是如何工作的,以及其背后所依赖的相关标准。
MetaMask 的架构概览
MetaMask 采用了一种模块化的架构设计,各个模块之间职责明确,相互协作,以实现钱包的各项功能。主要模块包括:
- background.js:负责处理后台任务,如事务签名、状态更新等。
- contentscript.js:负责与 Web3 网站进行交互,注入 MetaMask 提供的 Web3 实例。
- ui:包含用户界面相关的组件和逻辑,如界面渲染、用户输入处理等。
- lib:包含各种工具函数和辅助模块,如助记词管理、密钥派生等。
- app:整合各个模块,构建完整的 MetaMask 应用。
钱包创建流程
当用户首次安装 MetaMask 时,会触发钱包创建流程。这个流程主要包括以下步骤:
- 生成助记词:MetaMask 使用
bip39
库生成一组随机的助记词(通常为 12 个单词)。相关代码位于lib/seed-phrase.js
文件中的generateMnemonic
函数。 - 派生主密钥:根据助记词和可选的密码,MetaMask 使用
hdkey
库派生出一个主密钥。相关代码位于lib/hd-keyring.js
文件中的_deserializeVault
函数。 - 派生账户私钥:基于 BIP44 定义的路径规则,MetaMask 从主密钥派生出默认账户的私钥。路径为
m/44'/60'/0'/0/0
。相关代码位于lib/hd-keyring.js
文件中的_pathFromIndex
函数。 - 加密存储:MetaMask 使用用户提供的密码对助记词和私钥进行加密,然后将加密后的数据存储在本地。相关代码位于
lib/keyring.js
文件中的serialize
函数。
种子短语和私钥的安全存储
MetaMask 采用了多重安全措施来保护用户的种子短语和私钥,其中最关键的是加密存储。
加密种子短语和私钥
MetaMask 使用对称加密算法(如 AES)对种子短语和私钥进行加密,然后将加密后的密文存储在本地。加密密钥是从用户的密码中派生出来的。相关的代码位于 lib/keyring.js
文件中的 encrypt
函数:
async encrypt(password, object) {
const salt = this._getSalt() || crypto.randomBytes(16);
const key = await this._getKey(password, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', key, crypto.randomBytes(16));
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(object)), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([salt, ciphertext, tag]).toString('base64');
}
这个函数首先生成一个随机的盐值(salt),然后使用 _getKey
函数从用户密码和盐值派生出加密密钥。接着,它使用 AES-256-GCM 算法创建一个 cipher 对象,随机生成一个初始化向量(IV),并使用 cipher 对象加密种子短语或私钥的 JSON 字符串表示。最后,它将盐值、密文和认证标签(tag)连接起来,并转换为 Base64 编码的字符串。
解密种子短语和私钥
当用户解锁 MetaMask 时,需要使用用户的密码来派生出解密密钥,然后用其解密种子短语和私钥。相关的代码位于 lib/keyring.js
文件中的 decrypt
函数:
async decrypt(password, encryptedString) {
const encryptedBuffer = Buffer.from(encryptedString, 'base64');
const salt = encryptedBuffer.slice(0, 16);
const ciphertext = encryptedBuffer.slice(16, -16);
const tag = encryptedBuffer.slice(-16);
const key = await this._getKey(password, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, crypto.randomBytes(16));
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(plaintext.toString());
}
这个函数首先从 Base64 编码的字符串中提取出盐值、密文和认证标签。然后,它使用 _getKey
函数和盐值从用户密码派生出解密密钥。接着,它创建一个 decipher 对象,设置认证标签,并使用 decipher 对象解密密文。最后,它将解密后的明文转换为字符串,并解析为 JavaScript 对象。
DApp 交互和交易签名
MetaMask 通过注入一个自定义的 Web3 实例,使得 DApp 能够与以太坊网络进行交互。这个自定义的 Web3 实例在 contentscript.js
文件中定义,其结构如下:
const inpageProvider = new MetamaskInpageProvider(metamaskStream);
const web3 = new Web3(inpageProvider);
web3.setProvider = () => {
console.error('MetaMask: setProvider() is deprecated');
};
当用户在 DApp 中发起一笔交易时,MetaMask 会拦截这个请求,并向用户显示一个确认页面,列出交易的详细信息,如接收地址、交易金额、Gas 费用等。
用户确认后,MetaMask 会使用相应账户的私钥对交易进行签名。签名过程遵循以太坊的交易签名标准(如 EIP-155),并使用 ethereumjs-tx
库进行实现。相关的代码位于 lib/eth-tx-manager.js
文件中的 addUnapprovedTransaction
函数:
addUnapprovedTransaction(txParams, cb) {
const { from } = txParams;
this._validateTxParams(txParams);
const txMeta = {
id: createId(),
time: Date.now(),
status: 'unapproved',
metamaskNetworkId: this._getCurrentChainId(),
txParams: { ...txParams, from },
};
this._txs.push(txMeta);
this._addTxToHistory(txMeta);
this._updateTxsInState();
this._recomputeUnapprovedTxsEvent();
cb && cb();
}
签名完成后,MetaMask 将签名后的交易发送到以太坊网络,并返回交易哈希给 DApp。
页面注入对象
MetaMask 通过在 DApp 页面的 JavaScript 上下文中注入一个名为 window.ethereum
的对象来实现与 DApp 的交互。这个对象提供了一组标准化的 API,允许 DApp 与以太坊网络进行通信,发送交易,签名消息等。
window.ethereum
对象的结构和行为在很大程度上遵循了 EIP-1193(Ethereum Provider JavaScript API)标准。这个标准定义了 Ethereum Provider 应该如何与 DApp 进行交互,以及应该提供哪些 API。
以下是 window.ethereum
对象的一些关键属性和方法:
ethereum.isMetaMask
: 一个布尔值,指示当前 Provider 是否是 MetaMask。ethereum.networkVersion
: 一个字符串,表示当前连接的以太坊网络 ID。ethereum.selectedAddress
: 一个字符串,表示用户当前选择的以太坊地址(账户)。ethereum.request(args)
: 一个方法,允许 DApp 发送请求到 Ethereum Provider。这是 EIP-1193 中定义的主要方法,它取代了旧的ethereum.send()
方法。args
参数是一个对象,包含请求的详细信息,如方法名和参数。ethereum.on(eventName, callback)
: 一个方法,允许 DApp 监听 Ethereum Provider 触发的事件。常见的事件包括:accountsChanged
: 当用户切换账户时触发。networkChanged
: 当用户切换网络时触发。chainChanged
: 当用户切换链时触发(EIP-1193 中新增的事件)。
下面是一个简化的 window.ethereum
对象示例:
window.ethereum = {
isMetaMask: true,
networkVersion: '1',
selectedAddress: '0x1234567890123456789012345678901234567890',
request: async (args) => {
// 处理请求,如发送交易,签名消息等
// ...
},
on: (eventName, callback) => {
// 注册事件监听器
// ...
},
// 其他属性和方法
// ...
};
Web3 实例拦截
MetaMask 会通过其注入的自定义 Web3 实例(基于window.ethereum 创建的)拦截这个请求。具体而言,首次加载时就把MetamaskInpageProvider
注入到了window.ethereum,以便在交易发送到以太坊网络之前对其进行处理。
在 contentscript.js
文件中,MetaMask 定义了一个名为 MetamaskInpageProvider
的类,这个类继承自 EventEmitter
,并重写了 request
方法:
class MetamaskInpageProvider extends SafeEventEmitter {
// ...
async request(args) {
// 验证请求参数
// ...
return new Promise((resolve, reject) => {
this._rpcRequest({ method, params }, getRpcPromiseCallback(resolve, reject));
});
}
sendAsync(payload, cb) {
this._rpcRequest(payload, cb);
}
// ...
}
_rpcRequest
方法是处理所有 RPC 请求的核心方法,它会根据请求的方法名进行不同的处理。对于 eth_sendTransaction
方法(用于发送交易),MetaMask 会拦截这个请求,并执行以下步骤:
- 对交易参数进行验证和规范化处理。
- 向后台页面发送一个名为
addUnapprovedTransaction
的消息,将交易添加到未确认交易列表中。 - 在用户界面上显示一个确认页面,列出交易的详细信息,如接收地址、交易金额、Gas 费用等。
- 用户确认交易后,MetaMask 会使用相应账户的私钥对交易进行签名。
- 将签名后的交易发送到以太坊网络,并返回交易哈希给 DApp。
相关的代码片段如下(contentscript.js
):
async _rpcRequest(payload, cb) {
// ...
if (payload.method === 'eth_sendTransaction') {
const txMeta = await this._addUnapprovedTransactionAndGetMeta(
payload.params[0],
);
this._showConfirmationDialog(txMeta, () => {
// 用户确认交易后的处理逻辑
// ...
});
// 返回交易哈希给 DApp
cb(null, txMeta.hash);
} else {
// 处理其他 RPC 请求
// ...
}
// ...
}
在后台页面中,addUnapprovedTransaction
方法(位于 lib/eth-tx-manager.js
)会将交易添加到未确认交易列表中,并触发相应的事件和状态更新:
addUnapprovedTransaction(txParams, cb) {
// ...
const txMeta = {
id: createId(),
time: Date.now(),
status: 'unapproved',
metamaskNetworkId: this._getCurrentChainId(),
txParams: { ...txParams, from },
};
this._txs.push(txMeta);
this._addTxToHistory(txMeta);
this._updateTxsInState();
this._recomputeUnapprovedTxsEvent();
// ...
}