Písanie zabezpečeného rozšírenia prehliadača

Písanie zabezpečeného rozšírenia prehliadača

Na rozdiel od bežnej architektúry „klient-server“ sa decentralizované aplikácie vyznačujú:

  • Nie je potrebné ukladať databázu s používateľskými prihlasovacími údajmi a heslami. Prístupové informácie si ukladajú výlučne samotní používatelia a potvrdenie ich pravosti prebieha na úrovni protokolu.
  • Nie je potrebné používať server. Aplikačnú logiku je možné vykonávať na blockchainovej sieti, kde je možné uložiť požadované množstvo dát.

K dispozícii sú 2 relatívne bezpečné úložiská pre používateľské kľúče – hardvérové ​​peňaženky a rozšírenia prehliadača. Hardvérové ​​peňaženky sú väčšinou mimoriadne bezpečné, ale ťažko sa používajú a zďaleka nie sú zadarmo, ale rozšírenia prehliadača sú dokonalou kombináciou bezpečnosti a jednoduchého používania a môžu byť pre koncových používateľov tiež úplne zadarmo.

Berúc do úvahy toto všetko, chceli sme vytvoriť najbezpečnejšie rozšírenie, ktoré zjednoduší vývoj decentralizovaných aplikácií poskytnutím jednoduchého API pre prácu s transakciami a podpismi.
O tejto skúsenosti vám povieme nižšie.

Článok bude obsahovať podrobné pokyny, ako napísať rozšírenie prehliadača, s príkladmi kódu a snímkami obrazovky. Celý kód nájdete v úložiská. Každé potvrdenie logicky zodpovedá časti tohto článku.

Stručná história rozšírení prehliadača

Rozšírenia prehliadača existujú už dlho. V Internet Exploreri sa objavili už v roku 1999, vo Firefoxe v roku 2004. Veľmi dlho však neexistoval jednotný štandard pre rozšírenia.

Dá sa povedať, že sa objavil spolu s rozšíreniami vo štvrtej verzii prehliadača Google Chrome. Samozrejme, vtedy neexistovala žiadna špecifikácia, ale jeho základom sa stalo rozhranie Chrome API: Chrome dobyl väčšinu trhu s prehliadačmi a má vstavaný obchod s aplikáciami a v skutočnosti nastavil štandard pre rozšírenia prehliadača.

Mozilla mala svoj vlastný štandard, ale keď videla popularitu rozšírení Chrome, spoločnosť sa rozhodla vytvoriť kompatibilné API. V roku 2015 bola z iniciatívy Mozilly vytvorená špeciálna skupina v rámci konzorcia World Wide Web Consortium (W3C), ktorá pracovala na špecifikáciách rozšírenia pre rôzne prehliadače.

Ako základ boli použité existujúce rozšírenia API pre Chrome. Práca bola vykonaná s podporou spoločnosti Microsoft (Google sa odmietol podieľať na vývoji štandardu) a v dôsledku toho sa objavil návrh technické údaje.

Formálne špecifikáciu podporujú Edge, Firefox a Opera (všimnite si, že Chrome nie je na tomto zozname). V skutočnosti je však štandard do značnej miery kompatibilný s prehliadačom Chrome, pretože je v skutočnosti napísaný na základe jeho rozšírení. Môžete si prečítať viac o rozhraní WebExtensions API tu.

Štruktúra rozšírenia

Jediný súbor, ktorý sa vyžaduje pre rozšírenie, je manifest (manifest.json). Je to tiež „vstupný bod“ do rozšírenia.

manifest

Podľa špecifikácie je súbor manifestu platný súbor JSON. Úplný popis kľúčov manifestu s informáciami o tom, ktoré kľúče sú podporované v ktorom prehliadači je možné zobraziť tu.

Kľúče, ktoré nie sú v špecifikácii „môžu“ byť ignorované (Chrome aj Firefox hlásia chyby, ale rozšírenia naďalej fungujú).

