Пишемо безпечне браузерне розширення

Пишемо безпечне браузерне розширення

На відміну від поширеної «клієнт-серверної» архітектури, для децентралізованих додатків характерно:

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

Існує 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. scripts - Масив скриптів, які будуть виконані в 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. веб-доступні_ресурси — ресурси розширення, які може запитувати веб-сторінка, наприклад, зображення, файли JS, CSS, HTML.
  6. externally_connectable — тут можна явно вказати ID інших розширень та домени веб-сторінок, з яких можна підключатися. Домен може бути другого рівня і вищим. Не працює у Firefox.

Контекст виконання

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

Extension context

Тут доступна більшість API. У цьому контексті «живуть»:

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

Цей контекст існує незалежно від вікон та вкладок браузера. Фонова сторінка існує в єдиному екземплярі і працює завжди (виняток - 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 та до браузера в інших. Робиться це для кросбраузерності, але в рамках цієї статті можна було б використовувати і просто '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

Додати коментар або відгук