Създаваме безопасно браузерно разширение

Създаваме безопасно браузерно разширение

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

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

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

Като взехме предвид всичко това, решихме да създадем максимално безопасно разширение, което опростява разработката на децентрализирани приложения, като предлага прост API за работа с транзакции и подписи.
За този опит ще ви разкажем по-долу.

В статията ще предоставим стъпка по стъпка инструкции как да напишете браузерно разширение, с примери на код и скрийншотове. Целият код можете да намерите в репозитории. Всеки комит логически съответства на раздела на тази статия.

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

Браузерните разширения съществуват от доста време. В Internet Explorer те се появиха още през 1999 година, в Firefox — през 2004 година. Въпреки това, дълго време не съществуваше единен стандарт за разширения.

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

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

В основата бе взет вече съществуващият API за разширения за Chrome. Работата се извършва под подкрепата на Microsoft (Google отказа да участва в разработката на стандарта), и в резултат се появи проект. спецификация.

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

Структура на разширението

Единственият файл, който е задължителен за разширението — манифест (manifest.json). Той също така е „точката за вход“ в разширението.

Манифест

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

Ключовете, които не са в спецификацията, „могат“ да бъдат пренебрегнати (и Chrome, и Firefox изпращат грешки, но разширенията продължават да работят).

Искам да обърна внимание на някои моменти.

  1. background — обект, който включва следните полета:
    1. scripts — масив от скриптове, които ще бъдат изпълнени в контекста на background (ще говорим за това малко по-късно);
    2. page — вместо скриптове, които ще бъдат изпълнени на празната страница, може да се зададе HTML с съдържание. В този случай полето script ще бъде игнорирано, а скриптовете трябва да бъдат вградени в страницата с съдържание;
    3. persistent — бинарен флаг, ако не е указан, браузърът ще „убива“ background процеса, когато прецени, че той не прави нищо, и ще го рестартира при необходимост. В противен случай страницата ще бъде изключена само при затваряне на браузъра. Не се поддържа в Firefox.
  2. content_scripts — масив от обекти, позволяващ зареждането на различни скриптове за различни уеб страници. Всеки обект съдържа следните важни полета:
    1. matches — шаблон на URL,на базата на който се определя, дали конкретният content script ще се включи или не.
    2. js — списък от скриптове, които ще бъдат заредени в текущото съвпадение;
    3. exclude_matches — изключва от полето match URL, които отговарят на това поле.
  3. page_action — по същество е обект, който отговаря за иконката, която се показва до адресната лента в браузъра, и взаимодействието с нея. Позволява също така показването на изскачащ прозорец, който се задава с помощта на собствените си HTML, CSS и JS.
    1. default_popup — път до HTML файла с popup интерфейса, може да съдържа CSS и JS.
  4. permissions — масив за управление на правата на разширението. Съществуват 3 типа права, които са описани подробно. тук
  5. web_accessible_resources — ресурси на разширението, които уеб страницата може да поиска, например, изображения, JS файлове, CSS, HTML.
  6. externally_connectable — тук можете да укажете ID на други разширения и домейни на уеб страници, от които можете да се свързвате. Домейнът може да бъде второ ниво и по-високо. Не работи в Firefox.

Контекст на изпълнение

Разширението има три контекста за изпълнение на кода, тоест приложението се състои от три части с различно ниво на достъп до API на браузъра.

Контекст на разширението

Тук е налична по-голямата част от API. В този контекст „живеят“:

  1. Фоновата страница — “бекенд” част от разширението. Файлът се указва в манифеста по ключа “background”.
  2. Popup страница — popup страницата, която се появява при натискане на иконата на разширението. В манифеста browser_action -> default_popup.
  3. Персонализирана страница — страница на разширението, „живееща“ в отделна разделка от типа chrome-extension:///customPage.html.

Този контекст съществува независимо от прозорци и разделки на браузъра. Фоновата страница съществува в единствен екземпляр и работи винаги (изключение — event page, когато фоновият скрипт се стартира по събитие и „умира“ след неговото изпълнение). Popup страница съществува, когато е отворено прозорец popup, а Персонализирана страница — докато е отворена разделка с него. Достъп до други разделки и тяхното съдържание от този контекст няма.

Контекст на контент скрипт

