Пішам бяспечнае браузернае пашырэнне

Пішам бяспечнае браузернае пашырэнне

У адрозненне ад распаўсюджанай "кліент-сервернай" архітэктуры, для дэцэнтралізаваных прыкладанняў характэрна:

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

Існуе 2 адносна бяспечных сховішчы для ключоў карыстальнікаў – хардверныя кашалькі і браузерныя пашырэнні. Хардверныя кашалькі ў большасці сваёй максімальна бяспечныя, аднак складаныя ў выкарыстанні і далёка не бясплатныя, а вось браузерныя пашырэнні з'яўляюцца ідэальным спалучэннем бяспекі і прастаты ў выкарыстанні, а яшчэ могуць быць цалкам бясплатныя для канчатковых карыстачоў.

Улічваючы ўсё гэта, мы захацелі зрабіць максімальна бяспечнае пашырэнне, якое спрашчае распрацоўку дэцэнтралізаваных прыкладанняў, падаючы просты API для працы з транзакцыямі і подпісамі.
Пра гэты досвед мы вам і раскажам ніжэй.

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

Кароткая гісторыя браузерных пашырэнняў

Браўзэрныя пашырэнні існуюць дастаткова даўно. У Internet Explorer яны з'явіліся яшчэ ў 1999-м годзе, у Firefox – у 2004-м. Тым не менш, вельмі доўга не было адзінага стандарта для пашырэнняў.

Можна сказаць, што ён з'явіўся разам з пашырэннямі ў чацвёртай версіі Google Chrome. Вядома, ніякай спецыфікацыі тады не было, але менавіта API Chrome стаў яе асновай: заваяваўшы вялікую частку рынка браўзэраў і маючы ўбудаваную краму прыкладанняў, Chrome фактычна задаў стандарт для браузерных пашырэнняў.

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

За аснову быў узяты ўжо існуючы API пашырэнняў для Сhrome. Праца вялася пры падтрымцы Microsoft (Google у распрацоўцы стандарту ўдзельнічаць адмовіўся), і ў выніку з'явіўся чарнавік спецыфікацыі.

Фармальна спецыфікацыю падтрымліваюць Edge, Firefox і Opera (заўважце, што ў гэтым спісе адсутнічае Chrome). Але насамрэч стандарт шмат у чым сумяшчальны і з Chrome, бо фактычна напісаны на аснове яго пашырэнняў. Больш падрабязна пра WebExtensions API можна прачытаць тут.

Структура пашырэння

Адзіны файл, які абавязкова патрэбен для пашырэння - маніфест (manifest.json). Ён жа з'яўляецца "кропкай уваходу" ў пашырэнне.

маніфест

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

Ключы, якіх няма ў спецыфікацыі, "могуць" быць праігнараваныя (і Chrome, і Firefox пішуць пра памылкі, але пашырэнні працягваюць працаваць).

А я хацеў бы звярнуць увагу на некаторыя моманты.

  1. фон - аб'ект, які ўключае ў сябе наступныя палі:
    1. скрыпты — масіў скрыптоў, якія будуць выкананы ў background-кантэксце (пагаворым пра гэта крыху пазней);
    2. старонка - замест скрыптоў, якія будуць выконваюцца ў пустой старонцы, можна задаць html з кантэнтам. У гэтым выпадку поле script будзе праігнаравана, а скрыпты трэба будзе ўставіць у старонку з кантэнтам;
    3. ўпарціцца - бінарны сцяг, калі не паказаны, то браўзэр будзе "забіваць" background-працэс, калі палічыць, што ён нічога не робіць, і перазапускаць пры неабходнасці. У адваротным выпадку старонка будзе выгружана толькі пры зачыненні браўзэра. Не падтрымліваецца ў Firefox.
  2. content_scripts - масіў аб'ектаў, які дазваляе загружаць розныя скрыпты да розных вэб старонак. Кожны аб'ект змяшчае наступныя важныя палі:
    1. запалкі - патэрн 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. дазволаў - масіў для кіравання правамі пашырэння. Існуе 3 тыпу правоў, якія падрабязна апісаны тут
  5. web_accessible_resources - рэсурсы пашырэння, якія можа запытваць вэб старонка, напрыклад, выявы, файлы JS, CSS, HTML.
  6. externally_connectable - тут можна відавочна паказаць ID іншых пашырэнняў і дамены вэб-старонак, з якіх можна падлучацца. Дамен можа быць другога ўзроўню і вышэй. Не працуе ў Firefox.

Кантэкст выканання

У пашырэнні ёсць тры кантэксты выканання кода, гэта значыць, прыкладанне складаецца з трох частак з розным узроўнем доступу да API браўзэра.

Extension context

