安全なブラウザ拡張機能の作成

安全なブラウザ拡張機能の作成

一般的な「クライアント/サーバー」アーキテクチャとは異なり、分散型アプリケーションには次のような特徴があります。

  • ユーザーのログイン情報とパスワードをデータベースに保存する必要はありません。アクセス情報はユーザー自身によってのみ保存され、その信頼性の確認はプロトコル レベルで行われます。
  • サーバーを使用する必要はありません。アプリケーション ロジックはブロックチェーン ネットワーク上で実行でき、必要な量のデータを保存できます。

ユーザー キーには、ハードウェア ウォレットとブラウザ拡張機能という 2 つの比較的安全なストレージがあります。ハードウェア ウォレットのほとんどは非常に安全ですが、使いにくく、無料とは程遠いものですが、ブラウザ拡張機能はセキュリティと使いやすさの完璧な組み合わせであり、エンド ユーザーにとって完全に無料であることもあります。

これらすべてを考慮して、トランザクションと署名を操作するためのシンプルな API を提供することで、分散アプリケーションの開発を簡素化する最も安全な拡張機能を作成したいと考えました。
その体験談を以下でお伝えしていきます。

この記事では、ブラウザ拡張機能の作成方法について、コード例とスクリーンショットを使用して段階的に説明します。 すべてのコードは次の場所にあります。 リポジトリ。各コミットは論理的にこの記事のセクションに対応します。

ブラウザ拡張機能の簡単な歴史

ブラウザ拡張機能は長い間存在しています。これらは 1999 年に Internet Explorer に登場し、2004 年に Firefox に登場しました。しかし、非常に長い間、拡張機能に関する単一の標準は存在しませんでした。

Google Chromeの第4バージョンで拡張機能とともに登場したと言えるでしょう。もちろん、当時は仕様はありませんでしたが、その基礎となったのは Chrome API でした。ブラウザ市場の大部分を征服し、組み込みのアプリケーション ストアを備えた Chrome は、実際にブラウザ拡張機能の標準を設定しました。

Mozilla は独自の標準を持っていましたが、Chrome 拡張機能の人気を見て、互換性のある API を作成することにしました。 2015 年、Mozilla の主導により、World Wide Web Consortium (W3C) 内にクロスブラウザ拡張仕様に取り組む特別なグループが設立されました。

Chrome の既存の API 拡張機能が基礎として採用されました。この作業は Microsoft の支援を受けて実施され (Google は標準の開発への参加を拒否した)、その結果草案が登場しました。 仕様書.

正式には、この仕様は Edge、Firefox、Opera でサポートされています (Chrome はこのリストに含まれていないことに注意してください)。しかし実際には、この標準は Chrome の拡張機能に基づいて作成されているため、Chrome とほぼ互換性があります。 WebExtensions API の詳細については、こちらをご覧ください。 ここで.

拡張構造

拡張機能に必要なファイルはマニフェスト (manifest.json) だけです。それは拡張への「入り口」でもあります。

マニフェスト

仕様によれば、マニフェスト ファイルは有効な JSON ファイルです。マニフェスト キーの完全な説明と、どのブラウザでどのキーがサポートされ、表示できるかについての情報 ここで.

仕様にないキーは無視される可能性があります (Chrome と Firefox の両方でエラーが報告されますが、拡張機能は引き続き機能します)。

そしていくつかの点に注目したいと思います。

  1. 背景 — 次のフィールドを含むオブジェクト:
    1. スクリプト — バックグラウンド コンテキストで実行されるスクリプトの配列 (これについては後で説明します)。
    2. ページ - 空のページで実行されるスクリプトの代わりに、コンテンツを含む HTML を指定できます。この場合、スクリプト フィールドは無視され、スクリプトをコンテンツ ページに挿入する必要があります。
    3. 永続性 — バイナリ フラグ。指定されていない場合、ブラウザはバックグラウンド プロセスが何もしていないとみなしたときにバックグラウンド プロセスを「強制終了」し、必要に応じてバックグラウンド プロセスを再起動します。それ以外の場合、ページはブラウザを閉じたときにのみアンロードされます。 Firefox ではサポートされていません。
  2. コンテンツスクリプト — さまざまなスクリプトをさまざまな Web ページにロードできるようにするオブジェクトの配列。各オブジェクトには次の重要なフィールドが含まれています。
    1. マッチ - パターンのURL、特定のコンテンツ スクリプトが含まれるかどうかが決定されます。
    2. js — このマッチにロードされるスクリプトのリスト。
    3. exclude_matches - フィールドから除外します match このフィールドに一致する URL。
  3. ページアクション - 実際には、ブラウザのアドレス バーの横に表示されるアイコンとそのアイコンとの対話を担当するオブジェクトです。また、独自の HTML、CSS、JS を使用して定義されたポップアップ ウィンドウを表示することもできます。
    1. デフォルトポップアップ — ポップアップ インターフェイスを含む HTML ファイルへのパス。CSS と JS が含まれる場合があります。
  4. パーミッション — 拡張権限を管理するための配列。権利には3種類あり、詳しく解説します。 ここで
  5. web_accessible_resources — Web ページがリクエストできる拡張リソース (画像、JS、CSS、HTML ファイルなど)。
  6. 外部接続可能 — ここでは、接続元の他の拡張機能の ID と Web ページのドメインを明示的に指定できます。ドメインは第 2 レベル以上にすることができます。 Firefox では動作しません。