Файлът на контент скрипта се стартира заедно с всяка разделка на браузъра. Той има достъп до част от API на разширението и DOM дървото на уеб страницата. Именно контент скриптовете отговарят за взаимодействието с страницата. Разширенията, манипулиращи DOM дървото, правят това в контент скриптове – например, блокировчици на реклами или преводачи. Освен това контент скриптът може да комуникира със страницата чрез стандартния postMessage.

Контекст на уеб страницата

Това е самата уеб страница. Тя няма нищо общо с разширението и няма достъп до него, освен в случаите, когато в манифеста изрично не е посочен домейнът на тази страница (за това — по-долу).

Обмен на съобщения

Различните части на приложението трябва да обменят съобщения помежду си. За тази цел съществува API runtime.sendMessage за изпращане на съобщение background и tabs.sendMessage за изпращане на съобщение на страницата (контент скрипт, popup или уеб страница, при наличие) externally_connectable). По-долу е пример за контакт с API на 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))
    }
)

За пълноценно общуване може да се създадат връзки чрез 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"});

Сървър или background:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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=<ид на разширението>
  "name": "Signer",
  "description": "Демо на разширение",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скриптове, които ще се изпълняват в background, може да има няколко
  "background": {
    "scripts": ["background.js"]
  },

  // Какъв html да се използва за popup
  "browser_action": {
    "default_title": "Моето разширение",
    "default_popup": "popup.html"
  },

  // Скриптове за съдържание.
  // Имаме един обект: за всички url, започващи с http или https, стартираме
  // контекст на contenscript със скрипта 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 да изберете load unpacked и папката с съответното разширение — в нашия случай dist.

Създаваме безопасно браузерно разширение

Сега нашето разширение е инсталирано и работи. Инструментите за разработчици за различни контексти могат да бъдат стартирани по следния начин:

popup ->

Създаваме безопасно браузерно разширение

Достъпът до конзолата на съдържание-скрипта се осъществява през конзолата на самата страница, на която е стартиран.Създаваме безопасно браузерно разширение

Обмен на съобщения

И така, необходимо е да установим два канала за комуникация: inpage background и popup background. Може, разбира се, просто да изпращате съобщения до порта и да изобретите свой протокол, но на мен повече ми допада подходът, който видях в проекта с отворен код metamask.

Това е браузърно разширение за работа с мрежата Ethereum. В него различните части на приложението комуникират чрез RPC с помощта на библиотеката dnode. Тя позволява бързо и удобно организиране на обмена, ако предоставите nodejs stream (става дума за обект, реализиращ същия интерфейс):

import Dnode from "dnode/browser";

// В този пример приемаме, че клиентът отдалечено извиква функции на сървъра, въпреки че нищо не ни пречи да направим това двупосочно

// Сървер
// 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 за popup и уеб-страницата, както и ще създаде 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 използваме extentionApi, който се свързва с Chrome в браузъра на Google и с browser в другите. Това се прави за кросбраузерност, но в рамките на тази статия може да се използва и просто 'chrome.runtime.connect'.

Създаваме инстанс на приложението в background скрипта:

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);
    // При установяване на свързване можем да укажем име, по това име определяме кой се свързва към нас, content script или 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;
    }
}

Следващата стъпка е да създадем връзка в content script:

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. Създаваме два стрима. Един — в посока страницата, над пост мейл. За тази цел използваме този пакет от създателите на metamask. Вторият стрим — към бекграунда, над порта, получен от runtime.connect. Свързваме ги. Сега страницата ще има стрим до бекграунда.
  2. Инжектираме скрипт в DOM. Изтегляме скрипта (достъпът до него беше разрешен в манифеста) и създаваме таг скрипт с неговото съдържание вътре:

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

Сега създаваме обект api в inpage и го съхраняваме в глобалния контекст:

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

Имаме готово Remote Procedure Call (RPC) с отделен API за страницата и UI. Когато свържем нова страница към фона, можем да го видим:

Създаваме безопасно браузерно разширение

Празен API и origin. От страната на страницата можем да извикаме функцията hello по следния начин:

Създаваме безопасно браузерно разширение

Работата с callback функции в съвременен JS е остаряла, затова ще напишем малък помощен инструмент за създаване на dnode, който позволява да предаваме в обекта API в utils.

Обектите на API сега ще изглеждат по следния начин:

export class SignerApp {

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

...

}

Получаването на обект от remote по следния начин:

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

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

А извикването на функции връща промис:

Създаваме безопасно браузерно разширение

Версия с асинхронни функции е налична тук..

В общи линии, подходът с RPC и стримове изглежда доста гъвкав: можем да използваме стрийм мултиплексинг и да създаваме няколко различни API за различни задачи. В принципесед dnode може да се използва навсякъде, важното е да обвием транспорта в вида на nodejs стрийм.

Алтернативата е формат JSON, който реализира протокола JSON RPC 2. Но той работи с конкретни транспортни протоколи (TCP и HTTP(S)), което в нашия случай не е приложимо.

Вътрешен стейт и localStorage

Нуждаем се от съхранение на вътрешния стейт на приложението — поне ключовете за подписване. Можем да добавим лесно стейт на приложението и методи за неговото променяне в popup 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)
        }
    }

    ...

} 

В background ще обвием всичко във функция и ще запишем обекта на приложението в window, за да можем да работим с него от консолата:

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, така че е полезно да създадем наблюдаемо хранилище (observable storage) и да се абонираме за промените.

Ще използваме библиотеката mobx (https://github.com/mobxjs/mobx). Избрахме я, защото не сме работили с нея, а много искахме да я проучим.

Добавяме инициализация на началното състояние и правим хранилището наблюдаемo:

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

export class SignerApp {

    constructor(initState = {}) {
        \/\/ Външно хранилището ще остане същото, просто сега всички негови полета станаха 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 замени всички полета на хранилището с proxy и прихваща всички обращения към тях. Може да се абонирате за тези обращения.

По-късно ще използвам термина “при промяна” често, въпреки че това не е напълно коректно. Mobx наблюдава именно достъпа до полетата. Използват се гетери и сетери на proxy обектите, които библиотеката създава.

Декораторите action имат две цели:

  1. В строг режим с флага enforceActions mobx забранява директната промяна на състоянието. Добра практика е да се работи именно в строг режим.
  2. Дори ако функцията променя състоянието няколко пъти - например, променяме няколко полета в няколко реда код - наблюдателите се уведомяват само когато тя приключи. Това е особено важно за фронтенда, където излишните актуализации на състоянието водят до ненужен рендер на елементите. В нашия случай нито първото, нито второто е особено актуално, но ще следваме най-добрите практики. Декораторите обикновено се прилагат на всички функции, които променят състоянието на наблюдаваните полета.

В background добавяме инициализация и запазване на състоянието в 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;
    }

    // Настройка на персистентност на състоянието

    // Резултатът от реакцията се присвоява на променлива, за да можем да отменим абонамента. Това не е нужно, оставено е за пример
    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);
        }
    }
}

Интересна е тук функцията reaction. Тя има два аргумента:

  1. Селектор на данните.
  2. Обработчик, който ще бъде извикан с тези данни всеки път, когато те се променят.

В контекста на redux, където явно получаваме състоянието като аргумент, mobx запомня към кои точно наблюдаеми елементи се обръщаме в селектора и само при тяхната промяна извиква обработчика.

Важно е да разберем как mobx решава на кои наблюдаеми се подписваме. Ако в кода бях написал селектора по следния начин:() => app.store, то reaction никога нямаше да бъде извикан, тъй като самото хранилище не е наблюдаемо, а само неговите полета са наблюдаемите.

Ако бях написал по следния начин: () => app.store.keys, отново нищо нямаше да се случи, тъй като при добавяне/изтриване на елементи от масива, препратката към него няма да се промени.

Mobx за първи път изпълнява функцията селектор и следи само за тези observable, до които имаме достъп. Това се прави чрез гетъри прокси. Затова тук е използвана вградената функция toJS. Тя връща нов обект, в който всички проксита са заменени с оригиналните полета. По време на изпълнението тя чете всички полета на обекта – следователно сработват гетърите.

В конзолата popup отново ще добавим няколко ключа. Този път те попаднаха и в localStorage:

Създаваме безопасно браузерно разширение

При презареждане на background-страницата информацията остава на мястото си.

Целият код на приложението до този момент може да бъде видян тук..

Сигурно съхранение на частни ключове

Да се съхраняват частни ключове в открит вид не е безопасно: винаги съществува вероятност да ви хакнат, да получат достъп до компютъра ви и така нататък. Затова в localStorage ще съхраняваме ключовете в закодирана паролата форма.

За по-голяма сигурност ще добавим на приложението стейт locked, в който достъпът до ключовете няма да бъде възможен изобщо. Автоматично ще превеждаме разширението в стейт locked по тайм-аут.

Mobx позволява да се съхранява само минимален набор от данни, а останалото автоматично да се изчислява на тяхна база. Това са т.нар. computed properties. Може да се сравнят с view в базите данни:

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 - приложението е заключено
            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('Приложението е заключено')
        }
    }

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

    static _decryptVault(str, pass) {
        if (str === undefined) {
            throw new Error('Хранилището не е инициализирано')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        } catch (e) {
            throw new Error('Грешна парола')
        }
    }
}

Сега съхраняваме само шифровани ключове и паролата. Всичко останало се изчислява. Преходът в стейт locked го правим чрез премахване на паролата от стейта. В публичния 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)
}

Браузерът разполага с idle API, чрез който можете да се абонирате за събитие — изменения на състоянието. Съответно, състоянието може да бъде idle, активно и заключено. За idle можете да настроите тайм-аут, а заключеното се установява, когато операционната система е блокирана. Също така ще променим селектора за запис в 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(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

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

    // Свързване с други контексти
    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 и библиотеката waves-transactions.

Първо, ще добавим в състоянието масив от съобщения, които трябва да бъдат подписани, след това — методи за добавяне на ново съобщение, потвърждаване на подписа и отказ:

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(`ново съобщение: ${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('Потребителят отхвърли съобщението'));
                            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(`Няма съобщение с 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(`Няма съобщение с id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Когато получим ново съобщение, добавяме в него метаданни и правим observable и добавяме в store.messages.

Ако не го направим observable ръчно, то mobx ще го направи сам при добавяне в масива messages. Обаче той ще създаде нов обект, към който нямаме референция, а тя ще е необходима за следващата стъпка.

След това връщаме промис, който се резолва при промяна на статуса на съобщението. За статуса следи reaction, който сам ще се "убие" при смяна на статуса.

Кодът на методите approve и reject е много прост: просто променяме статуса на съобщението, предварително подписвайки го, ако е необходимо.

Одобрява и отхвърляме през 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

Интерфейсът се нуждае от достъп до състоянието на приложението. От страната на интерфейса ще направим observable състояние и ще добавим в API функция, която ще променя това състояние. Ще добавим observable в обекта API, получен от background:

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

В края ние стартираме рендерирането на интерфейса на приложението. Това е react приложение. Background-обектът просто се предава чрез props. Правилно, разбира се, е да се направи отделен сервис за методите и store за състоянието, но в рамките на тази статия това е достатъчно:

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

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

С помощта на mobx е много лесно да стартираме рендерирането при промяна на данните. Просто прикачваме декоратора observer от пакета mobx-react На компонент, и рендер ще бъде автоматично извикан при промяна на всякакви observable, на които компонентът се позовава. Не са нужни никакви mapStateToProps или connect, както в redux. Всичко работи веднага „от кутията“:

внесете React, {Component, Fragment} от 'react'
внесете {observer} от "mobx-react";
внесете Init от '.\/components\/Initialize'
внесете Keys от '.\/components\/Keys'
внесете Sign от '.\/components\/Sign'
внесете Unlock от '.\/components\/Unlock'

@observer \/\/ Компонент с този декоратор ще извика автоматично метода render, ако се променят observable, на които той се позовава.
експорт по подразбиране клас App разширява Component {

    \/\/ Разумно е да се изнесе логиката за рендиране на страниците в маршрутизиране и да не се използват вложени тернарни оператори,
    \/\/ и да се свързват observable и методи за фон директно с компонентите, които ги използват
    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 &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!locked &amp;&amp; <button onclick="{()" > lock()}&gt;Заключи приложението</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Изтрий всички ключове и инициализирай</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}
    );

...
}

И така, приложението е готово. Уеб страниците могат да искат подпис на транзакции:

Създаваме безопасно браузерно разширение

Създаваме безопасно браузерно разширение

Кодът е достъпен в тази връзката.

Заключение

Ако сте прочели статията до края, но имате въпроси, можете да ги зададете в репозитория с разширението. Там ще намерите и комити за всяка обозначена стъпка.

А ако ви е интересно да видите кода на истинското разширение, ще можете да намерите това тук..

Код, репозиториум и описание на работата от siemarell

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster