Biztonságos böngészőbővítmény írása

Biztonságos böngészőbővítmény írása

A szokásos „kliens-szerver” architektúrától eltérően a decentralizált alkalmazások jellemzői:

  • Nincs szükség adatbázis tárolására felhasználói bejelentkezési adatokkal és jelszavakkal. A hozzáférési információkat kizárólag maguk a felhasználók tárolják, és hitelességük megerősítése a protokoll szintjén történik.
  • Nem szükséges szervert használni. Az alkalmazási logika blokklánc hálózaton is végrehajtható, ahol lehetőség van a szükséges adatmennyiség tárolására.

2 viszonylag biztonságos tároló található a felhasználói kulcsok számára – hardveres pénztárcák és böngészőbővítmények. A hardveres pénztárcák többnyire rendkívül biztonságosak, de nehezen használhatók és korántsem ingyenesek, de a böngészőbővítmények a biztonság és a könnyű használat tökéletes kombinációját jelentik, és a végfelhasználók számára teljesen ingyenesek is lehetnek.

Mindezeket figyelembe véve a legbiztonságosabb bővítményt szerettük volna készíteni, amely leegyszerűsíti a decentralizált alkalmazások fejlesztését azáltal, hogy egyszerű API-t biztosít a tranzakciók és aláírások kezelésére.
Erről az élményről az alábbiakban mesélünk.

A cikk lépésről lépésre tartalmazza a böngészőbővítmény megírására vonatkozó utasításokat, kódpéldákkal és képernyőképekkel. Az összes kódot megtalálod adattárak. Minden kötelezettségvállalás logikailag megfelel a cikk egy szakaszának.

A böngészőbővítmények rövid története

A böngészőbővítmények már régóta léteznek. Internet Explorerben 1999-ben, Firefoxban 2004-ben jelentek meg. Azonban nagyon sokáig nem volt egységes szabvány a bővítményekre.

Elmondhatjuk, hogy a Google Chrome negyedik verziójában a bővítményekkel együtt jelent meg. Természetesen akkor még nem volt specifikáció, de a Chrome API volt az alapja: a böngészőpiac nagy részét meghódító, beépített alkalmazásbolttal rendelkező Chrome tulajdonképpen a böngészőbővítmények mércéjét állította fel.

A Mozillának volt saját szabványa, de látva a Chrome-bővítmények népszerűségét, a cég úgy döntött, hogy kompatibilis API-t készít. 2015-ben a Mozilla kezdeményezésére egy speciális csoport jött létre a World Wide Web Consortium (W3C) keretein belül, amely a böngészők közötti bővítmények specifikációin dolgozott.

A Chrome meglévő API-bővítményeit vették alapul. A munka a Microsoft támogatásával történt (a Google nem volt hajlandó részt venni a szabvány kidolgozásában), és ennek eredményeként megjelent egy tervezet specifikációk.

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

Kiterjesztés szerkezete

A kiterjesztéshez csak a jegyzékfájl (manifest.json) szükséges. Ez egyben a „belépési pont” is a terjeszkedéshez.

kiáltvány

A specifikáció szerint a jegyzékfájl egy érvényes JSON-fájl. A jegyzékkulcsok teljes leírása, amely információkat tartalmaz arról, hogy melyik böngészőben mely kulcsok támogatottak itt.

