编写安全的浏览器扩展

编写安全的浏览器扩展

与常见的“客户端-服务器”架构不同,去中心化应用程序的特点是:

  • 无需存储包含用户登录名和密码的数据库。 访问信息仅由用户自己存储,其真实性的确认发生在协议级别。
  • 无需使用服务器。 应用程序逻辑可以在区块链网络上执行,可以在其中存储所需的数据量。

用户密钥有两种相对安全的存储方式:硬件钱包和浏览器扩展。 硬件钱包大多非常安全,但难以使用且远非免费,但浏览器扩展是安全性和易用性的完美结合,并且对最终用户来说也可以完全免费。

考虑到所有这些,我们希望制作最安全的扩展,通过提供用于处理交易和签名的简单 API 来简化去中心化应用程序的开发。
下面我们就来给大家讲讲这段经历。

本文将包含有关如何编写浏览器扩展的分步说明,以及代码示例和屏幕截图。 您可以在中找到所有代码 储存库。 每个提交在逻辑上都对应于本文的一个部分。

浏览器扩展简史

浏览器扩展已经存在很长时间了。 它们于 1999 年出现在 Internet Explorer 中,于 2004 年出现在 Firefox 中。 然而,很长一段时间以来,扩展并没有单一的标准。

可以说,它是与扩展一起出现在第四版 Google Chrome 中的。 当然,当时还没有规范,但 Chrome API 成为了它的基础:在征服了大部分浏览器市场并拥有内置应用程序商店后,Chrome 实际上为浏览器扩展设定了标准。

Mozilla 有自己的标准,但看到 Chrome 扩展的流行,该公司决定制作一个兼容的 API。 2015 年,在 Mozilla 的倡议下,万维网联盟 (W3C) 内成立了一个专门小组来研究跨浏览器扩展规范。

以 Chrome 现有的 API 扩展为基础。 这项工作是在微软的支持下进行的(谷歌拒绝参与该标准的制定),结果出现了一份草案 规格.

正式而言,该规范受到 Edge、Firefox 和 Opera 的支持(请注意,Chrome 不在此列表中)。 但事实上,该标准在很大程度上与 Chrome 兼容,因为它实际上是基于其扩展编写的。 您可以阅读有关 WebExtensions API 的更多信息 这里.

扩展结构

扩展所需的唯一文件是清单 (manifest.json)。 这也是扩张的“入口点”。

宣言

根据规范,清单文件是有效的 JSON 文件。 清单键的完整描述,其中包含有关在哪个浏览器中可以查看哪些键受支持的信息 这里.

不在规范中的键“可能”会被忽略(Chrome 和 Firefox 都会报告错误,但扩展程序会继续工作)。

我想提请大家注意以下几点。

  1. 背景 — 包含以下字段的对象:
    1. 脚本 — 将在后台上下文中执行的脚本数组(我们稍后会讨论这个);
    2. - 您可以指定带有内容的 html,而不是在空页面中执行的脚本。 在这种情况下,脚本字段将被忽略,并且需要将脚本插入到内容页面中;
    3. 坚持 — 一个二进制标志,如果未指定,浏览器会在认为后台进程没有执行任何操作时“杀死”后台进程,并在必要时重新启动它。 否则,只有当浏览器关闭时才会卸载页面。 火狐浏览器不支持。
  2. 内容脚本 — 允许您将不同脚本加载到不同网页的对象数组。 每个对象包含以下重要字段:
    1. 火柴 - 模式网址,它确定是否包含特定内容脚本。
    2. js — 将加载到这场比赛中的脚本列表;
    3. 排除_匹配项 - 从该字段中排除 match 与该字段匹配的 URL。
  3. 页面操作 - 实际上是一个对象,负责在浏览器中地址栏旁边显示的图标并与其交互。 它还允许您显示一个弹出窗口,该窗口是使用您自己的 HTML、CSS 和 JS 定义的。
    1. 默认弹出窗口 — 带有弹出界面的 HTML 文件的路径,可能包含 CSS 和 JS。
  4. 权限 — 用于管理扩展权限的数组。 权限分为3种,详细描述 这里
  5. 网络可访问资源 — 网页可以请求的扩展资源,例如图像、JS、CSS、HTML 文件。
  6. 可外部连接 — 在这里您可以明确指定其他扩展的 ID 以及您可以连接的网页域。 域可以是二级或更高级别。 在 Firefox 中不起作用。

