区块链钱包开发(三)—— 钱包的初始化(入口文件加载)

175 阅读3分钟

区块链钱包开发(三)—— 钱包的初始化(入口文件加载)

前言

钱包完整的初始化是一个很复杂的过程,包括文件加载,各种消息通道的建立,各种事件的监听,控制器的初始化,UI的初始化等等,这些后面会讲但是不在本文讨论的范围,本文只讨论初始化时加载的第一个文件,也是入口文件app-init.js:

"background": {
  "service_worker": "scripts/app-init.js"
},

入口文件解析

源码地址:github.com/MetaMask/me…

//app-init.js

let scriptsLoadInitiated = false;
//所有通过importScripts加载的脚本都会立即同步阻塞执行
function importAllScripts() {
  if (scriptsLoadInitiated) {
    return;
  }
  scriptsLoadInitiated = true;
  importScripts(...fileNames);
}

// 首次安装时会触发此事件,触发时会同步加载所有预设的脚本文件
self.addEventListener('install', importAllScripts);

// 注册一个保活事件,在MV3中,如果Service Worker长时间不活跃会被浏览器强制关闭
// UI 会定期发送消息(通过 setInterval)以保持 Service Worker 活跃
// 每次收到消息时重新加载脚本,确保环境完整
chrome.runtime.onMessage.addListener(() => {
  importAllScripts();
  return false;
});

// 如果service worker状态为 'activated'(已激活),说明是重启而非首次安装
// 这是一种默认机制, 浏览器强制关闭service worker后再重新激活,service worker不会经历从install到activated的转变,而是总是activated
if (self.serviceWorker.state === 'activated') {
  importAllScripts();
}
// 动态注册content-script脚本
const registerInPageContentScript = async () => {
  try {
    await chrome.scripting.registerContentScripts([
      {
        id: 'inpage',
        matches: ['file://*/*', 'http://*/*', 'https://*/*'],
        js: ['scripts/inpage.js'],
        runAt: 'document_start',
        world: 'MAIN',
        allFrames: true,
      },
    ]);
  } catch (err) {
    console.warn(`Dropped attempt to register inpage content script. ${err}`);
  }
};


const deferredOnInstalledListener = Promise.withResolvers();
globalThis.stateHooks.onInstalledListener = deferredOnInstalledListener.promise;

/**
 * 这里需要监听onInstalled事件,将事件的返回值details存到全局变量中,因为后续需要用details.reason来判
 * 这是什么原因触发了onInstalled事件。details.reason是一个浏览器内置的枚举类型,有四种状态
 *(install/update/chrome_update/shared_module_update)不同的状态有不同的处理方式 
 */
chrome.runtime.onInstalled.addListener(function listener(details) {
  chrome.runtime.onInstalled.removeListener(listener);
  deferredOnInstalledListener.resolve(details);
  delete globalThis.stateHooks.onInstalledListener;
});

registerInPageContentScript();

关键点解读

1. 为什么要动态注册content-script脚本

文件最后执行registerInPageContentScript()将inpage.js注入到了content_scripts,而不是在manifest.json文件中直接声明,这里是由于浏览器的一个bug: bugs.chromium.org/p/chromium/…

因为inpage.js负责在每个网页中注入window.ethereum provider,所以需要访问网页上下文环境,Chrome扩展可以在两种执行环境中注入内容脚本:

  • ISOLATED world(默认):脚本在独立环境中运行,有自己的JavaScript执行上下文
  • MAIN world:脚本直接在页面的主环境中运行,可以直接访问页面的JavaScript上下文

由于bug的存在导致在manifest.json声明world: 'MAIN'无法生效,所以只能在安装的初始阶段动态注册。

2. 保活机制的实现

我们希望钱包插件一直处于工作状态,但在MV3中,如果一段时间(通常约30秒)内没有事件触发,浏览器会自动终止Service Worker。

在app-init.js中的如下代码严格来说不应该叫保活机制,而是复活机制,只有在service worker重新启动时才需要重新importAllScripts:

chrome.runtime.onMessage.addListener(() => { 
  importAllScripts(); 
  return false; 
});

真正的保活机制实际上是在background.js初始化时做的:

// 每两秒触发saveTimestamp将当前时间写入浏览器存储
const SAVE_TIMESTAMP_INTERVAL_MS = 2 * 1000;
saveTimestamp();
setInterval(saveTimestamp, SAVE_TIMESTAMP_INTERVAL_MS);

function saveTimestamp() {
  const timestamp = new Date().toISOString();
  browser.storage.session.set({ timestamp });
}

真实情况要更加复杂,我们可以发现在app-init.js中还有如下代码:

// service worker被意外关闭再重启时,其状态一直保持activated,这段代码保证了
// service worker重启时importAllScripts能被执行
if (self.serviceWorker.state === 'activated') {
  importAllScripts();
}

3. 多层保障机制的设计思路

为了保证在任何情况下service worker都可以正常运行,MetaMask在初始化时采用了多层保障机制:

首先我们要达成一种共识:在插件首次安装时,我们必须要在service worker处于installing状态以后才去执行importAllScripts,具体原因可能是会导致一些初始化顺序问题,或者不同浏览器兼容问题等。如果不需要保证这一点,我们可以不加任何判断和事件直接在app-init.js中调用importAllScripts,这样最简单而且保证importAllScripts在任何情况下百分百运行。

我们在前面提到,service worker重启时会直接进入activated状态,因此self.addEventListener('install', importAllScripts);是不会被触发的,我们需要保证重启时importAllScripts能被执行才添加了:

if (self.serviceWorker.state === 'activated') {
  importAllScripts();
}

理论上这样就可以保证任何情况下service worker的活跃,但MetaMask又添加了:

