Пишување безбедна екстензија на прелистувачот

Пишување безбедна екстензија на прелистувачот

За разлика од вообичаената архитектура „клиент-сервер“, децентрализираните апликации се карактеризираат со:

  • Нема потреба да се складира база на податоци со кориснички најави и лозинки. Информациите за пристап се чуваат исклучиво од самите корисници, а потврдата за нивната автентичност се јавува на ниво на протокол.
  • Нема потреба да користите сервер. Логиката на апликацијата може да се изврши на блокчејн мрежа, каде што е можно да се складира потребната количина на податоци.

Постојат 2 релативно безбедни складишта за кориснички клучеви - хардверски паричници и екстензии на прелистувачот. Хардверските паричници се претежно исклучително безбедни, но тешки за користење и далеку од бесплатни, но екстензиите на прелистувачот се совршена комбинација на безбедност и леснотија на користење, а исто така може да бидат целосно бесплатни за крајните корисници.

Земајќи го сето ова предвид, сакавме да направиме најсигурна екстензија која го поедноставува развојот на децентрализирани апликации преку обезбедување на едноставен API за работа со трансакции и потписи.
За ова искуство ќе ви кажеме подолу.

Статијата ќе содржи чекор-по-чекор инструкции за тоа како да напишете екстензија на прелистувачот, со примери на код и слики од екранот. Можете да го најдете целиот код во складишта. Секое извршување логично одговара на дел од овој член.

Кратка историја на екстензии на прелистувачи

Екстензии на прелистувачи постојат долго време. Тие се појавија во Internet Explorer уште во 1999 година, во Firefox во 2004 година. Сепак, долго време немаше единствен стандард за екстензии.

Можеме да кажеме дека се појави заедно со екстензии во четвртата верзија на Google Chrome. Се разбира, тогаш немаше спецификација, но токму Chrome API стана негова основа: откако го освои најголемиот дел од пазарот на прелистувачи и имајќи вградена продавница за апликации, 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. позадина — објект кој ги вклучува следните полиња:
    1. скрипти — низа скрипти што ќе се извршуваат во контекст на позадината (ќе зборуваме за ова малку подоцна);
    2. страница - наместо скрипти кои ќе се извршуваат на празна страница, можете да наведете html со содржина. Во овој случај, полето за скрипта ќе биде игнорирано, а скриптите ќе треба да се вметнат во страницата со содржина;
    3. перзистираат — бинарно знаменце, ако не е одредено, прелистувачот ќе го „убие“ процесот на заднина кога смета дека не прави ништо и ќе го рестартира ако е потребно. Во спротивно, страницата ќе се истовари само кога прелистувачот е затворен. Не е поддржано во Firefox.
  2. содржина_скрипти — низа од објекти што ви овозможува да вчитате различни скрипти на различни веб-страници. Секој објект ги содржи следните важни полиња:
    1. натпревари - URL-адреса на шема, што одредува дали одредена скрипта за содржина ќе биде вклучена или не.
    2. js — список на скрипти што ќе се вчитаат во овој натпревар;
    3. исклучува_совпаѓања - исклучува од теренот match URL-адреси што одговараат на ова поле.
  3. страница_акција - всушност е објект кој е одговорен за иконата што се прикажува до лентата за адреси во прелистувачот и интеракцијата со неа. Исто така, ви овозможува да прикажете скокачки прозорец, кој е дефиниран со користење на вашиот сопствен HTML, CSS и JS.
    1. default_popup — патека до HTML-датотеката со скокачки интерфејс, може да содржи CSS и JS.
  4. дозволи — низа за управување со правата на екстензии. Постојат 3 типа на права, кои се детално опишани тука
  5. веб_пристапни_ресурси — ресурси за екстензии што веб-страницата може да ги побара, на пример, слики, JS, CSS, HTML-датотеки.
  6. надворешно_поврзливо — овде можете експлицитно да ги наведете ИД на други екстензии и домени на веб-страници од кои можете да се поврзете. Доменот може да биде втор или повисок. Не работи во Firefox.

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

Наставката има три контексти за извршување на кодот, односно апликацијата се состои од три дела со различни нивоа на пристап до API на прелистувачот.

Контекст на проширување