执行上下文

该扩展具有三个代码执行上下文,即应用程序由对浏览器 API 具有不同访问级别的三个部分组成。

扩展上下文

大多数 API 都可以在这里找到。 在这种背景下,他们“生活”:

  1. 背景页面 — 扩展的“后端”部分。 该文件是使用“background”键在清单中指定的。
  2. 弹出页面 — 单击扩展程序图标时出现的弹出页面。 在宣言中 browser_action -> default_popup.
  3. 自定义页面 — 扩展页面,“生活”在视图的单独选项卡中 chrome-extension://<id_расширения>/customPage.html.

此上下文独立于浏览器窗口和选项卡而存在。 背景页面 存在于单个副本中并且始终有效(事件页面除外,当后台脚本由事件启动并在执行后“死亡”时)。 弹出页面 当弹出窗口打开时存在,并且 自定义页面 - 当带有它的选项卡打开时。 无法从此上下文访问其他选项卡及其内容。

内容脚本上下文

内容脚本文件与每个浏览器选项卡一起启动。 它可以访问扩展程序的部分 API 和网页的 DOM 树。 内容脚本负责与页面交互。 操作 DOM 树的扩展在内容脚本中执行此操作 - 例如,广告拦截器或翻译器。 此外,内容脚本可以通过标准与页面通信 postMessage.

网页上下文

这是实际的网页本身。 它与扩展无关,并且无权访问该扩展,除非清单中未明确指示此页面的域(更多内容见下文)。

消息

应用程序的不同部分必须相互交换消息。 有一个 API 可以实现这个目的 runtime.sendMessage 发送消息 background и tabs.sendMessage 向页面发送消息(内容脚本、弹出窗口或网页(如果有) externally_connectable)。 以下是访问 Chrome API 时的示例。

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

为了充分沟通,您可以通过以下方式创建连接 runtime.connect。 作为回应,我们将收到 runtime.Port,当它打开时,您可以向其发送任意数量的消息。 以客户端为例, contentscript,它看起来像这样:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

服务器或后台:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
    console.assert(port.name === "knockknock");
    port.onMessage.addListener(function(msg) {
        if (msg.joke === "Knock knock")
            port.postMessage({question: "Who's there?"});
        else if (msg.answer === "Madame")
            port.postMessage({question: "Madame who?"});
        else if (msg.answer === "Madame... Bovary")
            port.postMessage({question: "I don't get it."});
    });
});

// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
    ...
});

还有一个活动 onDisconnect 和方法 disconnect.

应用图

让我们制作一个浏览器扩展,用于存储私钥、提供对公共信息(地址、公钥)的访问,与页面进行通信,并允许第三方应用程序请求交易签名。

应用开发

我们的应用程序必须与用户交互,并为页面提供 API 来调用方法(例如,签署交易)。 凑合着用一个 contentscript 不起作用,因为它只能访问 DOM,而不能访问页面的 JS。 通过连接 runtime.connect 我们不能,因为所有域都需要该 API,并且只能在清单中指定特定的域。 结果,图表将如下所示:

编写安全的浏览器扩展

还会有另一个剧本—— inpage,我们将其注入到页面中。 它将在其上下文中运行并提供用于使用扩展的 API。

开始

所有浏览器扩展代码均可在以下位置获取 GitHub上。 在描述过程中会有提交的链接。

让我们从宣言开始:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

创建空的background.js、popup.js、inpage.js 和contentscript.js。 我们添加 popup.html - 我们的应用程序已经可以加载到 Google Chrome 中并确保它可以正常工作。