A rád by som upozornil na niektoré body.

  1. pozadia — objekt, ktorý obsahuje nasledujúce polia:
    1. skripty — pole skriptov, ktoré sa vykonajú v kontexte na pozadí (o tom si povieme trochu neskôr);
    2. strana - namiesto skriptov, ktoré sa vykonajú na prázdnej stránke, môžete zadať html s obsahom. V tomto prípade bude pole skriptu ignorované a skripty bude potrebné vložiť na stránku s obsahom;
    3. vytrvalý — binárny príznak, ak nie je zadaný, prehliadač „zabije“ proces na pozadí, keď usúdi, že nič nerobí, a v prípade potreby ho reštartuje. V opačnom prípade sa stránka uvoľní až po zatvorení prehliadača. Nepodporované vo Firefoxe.
  2. content_scripts — pole objektov, ktoré vám umožňujú načítať rôzne skripty na rôzne webové stránky. Každý objekt obsahuje nasledujúce dôležité polia:
    1. zápasov - vzorová adresa URL, ktorý určuje, či bude konkrétny skript obsahu zahrnutý alebo nie.
    2. js — zoznam skriptov, ktoré sa načítajú do tohto zápasu;
    3. vylúčiť_zhody - vylučuje z poľa match Adresy URL, ktoré zodpovedajú tomuto poľu.
  3. page_action - je vlastne objekt, ktorý je zodpovedný za ikonu, ktorá sa zobrazuje vedľa panela s adresou v prehliadači a interakciu s ňou. Umožňuje tiež zobraziť vyskakovacie okno, ktoré je definované pomocou vlastného HTML, CSS a JS.
    1. default_popup — cesta k súboru HTML s kontextovým rozhraním, môže obsahovať CSS a JS.
  4. oprávnenia — pole na správu práv rozšírenia. Existujú 3 druhy práv, ktoré sú podrobne popísané tu
  5. web_accessible_resources — zdroje rozšírenia, ktoré si webová stránka môže vyžiadať, napríklad obrázky, súbory JS, CSS, HTML.
  6. externe_pripojiteľné — tu môžete explicitne zadať ID ďalších rozšírení a domén webových stránok, z ktorých sa môžete pripojiť. Doména môže byť druhej úrovne alebo vyššej. Nefunguje vo Firefoxe.

Kontext vykonávania

Rozšírenie má tri kontexty spúšťania kódu, to znamená, že aplikácia pozostáva z troch častí s rôznymi úrovňami prístupu k API prehliadača.

Kontext rozšírenia

Väčšina API je k dispozícii tu. V tomto kontexte „žijú“:

  1. Stránka na pozadí — „backend“ časť rozšírenia. Súbor je špecifikovaný v manifeste pomocou kľúča „pozadie“.
  2. Vyskakovacia stránka — vyskakovacia stránka, ktorá sa zobrazí po kliknutí na ikonu rozšírenia. V manifeste browser_action -> default_popup.
  3. Vlastná stránka — stránka rozšírenia, „žijúca“ na samostatnej karte zobrazenia chrome-extension://<id_расширения>/customPage.html.

Tento kontext existuje nezávisle od okien a kariet prehliadača. Stránka na pozadí existuje v jednej kópii a vždy funguje (výnimkou je stránka udalosti, keď je skript na pozadí spustený udalosťou a po jej vykonaní „zomrie“). Vyskakovacia stránka existuje, keď je vyskakovacie okno otvorené a Vlastná stránka — kým je karta s ním otvorená. Z tohto kontextu nie je prístup k iným kartám a ich obsahu.

Kontext skriptu obsahu

Súbor skriptu obsahu sa spustí spolu s každou kartou prehliadača. Má prístup k časti API rozšírenia a k stromu DOM webovej stránky. Sú to skripty obsahu, ktoré sú zodpovedné za interakciu so stránkou. Rozšírenia, ktoré manipulujú so stromom DOM, to robia v skriptoch obsahu – napríklad blokovače reklám alebo prekladače. Obsahový skript môže tiež komunikovať so stránkou štandardne postMessage.

Kontext webovej stránky

