Scrierea unei extensii de browser securizate

Scrierea unei extensii de browser securizate

В отличие от распространенной «клиент-серверной» архитектуры, для децентрализованных приложений характерно:

  • Отсутствие необходимости хранить базу данных с логинами и паролями пользователя. Информация для доступа хранится исключительно у самих пользователей, а подтверждение их достоверности происходит на уровне протокола.
  • Отсутствие необходимости использовать сервер. Логика приложения может выполняться в блокчейн-сети, где возможно и хранение необходимого количества данных.

Существует 2 относительно безопасных хранилища для ключей пользователей — хардверные кошельки и браузерные расширения. Хардверные кошельки в большинстве своем максимально безопасны, однако сложны в использовании и далеко не бесплатны, а вот браузерные расширения являются идеальным сочетанием безопасности и простоты в использовании, а еще могут быть совершенно бесплатны для конечных пользователей.

Учитывая все это, мы захотели сделать максимально безопасное расширение, которое упрощает разработку децентрализованных приложений, предоставляя простой API для работы с транзакциями и подписями.
Об этом опыте мы вам и расскажем ниже.

В статье будет пошаговая инструкция как написать браузерное расширение, с примерами кода и скриншотами. Весь код вы можете найти в depozite. Каждый коммит логически соответствует разделу данной статьи.

Краткая история браузерных расширений

Браузерные расширения существуют достаточно давно. В Internet Explorer они появились еще в 1999-м году, в Firefox — в 2004-м. Тем не менее, очень долго не было единого стандарта для расширений.

Можно сказать, что он появился вместе с расширениями в четвертой версии Google Chrome. Конечно, никакой спецификации тогда не было, но именно API Chrome стал ее основой: завоевав большую часть рынка браузеров и имея встроенный магазин приложений, Chrome фактически задал стандарт для браузерных расширений.

У Mozilla был свой стандарт, но, видя популярность расширений для Chrome, компания решила сделать совместимый API. В 2015 году по инициативе Mozilla в рамках World Wide Web Consortium (W3C) была создана специальная группа для работы над спецификациями кроссбраузерных расширений.

За основу был взят уже существующий API расширений для Сhrome. Работа велась при поддержке Microsoft (Google в разработке стандарта участвовать отказался), и в результате появился черновик specificații.

Формально спецификацию поддерживают Edge, Firefox и Opera (заметьте, что в этом списке отсутствует Chrome). Но на самом деле стандарт во многом совместим и с Chrome, так как фактически написан на основе его расширений. Подробнее о WebExtensions API можно прочитать aici.

Структура расширения

Единственный файл, который обязательно нужен для расширения — манифест (manifest.json). Он же является “точкой входа” в расширение.

manifest

По спецификации файл манифеста является валидным JSON файлом. Полное описание ключей манифеста с информацией о том, какие ключи в поддерживается в каком браузере, можно посмотреть aici.

Ключи, которых нет в спецификации, “могут” быть проигнорированы (и Chrome, и Firefox пишут об ошибках, но расширения продолжают работать).

А я бы хотел обратить внимание на некоторые моменты.

  1. fundal — объект, который включает в себя следующие поля:
    1. script-uri — массив скриптов, которые будут выполнены в background-контексте (поговорим об этом чуть позже);
    2. pagină — вместо скриптов, которые будут выполнятся в пустой странице, можно задать html с контентом. В этом случае поле script будет проигнорировано, а скрипты нужно будет вставить в страницу с контентом;
    3. persista — бинарный флаг, eсли не указан, то браузер будет «убивать» background-процесс, когда посчитает, что он ничего не делает, и перезапускать при необходимости. В противном случае страница будет выгружена только при закрытии браузера. Не поддерживается в Firefox.
  2. content_scripts — массив объектов, позволяющий загружать разные скрипты к разным веб страницам. Каждый объект содержит следующие важные поля:
    1. meciuri - паттерн url, по которому определяется, будет включаться конкретный content script или нет.
    2. js — список скриптов которые будут загружены в данный матч;
    3. exclude_matches — исключает из поля match URL, которые удовлетворяют этому полю.
  3. page_action — фактически является объектом, который отвечает за иконку, которая отображается рядом с адресной строкой в браузере, и взаимодействие с ней. Позволяет так же показывать popup окно, которое задается с помощью своих HTML, CSS и JS.
    1. default_popup — путь до HTML файла с popup-интерфейсом, может содержать CSS и JS.
  4. permisiuni — массив для управления правами расширения. Существует 3 типа прав, которые подробно описаны aici
  5. web_accessible_resources — ресурсы расширения, которые может запрашивать веб страница, например, изображения, файлы JS, CSS, HTML.
  6. externally_connectable — здесь можно явно указать ID других расширений и домены веб-страниц, с которых можно подключаться. Домен может быть второго уровня и выше. Не работает в Firefox.