为了验证这一点,您可以获取代码 。 除了我们所做的之外,该链接还使用 webpack 配置了项目的程序集。 要将应用程序添加到浏览器,在 chrome://extensions 中,您需要选择加载解压的文件以及具有相应扩展名的文件夹 - 在我们的示例中为 dist。

编写安全的浏览器扩展

现在我们的扩展已安装并运行。 您可以针对不同的上下文运行开发人员工具,如下所示:

弹出窗口->

编写安全的浏览器扩展

对内容脚本控制台的访问是通过启动它的页面本身的控制台进行的。编写安全的浏览器扩展

消息

所以,我们需要建立两个沟通渠道:inpage <->后台和popup <->后台。 当然,您可以只向端口发送消息并发明自己的协议,但我更喜欢在元掩码开源项目中看到的方法。

这是一个用于以太坊网络的浏览器扩展。 其中,应用程序的不同部分使用 dnode 库通过 RPC 进行通信。 如果你为它提供一个nodejs流作为传输(意味着实现相同接口的对象),它允许你非常快速和方便地组织交换:

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

现在我们将创建一个应用程序类。 它将为弹出窗口和网页创建 API 对象,并为它们创建一个 dnode:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(origin);
            console.log(remote)
        })
    }
}

在这里和下面,我们使用extensionApi代替全局Chrome对象,它可以访问Google浏览器中的Chrome和其他浏览器中的Chrome。 这样做是为了跨浏览器兼容性,但出于本文的目的,可以简单地使用“chrome.runtime.connect”。

让我们在后台脚本中创建一个应用程序实例:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

由于 dnode 使用流,并且我们接收端口,因此需要一个适配器类。 它是使用 Readable-stream 库制作的,该库在浏览器中实现了 NodeJS 流:

import {Duplex} from 'readable-stream';

export class PortStream extends Duplex{
    constructor(port){
        super({objectMode: true});
        this._port = port;
        port.onMessage.addListener(this._onMessage.bind(this));
        port.onDisconnect.addListener(this._onDisconnect.bind(this))
    }

    _onMessage(msg) {
        if (Buffer.isBuffer(msg)) {
            delete msg._isBuffer;
            const data = new Buffer(msg);
            this.push(data)
        } else {
            this.push(msg)
        }
    }

    _onDisconnect() {
        this.destroy()
    }

    _read(){}

    _write(msg, encoding, cb) {
        try {
            if (Buffer.isBuffer(msg)) {
                const data = msg.toJSON();
                data._isBuffer = true;
                this._port.postMessage(data)
            } else {
                this._port.postMessage(msg)
            }
        } catch (err) {
            return cb(new Error('PortStream - disconnected'))
        }
        cb()
    }
}

现在让我们在 UI 中创建一个连接:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

然后我们在内容脚本中创建连接:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import PostMessageStream from 'post-message-stream';

setupConnection();
injectScript();

function setupConnection(){
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

由于我们需要的 API 不是在内容脚本中,而是直接在页面上,因此我们做了两件事:

  1. 我们创建两个流。 一 - 朝向页面,位于 postMessage 之上。 为此,我们使用这个 这个包 来自metamask 的创建者。 第二个流是通过端口接收到的后台 runtime.connect。 我们来买吧。 现在页面将有一个流到后台。
  2. 将脚本注入到 DOM 中。 下载脚本(清单中允许访问它)并创建标签 script 其内容如下:

import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

现在我们在inpage中创建一个api对象并将其设置为全局:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

我们准备好了 具有用于页面和 UI 的单独 API 的远程过程调用 (RPC)。 当将新页面连接到后台时,我们可以看到:

编写安全的浏览器扩展

空 API 和来源。 在页面端,我们可以像这样调用 hello 函数:

编写安全的浏览器扩展

在现代 JS 中使用回调函数是不礼貌的,所以让我们编写一个小助手来创建一个 dnode,它允许您将 API 对象传递给 utils。

API 对象现在看起来像这样:

export class SignerApp {

    popupApi() {
        return {
            hello: async () => "world"
        }
    }

...

}

从远程获取对象,如下所示:

import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // С помощью утилит меняем все callback на promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

调用函数会返回一个承诺:

编写安全的浏览器扩展

具有异步功能的版本 这里.

总的来说,RPC 和流方法看起来相当灵活:我们可以使用 Steam 多路复用并为不同的任务创建多个不同的 API。 原则上,dnode可以在任何地方使用,主要是将传输以nodejs流的形式包装起来。

另一种选择是 JSON 格式,它实现了 JSON RPC 2 协议。但是,它适用于特定的传输(TCP 和 HTTP(S)),这不适用于我们的情况。

内部状态和本地存储

我们需要存储应用程序的内部状态 - 至少是签名密钥。 我们可以很容易地向应用程序添加状态以及在弹出 API 中更改它的方法:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

    popupApi(){
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index)
        }
    }

    ...

} 

