Píšeme bezpečné rozšírenie pre prehliadač

Píšeme bezpečné rozšírenie pre prehliadač

Na rozdiel od bežnej architektúry "klient-server" sú pre decentralizované aplikácie charakteristické:

  • Neexistuje potreba uchovávať databázu s prihláseniami a heslami používateľov. Prístupové informácie sú uchovávané výlučne u samotných používateľov a overenie ich pravdivosti sa vykonáva na úrovni protokolu.
  • Neexistuje potreba používať server. Logika aplikácie môže byť vykonávaná v blockhainovej sieti, kde je možné uchovávať potrebné množstvo dát.

Existujú dve relatívne bezpečné úložiská pre kľúče používateľov — hardvérové peňaženky a prehliadačové rozšírenia. Hardvérové peňaženky sú väčšinou maximálne bezpečné, avšak ťažko použiteľné a nie sú zadarmo, zatiaľ čo prehliadačové rozšírenia sú ideálnym spojením bezpečnosti a jednoduchej použiteľnosti, a môžu byť úplne zadarmo pre koncových používateľov.

Zohľadňujúc to všetko, sme sa rozhodli vytvoriť maximálne bezpečné rozšírenie, ktoré zjednodušuje vývoj decentralizovaných aplikácií poskytnutím jednoduchého API na prácu s transakciami a podpismi.
O tejto skúsenosti vám budeme rozprávať nižšie.

V článku nájdete krok za krokom návod, ako napísať prehliadačové rozšírenie, s ukážkami kódu a snímkami obrazovky. Celý kód nájdete v repozitári. Každý commit logicky zodpovedá sekcii tohto článku.

Stručná história prehliadačových rozšírení

Prehliadačové rozšírenia existujú už dosť dlho. V Internet Exploreri sa objavili už v roku 1999, vo Firefoxe — v roku 2004. Napriek tomu dlho neexistoval jednotný štandard pre rozšírenia.

Dá sa povedať, že ten sa objavil spolu s rozšíreniami v štvrtej verzii Google Chrome. Samozrejme, vtedy neexistovala žiadna špecifikácia, ale práve API Chrome sa stalo jej základom: získajúc podstatnú časť trhu prehliadačov a s integrovaným obchodom s aplikáciami, Chrome v podstate stanovil štandard pre prehliadačové rozšírenia.

Mozilla mala svoj štandard, ale vidząc popularitu rozšírení pre Chrome, sa spoločnosť rozhodla vytvoriť kompatibilné API. V roku 2015, na podnet Mozilly, bola v rámci World Wide Web Consortium (W3C) vytvorená špeciálna skupina na prácu na špecifikáciách krížových prehliadačových rozšírení.

Ako základ bol použitý už existujúci API rozšírení pre Chrome. Práca prebiehala s podporou Microsoftu (Google sa odmietol zúčastniť na vývoji štandardu) a výsledkom je návrh. špecifikácie.

Formálne špecifikáciu podporujú Edge, Firefox a Opera (nechajte si všimnúť, že v tomto zozname chýba Chrome). Ale v skutočnosti je štandard do veľkej miery kompatibilný aj s Chrome, pretože je prakticky napísaný na základe jeho rozšírení. Viac informácií o WebExtensions API si môžete prečítať. tu.

Štruktúra rozšírenia

Jediný súbor, ktorý je pre rozšírenie nevyhnutný, je manifest (manifest.json). Ten je zároveň “vstupným bodom” do rozšírenia.

Manifest

Podľa špecifikácie je súbor manifestu platným JSON súborom. Kompletný popis kľúčov manifestu s informáciami o tom, ktoré kľúče sú podporované v akých prehliadačoch, si môžete pozrieť. tu.

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