Контекст выполнения

У расширения есть три контекста исполнения кода, то есть, приложение состоит из трех частей с разным уровнем доступа к API браузера.

Extension context

Здесь доступна большая часть API. В этом контексте «живут»:

  1. Pagina de fundal — “backend” часть расширения. Файл указывается в манифесте по ключу “background”.
  2. Popup page — popup страница, которая появляется при нажатии на иконку расширения. В манифесте browser_action -> default_popup.
  3. Pagina personalizată — страница расширения, «живущая» в отдельной вкладке вида chrome-extension://<id_расширения>/customPage.html.

Этот контекст существует независимо от окон и вкладок браузера. Pagina de fundal существует в единственном экземпляре и работает всегда (исключение — event page, когда background-скрипт запускается по событию и «умирает» после его выполнения). Popup page существует, когда открыто окно popup, а Pagina personalizată — пока открыта вкладка с ней. Доступа к другим вкладкам и их содержимому из этого контекста нет.

Contextul scriptului de conținut

Fișierul script de conținut este lansat împreună cu fiecare filă de browser. Are acces la o parte din API-ul extensiei și la arborele DOM al paginii web. Scripturile de conținut sunt responsabile pentru interacțiunea cu pagina. Extensiile care manipulează arborele DOM fac acest lucru în scripturile de conținut - de exemplu, blocatorii de anunțuri sau traducătorii. De asemenea, scriptul de conținut poate comunica cu pagina prin standard postMessage.

Contextul paginii web

Aceasta este pagina web propriu-zisă. Nu are nicio legătură cu extensia și nu are acces acolo, cu excepția cazurilor în care domeniul acestei pagini nu este indicat în mod explicit în manifest (mai multe despre asta mai jos).

Schimb de mesaje

Diferite părți ale aplicației trebuie să facă schimb de mesaje între ele. Există un API pentru asta runtime.sendMessage pentru a trimite un mesaj background и tabs.sendMessage pentru a trimite un mesaj către o pagină (script de conținut, pop-up sau pagină web, dacă este disponibil externally_connectable). Mai jos este un exemplu când accesați API-ul Chrome.

// Сообщением может быть любой 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))
    }
)

Pentru o comunicare completă, puteți crea conexiuni prin runtime.connect. Ca răspuns vom primi runtime.Port, căruia, în timp ce este deschis, puteți trimite orice număr de mesaje. Pe partea clientului, de exemplu, contentscript, arata cam asa:

// Опять же 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"});

Server sau fundal:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
    ...
});

Există și un eveniment onDisconnect si metoda disconnect.

Diagrama aplicației