在后台,我们将所有内容包装在一个函数中并将应用程序对象写入窗口,以便我们可以从控制台使用它:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const app = new SignerApp();

    if (DEV_MODE) {
        global.app = app;
    }

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url;
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

让我们从 UI 控制台添加一些键,看看状态会发生什么:

编写安全的浏览器扩展

状态需要持久化,以便重新启动时密钥不会丢失。

我们将其存储在 localStorage 中,每次更改时都会覆盖它。 随后,UI 也需要访问它,我也想订阅更改。 基于此,可以方便地创建一个可观察的存储并订阅其更改。

我们将使用 mobx 库(https://github.com/mobxjs/mobx)。 选择落在了它身上,因为我不必与它一起工作,但我真的很想研究它。

让我们添加初始状态的初始化并使存储可观察:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

    @action
    removeKey(index) {
        this.store.keys.splice(index, 1)
    }

    ...

}

“在幕后”,mobx 用代理替换了所有存储字段,并拦截对它们的所有调用。 可以订阅这些消息。

下面我会经常使用“改变时”这个词,尽管这并不完全正确。 Mobx 跟踪对字段的访问。 使用库创建的代理对象的 getter 和 setter。

动作装饰器有两个目的:

  1. 在带有enforceActions标志的严格模式下,mobx禁止直接改变状态。 在严格的条件下工作被认为是良好的做法。
  2. 即使一个函数多次更改状态 - 例如,我们更改几行代码中的多个字段 - 仅当它完成时观察者才会收到通知。 这对于前端尤其重要,因为不必要的状态更新会导致不必要的元素渲染。 就我们而言,第一个和第二个都不是特别相关,但我们将遵循最佳实践。 通常将装饰器附加到所有更改观察字段状态的函数。

在后台,我们将添加初始化并将状态保存在 localStorage 中:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

这里的反应函数很有趣。 它有两个参数:

  1. 数据选择器。
  2. 每次数据更改时都会使用该数据调用的处理程序。

与 redux 不同的是,我们显式地接收状态作为参数,mobx 会记住我们在选择器内访问哪些可观察量,并且仅在它们发生变化时调用处理程序。

准确理解 mobx 如何决定我们订阅哪些可观察对象非常重要。 如果我在这样的代码中编写一个选择器() => app.store,那么反应永远不会被调用,因为存储本身是不可观察的,只有它的字段是可观察的。

如果我这样写 () => app.store.keys,那么什么也不会发生,因为当添加/删除数组元素时,对它的引用不会改变。

Mobx 第一次充当选择器,只跟踪我们访问过的可观察量。 这是通过代理 getter 完成的。 因此,这里使用内置函数 toJS。 它返回一个新对象,其中所有代理都替换为原始字段。 在执行期间,它读取对象的所有字段 - 因此触发 getter。

在弹出的控制台中,我们将再次添加几个键。 这次它们也最终进入了 localStorage:

编写安全的浏览器扩展

当后台页面重新加载时,信息仍然存在。