chrome.runtime.onMessage.addListener(() => { 
  importAllScripts(); 
  return false; 
});

在监听器中被触发一定是在直接在app-init.js调用触发之后,看起来有些冗余,这是一种防御性编程策略。MetaMask团队一定做了大量测试,覆盖各种极端场景、不同浏览器和操作系统等,这里看似冗余的代码实际上是一种兜底机制,尽最大努力保证钱包的正常运行。

一句话总结:为了使service worker一直正常运行,我们在background.js中通过每隔两秒触发事件的方式保活,如果出现意外情况我们可以通过判断self.serviceWorker的状态是否是activated复活service worker,如果这里意外没有触发复活,我们通过监听chrome.runtime.onMessage事件重新触发复活。

执行初始化流程图

flowchart TD
    N["浏览器启动Service Worker"] --> O{"首次安装?"}
    O -- 是 --> P["触发install事件"]
    P --> Q["执行importAllScripts"]
    O -- 否-也就是重启 --> R{"Service Worker状态?"}
    R -- activated --> S["直接执行importAllScripts"]
    R -- 其他状态 --> T["等待消息激活"]
    T --> U["收到UI保活消息"]
    U --> V["执行importAllScripts"]
    Q --> W["注册onInstalled监听器"]
    S --> W
    V --> W
    W --> X["注册inpage内容脚本"]
    X --> Y["结束"]

最终加载的文件集合

最终加载的文件列表包括:

MetaMask加载文件列表

commonxx/backgroundxx

这些是被webpack分块打包的公共代码块后台脚本分块,我们可以把它们统一看成是background.js,这部分后面的章节会详细讲解。

sentry-install.js

负责初始化实时错误监控和日志记录工具Sentry,用于收集生产环境中的错误信息。

init-globals.js

初始化全局变量,确保基本API在不同环境中的一致性:

const keys = ['XMLHttpRequest'];

keys.forEach((key) => {
  if (!Reflect.has(globalThis, key)) {
    globalThis[key] = undefined;
  }
});

if (!Reflect.has(globalThis, 'window')) {
  globalThis.window = globalThis;
}

安全相关文件

MetaMask采用了多层次的安全架构,包括以下几个关键文件:

lockdown-install.js

导入SES (Secure ECMAScript) 库,这是一个用于创建安全执行环境的JavaScript库:

import 'ses';
export {};
lockdown-run.js

执行SES库的lockdown()函数,冻结JavaScript环境中的所有内置对象:

try {
  lockdown({
    consoleTaming: 'unsafe',
    errorTaming: 'unsafe',
    domainTaming: 'unsafe',
    overrideTaming: 'severe',
  });
} catch (error) {
  console.error('Lockdown failed:', error);
  if (globalThis.sentry && globalThis.sentry.captureException) {
    globalThis.sentry.captureException(
      new Error(`Lockdown failed: ${error.message}`),
    );
  }
}

配置参数解释:

  • consoleTaming: 'unsafe':保留控制台功能的原始行为
  • errorTaming: 'unsafe':保留错误对象的原始行为
  • domainTaming: 'unsafe':不限制domain相关功能
  • overrideTaming: 'severe':严格防止对内置原型的修改
lockdown-more.js

lockdown()之后执行,进一步增强全局对象的安全性:

// 使globalThis上所有的"object"和"function"类型的自有属性变为不可配置和不可写
try {
  /**
   * 立即执行函数,用于保护JavaScript内置对象
   */
  (function protectIntrinsics() {

    // 获取新隔离区全局对象的所有自有键名
    const namedIntrinsics = Reflect.ownKeys(new Compartment().globalThis);

    // 这些命名的内置对象不会被`lockdown`自动硬化,需要手动处理
    const shouldHardenManually = new Set(['eval', 'Function', 'Symbol']);

    // 收集需要保护的全局属性
    const globalProperties = new Set([
      // 包含所有命名的内置对象
      ...namedIntrinsics,
    ]);

    // 遍历所有全局属性并进行保护
    globalProperties.forEach((propertyName) => {
      // 实现代码省略...
      
      // 对需要手动硬化的特殊内置对象进行处理
      if (shouldHardenManually.has(propertyName)) {
        try {
          // 尝试硬化对象
          harden(globalThis[propertyName]);
        } catch (err) {
          // 错误处理代码省略...
        }
      }
    });
  })();
} catch (error) {
  console.error('Protecting intrinsics failed:', error);
}
runtime-cjs.js

导入LavaMoat的Lavapack运行时库,LavaMoat是metamask团队开发的安全工具,限制每个依赖包只能访问其功能所需的最小资源集。防止供应链攻击(指黑客通过入侵软件依赖库,在库软件中植入恶意代码,间接攻击目标系统)

import '@lavamoat/lavapack/src/runtime-cjs';
export {};

安全架构的重要性

MetaMask采用了SES和LavaMoat两套互补的安全系统:

  1. SES (Secure ECMAScript)

    • 工作在JavaScript引擎层面
    • 冻结所有内置对象的原型,防止原型污染
    • 创建一个确定性、不可变的JavaScript基础环境
  2. LavaMoat

    • 工作在模块系统层面
    • 为每个依赖包创建独立的沙箱环境
    • 基于策略文件控制模块可访问的资源
    • 防止供应链攻击(指黑客通过入侵软件依赖库,在库软件中植入恶意代码)

这种多层次安全架构对于处理加密资产的应用至关重要,确保即使一层防御被突破,其他层仍能提供保护。SES和LavaMoat为钱包提供了安全的运行时环境,在语言层面防止原型链攻击和全局对象污染,在模块层面防止未授权的API访问和模块间越权。

学习交流请添加vx: gh313061

下期预告:搭建stream风格的通信框架