Chcel by som sa zamerať na niektoré aspekty.

  1. background — objekt, ktorý obsahuje nasledujúce polia:
    1. scripts — pole skriptov, ktoré sa vykonajú v pozadí (o tom sa budeme baviť neskôr);
    2. (stránky, na ktoré sa presmerováva z reklamných bannerov); — namiesto skriptov, ktoré budú vykonané na prázdnej stránke, je možné zadať HTML s obsahom. V tomto prípade sa pole script bude ignorovať a skripty bude potrebné vložiť do stránky s obsahom;
    3. persistent — binárna vlajka, ak nie je uvedená, prehliadač „ukončí“ background-proces, keď uzná, že nič nerobí, a reštartuje ho podľa potreby. Inak sa stránka uvoľní iba pri zavretí prehliadača. Nepodporuje sa vo Firefoxe.
  2. content_scripts — pole objektov, ktoré umožňuje načítavať rôzne skripty na rôzne webové stránky. Každý objekt obsahuje nasledujúce dôležité polia:
    1. matches — url vzor, na základe ktorého sa určuje, či bude konkrétny content script zahrnutý alebo nie.
    2. js — zoznam skriptov, ktoré sa načítajú v danom zápase;
    3. exclude_matches — vylučuje z poľa match URL, ktoré spĺňajú toto pole.
  3. page_action — je vlastne objekt, ktorý zodpovedá za ikonu zobrazenú vedľa adresného riadku v prehliadači a interakciu s ňou. Umožňuje tiež zobraziť popup okno, ktoré je definované pomocou vlastného HTML, CSS a JS.
    1. default_popup — cesta k HTML súboru s popup rozhraním, môže obsahovať CSS a JS.
  4. permissions — pole pre správu práv rozšírenia. Existujú 3 typy práv, ktoré sú podrobne popísané tu
  5. web_accessible_resources — zdroje rozšírenia, ktoré môže vyžadovať webová stránka, napríklad obrázky, súbory JS, CSS, HTML.
  6. externally_connectable — tu môžete výslovne uviesť ID iných rozšírení a domény webových stránok, z ktorých sa môže pripojiť. Doména môže byť druhej úrovne a vyššej. Neplatí v prehliadači Firefox.

Kontext vykonávania

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

Kontext rozšírenia

Tu je dostupná väčšina API. V tomto kontexte 'žijú':

  1. Pozadia — 'backend' časť rozšírenia. Súbor je uvedený v manifeste pod kľúčom 'background'.
  2. Pop-up stránka — pop-up stránka, ktorá sa objaví 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 typu chrome-extension:///customPage.html.

Tento kontext existuje nezávisle od okien a kariet prehliadača. Pozadia existuje v jedinom exemplári a funguje vždy (výnimka — event page, keď sa background skript spúšťa na základe udalosti a 'umiera' po jej vykonaní). Pop-up stránka existuje, keď je otvorené okno pop-up, a Vlastná stránka — kým je otvorená karta s ním. Prístup k iným kartám a ich obsahu z tohto kontextu nie je možný.

Kontext skriptu obsahu

Súbor skriptu obsahu sa spúšťa spolu s každou kartou prehliadača. Má prístup k časti API rozšírenia a k DOM stromu webovej stránky. Práve skripty obsahu sú zodpovedné za interakciu so stránkou. Rozšírenia, ktoré manipulujú s DOM stromom, to robia v skriptoch obsahu – napríklad blokátory reklám alebo prekladatelia. Skript obsahu môže tiež komunikovať so stránkou prostredníctvom štandardného postMessage.

Kontext webovej stránky

To je vlastne samotná webová stránka. K rozšíreniu nemá žiadny vzťah a nemá k nemu prístup, okrem prípadov, keď je v manifeste výslovne uvedená doména tejto stránky (o tom – nižšie).

Výmena správ

Rôzne časti aplikácie musia medzi sebou vymieňať správy. Na to existuje API runtime.sendMessage na odosielanie správy background a tabs.sendMessage na odosielanie správy na stránku (skriptu obsahu, pop-up alebo webovej stránke za prítomnosti externally_connectable). Nižšie je uvedený príklad pri volaní 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))
    }
)

Na plnohodnotnú komunikáciu je možné vytvárať spojenia cez runtime.connect. Na oplátku dostaneme runtime.Port, do ktorého, pokiaľ je otvorený, je možné posielať ľubovoľný počet správ. Na strane klienta, napríklad, contentscript, to vyzerá 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 background:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
    console.assert(port.name === "knockknock");
    port.onMessage.addListener(function(msg) {
        if (msg.joke === "Knock knock")
            port.postMessage({question: "Who's there?"});
        else if (msg.answer === "Madame")
            port.postMessage({question: "Madame who?"});
        else if (msg.answer === "Madame... Bovary")
            port.postMessage({question: "I don't get it."});
    });
});

// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
    ...
});

Existuje aj udalosť onDisconnect a metóda disconnect.

Schéma aplikácie

Vytvorme rozšírenie pre prehliadač, ktoré uchováva súkromné kľúče, poskytuje prístup k verejným informáciám (adresa, verejný kľúč komunikuje so stránkou a umožňuje externým aplikáciám požiadať o podpis tranzakcií.

Vývoj aplikácie

Naša aplikácia by mala interagovať so používateľom a poskytovať stránke API na volanie metód (napríklad na podpisovanie tranzakcií). S jedným len contentscript to nedokážeme, pretože má prístup iba k DOM, ale nie k JS stránky. Pripojiť sa cez runtime.connect nemôžeme, pretože API je potrebné na všetkých doménach, a v manifeste je možné uvádzať len konkrétne. Nakoniec bude schéma vyzerať takto:

Píšeme bezpečné rozšírenie pre prehliadač

Bude ešte jeden skript — inpage, ktorý budeme injektovať do stránky. Bude sa vykonávať v jej kontexte a poskytovať API na prácu s rozšírením.

Začiatok

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

Začneme s manifestom:

{
  // Názov a popis, verzia. To všetko bude viditeľné v prehliadači na chrome://extensions/?id=<id rozšírenia>
  "name": "Signer",
  "description": "Demo rozšírenie",
  "version": "0.0.1",
  "manifest_version": 2,

  // Skripty, ktoré sa budú vykonávať v background, môže ich byť viac
  "background": {
    "scripts": ["background.js"]
  },

  // Aký html použiť pre popup
  "browser_action": {
    "default_title": "Môj rozšírenie",
    "default_popup": "popup.html"
  },

  // Obsahové skripty.
  // Máme jeden objekt: pre všetky url začínajúce s http alebo https spúšťame
  // kontenscript context so skriptom contentscript.js. Spúšťať okamžite po získaní dokumentu pre všetky rámce
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Povolený prístup k localStorage a idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Tu sa uvádzajú zdroje, ku ktorým bude mať prístup web stránka. To znamená, že ich bude možné žiadať fetche'm alebo jednoducho xhr
  "web_accessible_resources": ["inpage.js"]
}

Vytvárame prázdne súbory background.js, popup.js, inpage.js a contentscript.js. Pridávame popup.html — a našu aplikáciu je možné už nahrať do Google Chrome a overiť, či funguje.

Aby sme sa o tom presvedčili, môžeme si vziať kód odtiaľto. Okrem toho, čo sme urobili, je na odkaze nastavená kompilácia projektu pomocou webpacku. Aby sme aplikáciu pridali do prehliadača, v chrome://extensions musíme zvoliť načítať rozbalené a priečinok s príslušným rozšírením — v našom prípade dist.

Píšeme bezpečné rozšírenie pre prehliadač

Teraz je naše rozšírenie nainštalované a funguje. Nástroje pre vývojárov pre rôzne kontexty môžeme spustiť nasledujúcim spôsobom:

popup ->

Píšeme bezpečné rozšírenie pre prehliadač

Prístup k konzole obsahu skriptu sa uskutočňuje cez konzolu samotnej stránky, na ktorej je spustený.Píšeme bezpečné rozšírenie pre prehliadač

Výmena správ

Takže potrebujeme nastaviť dva komunikačné kanály: inpage background a popup background. Samozrejme, môžeme jednoducho posielať správy na port a vymyslieť si vlastný protokol, ale mne sa viac páči prístup, ktorý som videl v projekte s otvoreným kódom metamask.

To je prehliadačové rozšírenie na prácu so sieťou Ethereum. V ňom rôzne časti aplikácie komunikujú cez RPC pomocou knižnice dnode. Umožňuje relatívne rýchlo a pohodlne zorganizovať výmenu, ak jej poskytneme nodejs stream ako transport (myslí sa objekt, ktorý implementuje ten istý rozhraní):

import Dnode from "dnode/browser";

// V tomto príklade predpokladajme, že klient vzdialene volá funkcie na serveri, hoci nič nám nebráni to urobiť obojsmerne.

// Server
// API, ktoré chceme poskytnúť
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transport, nad ktorým bude fungovať dnode. Akýkoľvek nodejs stream. V prehliadači je knižnica 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Klient
const dnodeClient = Dnode() // Volanie bez argumentu znamená, že neposkytujeme API na druhej strane.

// Vypíše do konzoly world
dnodeClient.once('remote', remote => {
    remote.hello((err, value) => console.log(value))
})

Teraz vytvoríme triedu aplikácie. Bude vytvárať objekty API pre popup a webovú stránku, ako aj vytvárať dnode pre ne:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Vráti API objekt pre ui
    popupApi() {
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Vráti API objekt pre stránku
    pageApi() {
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Pripojí popup ui
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Pripojí stránku
    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 ďalej namiesto globálneho objektu Chrome používame extentionApi, ktorý sa odvoláva na Chrome v prehliadači od Googlu a na browser v iných. Robí sa to pre cross-browser kompatibilitu, ale v rámci tohto článku by sa dalo použiť aj jednoducho 'chrome.runtime.connect'.

Vytvoríme inštanciu aplikácie v background skripte:

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

const app = new SignerApp();

// onConnect sa spúšťa pri pripojení 'procesov' (contentscript, popup, alebo stránka rozšírenia)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Pri nadviazaní spojenia môžeme uviesť meno, podľa ktorého určujeme, kto sa pripojil, contentscript alebo ui
    if (processName === 'contentscript') {
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    } else {
        app.connectPopup(portStream)
    }
}

Keďže dnode pracuje so streamami a my získavame port, potrebujeme triedu-adaptér. Tá je vyrobená 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 vytvoríme pripojenie v UI:

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

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

setupUi().catch(console.error);

async function setupUi(){
    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)
        })
    });

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