Тут даступная большая частка API. У гэтым кантэксце "жывуць":

  1. Background page - "backend" частка пашырэння. Файл паказваецца ў маніфэсце па ключы "background".
  2. Popup page - popup старонка, якая з'яўляецца пры націску на абразок пашырэння. У маніфесце browser_action -> default_popup.
  3. Карыстальніцкая старонка - Старонка пашырэння, «якая жыве» ў асобнай укладцы выгляду chrome-extension://<id_расширения>/customPage.html.

Гэты кантэкст існуе незалежна ад вокнаў і ўкладак браўзэра. Background page існуе ў адзіным асобніку і працуе заўсёды (выключэнне - event page, калі background-скрыпт запускаецца па падзеі і "памірае" пасля яго выканання). Popup page існуе, калі адкрыта акно popup, а Карыстальніцкая старонка - Пакуль адкрыта ўкладка з ёй. Доступу да іншых укладак і іх змесціва з гэтага кантэксту няма.

Content script context

Файл кантэнт-скрыпта запускаецца разам з кожнай укладкай браўзэра. У яго ёсць доступ да часткі API пашырэння і да DOM-дрэва вэб-старонкі. Менавіта кантэнт-скрыпты адказваюць за ўзаемадзеянне са старонкай. Пашырэньні, якія маніпулююць DOM-дрэвам, робяць гэта ў кантэнт-скрыптах – напрыклад, блакавальнікі рэкламы або перакладчыкі. Таксама кантэнт-скрыпт можа мець зносіны са старонкай праз стандартны postMessage.

Web page context

Гэта ўласна сама вэб-старонка. Да пашырэння яна не мае ніякага стаўлення і доступу туды не мае, акрамя выпадкаў, калі ў маніфесце відавочна не паказаны дамен гэтай старонкі (пра гэта - ніжэй).

абмен паведамленнямі

Розныя часткі прыкладання павінны абменьвацца паведамленнямі паміж сабой. Для гэтага існуе 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=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

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

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

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

Ствараем пустыя background.js, popup.js, inpage.js і contentscript.js. Дадаем popup.html - і наша дадатак ужо можна загрузіць у Google Chrome і пераканацца, што яно працуе.

Каб пераканацца ў гэтым, можна ўзяць код адсюль. Акрамя таго, што мы зрабілі, па спасылцы настроена зборка праекту з дапамогай webpack. Каб дадаць прыкладанне ў браўзэр, у chrome://extensions трэба абраць load unpacked і тэчку з адпаведным пашырэннем - у нашым выпадку dist.

Пішам бяспечнае браузернае пашырэнне

Цяпер наша пашырэнне ўстаноўлена і працуе. Запусціць інструменты для распрацоўшчыкаў для розных кантэкстаў можна наступным чынам:

popup ->

Пішам бяспечнае браузернае пашырэнне

Доступ да кансолі кантэнт-скрыпту ажыццяўляецца праз кансоль самай старонкі, на якой ён запушчаны.Пішам бяспечнае браузернае пашырэнне

абмен паведамленнямі

Такім чынам, нам неабходна ўсталяваць два каналы сувязі: inpage <-> background і popup <-> background. Можна, вядома, проста адпраўляць паведамленні ў порт і вынайсці свой пратакол, але мне больш падабаецца падыход, які я падгледзеў у праекце з адчыненым кодам metamask.

Гэта браузернае пашырэнне для працы з сеткай Ethereum. У ім розныя часткі прыкладання маюць зносіны праз RPC пры дапамозе бібліятэкі dnode. Яна дазваляе досыць хутка і зручна арганізаваць абмен, калі ў якасці транспарта ёй падаць nodejs stream (маецца на ўвазе аб'ект, які рэалізуе той жа інтэрфейс):

import Dnode from "dnode/browser";

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

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

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

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

Цяпер мы створым клас прыкладання. Яно будзе ствараць аб'екты API для 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);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или 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. Ствараем два стрымы. Адзін - у бок старонкі, па-над postMessage. Для гэтага мы выкарыстоўваем вось гэты пакет ад стваральнікаў metamask. Другі стрым — да background па-над портам, атрыманага ад runtime.connect. Пайпім іх. Цяпер у старонкі будзе стрым да бэкграўнду.
  2. Інжэктым скрыпт у DOM. Выпампоўваем скрыпт (доступ да яго быў дазволены ў маніфесце) і ствараем тэг script з яго змесцівам усярэдзіне:

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

setupConnection();
injectScript();

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

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

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

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

Цяпер ствараем аб'ект api у inpage і заводзім яго 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;
}

У нас гатовы Remote Procedure Call (RPC) з асобным API для старонкі і UI. Пры падключэнні новай старонкі да background мы можам гэта ўбачыць:

Пішам бяспечнае браузернае пашырэнне

Пусты 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 і стрымамі здаецца дастаткова гнуткім: мы можам выкарыстоўваць steam multiplexing і ствараць некалькі розных 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). Выбар упаў на яе, бо працаваць з ёй не даводзілася, а вельмі хацелася яе вывучыць.

Дадамо ініцыялізацыю пачатковага стейта і зробім store observable:

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

export class SignerApp {

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

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

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

    ...

}

Пад капотам mobx замяніў усе палі store на proxy і перахапляе ўсе звароты да іх. На гэтыя звароты можна будзе падпісвацца.

Далей я буду часта выкарыстоўваць тэрмін "пры змене", хоць гэта не зусім карэктна. Mobx адсочвае менавіта доступ да палёў. Выкарыстоўваюцца гетэры і сетэры проксі-аб'ектаў, якія стварае бібліятэка.

Дэкаратары 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;
    }

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

Цікавая тут функцыя reaction. У яе два аргументы:

  1. Селектар дадзеных.
  2. Апрацоўшчык, які будзе выкліканы з гэтымі дадзенымі кожны раз, калі яны мяняюцца.

У адрозненне ад redux, дзе мы відавочна атрымліваем стейт у якасці аргументу, mobx запамінае да якіх менавіта observable мы звяртаемся ўсярэдзіне селектара, і толькі пры іх змене выклікае апрацоўшчык.

Важна разумець, як mobx вырашае, на якія observable мы падпісваемся. Калі б у кодзе я напісаў селектар вось так() => 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 - приложение 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')
        }
    }
}

Цяпер мы захоўваем толькі шыфраваныя ключы і пароль. Усё астатняе вылічаецца. Пераклад у стейт locked мы робім з дапамогай выдалення пароля з стейта. У грамадскім API з'явіўся спосаб для ініцыялізацыі сховішча.

Для шыфравання напісаны утыліты з выкарыстаннем сrypto-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, active и locked. Для idle можна наладзіць таймаўт, а locked усталёўваецца, калі блакуецца сама АС. Таксама мы памяняем селектар для захавання ў localStorage:

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

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

setupApp();

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

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

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

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

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

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

Код да гэтага кроку знаходзіцца тут.

Транзакцыі

Такім чынам, мы падышлі да самага галоўнага: стварэнню і подпісы транзакцый у блокчейне. Мы будзем выкарыстоўваць блокчейн WAVES і бібліятэку 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(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

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

    ...
}

Пры атрыманні новага паведамлення мы дадаем у яго метададзеныя, які робіцца observable і дадаем у store.messages.

Калі не зрабіць observable ўручную, то mobx зробіць гэта сам пры даданні ў масіў messages. Аднак ён створыць новы аб'ект, на які ў нас ня будзе спасылкі, а яна спатрэбіцца для наступнага кроку.

Далей мы вяртаем проміс, які рэзалюецца пры змене статуту паведамлення. За статутам сочыць reaction, які сам сябе заб'е пры змене статуту.

Код метадаў approve и reject вельмі просты: мы проста мяняем статус паведамлення, папярэдне падпісаўшы яго, калі неабходна.

Approve і reject мы выносім у API UI, newMessage - у API старонкі:

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

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

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

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

    ...
}

Цяпер паспрабуем падпісаць транзакцыю пашырэннем:

Пішам бяспечнае браузернае пашырэнне

У цэлым усё гатова, засталося дадаць просты UI.

UI

Інтэрфейсу патрэбен доступ да стейт прыкладання. На баку 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(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

З дапамогай mobx вельмі проста запускаць рэндэр пры змене дадзеных. Мы проста вешаем дэкаратар observer з пакета mobx-react на кампанент, і рэндэр будзе аўтаматычна выклікацца пры змене любых observable, на якія спасылаецца кампанент. Не трэба ніякіх mapStateToProps ці connect, як у redux. Усё працуе адразу «са скрынкі»:

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

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

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

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

Астатнія кампаненты можна паглядзець у кодзе у тэчцы UI.

Зараз у класе прыкладання неабходна зрабіць селектар стейта для UI і пры ім змене апавяшчаць UI. Для гэтага дадамо метад getState и reaction, які выклікае remote.updateState:

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

export class SignerApp {

    ...

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

    ...

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

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

        })
    }

    ...
}

Пры атрыманні аб'екта remote ствараецца reaction на змяненне стейта, які выклікае функцыю на баку UI.

Апошні штрых - дадамо адлюстраванне новых паведамленняў на абразку пашырэння:

function setupApp() {
...

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

...
}

Такім чынам, дадатак гатова. Вэб-старонкі могуць запытваць подпіс транзакцый:

Пішам бяспечнае браузернае пашырэнне

Пішам бяспечнае браузернае пашырэнне

Код даступны па гэтай спасылцы.

Заключэнне

Калі вы дачыталі артыкул да канца, але ў вас засталіся пытанні, вы можаце задаць іх у рэпазітары з пашырэннем. Там жа вы знойдзеце коміты пад кожны пазначаны крок.

А калі вам цікава паглядзець код сапраўднага пашырэння, тыя вы зможаце знайсці гэта тут.

Код, рэпазітар і апісанне працы ад siemarell

Крыніца: habr.com

Дадаць каментар