Să facem o extensie de browser care stochează chei private, oferă acces la informații publice (adresa, cheia publică comunică cu pagina și permite aplicațiilor terțe să solicite o semnătură pentru tranzacții.

Dezvoltarea aplicației

Aplicația noastră trebuie să interacționeze cu utilizatorul și să furnizeze paginii un API pentru a apela metode (de exemplu, pentru a semna tranzacții). Descurcă-te doar cu unul contentscript nu va funcționa, deoarece are acces doar la DOM, dar nu și la JS-ul paginii. Conectați prin runtime.connect nu putem, deoarece API-ul este necesar pe toate domeniile și numai anumite domenii pot fi specificate în manifest. Ca rezultat, diagrama va arăta astfel:

Scrierea unei extensii de browser securizate

Va fi un alt scenariu - inpage, pe care îl vom injecta în pagină. Acesta va rula în contextul său și va furniza un API pentru lucrul cu extensia.

Home

Toate codurile de extensie ale browserului sunt disponibile la GitHub. În timpul descrierii vor exista link-uri către comite.

Să începem cu manifestul:

{
  // Имя и описание, версия. Все это будет видно в браузере в 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"]
}

Creați background.js, popup.js, inpage.js și contentscript.js goale. Adăugăm popup.html - iar aplicația noastră poate fi deja încărcată în Google Chrome și ne asigurăm că funcționează.

Pentru a verifica acest lucru, puteți lua codul prin urmare. Pe lângă ceea ce am făcut, linkul a configurat asamblarea proiectului folosind webpack. Pentru a adăuga o aplicație în browser, în chrome://extensions trebuie să selectați load unpacked și folderul cu extensia corespunzătoare - în cazul nostru dist.

Scrierea unei extensii de browser securizate

Acum extensia noastră este instalată și funcționează. Puteți rula instrumentele pentru dezvoltatori pentru diferite contexte, după cum urmează:

pop-up ->

Scrierea unei extensii de browser securizate

Accesul la consola de script de conținut se realizează prin consola paginii însăși pe care este lansat.Scrierea unei extensii de browser securizate

Schimb de mesaje

Deci, trebuie să stabilim două canale de comunicare: inpage <-> background și popup <-> background. Puteți, desigur, să trimiteți mesaje către port și să vă inventați propriul protocol, dar prefer abordarea pe care am văzut-o în proiectul metamask open source.

Aceasta este o extensie de browser pentru lucrul cu rețeaua Ethereum. În ea, diferite părți ale aplicației comunică prin RPC folosind biblioteca dnode. Vă permite să organizați un schimb destul de rapid și convenabil dacă îi furnizați un flux nodejs ca transport (adică un obiect care implementează aceeași interfață):

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

Acum vom crea o clasă de aplicație. Va crea obiecte API pentru pop-up și pagina web și va crea un nod pentru ele:

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

Aici și mai jos, în loc de obiectul global Chrome, folosim extensionApi, care accesează Chrome în browserul Google și browserul în altele. Acest lucru se face pentru compatibilitatea între browsere, dar în scopul acestui articol s-ar putea folosi pur și simplu „chrome.runtime.connect”.

Să creăm o instanță de aplicație în scriptul de fundal:

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

Deoarece dnode funcționează cu fluxuri și primim un port, este nevoie de o clasă de adaptor. Este realizat folosind biblioteca de fluxuri citibile, care implementează fluxurile nodejs în browser:

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

Acum să creăm o conexiune în 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;
    }
}

Apoi creăm conexiunea în scriptul de conținut:

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

Deoarece avem nevoie de API nu în scriptul de conținut, ci direct pe pagină, facem două lucruri:

  1. Creăm două fluxuri. Unu - spre pagină, deasupra postMesajului. Pentru asta folosim asta acest pachet de la creatorii metamaskului. Al doilea flux este de fundal peste portul primit de la runtime.connect. Să le cumpărăm. Acum pagina va avea un flux în fundal.
  2. Injectați scriptul în DOM. Descărcați scriptul (accesul la acesta a fost permis în manifest) și creați o etichetă script cu conținutul său în interior:

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

Acum creăm un obiect API în inpage și îl setăm la global:

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

Suntem gata Remote Procedure Call (RPC) cu API separată pentru pagină și UI. Când conectăm o pagină nouă la fundal, putem vedea asta:

Scrierea unei extensii de browser securizate

API gol și origine. Pe partea paginii, putem apela funcția hello astfel:

Scrierea unei extensii de browser securizate

Lucrul cu funcțiile de apel invers în JS modern este o manieră proastă, așa că haideți să scriem un mic ajutor pentru a crea un dnode care vă permite să transmiteți un obiect API către utils.

Obiectele API vor arăta acum astfel:

export class SignerApp {

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

...

}

Obținerea unui obiect de la distanță ca acesta:

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

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

Și apelarea funcțiilor returnează o promisiune:

Scrierea unei extensii de browser securizate

Versiune cu funcții asincrone disponibile aici.

În general, abordarea RPC și stream pare destul de flexibilă: putem folosi multiplexarea cu abur și putem crea mai multe API-uri diferite pentru sarcini diferite. În principiu, dnode poate fi folosit oriunde, principalul lucru este să înfășurați transportul sub forma unui flux nodejs.

O alternativă este formatul JSON, care implementează protocolul JSON RPC 2. Cu toate acestea, funcționează cu transporturi specifice (TCP și HTTP(S)), ceea ce nu este aplicabil în cazul nostru.

Stat intern și local Stocare