Azokat a kulcsokat, amelyek nem szerepelnek a specifikációban, figyelmen kívül lehet hagyni (a Chrome és a Firefox is hibát jelez, de a bővítmények továbbra is működnek).

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

  1. háttér — egy objektum, amely a következő mezőket tartalmazza:
    1. szkriptek — szkriptek tömbje, amelyek a háttérben futnak le (erről egy kicsit később beszélünk);
    2. oldal - az üres oldalon lefutó szkriptek helyett tartalommal rendelkező html is megadható. Ebben az esetben a script mezőt figyelmen kívül hagyja, és a szkripteket be kell illeszteni a tartalomoldalra;
    3. kitartó — egy bináris jelző, ha nincs megadva, a böngésző „megöli” a háttérfolyamatot, ha úgy ítéli meg, hogy nem csinál semmit, és szükség esetén újraindítja. Ellenkező esetben az oldal csak a böngésző bezárásakor kerül kiürítésre. A Firefox nem támogatja.
  2. content_scripts — objektumok tömbje, amely lehetővé teszi különböző szkriptek betöltését különböző weboldalakra. Minden objektum a következő fontos mezőket tartalmazza:
    1. gyufa - minta url, amely meghatározza, hogy egy adott tartalomszkript belekerül-e vagy sem.
    2. js — az ebbe a meccsbe betöltendő szkriptek listája;
    3. exclude_matches - kizárja a mezőnyből match URL, которые удовлетворяют этому полю.
  3. page_action - valójában egy objektum, amely a böngésző címsora mellett megjelenő ikonért és a vele való interakcióért felelős. Lehetővé teszi egy felugró ablak megjelenítését is, amelyet saját HTML, CSS és JS használatával határoz meg.
    1. default_popup — a HTML-fájl elérési útja a felugró felülettel, tartalmazhat CSS-t és JS-t.
  4. engedélyek — egy tömb a kiterjesztési jogok kezelésére. A jogoknak 3 típusa van, amelyeket részletesen ismertetünk itt
  5. web_accessible_resources — kiterjesztési erőforrások, amelyeket egy weboldal kérhet, például képek, JS, CSS, HTML fájlok.
  6. külsőleg_csatlakoztatható — itt kifejezetten megadhatja a weboldalak egyéb kiterjesztésének és domainjének azonosítóit, ahonnan csatlakozhat. A domain lehet második vagy magasabb szintű. Firefoxban nem működik.

Végrehajtási kontextus

A bővítmény három kódvégrehajtási kontextussal rendelkezik, vagyis az alkalmazás három részből áll, amelyek különböző szintű hozzáféréssel rendelkeznek a böngésző API-hoz.

Kiterjesztés kontextusa

Az API nagy része itt érhető el. Ebben az összefüggésben „élnek”:

  1. Háttér oldal — a kiterjesztés „háttér” része. A fájl a „háttér” billentyűvel van megadva a jegyzékben.
  2. Felugró oldal — egy felugró oldal, amely akkor jelenik meg, amikor a bővítmény ikonjára kattint. A kiáltványban browser_action -> default_popup.
  3. Egyedi oldal — bővítményoldal, „élő” a nézet külön lapján chrome-extension://<id_расширения>/customPage.html.

Ez a kontextus a böngészőablakoktól és -lapoktól függetlenül létezik. Háttér oldal egyetlen példányban létezik, és mindig működik (kivétel az eseményoldal, amikor a háttérszkriptet egy esemény elindítja, és a végrehajtása után „meghal”). Felugró oldal létezik, amikor a felugró ablak nyitva van, és Egyedi oldal — пока открыта вкладка с ней. Доступа к другим вкладкам и их содержимому из этого контекста нет.

Tartalom szkript kontextusa

A tartalomszkriptfájl minden böngészőlappal együtt elindul. Hozzáfér a bővítmény API-jának egy részéhez és a weboldal DOM-fájához. A tartalomszkriptek felelősek az oldallal való interakcióért. A DOM-fát kezelő bővítmények ezt tartalmi szkriptekben teszik meg – például a hirdetésblokkolókban vagy a fordítókban. Ezenkívül a tartalomszkript szabványos módon kommunikálhat az oldallal postMessage.

Weboldal kontextusa

Это собственно сама веб-страница. К расширению она не имеет никакого отношения и доступа туда не имеет, кроме случаев, когда в манифесте явно не указан домен этой страницы (об этом — ниже).

Üzenetcsere

Az alkalmazás különböző részeinek üzenetet kell váltaniuk egymással. Erre van egy API runtime.sendMessage для отправки сообщения background и tabs.sendMessage üzenet küldése egy oldalnak (tartalomszkript, előugró ablak vagy weboldal, ha elérhető externally_connectable). Az alábbiakban egy példa látható a Chrome API elérésekor.

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

