Psaní zabezpečeného rozšíření prohlížeče

Psaní zabezpečeného rozšíření prohlížeče

Na rozdíl od běžné architektury „klient-server“ se decentralizované aplikace vyznačují:

  • Není potřeba ukládat databázi s uživatelskými přihlašovacími údaji a hesly. Přístupové informace jsou ukládány výhradně samotnými uživateli a potvrzení jejich pravosti probíhá na úrovni protokolu.
  • Není třeba používat server. Aplikační logiku lze provádět na blockchainové síti, kde je možné uložit potřebné množství dat.

K dispozici jsou 2 relativně bezpečná úložiště pro uživatelské klíče – hardwarové peněženky a rozšíření prohlížeče. Hardwarové peněženky jsou většinou extrémně bezpečné, ale obtížně se používají a zdaleka nejsou zdarma, ale rozšíření prohlížeče jsou dokonalou kombinací zabezpečení a snadného použití a pro koncové uživatele mohou být také zcela zdarma.

Vzhledem k tomu všemu jsme chtěli vytvořit nejbezpečnější rozšíření, které zjednoduší vývoj decentralizovaných aplikací poskytnutím jednoduchého API pro práci s transakcemi a podpisy.
O této zkušenosti vám povíme níže.

Článek bude obsahovat podrobné pokyny, jak napsat rozšíření prohlížeče, s příklady kódu a snímky obrazovky. Celý kód najdete v úložišť. Každé potvrzení logicky odpovídá části tohoto článku.

Stručná historie rozšíření prohlížeče

Rozšíření pro prohlížeče existují již dlouhou dobu. V Internet Exploreru se objevily již v roce 1999, ve Firefoxu v roce 2004. Po velmi dlouhou dobu však neexistoval jediný standard pro rozšíření.

Dá se říci, že se objevil spolu s rozšířeními ve čtvrté verzi Google Chrome. Tehdy samozřejmě neexistovala žádná specifikace, ale jejím základem se stalo rozhraní API pro Chrome: Chrome dobyl většinu trhu s prohlížeči a měl vestavěný obchod s aplikacemi a ve skutečnosti nastavil standard pro rozšíření prohlížeče.

Mozilla měla svůj vlastní standard, ale vzhledem k popularitě rozšíření Chrome se společnost rozhodla vytvořit kompatibilní API. V roce 2015 byla z iniciativy Mozilly vytvořena speciální skupina v rámci World Wide Web Consortium (W3C), která pracovala na specifikacích rozšíření pro různé prohlížeče.

Jako základ byla vzata stávající rozšíření API pro Chrome. Práce byla provedena s podporou společnosti Microsoft (Google se odmítl podílet na vývoji standardu) a v důsledku toho se objevil návrh Specifikace.

Formálně je specifikace podporována Edge, Firefox a Opera (všimněte si, že Chrome není na tomto seznamu). Ale ve skutečnosti je standard do značné míry kompatibilní s Chrome, protože je ve skutečnosti napsán na základě jeho rozšíření. Můžete si přečíst více o rozhraní WebExtensions API zde.

Struktura rozšíření

Jediný soubor, který je vyžadován pro rozšíření, je manifest (manifest.json). Je to také „vstupní bod“ do expanze.

Manifest

Podle specifikace je soubor manifestu platný soubor JSON. Úplný popis klíčů manifestu s informacemi o tom, které klíče jsou podporovány ve kterém prohlížeči, lze zobrazit zde.

Klíče, které nejsou ve specifikaci „mohou“ být ignorovány (Chrome i Firefox hlásí chyby, ale rozšíření nadále fungují).

A rád bych upozornil na některé body.

  1. pozadí — objekt, který obsahuje následující pole:
    1. skripty — pole skriptů, které budou spuštěny v kontextu na pozadí (o tom si povíme trochu později);
    2. strana - místo skriptů, které se budou spouštět na prázdné stránce, můžete zadat html s obsahem. V tomto případě bude pole skriptu ignorováno a skripty bude nutné vložit na stránku s obsahem;
    3. vytrvalý — binární příznak, pokud není zadán, prohlížeč „zabije“ proces na pozadí, když se domnívá, že nic nedělá, a v případě potřeby jej restartuje. V opačném případě bude stránka uvolněna pouze při zavření prohlížeče. Není podporováno ve Firefoxu.
  2. content_scripts — pole objektů, které vám umožní načíst různé skripty na různé webové stránky. Každý objekt obsahuje následující důležitá pole:
    1. zápasy - adresa URL vzoru, která určuje, zda bude zahrnut konkrétní skript obsahu či nikoli.
    2. js — seznam skriptů, které budou načteny do této shody;
    3. vyloučit_shody - vylučuje z oboru match Adresy URL, které odpovídají tomuto poli.
  3. page_action - je vlastně objekt, který je zodpovědný za ikonu, která se zobrazuje vedle adresního řádku v prohlížeči a interakci s ní. Umožňuje také zobrazit vyskakovací okno, které je definováno pomocí vlastního HTML, CSS a JS.
    1. default_popup — cesta k souboru HTML s vyskakovacím rozhraním, může obsahovat CSS a JS.
  4. oprávnění — pole pro správu práv rozšíření. Existují 3 druhy práv, které jsou podrobně popsány zde
  5. web_accessible_resources — rozšiřující zdroje, které si webová stránka může vyžádat, například obrázky, JS, CSS, HTML soubory.
  6. externě_připojitelné — zde můžete explicitně zadat ID dalších rozšíření a domén webových stránek, ze kterých se můžete připojit. Doména může být druhé úrovně nebo vyšší. Nefunguje ve Firefoxu.

Kontext provádění

Rozšíření má tři kontexty provádění kódu, to znamená, že aplikace se skládá ze tří částí s různými úrovněmi přístupu k rozhraní API prohlížeče.

Kontext rozšíření

Většina API je k dispozici zde. V tomto kontextu „žijí“:

  1. Stránka na pozadí — „backend“ část rozšíření. Soubor je specifikován v manifestu pomocí klíče „pozadí“.
  2. Vyskakovací stránka — vyskakovací stránka, která se zobrazí po kliknutí na ikonu rozšíření. V manifestu browser_action -> default_popup.
  3. Vlastní stránka — stránka rozšíření, „živá“ na samostatné kartě zobrazení chrome-extension://<id_расширения>/customPage.html.

Tento kontext existuje nezávisle na oknech a kartách prohlížeče. Stránka na pozadí existuje v jediné kopii a vždy funguje (výjimkou je stránka události, kdy je skript na pozadí spuštěn událostí a po jejím provedení „umře“). Vyskakovací stránka existuje, když je otevřené vyskakovací okno, a Vlastní stránka — když je karta s ním otevřená. Z tohoto kontextu není přístup k dalším kartám a jejich obsahu.

Kontext skriptu obsahu

Soubor skriptu obsahu se spustí spolu s každou kartou prohlížeče. Má přístup k části API rozšíření a ke stromu DOM webové stránky. Právě obsahové skripty jsou zodpovědné za interakci se stránkou. Rozšíření, která manipulují se stromem DOM, to dělají ve skriptech obsahu – například blokovače reklam nebo překladače. Obsahový skript také může komunikovat se stránkou prostřednictvím standardu postMessage.

Kontext webové stránky

Toto je samotná webová stránka. Nemá nic společného s rozšířením a nemá tam přístup, s výjimkou případů, kdy doména této stránky není výslovně uvedena v manifestu (více o tom níže).

Zprávy

Různé části aplikace si musí vyměňovat zprávy. Existuje na to API runtime.sendMessage odeslat zprávu background и tabs.sendMessage pro odeslání zprávy na stránku (skript obsahu, vyskakovací okno nebo webovou stránku, je-li k dispozici externally_connectable). Níže je uveden příklad přístupu k rozhraní 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))
    }
)

Pro plnou komunikaci můžete vytvářet spojení prostřednictvím runtime.connect. Jako odpověď obdržíme runtime.Port, na který, když je otevřený, můžete posílat libovolný počet zpráv. Na straně klienta je např. contentscript, vypadá to takto:

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

Server nebo pozadí:

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

Je zde také akce onDisconnect a způsob disconnect.

Schéma aplikace

Udělejme rozšíření prohlížeče, které uchovává soukromé klíče, poskytuje přístup k veřejným informacím (adresa, veřejný klíč komunikuje se stránkou a umožňuje aplikacím třetích stran vyžadovat podpis pro transakce.

Vývoj aplikací

Naše aplikace musí jak komunikovat s uživatelem, tak poskytovat stránce API pro volání metod (například pro podepisování transakcí). Vystačíte si s jedním contentscript nebude fungovat, protože má přístup pouze k DOM, ale ne k JS stránky. Připojte se přes runtime.connect nemůžeme, protože rozhraní API je potřeba ve všech doménách a v manifestu lze zadat pouze konkrétní. V důsledku toho bude diagram vypadat takto:

Psaní zabezpečeného rozšíření prohlížeče

Bude další scénář - inpage, který vložíme do stránky. Poběží ve svém kontextu a poskytne API pro práci s rozšířením.

začátek

Veškerý kód rozšíření prohlížeče je k dispozici na adrese GitHub. Během popisu budou odkazy na commity.

Začněme manifestem:

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

Vytvořte prázdné background.js, popup.js, inpage.js a contentscript.js. Přidáme popup.html – a naši aplikaci již lze načíst do Google Chrome a zajistit, aby fungovala.

Chcete-li to ověřit, můžete si vzít kód proto. Kromě toho, co jsme udělali, odkaz nakonfiguroval sestavení projektu pomocí webpacku. Chcete-li přidat aplikaci do prohlížeče, musíte v chrome://extensions vybrat načíst rozbalený a složku s odpovídající příponou - v našem případě dist.

Psaní zabezpečeného rozšíření prohlížeče

Nyní je naše rozšíření nainstalováno a funguje. Vývojářské nástroje můžete spustit pro různé kontexty následovně:

vyskakovací okno ->

Psaní zabezpečeného rozšíření prohlížeče

Přístup ke konzole skriptu obsahu se provádí prostřednictvím konzole samotné stránky, na které je spuštěn.Psaní zabezpečeného rozšíření prohlížeče

Zprávy

Potřebujeme tedy vytvořit dva komunikační kanály: inpage <-> background a popup <-> background. Můžete samozřejmě jen posílat zprávy na port a vymýšlet si svůj vlastní protokol, ale preferuji přístup, který jsem viděl v projektu metamask open source.

Jedná se o rozšíření prohlížeče pro práci se sítí Ethereum. V něm různé části aplikace komunikují přes RPC pomocí knihovny dnode. Umožňuje vám organizovat výměnu poměrně rychle a pohodlně, pokud jí poskytnete stream nodejs jako transport (což znamená objekt, který implementuje stejné rozhraní):

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

Nyní vytvoříme aplikační třídu. Vytvoří objekty API pro vyskakovací okno a webovou stránku a vytvoří pro ně 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)
        })
    }
}

Zde a níže místo globálního objektu Chrome používáme rozšířeníApi, které přistupuje k Chrome v prohlížeči Google a prohlížeči v ostatních. To se provádí pro kompatibilitu mezi různými prohlížeči, ale pro účely tohoto článku lze jednoduše použít 'chrome.runtime.connect'.

Vytvořme instanci aplikace ve skriptu na pozadí:

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

Protože dnode pracuje se streamy a my přijímáme port, je potřeba třída adaptéru. Je vytvořen pomocí knihovny readable-stream, která implementuje nodejs streamy v prohlížeči:

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

Nyní vytvoříme připojení v uživatelském rozhraní:

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

Poté vytvoříme připojení ve skriptu obsahu:

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

Protože potřebujeme rozhraní API ne ve skriptu obsahu, ale přímo na stránce, děláme dvě věci:

  1. Vytváříme dva proudy. Jedna – směrem ke stránce, v horní části zprávy. K tomu používáme toto tento balíček od tvůrců metamasky. Druhý proud je na pozadí přes port přijatý z runtime.connect. Pojďme si je koupit. Nyní bude mít stránka stream na pozadí.
  2. Vložte skript do DOM. Stáhněte si skript (přístup k němu byl povolen v manifestu) a vytvořte značku script s jeho obsahem uvnitř:

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

Nyní vytvoříme objekt api v inpage a nastavíme jej na globální:

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

Jsme připraveni Vzdálené volání procedur (RPC) se samostatným API pro stránku a uživatelské rozhraní. Při připojení nové stránky k pozadí můžeme vidět toto:

Psaní zabezpečeného rozšíření prohlížeče

Prázdné API a původ. Na straně stránky můžeme zavolat funkci hello takto:

Psaní zabezpečeného rozšíření prohlížeče

Práce s funkcemi zpětného volání v moderním JS je špatné chování, takže si pojďme napsat malého pomocníka pro vytvoření dnade, který vám umožní předat objekt API utils.

Objekty API budou nyní vypadat takto:

export class SignerApp {

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

...

}

Získání objektu z dálkového ovladače takto:

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

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

A volání funkcí vrátí slib:

Psaní zabezpečeného rozšíření prohlížeče

K dispozici verze s asynchronními funkcemi zde.

Celkově se přístup RPC a stream jeví docela flexibilní: můžeme použít multiplexování páry a vytvořit několik různých API pro různé úkoly. V zásadě lze dnode použít kdekoli, hlavní je obalit transport ve formě nodejs streamu.

Alternativou je formát JSON, který implementuje protokol JSON RPC 2. Ten však pracuje se specifickými transporty (TCP a HTTP(S)), což v našem případě není použitelné.

Vnitřní stav a místní úložiště

Budeme potřebovat uložit vnitřní stav aplikace – alespoň podpisové klíče. Do aplikace můžeme poměrně snadno přidat stav a metody pro jeho změnu ve vyskakovacím 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)
        }
    }

    ...

} 

Na pozadí vše zabalíme do funkce a zapíšeme objekt aplikace do okna, abychom s ním mohli pracovat z konzole:

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

Pojďme přidat několik klíčů z konzoly uživatelského rozhraní a uvidíme, co se stane se stavem:

Psaní zabezpečeného rozšíření prohlížeče

Stav musí být trvalý, aby se klíče při restartu neztratily.

Uložíme jej do localStorage a při každé změně jej přepíšeme. Následně k němu bude nutný přístup i pro UI a také bych se rád přihlásil ke změnám. Na základě toho bude vhodné vytvořit pozorovatelné úložiště a přihlásit se k odběru jeho změn.

Použijeme knihovnu mobx (https://github.com/mobxjs/mobx). Volba padla na to, protože jsem s tím nemusel pracovat, ale opravdu jsem to chtěl studovat.

Přidáme inicializaci počátečního stavu a učiníme obchod pozorovatelným:

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

    ...

}

"Pod pokličkou," mobx nahradil všechna pole obchodů proxy a zachycuje všechny hovory na ně. K odběru těchto zpráv bude možné se přihlásit.

Níže budu často používat termín „při změně“, i když to není zcela správné. Mobx sleduje přístup k polím. Používají se gettry a settery proxy objektů, které knihovna vytvoří.

Akční dekoratéři slouží ke dvěma účelům:

  1. V přísném režimu s příznakem forceActions mobx zakazuje přímou změnu stavu. Za dobrou praxi se považuje pracovat za přísných podmínek.
  2. I když funkce změní stav několikrát - například změníme několik polí v několika řádcích kódu - pozorovatelé jsou upozorněni pouze na dokončení. To je důležité zejména pro frontend, kde zbytečné aktualizace stavu vedou ke zbytečnému vykreslování prvků. V našem případě není první ani druhý zvlášť relevantní, ale budeme se řídit osvědčenými postupy. Ke všem funkcím, které mění stav sledovaných polí, je zvykem připojovat dekorátory.

Na pozadí přidáme inicializaci a uložení stavu do 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)
        }
    }
}

Zajímavá je zde reakční funkce. Má dva argumenty:

  1. Výběr dat.
  2. Obslužná rutina, která bude volána s těmito daty pokaždé, když se změní.

Na rozdíl od redux, kde explicitně přijímáme stav jako argument, si mobx pamatuje, ke kterým pozorovatelným objektům máme přístup uvnitř selektoru, a zavolá handler pouze tehdy, když se změní.

Je důležité přesně porozumět tomu, jak mobx rozhoduje o tom, které pozorovatelné položky odebíráme. Kdybych napsal selektor v kódu takto() => app.store, pak nebude reakce nikdy vyvolána, protože úložiště samotné není pozorovatelné, pouze jeho pole jsou.

Kdybych to napsal takhle () => app.store.keys, pak by se opět nic nestalo, protože při přidávání/odebírání prvků pole se odkaz na něj nezmění.

Mobx funguje jako selektor poprvé a sleduje pouze ty pozorovatelné, ke kterým jsme přistupovali. To se provádí pomocí proxy getterů. Proto je zde použita vestavěná funkce toJS. Vrátí nový objekt se všemi proxy nahrazenými původními poli. Během provádění čte všechna pole objektu - proto se spouštějí getry.

Ve vyskakovací konzoli opět přidáme několik kláves. Tentokrát skončili také v localStorage:

Psaní zabezpečeného rozšíření prohlížeče

Když se stránka na pozadí znovu načte, informace zůstanou na svém místě.

Všechny kódy aplikace až do tohoto okamžiku lze zobrazit zde.

Bezpečné uložení soukromých klíčů

Ukládání soukromých klíčů v čistém textu není bezpečné: vždy existuje šance, že budete napadeni, získáte přístup k počítači atd. Proto v localStorage uložíme klíče v heslem zašifrované podobě.

Pro větší bezpečnost do aplikace přidáme uzamčený stav, ve kterém nebude přístup ke klíčům vůbec. Pobočku automaticky převedeme do uzamčeného stavu z důvodu časového limitu.

Mobx umožňuje uložit jen minimální sadu dat a zbytek se na základě toho automaticky vypočítá. Jedná se o takzvané počítané vlastnosti. Lze je přirovnat k pohledům v databázích:

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

Nyní ukládáme pouze zašifrované klíče a heslo. Vše ostatní se počítá. Převod do uzamčeného stavu provedeme odstraněním hesla ze stavu. Veřejné API má nyní metodu pro inicializaci úložiště.

Napsáno pro šifrování nástroje využívající crypto-js:

import CryptoJS from 'crypto-js'

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

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

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

Prohlížeč má idle API, přes které se můžete přihlásit k odběru událostí – změn stavu. Stát tedy může být idle, active и locked. Pro nečinnost můžete nastavit časový limit a zámek je nastaven, když je blokován samotný OS. Změníme také volič pro ukládání na 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)
        }
    }
}

Kód před tímto krokem je zde.

Transakce

Takže se dostáváme k tomu nejdůležitějšímu: vytváření a podepisování transakcí na blockchainu. Využijeme blockchain a knihovnu WAVES vlny-transakce.

Nejprve přidejte do stavu pole zpráv, které je třeba podepsat, a poté přidejte metody pro přidání nové zprávy, potvrzení podpisu a odmítnutí:

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

    ...
}

Když obdržíme novou zprávu, přidáme k ní metadata, udělejte observable a přidat do store.messages.

Pokud ne observable ručně, pak to mobx udělá sám při přidávání zpráv do pole. Vytvoří však nový objekt, na který nebudeme mít referenci, ale budeme jej potřebovat pro další krok.

Dále vrátíme příslib, který se vyřeší, když se změní stav zprávy. Stav je sledován reakcí, která se při změně stavu „zabije“.

Kód metody approve и reject velmi jednoduché: jednoduše změníme stav zprávy po jejím podepsání v případě potřeby.

Schválit a zamítnout jsme dali do rozhraní UI API, newMessage do rozhraní API stránky:

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

    ...
}

Nyní zkusme podepsat transakci s rozšířením:

Psaní zabezpečeného rozšíření prohlížeče

Obecně je vše připraveno, zbývá jen přidat jednoduché uživatelské rozhraní.

UI

Rozhraní potřebuje přístup ke stavu aplikace. Na straně uživatelského rozhraní to uděláme observable stavu a přidejte do API funkci, která tento stav změní. Přidejme observable na objekt API přijatý z pozadí:

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

Na konci začneme renderovat rozhraní aplikace. Toto je aplikace pro reakce. Objekt pozadí je jednoduše předán pomocí rekvizit. Bylo by samozřejmě správné vytvořit samostatnou službu pro metody a úložiště pro stát, ale pro účely tohoto článku to stačí:

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

S mobx je velmi snadné začít vykreslovat při změně dat. Dekoratér pozorovatele jednoduše zavěsíme z balení mobx-reagovat na komponentě a render bude automaticky volán, když se změní jakékoli pozorovatelné, na které komponenta odkazuje. Nepotřebujete žádné mapStateToProps ani připojení jako v reduxu. Vše funguje hned po vybalení:

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

Zbývající součásti lze zobrazit v kódu ve složce uživatelského rozhraní.

Nyní ve třídě aplikace musíte vytvořit selektor stavu pro uživatelské rozhraní a upozornit uživatelské rozhraní, když se změní. Chcete-li to provést, přidejte metodu getState и reactionpovolání 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())

        })
    }

    ...
}

Při příjmu předmětu remote je vytvořen reaction změnit stav, který volá funkci na straně uživatelského rozhraní.

Posledním dotykem je přidání zobrazení nových zpráv na ikonu rozšíření:

function setupApp() {
...

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

...
}

Takže aplikace je připravena. Webové stránky mohou vyžadovat podpis pro transakce:

Psaní zabezpečeného rozšíření prohlížeče

Psaní zabezpečeného rozšíření prohlížeče

Kód je k dispozici zde odkaz.

Závěr

Pokud jste článek dočetli až do konce, ale stále máte otázky, můžete se jich zeptat na úložiště s rozšířením. Tam také najdete commity pro každý určený krok.

A pokud máte zájem podívat se na kód skutečného rozšíření, najdete toto zde.

Kód, úložiště a popis práce z siemarell

Zdroj: www.habr.com

Přidat komentář