実行コンテキスト

拡張機能には 3 つのコード実行コンテキストがあります。つまり、アプリケーションは、ブラウザー API へのアクセスのレベルが異なる 3 つの部分で構成されます。

拡張コンテキスト

API のほとんどはここから入手できます。この文脈において、彼らは次のように「生きている」のです。

  1. 背景ページ — 拡張機能の「バックエンド」部分。ファイルはマニフェスト内で「background」キーを使用して指定されます。
  2. ポップアップページ — 拡張機能アイコンをクリックすると表示されるポップアップ ページ。マニフェストでは browser_action -> default_popup.
  3. カスタムページ — 拡張ページ、ビューの別のタブの「リビング」 chrome-extension://<id_расширения>/customPage.html.

このコンテキストは、ブラウザのウィンドウやタブとは独立して存在します。 背景ページ 単一のコピーに存在し、常に機能します (バックグラウンド スクリプトがイベントによって起動され、実行後に「終了」するイベント ページは例外です)。 ポップアップページ ポップアップ ウィンドウが開いているときに存在し、 カスタムページ — それを含むタブが開いている間。このコンテキストから他のタブとその内容にはアクセスできません。

コンテンツスクリプトコンテキスト

コンテンツ スクリプト ファイルは、ブラウザの各タブとともに起動されます。拡張機能の API の一部と Web ページの DOM ツリーにアクセスできます。ページとの対話を担当するのはコンテンツ スクリプトです。 DOM ツリーを操作する拡張機能 (広告ブロッカーやトランスレーターなど) は、コンテンツ スクリプトでこれを実行します。また、コンテンツ スクリプトは標準経由でページと通信できます。 postMessage.

Web ページのコンテキスト

これは実際の Web ページそのものです。このページのドメインがマニフェストで明示的に指定されていない場合を除き、拡張機能とは何の関係もなく、そこにアクセスすることもできません (これについては以下で詳しく説明します)。

メッセージング

アプリケーションのさまざまな部分は相互にメッセージを交換する必要があります。これにはAPIがあります runtime.sendMessage メッセージを送る background и tabs.sendMessage ページ (利用可能な場合はコンテンツ スクリプト、ポップアップ、または Web ページ) にメッセージを送信します。 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 (たとえば、トランザクションに署名するため) をページに提供する必要があります。 1つだけで間に合わせよう 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) を持つフォルダーを選択する必要があります。

安全なブラウザ拡張機能の作成

これで拡張機能がインストールされ、動作するようになりました。次のように、さまざまなコンテキストに対して開発者ツールを実行できます。

ポップアップ ->

安全なブラウザ拡張機能の作成

コンテンツ スクリプト コンソールへのアクセスは、それが起動されるページ自体のコンソールを通じて実行されます。安全なブラウザ拡張機能の作成

メッセージング

したがって、ページ内 バックグラウンドとポップアップ バックグラウンドという 2 つのコミュニケーション チャネルを確立する必要があります。もちろん、メッセージをポートに送信して独自のプロトコルを作成することもできますが、私はメタマスク オープン ソース プロジェクトで見たアプローチを好みます。

これは、イーサリアム ネットワークを操作するためのブラウザ拡張機能です。その中で、アプリケーションのさまざまな部分が 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)))
})

次に、アプリケーションクラスを作成します。ポップアップと Web ページの 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)
        })
    }
}

ここと以下では、グローバル Chrome オブジェクトの代わりに、Google のブラウザと他のブラウザの Chrome にアクセスする extensionApi を使用します。これはブラウザ間の互換性のために行われますが、この記事の目的では、単に「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 はストリームを操作し、ポートを受け取るため、アダプター クラスが必要です。これは、ブラウザに nodejs ストリームを実装する readable-stream ライブラリを使用して作成されます。

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 が必要なため、次の 2 つのことを行います。

  1. 2 つのストリームを作成します。 1 つ目 - ページに向かって、postMessage の上にあります。このためにこれを使用します このパッケージ メタマスクの作成者から。 2 番目のストリームは、から受信したポートをバックグラウンドで送信します。 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 でコールバック関数を扱うのはマナー違反です。そのため、API オブジェクトを utils に渡すことができる dnode を作成するための小さなヘルパーを作成しましょう。

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))
    })
});