Va trebui să stocăm starea internă a aplicației - cel puțin cheile de semnare. Putem adăuga destul de ușor o stare aplicației și metode de modificare în API-ul pop-up:

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

    ...

} 

În fundal, vom include totul într-o funcție și vom scrie obiectul aplicației în fereastră, astfel încât să putem lucra cu el din consolă:

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

Să adăugăm câteva chei din consola UI și să vedem ce se întâmplă cu starea:

Scrierea unei extensii de browser securizate

Starea trebuie să fie persistentă, astfel încât cheile să nu se piardă la repornire.

Îl vom stoca în localStorage, suprascriindu-l la fiecare modificare. Ulterior, accesul la acesta va fi necesar și pentru UI și, de asemenea, aș dori să mă abonez la modificări. Pe baza acestui lucru, va fi convenabil să creați o stocare observabilă și să vă abonați la modificările acesteia.

Vom folosi biblioteca mobx (https://github.com/mobxjs/mobx). Alegerea a căzut pe seama ei pentru că nu trebuia să lucrez cu el, dar îmi doream foarte mult să-l studiez.

Să adăugăm inițializarea stării inițiale și să facem magazinul observabil:

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

    ...

}

„Sub capotă”, mobx a înlocuit toate câmpurile magazinului cu proxy și interceptează toate apelurile către acestea. Va fi posibil să vă abonați la aceste mesaje.

Mai jos voi folosi adesea termenul „când schimb”, deși acest lucru nu este în întregime corect. Mobx urmărește accesul la câmpuri. Sunt utilizați aplicații de obținere și de setare a obiectelor proxy pe care le creează biblioteca.

Decoratorii de acțiune au două scopuri:

  1. În modul strict cu indicatorul enforceActions, mobx interzice schimbarea directă a stării. Este considerată o bună practică să lucrezi în condiții stricte.
  2. Chiar dacă o funcție schimbă starea de mai multe ori - de exemplu, schimbăm mai multe câmpuri în mai multe rânduri de cod - observatorii sunt anunțați doar când se completează. Acest lucru este deosebit de important pentru frontend, unde actualizările inutile ale stării duc la redarea inutilă a elementelor. În cazul nostru, nici primul, nici al doilea nu este deosebit de relevant, dar vom urma cele mai bune practici. Este obișnuit să atașați decoratori la toate funcțiile care schimbă starea câmpurilor observate.

În fundal vom adăuga inițializarea și salvarea stării în 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)
        }
    }
}

Funcția de reacție este interesantă aici. Are doua argumente:

  1. Selector de date.
  2. Un handler care va fi apelat cu aceste date de fiecare dată când se schimbă.

Spre deosebire de redux, unde primim în mod explicit starea ca argument, mobx își amintește ce observabile accesăm în interiorul selectorului și apelează handler-ul numai atunci când se schimbă.

Este important să înțelegem exact cum decide mobx la ce observabile ne abonam. Dacă am scris un selector în cod ca acesta() => app.store, atunci reacția nu va fi niciodată numită, deoarece stocarea în sine nu este observabilă, doar câmpurile sale sunt.

Daca am scris-o asa () => app.store.keys, apoi din nou nu s-ar întâmpla nimic, deoarece atunci când adăugați/eliminați elemente de matrice, referința la acesta nu se va schimba.

Mobx acționează ca un selector pentru prima dată și urmărește doar observabilele pe care le-am accesat. Acest lucru se face prin intermediul proxy getters. Prin urmare, funcția încorporată este utilizată aici toJS. Returnează un obiect nou cu toate proxy-urile înlocuite cu câmpurile originale. În timpul execuției, citește toate câmpurile obiectului - prin urmare getter-urile sunt declanșate.

În consola pop-up vom adăuga din nou mai multe chei. De data aceasta au ajuns și în localStorage:

Scrierea unei extensii de browser securizate

Când pagina de fundal este reîncărcată, informațiile rămân la locul lor.

Tot codul aplicației până în acest moment poate fi vizualizat aici.

Stocarea securizată a cheilor private

Stocarea cheilor private în text clar este nesigură: există întotdeauna șansa să fiți piratat, să obțineți acces la computer și așa mai departe. Prin urmare, în localStorage vom stoca cheile într-o formă criptată cu parolă.

Pentru o mai mare securitate, vom adăuga aplicației o stare blocată, în care nu va exista deloc acces la chei. Vom transfera automat extensia în starea blocată din cauza unui timeout.

Mobx vă permite să stocați doar un set minim de date, iar restul este calculat automat pe baza acestuia. Acestea sunt așa-numitele proprietăți calculate. Ele pot fi comparate cu vizualizările din bazele de date:

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

Acum stocăm doar cheile criptate și parola. Orice altceva este calculat. Facem transferul într-o stare blocată prin eliminarea parolei din stare. API-ul public are acum o metodă de inițializare a stocării.

Scris pentru criptare utilitare care folosesc 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)
}

Browserul are un API inactiv prin care vă puteți abona la un eveniment - modificări de stare. Stat, în consecință, poate fi idle, active и locked. Pentru inactiv, puteți seta un timeout, iar blocat este setat atunci când sistemul de operare în sine este blocat. Vom schimba, de asemenea, selectorul pentru salvarea în 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)
        }
    }
}

Codul de dinaintea acestui pas este aici.

Tranzacție

Așadar, ajungem la cel mai important lucru: crearea și semnarea tranzacțiilor pe blockchain. Vom folosi blockchain-ul și biblioteca WAVES valuri-tranzacţii.

Mai întâi, să adăugăm la stat o serie de mesaje care trebuie semnate, apoi să adăugăm metode pentru adăugarea unui mesaj nou, confirmarea semnăturii și refuzul:

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'
    }

    ...
}

Când primim un mesaj nou, îi adăugăm metadate, faceți observable si adauga la store.messages.

Dacă nu observable manual, atunci mobx o va face singur atunci când adaugă mesaje în matrice. Cu toate acestea, va crea un nou obiect la care nu vom avea o referință, dar vom avea nevoie de el pentru următorul pas.

Apoi, returnăm o promisiune care se rezolvă atunci când starea mesajului se schimbă. Starea este monitorizată prin reacție, care se va „sinucide” atunci când starea se schimbă.

Codul metodei approve и reject foarte simplu: pur și simplu schimbăm starea mesajului, după ce îl semnăm dacă este necesar.

Am pus Aprobare și respingere în UI API, newMessage în pagina 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)
        }
    }

    ...
}

Acum să încercăm să semnăm tranzacția cu extensia:

Scrierea unei extensii de browser securizate

În general, totul este gata, tot ce rămâne este adăugați o interfață de utilizare simplă.

UI

Interfața are nevoie de acces la starea aplicației. Pe partea UI vom face observable stare și adăugați o funcție la API care va schimba această stare. Să adăugăm observable la obiectul API primit din fundal:

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

La sfârșit începem redarea interfeței aplicației. Aceasta este o aplicație de reacție. Obiectul de fundal este pur și simplu transmis folosind recuzită. Ar fi corect, desigur, să facem un serviciu separat pentru metode și un magazin pentru stat, dar în sensul acestui articol este suficient:

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

Cu mobx, este foarte ușor să începeți redarea atunci când datele se schimbă. Pur și simplu atârnăm decoratorul observator de pachet mobx-react pe componentă, iar randarea va fi apelată automat atunci când orice observabile la care face referire componentă se schimbă. Nu aveți nevoie de mapStateToProps sau de conectare ca în redux. Totul funcționează imediat din cutie:

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

Componentele rămase pot fi vizualizate în cod în folderul UI.

Acum, în clasa de aplicație, trebuie să faceți un selector de stare pentru UI și să notificați UI când se schimbă. Pentru a face acest lucru, să adăugăm o metodă getState и reactionchemând 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())

        })
    }

    ...
}

La primirea unui obiect remote este creat reaction pentru a schimba starea care apelează funcția din partea UI.

Atingerea finală este să adăugați afișarea mesajelor noi pe pictograma extensiei:

function setupApp() {
...

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

...
}

Deci, aplicația este gata. Paginile web pot solicita o semnătură pentru tranzacții:

Scrierea unei extensii de browser securizate

Scrierea unei extensii de browser securizate

Codul este disponibil aici legătură.

Concluzie

Dacă ați citit articolul până la sfârșit, dar mai aveți întrebări, le puteți adresa la adresa depozite cu extensie. Acolo veți găsi, de asemenea, commit-uri pentru fiecare pas desemnat.

Și dacă sunteți interesat să vă uitați la codul pentru extensia reală, puteți găsi acest lucru aici.

Cod, depozit și fișa postului de la siemarell

Sursa: www.habr.com

Adauga un comentariu