Potom vytvoríme pripojenie v obsahovom skripte:

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 {
        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 potrebujeme nie v obsahovom skripte, ale priamo na stránke, robíme dve veci:

  1. Vytvárame dva streamy. Jeden — smerom k stránke, nad postMessage. Na to používame tento balík od tvorcov metamask. Druhý stream — k background cez port, ktorý sme získali od . Spojujeme ich. Teraz bude mať stránka stream do pozadia. runtime.connectVkladať skript do DOM. Stiahneme skript (prístup k nemu bol povolený v manifeste) a vytvoríme tag
  2. s jeho obsahom vo vnútri: script import PostMessageStream from 'post-message-stream'; import {extensionApi} from ".\/utils\/extensionApi"; import {PortStream} from ".\/utils\/PortStream";setupConnection(); injectScript();function setupConnection(){ const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'}); const backgroundStream = new PortStream(backgroundPort);const pageStream = new PostMessageStream({ name: 'content', target: 'page', });pageStream.pipe(backgroundStream).pipe(pageStream); }function injectScript(){ try { 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 vytvárame objekt api v inpage a nastavujeme ho global:

Teraz vytvárame objekt api v inpage a nastavujeme ho na global:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

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

    
    global.SignerApp = pageApi;
}

Máme hotové Remote Procedure Call (RPC) s odděleným API pro stránku a UI. Při připojení nové stránky k pozadí to můžeme vidět:

Píšeme bezpečné rozšírenie pre prehliadač

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

Píšeme bezpečné rozšírenie pre prehliadač

Práce s callback funkcemi v moderním JS je nepřijatelná, proto napíšeme malého pomocníka pro vytváření dnode, který umožňuje předávat do objektu API v utils.

Objekty API nyní budou vypadat takto:

export class SignerApp {

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

...

}

Získání objektu od remote následujícím způsobem:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

A volání funkcí vrací promisu:

Píšeme bezpečné rozšírenie pre prehliadač

Verze s asynchronními funkcemi je dostupná tu.

Celkově se přístup s RPC a streamy zdá být dost flexibilní: můžeme použít stream multiplexing a vytvářet několik různých API pro různé úkoly. V zásadě lze dnode použít kdekoli, hlavní je obalit transport do podoby nodejs streamu.

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

Interní stav a localStorage

Budeme potřebovat uchovávat interní stav aplikace — minimálně klíče pro podepisování. Můžeme docela snadno přidat stav aplikaci a metody pro jeho změnu do 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)
        }
    }

    ...

} 

V pozadí zabalíme všechno do funkce a zapíšeme objekt aplikace do window, aby s ním bylo možné 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)
        }
    }
}