A teljes kommunikáció érdekében kapcsolatokat hozhat létre runtime.connect. Válaszul megkapjuk runtime.Port, amelyre, amíg nyitva van, tetszőleges számú üzenetet küldhet. Az ügyfél oldalon pl. contentscript, ez így néz ki:

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

Szerver vagy háttér:

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

Van egy rendezvény is onDisconnect és módszer disconnect.

Alkalmazási diagram

Давайте сделаем браузерное расширение, которое хранит приватные ключи, предоставляет доступ к публичной информации (адрес, публичный ключ общается со страницей и позволяет сторонним приложениям запросить подпись транзакций.

Alkalmazásfejlesztés

Alkalmazásunknak interakcióba kell lépnie a felhasználóval, és biztosítania kell az oldalt egy API-val a metódusok hívásához (például tranzakciók aláírásához). Elégedjen meg eggyel contentscript nem fog működni, mivel csak a DOM-hoz fér hozzá, de nem az oldal JS-éhez. Csatlakozás ezen keresztül runtime.connect nem tehetjük meg, mert az API-ra minden tartományon szükség van, és csak bizonyosakat lehet megadni a jegyzékben. Ennek eredményeként a diagram így fog kinézni:

Biztonságos böngészőbővítmény írása

Lesz még egy forgatókönyv... inpage, amit beszúrunk az oldalba. A környezetében fog futni, és API-t biztosít a bővítménnyel való együttműködéshez.

kezdet

Az összes böngészőbővítmény kódja elérhető a következő címen: GitHub. A leírás során a commitokra mutató linkek lesznek.

Kezdjük a kiáltvánnyal:

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

Hozzon létre üres background.js-t, popup.js-t, inpage.js-t és contentscript.js-t. Hozzáadjuk a popup.html-t - és máris betölthető az alkalmazásunk a Google Chrome-ba, és megbizonyosodhatunk a működéséről.

Ennek ellenőrzéséhez felveheti a kódot ezért. Amellett, amit tettünk, a link konfigurálta a projekt összeállítását webpack segítségével. Ha egy alkalmazást szeretne hozzáadni a böngészőhöz, a chrome://extensions-ben ki kell választania a betöltés kicsomagolt elemet és a megfelelő kiterjesztésű mappát - esetünkben a dist.

Biztonságos böngészőbővítmény írása

Most a bővítményünk telepítve van és működik. A fejlesztői eszközöket különböző kontextusokhoz az alábbiak szerint futtathatja:

felugró ablak ->

Biztonságos böngészőbővítmény írása

A tartalomszkript-konzolhoz való hozzáférés annak az oldalnak a konzolján keresztül történik, amelyen az elindult.Biztonságos böngészőbővítmény írása

Üzenetcsere

Tehát két kommunikációs csatornát kell létrehoznunk: az oldal <-> hátterét és a felugró <-> hátteret. Természetesen csak üzeneteket küldhet a portnak, és kitalálhatja a saját protokollját, de én jobban szeretem azt a megközelítést, amelyet a nyílt forráskódú metamaszk projektben láttam.

Ez egy böngészőbővítmény az Ethereum hálózattal való együttműködéshez. Ebben az alkalmazás különböző részei RPC-n keresztül kommunikálnak a dnode könyvtár használatával. Lehetővé teszi a csere gyors és kényelmes megszervezését, ha egy nodejs adatfolyamot biztosít szállításként (azaz ugyanazt az interfészt megvalósító objektumot):

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

Most létrehozunk egy alkalmazásosztályt. Létrehoz API-objektumokat a felugró ablakhoz és a weboldalhoz, és létrehoz egy dnode-ot nekik:

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

Itt és lent a globális Chrome-objektum helyett az extensionApi-t használjuk, amely a Google böngészőjében éri el a Chrome-ot, míg mások böngészőjében. Ez a böngészők közötti kompatibilitás érdekében történik, de e cikk céljaira egyszerűen használhatjuk a „chrome.runtime.connect” fájlt.

Hozzon létre egy alkalmazáspéldányt a háttérszkriptben:

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

Mivel a dnode adatfolyamokkal működik, és portot kapunk, szükség van egy adapterosztályra. Az olvasható adatfolyam könyvtár használatával készült, amely a nodejs adatfolyamokat valósítja meg a böngészőben:

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

Most hozzunk létre egy kapcsolatot a felhasználói felületen:

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

Ezután létrehozzuk a kapcsolatot a tartalomszkriptben:

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

Mivel az API-ra nem a tartalomszkriptben, hanem közvetlenül az oldalon van szükségünk, két dolgot teszünk:

  1. Két folyamot hozunk létre. Egy - az oldal felé, az üzenet tetején. Erre használjuk ezt ezt a csomagot от создателей metamask. Второй стрим — к background поверх порта, полученного от runtime.connect. Vásároljuk meg őket. Most az oldalnak lesz egy adatfolyama a háttérben.
  2. Инжектим скрипт в DOM. Выкачиваем скрипт (доступ к нему был разрешен в манифесте) и создаем тег script a benne lévő tartalommal:

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

Most létrehozunk egy API-objektumot az inpage-ban, és globálisra állítjuk:

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

Készen állunk Remote Procedure Call (RPC) külön API-val az oldalhoz és a felhasználói felülethez. Amikor új oldalt kapcsolunk a háttérhez, ezt láthatjuk:

Biztonságos böngészőbővítmény írása

Üres API és eredet. Az oldal oldalán a hello függvényt így hívhatjuk meg:

Biztonságos böngészőbővítmény írása

A visszahívási funkciókkal való munka a modern JS-ben rossz modor, ezért írjunk egy kis segítőt egy dnode létrehozásához, amely lehetővé teszi egy API objektum átadását a segédprogramoknak.

Az API objektumok most így fognak kinézni:

export class SignerApp {

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

...

}

Objektum lekérése a távirányítóról a következő módon:

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

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

A függvények meghívása pedig ígéretet ad:

Biztonságos böngészőbővítmény írása

Elérhető aszinkron funkciókkal rendelkező változat itt.

Összességében az RPC és stream megközelítés meglehetősen rugalmasnak tűnik: használhatunk steam multiplexelést, és több különböző API-t készíthetünk különböző feladatokhoz. A dnode elvileg bárhol használható, a lényeg, hogy a transzportot nodejs streambe csomagoljuk.

Alternatív megoldás a JSON formátum, amely a JSON RPC 2 protokollt valósítja meg, azonban meghatározott szállításokkal (TCP és HTTP(S)) működik, ami esetünkben nem alkalmazható.

Внутренний стейт и localStorage

Tárolnunk kell az alkalmazás belső állapotát – legalább az aláíró kulcsokat. A felugró API-ban könnyen hozzáadhatunk egy állapotot az alkalmazáshoz és a módosítási módszereket:

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

    ...

} 