Поголемиот дел од API е достапен овде. Во овој контекст тие „живеат“:

  1. Позадинска страница — „заднински“ дел од наставката. Датотеката е наведена во манифестот со помош на копчето „позадина“.
  2. Скокачка страница — скокачка страница што се појавува кога ќе кликнете на иконата за проширување. Во манифестот browser_action -> default_popup.
  3. Прилагодена страница — страница за проширување, „живеење“ во посебно јазиче на приказот chrome-extension://<id_расширения>/customPage.html.

Овој контекст постои независно од прозорците и јазичињата на прелистувачот. Позадинска страница постои во една копија и секогаш работи (исклучок е страницата за настан, кога скриптата за заднина е стартувана од некој настан и „умира“ по неговото извршување). Скокачка страница постои кога скокачкиот прозорец е отворен и Прилагодена страница — додека јазичето со него е отворено. Нема пристап до други јазичиња и нивната содржина од овој контекст.

Контекс на скрипта за содржина

Датотеката со скрипта за содржина се стартува заедно со секоја картичка на прелистувачот. Има пристап до дел од API-то на екстензијата и до дрвото DOM на веб-страницата. Скриптите за содржина се одговорни за интеракција со страницата. Екстензии кои манипулираат со дрвото DOM го прават тоа во скрипти за содржина - на пример, блокатори на реклами или преведувачи. Исто така, скриптата за содржина може да комуницира со страницата преку стандарден postMessage.

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

Ова е самата веб-страница. Нема никаква врска со наставката и нема пристап таму, освен во случаи кога доменот на оваа страница не е експлицитно наведен во манифестот (повеќе за ова подолу).

Пораки

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

Сервер или позадина:

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

За да го потврдите ова, можете да го земете кодот оттука. Покрај она што го направивме, врската го конфигурираше склопувањето на проектот користејќи веб-пак. За да додадете апликација во прелистувачот, во chrome://extensions треба да изберете load unpacked и папката со соодветната екстензија - во нашиот случај dist.

Пишување безбедна екстензија на прелистувачот

Сега нашата екстензија е инсталирана и работи. Можете да ги стартувате алатките за програмери за различни контексти на следниов начин:

скокачки прозорец ->

Пишување безбедна екстензија на прелистувачот

Пристапот до конзолата за скрипта за содржина се врши преку конзолата на самата страница на која е лансирана.Пишување безбедна екстензија на прелистувачот

Пораки

Значи, треба да воспоставиме два канали за комуникација: заднина на страницата <-> и заднина на скокачки прозорец <->. Се разбира, можете само да испраќате пораки до пристаништето и да измислите свој протокол, но јас го претпочитам пристапот што го видов во проектот со отворен код за метамаск.

Ова е екстензија на прелистувачот за работа со мрежата Ethereum. Во него, различни делови од апликацијата комуницираат преку RPC користејќи ја библиотеката dnode. Ви овозможува да организирате размена доста брзо и погодно ако му обезбедите проток на nodejs како транспорт (што значи објект што го имплементира истиот интерфејс):

import Dnode from "dnode/browser";

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

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

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

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

Сега ќе создадеме класа на апликации. Ќе создаде API објекти за скокачкиот прозорец и веб-страницата и ќе создаде dnode за нив:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(origin);
            console.log(remote)
        })
    }
}

Овде и подолу, наместо глобалниот објект на Chrome, користиме extensionApi, кој пристапува до Chrome во прелистувачот на Google и прелистувач во други. Ова е направено за компатибилност со вкрстени прелистувачи, но за целите на овој напис, можете едноставно да користите 'chrome.runtime.connect'.

Ајде да создадеме пример за апликација во скрипта за заднина:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Бидејќи dnode работи со стримови, а ние добиваме порта, потребна е класа на адаптер. Направено е со помош на библиотеката со читлив поток, која имплементира nodejs streams во прелистувачот:

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

Сега ајде да создадеме врска во интерфејсот:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

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

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Потоа ја креираме врската во скриптата за содржина:

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

setupConnection();
injectScript();

function setupConnection(){
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

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

Бидејќи ни треба API не во скриптата за содржина, туку директно на страницата, правиме две работи:

  1. Ние создаваме два потоци. Еден - кон страницата, на врвот на постПорака. За ова го користиме ова овој пакет од креаторите на метамаск. Вториот поток е за позадината на пристаништето добиено од 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 и го поставуваме на глобално:

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

Ние сме подготвени Повик за далечинска процедура (RPC) со посебен API за страница и интерфејс. Кога поврзуваме нова страница со позадина, можеме да го видиме ова:

Пишување безбедна екстензија на прелистувачот

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

Пишување безбедна екстензија на прелистувачот

Работењето со функциите за повратен повик во модерните JS е лош начин, па ајде да напишеме мал помошник за да создадеме dnode што ви овозможува да пренесете објект API на utils.

Објектите на API сега ќе изгледаат вака:

export class SignerApp {

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

...

}

Добивање на објект од далечина како ова:

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

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

И повикувањето функции враќа ветување:

Пишување безбедна екстензија на прелистувачот

Достапна е верзија со асинхрони функции тука.

Генерално, пристапот RPC и stream изгледа прилично флексибилен: можеме да користиме мултиплексирање на пареа и да создадеме неколку различни API за различни задачи. Во принцип, dnode може да се користи насекаде, главната работа е да го завиткате транспортот во форма на поток nodejs.

Алтернатива е форматот JSON, кој го имплементира протоколот JSON RPC 2. Сепак, работи со специфични транспорти (TCP и HTTP(S)), што не е применливо во нашиот случај.

Внатрешна состојба и локално складирање

Ќе треба да ја зачуваме внатрешната состојба на апликацијата - барем клучевите за потпишување. Можеме лесно да додадеме состојба на апликацијата и методи за нејзино менување во скокачкиот API:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

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

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

    ...

} 

Во позадина, ќе завиткаме сè во функција и ќе го запишеме објектот на апликацијата во прозорецот за да можеме да работиме со него од конзолата:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

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

setupApp();

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

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Ајде да додадеме неколку клучеви од конзолата за интерфејс и да видиме што се случува со состојбата:

Пишување безбедна екстензија на прелистувачот

Државата треба да се направи упорна за да не се изгубат клучевите при рестартирање.

Ќе го складираме во localStorage, препишувајќи го со секоја промена. Последователно, пристапот до него ќе биде неопходен и за интерфејсот, а исто така би сакал да се претплатам на промени. Врз основа на ова, ќе биде погодно да се создаде забележливо складирање и да се претплатите на неговите промени.

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

Ајде да додадеме иницијализација на почетната состојба и да ја направиме продавницата забележлива:

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

export class SignerApp {

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

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

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

    ...

}

„Под капакот“, mobx ги замени сите полиња на продавницата со прокси и ги пресретнува сите повици до нив. Ќе може да се претплатите на овие пораки.

Подолу често ќе го користам терминот „при промена“, иако тоа не е сосема точно. Mobx го следи пристапот до полињата. Се користат добивачи и поставувачи на прокси објекти што ги создава библиотеката.

Акционите декоратори служат за две цели:

  1. Во строг режим со знамето EnforceActions, mobx забранува директно менување на состојбата. Се смета за добра форма да се работи под строги услови.
  2. Дури и ако некоја функција ја менува состојбата неколку пати - на пример, менуваме неколку полиња во неколку линии код - набљудувачите се известуваат само кога таа ќе заврши. Ова е особено важно за предниот дел, каде што непотребните ажурирања на состојбата доведуваат до непотребно прикажување на елементите. Во нашиот случај, ниту првото ниту второто не се особено релевантни, но ќе ги следиме најдобрите практики. Вообичаено е да се прикачат декоратори на сите функции што ја менуваат состојбата на набљудуваните полиња.

Во позадина ќе додадеме иницијализација и зачувување на состојбата во localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Реакциската функција е интересна овде. Има два аргументи:

  1. Избор на податоци.
  2. Ракувач што ќе се повикува со овие податоци секогаш кога ќе се менуваат.

За разлика од redux, каде што експлицитно ја примаме состојбата како аргумент, mobx се сеќава на кои набљудувачи пристапуваме во селекторот и го повикува управувачот само кога тие се менуваат.

Важно е да се разбере точно како mobx одлучува на кои набљудувачи се претплатиме. Ако напишав селектор во вака шифра() => app.store, тогаш реакцијата никогаш нема да се повика, бидејќи самото складирање не може да се набљудува, само неговите полиња се.

Ако го напишав вака () => app.store.keys, тогаш повторно ништо не би се случило, бидејќи при додавање/отстранување на елементи од низата, референцата кон неа нема да се промени.

Mobx за прв пат делува како селектор и ги следи само набљудувачите до кои сме пристапиле. Ова се прави преку прокси-добивачи. Затоа, тука се користи вградената функција toJS. Тој враќа нов објект со сите прокси заменети со оригиналните полиња. За време на извршувањето, ги чита сите полиња на објектот - оттука се активираат добивачите.

Во скокачката конзола повторно ќе додадеме неколку клучеви. Овој пат тие влегоа и во localStorage:

Пишување безбедна екстензија на прелистувачот

Кога страницата за позадина повторно се вчита, информациите остануваат на место.

Сите кодови на апликацијата до овој момент може да се видат тука.

Безбедно складирање на приватни клучеви

Чувањето на приватните клучеви во јасен текст е небезбедно: секогаш постои шанса да бидете хакирани, да добиете пристап до вашиот компјутер итн. Затоа, во localStorage ќе ги складираме клучевите во форма шифрирана со лозинка.

За поголема безбедност, на апликацијата ќе додадеме заклучена состојба, во која воопшто нема да има пристап до копчињата. Автоматски ќе ја пренесеме екстензијата во заклучена состојба поради истек на време.

Mobx ви овозможува да складирате само минимален сет на податоци, а остатокот автоматски се пресметува врз основа на тоа. Тоа се таканаречените пресметани својства. Тие можат да се споредат со погледите во базите на податоци:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

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

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

Сега ги складираме само шифрираните клучеви и лозинката. Се друго е пресметано. Префрлањето го правиме во заклучена состојба со отстранување на лозинката од состојбата. Јавното API сега има метод за иницијализирање на складирањето.

Напишано за шифрирање комунални услуги кои користат крипто-js:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

Прелистувачот има неактивен API преку кој можете да се претплатите на настан - промени на состојбата. Држава, соодветно, може да биде idle, active и locked. За мирување, можете да поставите истек на време, а заклучено е поставено кога самиот ОС е блокиран. Ќе го смениме и избирачот за зачувување во localStorage:

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

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

setupApp();

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

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

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

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

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

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

Кодот пред овој чекор е тука.

Трансакција

Значи, доаѓаме до најважното нешто: креирање и потпишување трансакции на блокчејн. Ќе ги користиме блокчејнот и библиотеката WAVES бранови-трансакции.

Прво, да додадеме во состојбата низа пораки што треба да се потпишат, а потоа да додадеме методи за додавање нова порака, потврдување на потписот и одбивање:

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

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

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

    ...
}

Кога ќе добиеме нова порака, додаваме метаподатоци на неа, направи observable и додадете на store.messages.

Ако не observable рачно, тогаш mobx ќе го направи тоа сам кога додава пораки во низата. Сепак, ќе создаде нов објект на кој нема да имаме референца, но ќе ни треба за следниот чекор.

Следно, враќаме ветување што се решава кога ќе се промени статусот на пораката. Статусот се следи со реакција, која ќе се „убие“ кога ќе се промени статусот.

Код на методот approve и reject многу едноставно: едноставно го менуваме статусот на пораката, откако ќе ја потпишеме доколку е потребно.

Ставаме Одобри и отфрли во АПИ на интерфејсот, нова порака во 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 ќе направиме observable наведете и додадете функција на API што ќе ја промени оваа состојба. Да додадеме observable до објектот API добиен од позадина:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

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

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

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

    // Запуск интерфейса
    await initApp(background)
}

На крајот започнуваме со рендерирање на интерфејсот на апликацијата. Ова е апликација за реакција. Позадинскиот објект едноставно се пренесува со помош на реквизити. Би било точно, се разбира, да се направи посебна услуга за методи и продавница за државата, но за целите на овој член ова е доволно:

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 е многу лесно да се започне со рендерирање кога податоците се менуваат. Едноставно го закачуваме декораторот на набљудувачот од пакувањето мобкс-реагирај на компонентата, и рендерот ќе се повика автоматски кога ќе се променат сите набљудувани референци од компонентата. Не ви требаат никакви mapStateToProps или поврзување како во 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}
    );

...
}

Значи, апликацијата е подготвена. Веб-страниците може да бараат потпис за трансакции:

Пишување безбедна екстензија на прелистувачот

Пишување безбедна екстензија на прелистувачот

Кодот е достапен овде линк.

Заклучок

Ако сте ја прочитале статијата до крај, но сепак имате прашања, можете да ги поставите на складишта со проширување. Таму ќе најдете и обврски за секој назначен чекор.

И ако сте заинтересирани да го погледнете кодот за вистинското проширување, можете да го најдете ова тука.

Код, складиште и опис на работното место од симарел

Извор: www.habr.com

Додадете коментар