Pridáme z UI konzoly niekoľko kľúčov a pozrieme sa, čo sa stalo so stavom:

Píšeme bezpečné rozšírenie pre prehliadač

Stav je potrebné urobiť trvalým, aby sa pri reštartovaní nestratili kľúče.

Budem ukladať do localStorage a prepisovať pri každej zmene. Následne bude potrebný aj prístup k nemu pre UI, a chcem sa tiež prihlásiť na zmeny. Z tohto dôvodu bude praktické vytvoriť observačné úložisko (observable storage) a prihlásiť sa na jeho zmeny.

Na to použijeme knižnicu mobx (https://github.com/mobxjs/mobx). Vybrala som si ju, pretože som s ňou predtým nepracovala a veľmi som ju chcela preskúmať.

Pridáme inicializáciu počiatočného stavu a spravíme store observable:

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

export class SignerApp {

    constructor(initState = {}) {
        // Navonok bude store stále rovnakým objektom, len teraz sú všetky jeho polia proxy, ktoré sledujú prístup k nim
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Metódy, ktoré menia observable, je dobré obaliť dekorátorom
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

„Pod kapotou“ mobx nahradila všetky polia store na proxy a zachytáva všetky prístupy k nim. Na tieto prístupy sa dá prihlásiť.

Ďalej budem často používať termín „pri zmene“, aj keď to nie je úplne presné. Mobx sleduje presne prístup k poliam. Používajú sa getter a setter proxy objektov, ktoré vytvára knižnica.

Dekoratívy action slúžia dvom cieľom:

  1. V prísnom režime s flagom enforceActions mobx zakazuje meniť stav priamo. Dobrou praxou je pracovať práve v prísnom režime.
  2. Aj keď funkcia mení stav niekoľkokrát – napríklad meníme niekoľko polí v niekoľkých riadkoch kódu, - observeri sa upozornia iba po jej dokončení. To je obzvlášť dôležité pre frontend, kde nadbytočné aktualizácie stavu vedú k zbytočnému renderovaniu prvkov. V našom prípade ani prvé, ani druhé nie je veľmi aktuálne, avšak budeme dodržiavať osvedčené postupy. Dekorátory sa zvyčajne aplikuje na všetky funkcie, ktoré menia stav pozorovaných polí.

Do pozadia pridáme inicializáciu a uloženie stavu do localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Pomocné metódy. Ukladajú/čítajú objekt do/z localStorage vo forme JSON reťazca pod kľúčom '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;
    }

    // Nastavenie perzistencie stavu

    // Výsledok reaction je priradený do premennej, aby sa dala odhlásiť. To nepotrebujeme, je to ponechané ako príklad
    const localStorageReaction = reaction(
        () => toJS(app.store), // Funkcia-selektor dát
        saveState // Funkcia, ktorá bude vyvolaná pri zmene dát, ktoré vráti selektor
    );

    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 funkcia reaction. Má dva argumenty:

  1. Selector dát.
  2. Handler, ktorý sa vyvolá s týmito údajmi zakaždým, keď sa zmenia.

Na rozdiel od redux, kde jasne získavame stav ako argument, mobx si pamätá, na ktoré konkrétne observable sa odkazujeme v selektore, a iba pri ich zmene vyvoláva handler.

Je dôležité pochopiť, ako presne mobx rozhoduje, na ktoré observable sa prihlasujeme. Keby som v kóde napísal selektor takto() => app.store, tak by sa reaction nikdy nevyvolal, pretože samotné úložisko nie je pozorovateľné, pozorovateľné sú len jeho polia.

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

Mobx po prvýkrát vykonáva funkciu selektora a sleduje len tie observable, ku ktorým sme mali prístup. Je to dosiahnuté prostredníctvom proxy getterov. Preto je tu použitá vstavaná funkcia toJS. Táto funkcia vráti nový objekt, v ktorom sú všetky proxy nahradené pôvodnými poľami. Počas vykonávania sa prečítajú všetky polia objektu – a tým sa spustia gettery.

V konzole popup opäť pridáme niekoľko kľúčov. Tentokrát sa dostali aj do localStorage:

Píšeme bezpečné rozšírenie pre prehliadač

Pri reštartovaní pozadia stránky ostáva informácia na svojom mieste.

Celý kód aplikácie doteraz si môžete pozrieť tu.

Bezpečné uchovávanie súkromných kľúčov

Uchovávať súkromné kľúče v otvorenej podobe nie je bezpečné: vždy existuje pravdepodobnosť, že vás hacknú, získajú prístup k vášmu počítaču a tak ďalej. Preto budeme v localStorage uchovávať kľúče zašifrované heslom.

Pre väčšiu bezpečnosť pridáme aplikácii stav locked, v ktorom nebude prístup k kľúčom vôbec možný. Automaticky premeníme rozšírenie na stav locked po timeoutu.

Mobx umožňuje uchovávať iba minimálny súbor údajov a zvyšok automaticky vypočítavať na ich základe. To sú takzvané computed properties. Môžu sa porovnať s view v databázach:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Nástroje na bezpečné šifrovanie reťazcov. Používajú crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Ukladáme heslo a zašifrované kľúče. Ak je heslo null - aplikácia je zamknutá
            password: null,
            vault: initState.vault,

            // Getter na vypočítané polia. Môžeme urobiť analógiu s pohľadom v db.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Inicializácia prázdneho úložiska novým heslom
    @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
        )
    }

    ... // kód na pripojenie a api

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

    _checkLocked() {
        if (this.store.locked){
            throw new Error('Aplikácia je zamknutá')
        }
    }

    // Metódy na šifrovanie/dešifrovanie úložiska
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Úložisko nie je inicializované')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Nesprávne heslo')
        }
    }
}