A háttérben mindent egy függvénybe csomagolunk, és az alkalmazás objektumot ablakba írjuk, hogy a konzolról dolgozhassunk vele:

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

Adjunk hozzá néhány kulcsot a felhasználói felület konzoljáról, és nézzük meg, mi történt az állapottal:

Biztonságos böngészőbővítmény írása

Az állapotot tartóssá kell tenni, hogy újraindításkor ne vesszenek el a kulcsok.

A localStorage-ban fogjuk tárolni, minden változtatásnál felülírva. Ezt követően a felhasználói felülethez is szükséges lesz hozzáférni, illetve a változásokra is szeretnék feliratkozni. Ez alapján kényelmes lesz megfigyelhető tárolót létrehozni, és előfizetni a változásaira.

A mobx könyvtárat fogjuk használni (https://github.com/mobxjs/mobx). Azért esett rá a választás, mert nem kellett vele dolgoznom, de nagyon szerettem volna tanulni.

Adjuk hozzá a kezdeti állapot inicializálását, és tegyük megfigyelhetővé az áruházat:

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

    ...

}

„A motorháztető alatt” a mobx az összes bolt mezőjét proxyra cserélte, és elfogja az összes hívást. Lehetőség lesz ezekre az üzenetekre feliratkozni.

Az alábbiakban gyakran használom a „váltáskor” kifejezést, bár ez nem teljesen helyes. A Mobx nyomon követi a mezők elérését. A programkönyvtár által létrehozott proxy objektumok getterei és beállítói használatosak.