Toto je samotná webová stránka. Nemá nič spoločné s rozšírením a nemá tam prístup, s výnimkou prípadov, keď doména tejto stránky nie je výslovne uvedená v manifeste (viac o tom nižšie).

messaging

Rôzne časti aplikácie si musia navzájom vymieňať správy. Existuje na to API runtime.sendMessage poslať správu background и tabs.sendMessage na odoslanie správy na stránku (skript obsahu, kontextové okno alebo webovú stránku, ak je k dispozícii externally_connectable). Nižšie je uvedený príklad pri prístupe k rozhraniu Chrome API.

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

Pre úplnú komunikáciu môžete vytvárať spojenia prostredníctvom runtime.connect. Ako odpoveď dostaneme runtime.Port, na ktorý, keď je otvorený, môžete posielať ľubovoľný počet správ. Na strane klienta je napr. contentscript, vyzerá 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 alebo pozadie:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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 tam aj podujatie onDisconnect a metóda disconnect.

Schéma aplikácie

Urobme rozšírenie prehliadača, ktoré ukladá súkromné ​​kľúče, poskytuje prístup k verejným informáciám (adresa, verejný kľúč komunikuje so stránkou a umožňuje aplikáciám tretích strán vyžadovať podpis pre transakcie.

Vývoj aplikácií

Naša aplikácia musí interagovať s používateľom a poskytnúť stránke API na volanie metód (napríklad na podpisovanie transakcií). Vystačiť si len s jedným contentscript nebude fungovať, pretože má prístup iba k DOM, ale nie k JS stránky. Pripojiť cez runtime.connect nemôžeme, pretože rozhranie API je potrebné vo všetkých doménach a v manifeste je možné špecifikovať iba konkrétne domény. V dôsledku toho bude diagram vyzerať takto:

Písanie zabezpečeného rozšírenia prehliadača

Bude ďalší scenár - inpage, ktorý vložíme do stránky. Spustí sa vo svojom kontexte a poskytne API na prácu s rozšírením.

začiatok

Celý kód rozšírenia prehliadača je dostupný na GitHub. Počas popisu budú odkazy na commity.

Začnime manifestom:

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

Vytvorte prázdne súbory background.js, popup.js, inpage.js a contentscript.js. Pridávame popup.html – a naša aplikácia sa už dá načítať do prehliadača Google Chrome a uistiť sa, že funguje.

Na overenie si môžete vziať kód preto. Okrem toho, čo sme urobili, odkaz nakonfiguroval zostavenie projektu pomocou webpacku. Ak chcete pridať aplikáciu do prehliadača, v chrome://extensions musíte vybrať načítať rozbalené a priečinok s príslušnou príponou - v našom prípade dist.

Písanie zabezpečeného rozšírenia prehliadača

Teraz je naše rozšírenie nainštalované a funguje. Vývojárske nástroje môžete spustiť pre rôzne kontexty takto:

vyskakovacie okno ->

Písanie zabezpečeného rozšírenia prehliadača

Prístup ku konzole skriptu obsahu sa vykonáva cez konzolu samotnej stránky, na ktorej je spustený.Písanie zabezpečeného rozšírenia prehliadača

messaging

Potrebujeme teda vytvoriť dva komunikačné kanály: inpage <-> background a popup <-> background. Môžete samozrejme len posielať správy na port a vymyslieť si vlastný protokol, ale ja preferujem prístup, ktorý som videl v projekte metamask open source.

Toto je rozšírenie prehliadača pre prácu so sieťou Ethereum. V ňom rôzne časti aplikácie komunikujú cez RPC pomocou knižnice dnode. Umožňuje vám organizovať výmenu pomerne rýchlo a pohodlne, ak jej poskytnete nodejs stream ako transport (čo znamená objekt, ktorý implementuje rovnaké rozhranie):

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

Teraz vytvoríme triedu aplikácie. Vytvorí objekty API pre kontextové okno a webovú stránku a vytvorí pre ne 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)
        })
    }
}

Tu a nižšie namiesto globálneho objektu Chrome používame rozšírenieApi, ktoré pristupuje k prehliadaču Chrome v prehliadači Google a prehliadaču v iných prehliadačoch. Toto sa robí kvôli kompatibilite medzi prehliadačmi, ale na účely tohto článku by ste mohli jednoducho použiť „chrome.runtime.connect“.

Vytvorme inštanciu aplikácie v skripte 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)
    }
}

Keďže dnode pracuje s prúdmi a my prijímame port, je potrebná trieda adaptéra. Vyrába sa pomocou knižnice readable-stream, ktorá implementuje nodejs streamy v prehliadač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()
    }
}

Teraz vytvorte pripojenie v používateľskom 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;
    }
}

Potom vytvoríme spojenie v skripte 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);
    }
}

Keďže API nepotrebujeme v skripte obsahu, ale priamo na stránke, robíme dve veci:

  1. Vytvárame dva prúdy. Jedna – smerom k stránke, v hornej časti správy. Na to používame toto tento balík od tvorcov metamasky. Druhý prúd je na pozadí cez port prijatý z runtime.connect. Poďme si ich kúpiť. Teraz bude mať stránka stream na pozadí.
  2. Vložte skript do DOM. Stiahnite si skript (prístup k nemu bol povolený v manifeste) a vytvorte značku script s obsahom vo vnútri:

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

Teraz vytvoríme objekt api v inpage a nastavíme ho na globálne:

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

Sme pripravení Vzdialené volanie procedúr (RPC) so samostatným API pre stránku a používateľské rozhranie. Pri pripájaní novej stránky na pozadie vidíme toto:

Písanie zabezpečeného rozšírenia prehliadača

Prázdne API a pôvod. Na strane stránky môžeme zavolať funkciu ahoj takto:

Písanie zabezpečeného rozšírenia prehliadača

Práca s funkciami spätného volania v modernom JS je neslušné správanie, takže napíšme malého pomocníka na vytvorenie dnade, ktorý vám umožní odovzdať objekt API nástrojom.

Objekty API budú teraz vyzerať takto:

export class SignerApp {

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

...

}

Získanie objektu z diaľkového ovládača 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 volanie funkcií vráti prísľub:

Písanie zabezpečeného rozšírenia prehliadača

K dispozícii je verzia s asynchrónnymi funkciami tu.

Celkovo sa prístup RPC a stream javí ako dosť flexibilný: môžeme použiť multiplexovanie v pare a vytvoriť niekoľko rôznych rozhraní API pre rôzne úlohy. V zásade sa dnode dá použiť kdekoľvek, ide hlavne o to, obaliť transport vo forme nodejs streamu.

Alternatívou je formát JSON, ktorý implementuje protokol JSON RPC 2. Pracuje však so špecifickými transportmi (TCP a HTTP(S)), čo v našom prípade nie je použiteľné.

Vnútorný stav a miestne úložisko

Budeme musieť uložiť vnútorný stav aplikácie - aspoň podpisové kľúče. Do aplikácie môžeme pomerne jednoducho pridať stav a metódy na jeho zmenu v popup API:

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

export class SignerApp {

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

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

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

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

    ...

} 

Na pozadí všetko zabalíme do funkcie a zapíšeme objekt aplikácie do okna, aby sme s ním mohli pracovať z konzoly:

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

Pridajme niekoľko kľúčov z konzoly používateľského rozhrania a uvidíme, čo sa stane so stavom:

Písanie zabezpečeného rozšírenia prehliadača

Stav musí byť trvalý, aby sa kľúče pri reštartovaní nestratili.

Uložíme ho do localStorage a pri každej zmene ho prepíšeme. Následne k nemu bude potrebný prístup aj pre UI a tiež by som sa rád prihlásil k zmenám. Na základe toho bude vhodné vytvoriť pozorovateľné úložisko a prihlásiť sa na odber jeho zmien.

Použijeme knižnicu mobx (https://github.com/mobxjs/mobx). Voľba padla na to, pretože som s tým nemusel pracovať, ale naozaj som to chcel študovať.

Pridajme inicializáciu počiatočného stavu a urobme obchod pozorovateľný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 kapotou," mobx nahradil všetky polia obchodu s proxy a zachytáva všetky hovory na ne. Na odber týchto správ bude možné sa prihlásiť.

Nižšie budem často používať výraz „pri zmene“, aj keď to nie je úplne správne. Mobx sleduje prístup k poliam. Používajú sa získavače a nastavovače proxy objektov, ktoré knižnica vytvára.

Akčné dekorátory slúžia na dva účely:

  1. V prísnom režime s príznakom forceActions mobx zakazuje zmenu stavu priamo. Za dobrú prax sa považuje pracovať za prísnych podmienok.
  2. Aj keď funkcia niekoľkokrát zmení stav – napríklad zmeníme niekoľko polí v niekoľkých riadkoch kódu – pozorovatelia sú upozornení až po jej dokončení. To je dôležité najmä pre frontend, kde zbytočné aktualizácie stavu vedú k zbytočnému vykresľovaniu prvkov. V našom prípade nie je prvý ani druhý obzvlášť relevantný, ale budeme sa riadiť osvedčenými postupmi. Ku všetkým funkciám, ktoré menia stav sledovaných polí, je zvykom pripájať dekorátory.

Na pozadí pridáme inicializáciu a ukladanie 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)
        }
    }
}

Zaujímavá je tu funkcia reakcie. Má dva argumenty:

  1. Selektor údajov.
  2. Obslužný program, ktorý sa bude volať s týmito údajmi pri každej zmene.

Na rozdiel od reduxu, kde explicitne prijímame stav ako argument, mobx si pamätá, ku ktorým pozorovateľným prvkom pristupujeme vo vnútri selektora, a zavolá handler iba vtedy, keď sa zmenia.

Je dôležité presne pochopiť, ako mobx rozhoduje o tom, ktoré pozorovateľné položky odoberáme. Keby som napísal selektor v kóde takto() => app.store, potom sa reakcia nikdy nevyvolá, keďže samotné úložisko nie je pozorovateľné, iba jeho polia.

Keby som to napísal takto () => app.store.keys, potom by sa opäť nič nestalo, keďže pri pridávaní/odstraňovaní prvkov poľa sa odkaz naň nezmení.

Mobx funguje ako selektor prvýkrát a sleduje iba pozorovateľné položky, ku ktorým sme pristupovali. To sa deje prostredníctvom proxy getterov. Preto sa tu používa vstavaná funkcia toJS. Vráti nový objekt so všetkými proxy nahradenými pôvodnými poľami. Počas vykonávania číta všetky polia objektu - preto sa spúšťajú getre.

V kontextovej konzole opäť pridáme niekoľko klávesov. Tentoraz skončili aj v localStorage:

Písanie zabezpečeného rozšírenia prehliadača

Keď sa stránka na pozadí znova načíta, informácie zostanú na svojom mieste.

Všetky kódy aplikácie až do tohto bodu je možné zobraziť tu.

Bezpečné uloženie súkromných kľúčov

Ukladanie súkromných kľúčov vo forme čistého textu nie je bezpečné: vždy existuje šanca, že budete napadnutý hackermi, získate prístup k svojmu počítaču atď. Preto v localStorage uložíme kľúče v zašifrovanej forme.

Pre väčšiu bezpečnosť do aplikácie pridáme zamknutý stav, v ktorom nebude prístup ku kľúčom vôbec. Predĺženie automaticky prenesieme do uzamknutého stavu z dôvodu časového limitu.

Mobx umožňuje uložiť len minimálnu množinu údajov a zvyšok sa na základe toho automaticky vypočíta. Toto sú takzvané vypočítané vlastnosti. Možno ich porovnať so zobrazeniami v databázach:

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

Teraz ukladáme iba zašifrované kľúče a heslo. Všetko ostatné sa počíta. Prevod do uzamknutého stavu vykonáme odstránením hesla zo stavu. Verejné API má teraz metódu na inicializáciu úložiska.

Napísané pre šifrovanie nástroje využívajúce 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)
}

Prehliadač má nečinné API, cez ktoré sa môžete prihlásiť na odber udalosti - zmeny stavu. Štát podľa toho môže byť idle, active и locked. Pre nečinnosť môžete nastaviť časový limit a uzamknutý je nastavený, keď je zablokovaný samotný OS. Zmeníme aj volič pre ukladanie 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 pred týmto krokom je tu.

Transakcie

Dostávame sa teda k najdôležitejšej veci: vytváranie a podpisovanie transakcií na blockchaine. Budeme využívať blockchain a knižnicu WAVES vlny-transakcie.

Najprv pridajte do stavu pole správ, ktoré je potrebné podpísať, potom pridajte metódy na pridanie novej správy, potvrdenie podpisu a odmietnutie:

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

    ...
}

Keď dostaneme novú správu, pridáme k nej metadáta, urobte observable a pridať do store.messages.

Ak nie observable manuálne, potom to mobx urobí sám pri pridávaní správ do poľa. Vytvorí však nový objekt, na ktorý nebudeme mať referenciu, ale budeme ho potrebovať pre ďalší krok.

Ďalej vrátime prísľub, ktorý sa vyrieši, keď sa zmení stav správy. Stav je monitorovaný reakciou, ktorá sa pri zmene stavu „zabije“.

Kód metódy approve и reject veľmi jednoduché: v prípade potreby po podpísaní jednoducho zmeníme stav správy.

Do rozhrania UI API sme vložili možnosť Schváliť a zamietnuť, do rozhrania API stránky sme pridali newMessage:

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

    ...
}

Teraz skúsme podpísať transakciu s rozšírením:

Písanie zabezpečeného rozšírenia prehliadača

Vo všeobecnosti je všetko pripravené, zostáva pridať jednoduché používateľské rozhranie.

UI

Rozhranie potrebuje prístup k stavu aplikácie. Na strane používateľského rozhrania to urobíme observable stav a pridajte do API funkciu, ktorá tento stav zmení. Pridajme observable na objekt API prijatý z pozadia:

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 renderovať aplikačné rozhranie. Toto je aplikácia na reakcie. Objekt pozadia sa jednoducho odovzdáva pomocou rekvizít. Bolo by samozrejme správne vytvoriť samostatnú službu pre metódy a sklad pre štát, ale na účely tohto č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 veľmi jednoduché spustiť vykresľovanie pri zmene údajov. Dekoratér pozorovateľ jednoducho zavesíme z balenia mobx-reagovať na komponente a render sa automaticky zavolá, keď sa zmenia akékoľvek pozorovateľné prvky, na ktoré komponent odkazuje. Nepotrebujete žiadne mapStateToProps ani pripojenie ako v redux. Všetko funguje hneď 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>
    }
}

Zostávajúce komponenty je možné zobraziť v kóde v priečinku UI.

Teraz v triede aplikácií musíte vytvoriť selektor stavu pre používateľské rozhranie a upozorniť používateľské rozhranie, keď sa zmení. Ak to chcete urobiť, pridajte metódu getState и reactionvolania 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())

        })
    }

    ...
}

Pri prijímaní predmetu remote je vytvorený reaction zmeniť stav, ktorý volá funkciu na strane používateľského rozhrania.

Posledným dotykom je pridať zobrazenie nových správ na ikonu rozšírenia:

function setupApp() {
...

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

...
}

Takže aplikácia je pripravená. Webové stránky môžu vyžadovať podpis pre transakcie:

Písanie zabezpečeného rozšírenia prehliadača

Písanie zabezpečeného rozšírenia prehliadača

Kód je dostupný tu odkaz.

Záver

Ak ste článok dočítali až do konca, no stále máte otázky, môžete sa ich opýtať na úložiská s príponou. Nájdete tam aj commity pre každý určený krok.

A ak máte záujem pozrieť sa na kód skutočného rozšírenia, nájdete ho tu.

Kód, úložisko a popis práce z siemarell

Zdroj: hab.com

Pridať komentár