Teraz uchovávame iba zašifrované kľúče a heslo. Všetko ostatné je vypočítané. Prechod do stavu zamknuté vykonávame odstránením hesla zo stavu. V verejnom API sa objavila metóda na inicializáciu úložiska.

Na šifrovanie sú napísané nástroje používajúce srypto-js:

import CryptoJS from 'crypto-js'

// Používa sa na sťažovanie hádania hesla prostredníctvom preskúšania. Na každý variant hesla musí útočník vykonať 5000 hashovaní
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á idle API, cez ktoré sa môžete prihlásiť na udalosť — zmeny stavu. Stav môže byť, samozrejme, nečinnosť, active a uzamknutý. Pre idle môžete nastaviť časový interval, zatiaľ čo uzamknutý sa nastavuje, keď je blokovaný samotný operačný systém. Takisto zmeníme selektor na uloženie do 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;
    }

    // Teraz jasne voláme pole, ku ktorému bude prístup, reakcia bude fungovať správne
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Časový interval nečinnosti, keď sa udalosť spustí
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Ak používateľ uzamkne obrazovku alebo je nečinný po stanovenom intervale, uzamkneme aplikáciu
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Pripojenie k iným kontextom
    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 sa nachádza tu.

Transakcie

Takže sme sa dostali k najdôležitejšiemu: vytváraniu a podpisovaniu transakcií v blockchaine. Použijeme blockchain WAVES a knižnicu waves-transactions.

Najprv pridáme do stavu pole správ, ktoré je potrebné podpísať, potom — 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) {
        // Pre každý príspevok vytvárame metadata s id, stavom, časom vytvorenia a pod.
        const message = observable.object({
            id: uuid(), // Identifikátor, používam uuid
            origin, // Origin budeme neskôr zobrazovať v rozhraní
            data, //
            status: 'new', // Stavov bude štyri: new, signed, rejected a failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Vrátime promisu, v ktorej mobx monitoruje zmeny správy. Akonáhle sa status zmení, uzavrieme ho
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Budeme sledovať status správy
                (status, reaction) => { // druhý argument je odkaz na samotnú reakciu, aby ju bolo možné zničiť vo vnútri volania
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Užívateľ odmietol správu'));
                            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(`Žiadna správa s 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(`Žiadna správa s id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Keď dostaneme novú správu, pridáme do nej metadata, robíme observable a pridáme do store.messages.

Ak to neurobíme observable ručne, tak mobx to urobí sám pri pridávaní do poľa messages. Avšak vytvorí nový objekt, na ktorý nebudeme mať odkaz, a ten budeme potrebovať na ďalší krok.

Ďalej vraciame promisu, ktorá sa uzavrie pri zmene statusu správy. O status sa stará reakcia, ktorá sa sama "zabije" pri zmene statusu.

Kód metód approve a reject je veľmi jednoduchý: jednoducho meníme status správy, predtým ju podpíšeme, ak je to potrebné.

Aprovať a zamietnuť prenášame do API UI, newMessage – na API stránke:

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 sa pokúsime podpísať transakciu rozšírením:

Píšeme bezpečné rozšírenie pre prehliadač

Celkovo je všetko pripravené, zostáva pridať jednoduché UI.

UI

Rozhraniu je potrebný prístup k stavu aplikácie. Na strane UI urobíme observable stav a pridáme do API funkciu, ktorá tento stav zmení. Pridáme observable do objektu API, získaného 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() {
    // Pripojíme sa k portu, vytvoríme z neho stream
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Vytvoríme prázdny observable pre stav pozadia
    let backgroundState = observable.object({});
    const api = {
        // Poskytujeme pozadi funkciu, ktorá aktualizuje observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Vytvoríme RPC objekt
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Pridávame do pozadia observable s týmto stavom
    background.state = backgroundState;

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

    // Spúšťame rozhranie
    await initApp(background)
}

Na konci spúšťame renerovanie rozhrania aplikácie. Toto je react-aplikácia. Objekt pozadia sa jednoducho predáva pomocou props. Samozrejme, že by bolo správne vytvoriť samostatnú službu pre metódy a ukladanie pre stav, ale v rámci tohto článku je to dostatočné:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Inicializujeme aplikáciu s objektom pozadia ako props
export async function initApp(background){
    render(
        , 
        document.getElementById('app-content')
    );
}

S pomocou mobx je veľmi jednoduché spúšťať renderovanie pri zmene dát. Jednoducho pripojíme dekorátor observer z balíka mobx-react na komponent, a renderovanie sa automaticky vyvolá pri zmene akýchkoľvek observable, na ktoré komponent odkazuje. Nie je potrebné žiadne mapStateToProps alebo connect, ako v redux. Všetko funguje ihneď "z krabice":

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 // Komponent s týmto dekorátorom bude automaticky volať metódu render, ak sa zmení observable, na ktoré sa odkazuje
export default class App extends Component {

    // Samozrejme, je správne presunúť logiku renderovania stránok do routovania a nevyužívať vnorené ternárne operátory,
    // a viazať observable a metódy background priamo na tie komponenty, ktoré ich používajú
    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 &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!locked &amp;&amp; <button onclick="{()" > lock()}&gt;Zamknúť aplikáciu</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Vymazať všetky kľúče a inicializovať</button>}
            </div>
        </Fragment>
    }
}

Ostatné komponenty si môžete pozrieť v kóde v priečinku UI.

Teraz je potrebné v triede aplikácie vytvoriť selektor stavu pre UI a pri jeho zmene upozorniť UI. Na to pridáme metódu getState a reaction, ktorá volá 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) => {
            // Vytvoríme reakciu na zmeny stavu, ktorá vyvolá vzdialenú procedúru a aktualizuje stav v ui procese
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Ako tretí argument môžeme predať parametre. fireImmediately znamená, že reakcia sa vykoná prvýkrát okamžite.
                // To je potrebné na získanie počiatočného stavu. Delay umožňuje nastaviť debounce
                {fireImmediately: true, delay: 500}
            );
            // Zrušíme predplatné pri odpojení klienta
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Pri prijímaní objektu remote sa vytvorí reaction na zmenu stavu, ktorá vyvoláva funkciu na strane UI.

Posledný dotyk — pridáme zobrazenie nových správ na ikone rozšírenia:

function setupApp() {
...

    // Reakcia na nastavenie textu badge.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Takže aplikácia je hotová. Webové stránky môžu žiadať o podpis transakcií:

Píšeme bezpečné rozšírenie pre prehliadač

Píšeme bezpečné rozšírenie pre prehliadač

Kód je dostupný na tejto odkaze.

Záver

Ak ste prečítali článok do konca, ale máte ešte otázky, môžete sa na ne opýtať v repozitári s rozšírením. Tam nájdete aj commity k jednotlivým označeným krokom.

A ak vás zaujíma vidieť kód skutočného rozšírenia, môžete to nájsť tu.

Kód, repozitár a popis práce od siemarell

Zdroj: habr.com

Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster