編寫安全的瀏覽器擴充功能

編寫安全的瀏覽器擴充功能

與常見的「客戶端-伺服器」架構不同,去中心化應用程式的特點是:

  • 無需儲存包含使用者登入名稱和密碼的資料庫。 存取資訊僅由用戶自行存儲,其真實性的確認發生在協議層級。
  • 無需使用伺服器。 應用程式邏輯可以在區塊鏈網路上執行,可以在其中儲存所需的資料量。

用戶金鑰有兩種相對安全的儲存方式:硬體錢包和瀏覽器擴充。 硬體錢包大多非常安全,但難以使用且遠非免費,但瀏覽器擴充功能是安全性和易用性的完美結合,並且對最終用戶來說也可以完全免費。

考慮到所有這些,我們希望製作最安全的擴展,透過提供用於處理交易和簽名的簡單 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}
    );

...
}

這樣,應用程式就準備好了。 網頁可能會要求交易簽名:

編寫安全的瀏覽器擴充功能

編寫安全的瀏覽器擴充功能

程式碼可以在這裡找到 鏈接.

結論

如果您已讀完文章,但仍有疑問,可以透過以下方式提問: 具有擴充名的儲存庫。 在那裡您還可以找到每個指定步驟的提交。

如果您有興趣查看實際擴展的程式碼,您可以找到這個 這裡.

程式碼、儲存庫和工作說明來自 西馬雷爾

來源: www.habr.com

添加評論