Az akciódekorátorok két célt szolgálnak:

  1. Szigorú módban az enforceActions jelzővel a mobx tiltja az állapot közvetlen megváltoztatását. Jó gyakorlatnak számít a szigorú feltételek melletti munkavégzés.
  2. Még ha egy függvény többször is megváltoztatja az állapotát – például több mezőt változtatunk meg több kódsorban –, a megfigyelők csak akkor kapnak értesítést, amikor befejeződik. Ez különösen fontos a frontend számára, ahol a szükségtelen állapotfrissítések az elemek szükségtelen megjelenítéséhez vezetnek. Esetünkben sem az első, sem a második nem különösebben releváns, de követjük a legjobb gyakorlatokat. A megfigyelt mezők állapotát megváltoztató összes funkcióhoz dekorátorokat szokás csatolni.

A háttérben hozzáadjuk az inicializálást és az állapot mentését a localStorage-ban:

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

Itt érdekes a reakciófüggvény. Két érve van:

  1. Селектор данных.
  2. Egy kezelő, amelyet minden változáskor ezekkel az adatokkal hívunk meg.

Ellentétben a redux-szal, ahol kifejezetten az állapotot kapjuk argumentumként, a mobx megjegyzi, hogy mely megfigyelésekhez férünk hozzá a szelektoron belül, és csak akkor hívja meg a kezelőt, ha azok megváltoznak.

Fontos megérteni, hogy a mobx pontosan hogyan dönti el, hogy mely megfigyelésekre fizetünk elő. Ha egy szelektort írnék ilyen kódban() => app.store, akkor a reakciót soha nem hívják meg, mivel maga a tároló nem figyelhető meg, csak a mezői.

Ha így írtam () => app.store.keys, akkor megint nem történne semmi, hiszen tömbelemek hozzáadásakor/eltávolításakor a rá való hivatkozás nem változik.

A Mobx először működik választóként, és csak azokat a megfigyeléseket tartja nyilván, amelyeket elértünk. Ez proxy gettereken keresztül történik. Ezért itt a beépített funkciót használjuk toJS. Egy új objektumot ad vissza, az összes proxyt az eredeti mezőkkel helyettesítve. A végrehajtás során beolvassa az objektum összes mezőjét - így a getterek aktiválódnak.

A felugró konzolban ismét több kulcsot adunk hozzá. Ezúttal szintén a localStorage-ben kötöttek ki:

Biztonságos böngészőbővítmény írása

A háttéroldal újratöltése után az információ a helyén marad.

Az összes eddigi alkalmazáskód megtekinthető itt.

A privát kulcsok biztonságos tárolása

A privát kulcsok tiszta szövegben való tárolása nem biztonságos: mindig fennáll annak a lehetősége, hogy feltörik, hozzáférhet a számítógépéhez stb. Ezért a localStorage-ban a kulcsokat jelszóval titkosított formában tároljuk.

A nagyobb biztonság érdekében egy zárolt állapotot adunk az alkalmazáshoz, amelyben a kulcsokhoz egyáltalán nem lesz hozzáférés. Időtúllépés miatt automatikusan zárolt állapotba helyezzük a bővítményt.

A Mobx csak egy minimális adatkészlet tárolását teszi lehetővé, a többit a rendszer ez alapján automatikusan kiszámítja. Ezek az úgynevezett számított tulajdonságok. Összehasonlíthatóak az adatbázisokban lévő nézetekkel:

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

Most már csak a titkosított kulcsokat és jelszót tároljuk. Minden más ki van számolva. A zárolt állapotba való átvitelt úgy hajtjuk végre, hogy eltávolítjuk a jelszót az állapotból. A nyilvános API most már rendelkezik egy módszerrel a tároló inicializálására.

Titkosításra írva crypto-js-t használó segédprogramok:

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. A tétlenséghez beállíthat egy időtúllépést, a zárolás pedig akkor van beállítva, ha maga az operációs rendszer blokkolva van. A localStorage-ba való mentés választóját is módosítjuk:

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

A lépés előtti kód az itt.

ügyletek

Tehát elérkeztünk a legfontosabbhoz: tranzakciók létrehozásához és aláírásához a blokkláncon. A WAVES blokkláncot és könyvtárat fogjuk használni waves-transactions.

Először adjuk hozzá az állapothoz az aláírandó üzenetek tömbjét, majd adjunk hozzá módszereket az új üzenet hozzáadásához, az aláírás megerősítéséhez és az elutasításhoz:

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

    ...
}

Amikor új üzenetet kapunk, metaadatokat adunk hozzá observable és add hozzá store.messages.

Ha nem observable manuálisan, akkor a mobx ezt maga fogja megtenni, amikor üzeneteket ad hozzá a tömbhöz. Ez azonban létrehoz egy új objektumot, amelyre nem lesz hivatkozásunk, de szükségünk lesz rá a következő lépéshez.

Ezután egy ígéretet adunk vissza, amely az üzenet állapotának megváltozásakor megszűnik. Az állapotot a reakció figyeli, amely „megöli magát”, amikor az állapot megváltozik.

Код методов approve и reject nagyon egyszerű: egyszerűen megváltoztatjuk az üzenet állapotát, szükség esetén aláírás után.

Az UI API-ban a jóváhagyást és elutasítást, az oldal API-ban pedig a newMessage-t helyeztük el:

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

    ...
}

Most próbáljuk meg aláírni a tranzakciót a kiterjesztéssel:

Biztonságos böngészőbővítmény írása

Általában minden készen van, csak az maradt egyszerű felhasználói felület hozzáadása.

UI

Az interfésznek hozzá kell férnie az alkalmazás állapotához. A felhasználói felület oldalán megtesszük observable állapotot, és adjon hozzá egy függvényt az API-hoz, amely megváltoztatja ezt az állapotot. Tegyük hozzá observable a háttérből kapott API objektumhoz:

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

A végén elkezdjük az alkalmazás felületének renderelését. Ez egy reagáló alkalmazás. A háttérobjektumot egyszerűen átadjuk kellékek segítségével. Helyes lenne természetesen külön szolgáltatást készíteni a módszereknek és egy boltot az államnak, de e cikk céljaira ez is elég:

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

A mobx segítségével nagyon könnyű elindítani a renderelést, amikor az adatok megváltoznak. A megfigyelő dekorátort egyszerűen akasztjuk fel a csomagra mobx-react az összetevőn, és a render automatikusan meghívásra kerül, ha az összetevő által hivatkozott megfigyelhető adatok megváltoznak. Nincs szükséged mapStateToProps-ra vagy csatlakozásra, mint a reduxban. A dobozból kivéve minden működik:

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

A többi komponens megtekinthető a kódban az UI mappában.

Most az alkalmazásosztályban létre kell hoznia egy állapotválasztót a felhasználói felülethez, és értesítenie kell a felhasználói felületet, ha megváltozik. Ehhez adjunk hozzá egy módszert getState и reactionhívás 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())

        })
    }

    ...
}

Tárgy fogadásakor remote jön létre reaction a függvényt meghívó állapot megváltoztatásához a felhasználói felület oldalán.

Az utolsó érintés az új üzenetek megjelenítésének hozzáadása a bővítmény ikonjához:

function setupApp() {
...

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

...
}

Tehát az alkalmazás készen áll. A weboldalak aláírást kérhetnek a tranzakciókhoz:

Biztonságos böngészőbővítmény írása

Biztonságos böngészőbővítmény írása

A kód itt érhető el link.

Következtetés

Ha elolvasta a cikket a végéig, de továbbra is kérdései vannak, felteheti őket a címen adattárak kiterjesztéssel. Itt minden kijelölt lépésre vonatkozó kötelezettségvállalásokat is talál.

А если вам интересно посмотреть код настоящего расширения, то вы сможете найти это itt.

Kód, adattár és munkaköri leírás innen siemarell

Forrás: will.com

Hozzászólás