そして、関数を呼び出すと Promise が返されます。

安全なブラウザ拡張機能の作成

非同期機能を利用できるバージョン ここで.

全体として、RPC とストリームのアプローチは非常に柔軟であるようです。蒸気多重化を使用し、さまざまなタスク用にいくつかの異なる API を作成できます。原則として、dnode はどこでも使用できます。主なことは、nodejs ストリームの形式でトランスポートをラップすることです。

代わりに、JSON RPC 2 プロトコルを実装する JSON 形式がありますが、これは特定のトランスポート (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 はフィールドへのアクセスを追跡します。ライブラリが作成するプロキシ オブジェクトのゲッターとセッターが使用されます。

アクション デコレータは次の 2 つの目的を果たします。

  1. enforceActions フラグを使用した厳密モードでは、mobx は状態を直接変更することを禁止します。厳しい条件の下で作業することは良い習慣であると考えられています。
  2. 関数が状態を数回変更した場合でも (たとえば、コードの数行で複数のフィールドを変更した場合)、オブザーバーには関数が完了したときにのみ通知されます。これは、不必要な状態更新が要素の不必要なレンダリングにつながるフロントエンドにとって特に重要です。私たちの場合、1 つ目も 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)
        }
    }
}

ここで興味深いのは反応関数です。これには 2 つの引数があります。

  1. データセレクター。
  2. このデータが変更されるたびに呼び出されるハンドラー。

状態を引数として明示的に受け取る redux とは異なり、mobx はセレクター内でどのオブザーバブルにアクセスするかを記憶しており、変更された場合にのみハンドラーを呼び出します。

mobx がどの Observable をサブスクライブするかを正確に理解することが重要です。このようなコードでセレクターを書いた場合() => app.storeの場合、ストレージ自体は監視可能ではなく、そのフィールドのみが監視可能であるため、リアクションは呼び出されません。

こうやって書いたら () => app.store.keys、配列要素を追加または削除しても、その要素への参照は変更されないため、やはり何も起こりません。

Mobx は初めてセレクターとして機能し、アクセスしたオブザーバブルのみを監視します。これはプロキシ ゲッターを通じて行われます。したがって、ここでは組み込み関数が使用されます toJS。すべてのプロキシが元のフィールドに置き換えられた新しいオブジェクトを返します。実行中に、オブジェクトのすべてのフィールドが読み取られるため、ゲッターがトリガーされます。

ポップアップ コンソールで、いくつかのキーを再度追加します。今回も最終的に 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。 idle の場合はタイムアウトを設定でき、locked の場合は OS 自体がブロックされたときに設定されます。また、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 が自動的に実行します。ただし、参照を持たない新しいオブジェクトが作成されますが、次のステップで必要になります。

次に、メッセージのステータスが変化したときに解決される Promise を返します。ステータスは反応によって監視されており、ステータスが変化すると「自滅」します。

メソッドコード approve и reject 非常に単純です。必要に応じてメッセージに署名した後、メッセージのステータスを変更するだけです。

UI API に承認と拒否を配置し、ページ API に newMessage を配置します。

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

インターフェイスはアプリケーションの状態にアクセスする必要があります。 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)
}

最後に、アプリケーション インターフェイスのレンダリングを開始します。これは反応アプリケーションです。背景オブジェクトは単に props を使用して渡されます。もちろん、メソッド用のサービスと状態用のストアを別々に作成するのは正しいですが、この記事の目的ではこれで十分です。

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 が自動的に呼び出されます。 redux のように、mapStateToProps や接続は必要ありません。すべてが箱から出してすぐに機能します。

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}
    );

...
}

これでアプリケーションの準備は完了です。 Web ページでは、トランザクションの署名を要求する場合があります。

安全なブラウザ拡張機能の作成

安全なブラウザ拡張機能の作成

コードはここから入手できます リンク.

まとめ

記事を最後まで読んでも質問がある場合は、次のアドレスから質問してください。 拡張機能付きのリポジトリ。ここには、指定された各ステップのコミットも表示されます。

実際の拡張機能のコードに興味がある場合は、これを見つけることができます。 ここで.

コード、リポジトリ、およびジョブの説明 シーマレル

出所: habr.com

コメントを追加します