可以查看到目前为止的所有应用程序代码 这里.

私钥的安全存储

以明文形式存储私钥是不安全的:您总是有可能被黑客攻击、获得对计算机的访问权限等等。 因此,在 localStorage 中,我们将以密码加密的形式存储密钥。

为了提高安全性,我们将为应用程序添加锁定状态,在这种状态下根本无法访问密钥。 由于超时,我们会自动将分机转为锁定状态。

Mobx 允许您仅存储最少的数据集,其余数据将根据它自动计算。 这些就是所谓的计算属性。 它们可以与数据库中的视图进行比较:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

现在我们只存储加密的密钥和密码。 其他的一切都是计算出来的。 我们通过从状态中删除密码来转移到锁定状态。 公共 API 现在有一个初始化存储的方法。

为加密而写 使用 crypto-js 的实用程序:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

浏览器有一个空闲 API,您可以通过它订阅事件 - 状态更改。 因此,国家可能是 idle, active и locked。 对于空闲,您可以设置超时,并在操作系统本身被阻止时设置锁定。 我们还将更改保存到 localStorage 的选择器:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';
const IDLE_INTERVAL = 30;

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

这一步之前的代码是 这里.

交易

因此,我们来到了最重要的事情:在区块链上创建和签署交易。 我们将使用 WAVES 区块链和库 波浪交易.

首先,我们向状态添加需要签名的消息数组,然后添加添加新消息、确认签名和拒绝的方法:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

当我们收到新消息时,我们向其中添加元数据, observable 并添加到 store.messages.

如果你不这样做 observable 手动,然后 mobx 将在向数组添加消息时自行完成。 但是,它将创建一个新对象,我们不会引用该对象,但下一步需要它。

接下来,我们返回一个在消息状态更改时解析的承诺。 通过反应来监控状态,当状态发生变化时,反应会“自杀”。

方法代码 approve и reject 非常简单:我们只需在必要时签署消息后更改消息的状态即可。

我们把Approve和reject放在UI API中,newMessage放在页面API中:

export class SignerApp {
    ...
    popupApi() {
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index),

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

现在让我们尝试使用扩展名签署交易:

编写安全的浏览器扩展

总的来说,一切都准备好了,剩下的就是 添加简单的用户界面.

UI

该接口需要访问应用程序状态。 在 UI 方面我们会做 observable 状态并向 API 添加一个函数来更改此状态。 让我们添加 observable 从后台接收到的 API 对象:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

    if (DEV_MODE) {
        global.background = background;
    }

    // Запуск интерфейса
    await initApp(background)
}

最后我们开始渲染应用程序界面。 这是一个反应应用程序。 背景对象只需使用 prop 传递即可。 当然,为方法创建单独的服务并为状态创建单独的服务是正确的,但就本文而言,这已经足够了:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

使用 mobx,当数据发生变化时,可以很容易地开始渲染。 我们只需将观察者装饰器从包中挂起 mobx-反应 在组件上,当组件引用的任何可观察对象发生更改时,将自动调用 render。 您不需要任何 mapStateToProps 或像 redux 中那样进行连接。 一切都开箱即用:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

其余组件可以在代码中查看 在 UI 文件夹中.

现在,在应用程序类中,您需要为 UI 创建一个状态选择器,并在其更改时通知 UI。 为此,我们添加一个方法 getState и reaction呼叫 remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

收到物体时 remote 已创建 reaction 更改在 UI 端调用该函数的状态。

最后一步是在扩展图标上添加新消息的显示:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

这样,应用程序就准备好了。 网页可能会要求交易签名:

编写安全的浏览器扩展

编写安全的浏览器扩展

代码可以在这里找到 链接.

结论

如果您已读完文章,但仍有疑问,可以通过以下方式提问: 具有扩展名的存储库。 在那里您还可以找到每个指定步骤的提交。

如果您有兴趣查看实际扩展的代码,您可以找到这个 这里.

代码、存储库和工作描述来自 西马雷尔

来源: habr.com

添加评论