Pisanie bezpiecznego rozszerzenia przeglądarki

Pisanie bezpiecznego rozszerzenia przeglądarki

W przeciwieństwie do powszechnie stosowanej architektury „klient-serwer”, aplikacje zdecentralizowane charakteryzują się:

  • Brakiem potrzeby przechowywania bazy danych z loginy i hasłami użytkowników. Informacje dostępu przechowywane są wyłącznie u samych użytkowników, a potwierdzenie ich autentyczności odbywa się na poziomie protokołu.
  • Brakiem potrzeby korzystania z serwera. Logika aplikacji może być realizowana w sieci blockchain, gdzie możliwe jest także przechowywanie niezbędnej ilości danych.

Istnieją 2 stosunkowo bezpieczne miejsca do przechowywania kluczy użytkowników — portfele sprzętowe i rozszerzenia przeglądarki. Portfele sprzętowe są w większości maksymalnie bezpieczne, jednak trudne w użyciu i z reguły kosztowne, a rozszerzenia przeglądarki stanowią idealne połączenie bezpieczeństwa i prostoty użytkowania i mogą być zupełnie darmowe dla końcowych użytkowników.

Mając to wszystko na uwadze, postanowiliśmy stworzyć maksymalnie bezpieczne rozszerzenie, które upraszcza rozwój aplikacji zdecentralizowanych, oferując prosty API do obsługi transakcji i podpisów.
O tym doświadczeniu opowiemy poniżej.

W artykule znajdziesz krok po kroku instrukcję, jak napisać rozszerzenie przeglądarki, z przykładami kodu i zrzutami ekranu. Cały kod znajdziesz w repozytorium. Każde zatwierdzenie logicznie odpowiada sekcji tego artykułu.

Krótka historia rozszerzeń przeglądarek

Rozszerzenia przeglądarek istnieją już od dłuższego czasu. W Internet Explorer pojawiły się już w 1999 roku, w Firefox – w 2004 roku. Niemniej jednak przez długi czas brakowało jednego standardu dla rozszerzeń.

Można powiedzieć, że standard pojawił się razem z rozszerzeniami w czwartej wersji Google Chrome. Oczywiście, wtedy nie istniała żadna specyfikacja, ale to API Chrome stało się jej podstawą: zdobywając dużą część rynku przeglądarek i mając wbudowany sklep z aplikacjami, Chrome de facto ustanowił standard dla rozszerzeń przeglądarek.

Mozilla miała swoją specyfikację, ale widząc popularność rozszerzeń dla Chrome, firma postanowiła stworzyć zgodne API. W 2015 roku na inicjatywę Mozilli w ramach World Wide Web Consortium (W3C) powstała specjalna grupa zajmująca się pracą nad specyfikacjami rozszerzeń wielobrowersowych.

Podstawą był już istniejący API rozszerzeń dla Chrome. Prace były prowadzone przy wsparciu Microsoftu (Google odmówiło uczestnictwa w opracowywaniu standardu), a w rezultacie powstał szkic specyfikacji.

Formalnie specyfikację wspierają Edge, Firefox i Opera (zauważ, że w tej liście brakuje Chrome). Ale w rzeczywistości standard w dużej mierze jest zgodny również z Chrome, ponieważ został faktycznie napisany na podstawie jego rozszerzeń. Więcej informacji na temat WebExtensions API można przeczytać tutaj.

Struktura rozszerzenia

Jedynym plikiem, który jest niezbędny dla rozszerzenia, jest manifest (manifest.json). To on jest „punktem wejścia” do rozszerzenia.

Manifest

Zgodnie ze specyfikacją plik manifestu jest ważnym plikiem JSON. Pełny opis kluczów manifestu z informacjami o tym, które klucze są wspierane w jakiej przeglądarce, można zobaczyć tutaj.

Klucze, których nie ma w specyfikacji, „mogą” być zignorowane (zarówno Chrome, jak i Firefox zgłaszają błędy, ale rozszerzenia wciąż działają).

Chciałbym zwrócić uwagę na kilka kwestii.

  1. background — obiekt, który zawiera następujące pola:
    1. scripts — tablica skryptów, które będą wykonywane w kontekście background (błędy możemy omówić później);
    2. (strony, na które następuje przekierowanie z reklamowych banerów); — zamiast skryptów, które będą wykonywane na pustej stronie, można określić HTML z treścią. W takim przypadku pole script będzie zignorowane, a skrypty trzeba będzie wstawić do strony z treścią;
    3. utrzymujące się — binarny flaga, jeśli nie jest określona, przeglądarka będzie 'zabijać' proces background, kiedy uzna, że nic nie robi, aby uruchomić go ponownie w razie potrzeby. W przeciwnym razie strona będzie wygaszana tylko przy zamknięciu przeglądarki. Nie jest wspierane w Firefoxie.
  2. content_scripts — tablica obiektów, która pozwala ładować różne skrypty do różnych stron internetowych. Każdy obiekt zawiera następujące ważne pola:
    1. matcheswzorzec URL, na podstawie którego określa się, czy konkretny skrypt treści zostanie włączony, czy nie.
    2. js — lista skryptów, które będą załadowane w danym meczu;
    3. exclude_matches — wyklucza z pola dopasowanie URL, które spełniają to pole.
  3. page_action — właściwie jest obiektem odpowiedzialnym za ikonę, która jest wyświetlana obok paska adresu w przeglądarce, oraz interakcję z nią. Pozwala również na wyświetlanie okna popup, które jest definiowane za pomocą własnego HTML, CSS i JS.
    1. default_popup — ścieżka do pliku HTML z interfejsem popup, może zawierać CSS i JS.
  4. permissions — tablica do zarządzania uprawnieniami rozszerzenia. Istnieją 3 typy uprawnień, które są szczegółowo opisane tutaj
  5. web_accessible_resources — zasoby rozszerzenia, które mogą być żądane przez stronę internetową, takie jak obrazy, pliki JS, CSS, HTML.
  6. externally_connectable — tutaj można jawnie wskazać ID innych rozszerzeń oraz domeny stron internetowych, z których można się łączyć. Domena może być drugiego poziomu i wyżej. Nie działa w Firefoxie.

Kontekst wykonania

Rozszerzenie ma trzy konteksty wykonania kodu, co oznacza, że aplikacja składa się z trzech części z różnym poziomem dostępu do API przeglądarki.

Kontekst rozszerzenia

Tutaj dostępna jest większość API. W tym kontekście „żyją”:

  1. Strona w tle — część „backendowa” rozszerzenia. Plik jest podawany w manifeście pod kluczem „background”.
  2. Strona popup — strona popup, która pojawia się po kliknięciu na ikonę rozszerzenia. W manifeście browser_action -> default_popup.
  3. Strona niestandardowa — strona rozszerzenia, „żyjąca” w oddzielnej karcie o wyglądzie chrome-extension:///customPage.html.

Ten kontekst istnieje niezależnie od okien i kart przeglądarki. Strona w tle istnieje w jednym egzemplarzu i działa zawsze (wyjątek — strona zdarzenia, kiedy skrypt w tle jest uruchamiany w odpowiedzi na zdarzenie i „umiera” po jego wykonaniu). Strona popup istnieje, gdy otwarte jest okno popup, a Strona niestandardowa — dopóki karta z nim jest otwarta. Nie ma dostępu do innych kart i ich zawartości z tego kontekstu.

Kontekst skryptu treści

Plik skryptu treści uruchamia się razem z każdą kartą przeglądarki. Ma dostęp do części API rozszerzenia i do drzewa DOM strony internetowej. To właśnie skrypty treści odpowiadają za interakcję ze stroną. Rozszerzenia, które manipulują drzewem DOM, robią to za pomocą skryptów treści – na przykład blokery reklam lub tłumacze. Także skrypt treści może komunikować się ze stroną przez standardowe postMessage.

Kontekst strony internetowej

To sama strona internetowa. Nie ma ona żadnego związku z rozszerzeniem i nie ma do niego dostępu, chyba że w manifeście wyraźnie nie wskazano domeny tej strony (o tym więcej poniżej).

Wymiana wiadomości

Różne części aplikacji muszą wymieniać się wiadomościami między sobą. W tym celu istnieje API runtime.sendMessage do wysyłania wiadomości background i tabs.sendMessage do wysyłania wiadomości do strony (skryptu treści, popup’a lub strony internetowej, jeśli externally_connectable). Poniżej znajduje się przykład przywołania 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))
    }
)

Aby zapewnić pełnoprawną komunikację, można tworzyć połączenia przez runtime.connect. W odpowiedzi otrzymamy runtime.Port, do którego, dopóki jest otwarty, można wysyłać dowolną liczbę wiadomości. Po stronie klienta, na przykład, contentscript, wygląda to tak:

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

Serwer lub 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) {
    ...
});

Jest również zdarzenie onDisconnect i metodę rozłączenie.

Schemat aplikacji

Stwórzmy rozszerzenie przeglądarki, które przechowuje klucze prywatne, udostępnia dostęp do publicznych informacji (adres, publiczny klucz komunikuje się ze stroną i umożliwia aplikacjom zewnętrznym żądanie podpisania transakcji.

Rozwój aplikacji

Nasza aplikacja powinna zarówno wchodzić w interakcje z użytkownikiem, jak i udostępniać stronie API do wywoływania metod (na przykład do podpisywania transakcji). Nie możemy ograniczyć się wyłącznie do contentscript , ponieważ ma on dostęp tylko do DOM, a nie do JS strony. Nie możemy się połączyć przez runtime.connect , ponieważ API jest potrzebne na wszystkich domenach, a w manifeście można podać tylko konkretne. Ostatecznie schemat będzie wyglądał tak:

Pisanie bezpiecznego rozszerzenia przeglądarki

Będzie jeszcze jeden skrypt — inpage, który wstrzyknę w stronę. Będzie on wykonywany w jej kontekście i udostępni API do pracy z rozszerzeniem.

Początek

Cały kod rozszerzenia przeglądarki jest dostępny na GitHub. W opisie będą linki do commitów.

Zacznijmy od manifestu:

{
  // Nazwa i opis, wersja. Wszystko to będzie widoczne w przeglądarce pod adresem chrome://extensions/?id=<id rozszerzenia>
  "name": "Signer",
  "description": "Przykład rozszerzenia",
  "version": "0.0.1",
  "manifest_version": 2,

  // Skrypty, które będą wykonywane w tle, może ich być kilka
  "background": {
    "scripts": ["background.js"]
  },

  // Jaki html użyć dla popup
  "browser_action": {
    "default_title": "Moje Rozszerzenie",
    "default_popup": "popup.html"
  },

  // Skrypty zawartości.
  // Mamy jeden obiekt: dla wszystkich URL zaczynających się od http lub https uruchamiamy
  // kontekst contenscript ze skryptem contentscript.js. Uruchomimy go natychmiast po otrzymaniu dokumentu dla wszystkich ram.
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Dostęp do localStorage i idle api jest dozwolony
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Tutaj wskazuje się zasoby, do których będzie miał dostęp strona internetowa. Można je będzie żądać fetche'm lub zwykłym xhr
  "web_accessible_resources": ["inpage.js"]
}

Tworzymy puste pliki background.js, popup.js, inpage.js i contentscript.js. Dodajemy popup.html — i nasze aplikacja jest już gotowa do załadowania w Google Chrome, aby upewnić się, że działa.

Aby to zweryfikować, można wykorzystać kod stąd. Oprócz tego, co zrobiliśmy, w linku skonfigurowano budowanie projektu za pomocą webpack. Aby dodać aplikację do przeglądarki, w chrome://extensions należy wybrać opcję load unpacked i wskazać folder z odpowiednim rozszerzeniem — w naszym przypadku dist.

Pisanie bezpiecznego rozszerzenia przeglądarki

Teraz nasze rozszerzenie jest zainstalowane i działa. Narzędzia deweloperskie dla różnych kontekstów można uruchomić w następujący sposób:

popup ->

Pisanie bezpiecznego rozszerzenia przeglądarki

Dostęp do konsoli skryptu zawartości uzyskuje się przez konsolę samej strony, na której jest uruchomiony.Pisanie bezpiecznego rozszerzenia przeglądarki

Wymiana wiadomości

Zatem musimy ustanowić dwa kanały komunikacji: inpage <-> background i popup <-> background. Oczywiście można po prostu wysyłać wiadomości przez port i wymyślić własny protokół, ale bardziej podoba mi się podejście, które zobaczyłem w otwartym projekcie metamask.

To rozszerzenie przeglądarki do pracy z siecią Ethereum. W nim różne części aplikacji komunikują się przez RPC przy użyciu biblioteki dnode. Umożliwia ona dość szybkie i wygodne zorganizowanie wymiany, jeśli jako transport użyje się strumienia nodejs (mowa o obiekcie, który implementuje ten sam interfejs):

import Dnode from "dnode/browsers";

// W tym przykładzie umówmy się, że klient zdalnie wywołuje funkcje na serwerze, chociaż nic nie stoi na przeszkodzie, aby uczynić to dwukierunkowym

// Serwer
// API, które chcemy udostępnić
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transport, na którym będzie działać dnode. Dowolny strumień nodejs. W przeglądarce dostępna jest biblioteka 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Klient
const dnodeClient = Dnode() // Wywołanie bez argumentu oznacza, że nie udostępniamy API po drugiej stronie

// Wyświetli w konsoli world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Teraz stworzymy klasę aplikacji. Będzie ona tworzyć obiekty API dla popupu i strony internetowej, a także tworzyć dnode dla nich:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Zwraca obiekt API dla UI
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Zwraca obiekt API dla strony
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Łączy popup UI
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Łączy stronę
    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)
        })
    }
}

W tym miejscu i dalej zamiast globalnego obiektu Chrome używamy extentionApi, który odnosi się do Chrome w przeglądarce od Google oraz do browser w innych. Dzieje się tak dla zapewnienia zgodności między przeglądarkami, jednak w ramach tego artykułu można by użyć po prostu 'chrome.runtime.connect'.

Stworzymy instancję aplikacji w skrypcie background:

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

const app = new SignerApp();

// onConnect uruchamia się przy połączeniu 'procesów' (contentscript, popup, lub strona rozszerzenia)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Przy ustalaniu połączenia można podać nazwę, na podstawie której określamy, kto się z nami połączył, contentscript czy UI
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Ponieważ dnode działa na strumieniach, a my otrzymujemy port, potrzebna jest klasa-adapter. Została stworzona przy użyciu biblioteki readable-stream, która implementuje strumienie nodejs w przeglądarce:

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 tworzymy połączenie w 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(){
    // Podobnie jak w klasie aplikacji tworzymy port, opakowujemy w stream, tworzymy 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)
        })
    });

    // Udostępniamy obiekt API z konsoli
    if (DEV_MODE){
        global.background = background;
    }
}

Następnie tworzymy połączenie w skrypcie zawartości:

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 {
        // wstrzykuj skrypt na stronie
        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('Wstrzykiwanie nie powiodło się.', e);
    }
}

Ponieważ API potrzebujemy nie w skrypcie zawartości, a bezpośrednio na stronie, robimy dwie rzeczy:

  1. Tworzymy dwa strumienie. Jeden — w kierunku strony, ponad postMessage. Używamy do tego tego pakietu od twórców metamask. Drugi strumień — do tła przez port uzyskany od runtime.connect. Łączymy je. Teraz strumień będzie dostępny dla strony.
  2. Wstrzykujemy skrypt do DOM. Ładujemy skrypt (dostęp do niego był dozwolony w manifeście) i tworzymy tag script z jego zawartością wewnątrz:

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

setupConnection();
injectScript();

function setupConnection(){
    // Strumień do tła
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Strumień do strony
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // wstrzykuj skrypt na stronie
        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('Wstrzykiwanie nie powiodło się.', e);
    }
}

Teraz tworzymy obiekt api w inpage i udostępniamy go globalnie:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream do skryptu zawartości
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

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

    // Dostęp przez window
    global.SignerApp = pageApi;
}

Mamy to gotowe Zdalne wywołanie procedury (RPC) z oddzielnym API dla strony i UI. Podczas łączenia nowej strony z background możemy to zobaczyć:

Pisanie bezpiecznego rozszerzenia przeglądarki

Pusty API i origin. Po stronie strony możemy wywołać funkcję hello tak:

Pisanie bezpiecznego rozszerzenia przeglądarki

Pracowanie z funkcjami callback w nowoczesnym JS jest niewłaściwe, dlatego napiszemy małego pomocnika do tworzenia dnode, który pozwala na przekazywanie do obiektu API w utils.

Obiekty API będą teraz wyglądać tak:

export class SignerApp {

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

...

}

Otrzymywanie obiektu od remote w następujący sposób:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Za pomocą narzędzi zmieniamy wszystkie callbacki na promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

A wywołanie funkcji zwraca promisa:

Pisanie bezpiecznego rozszerzenia przeglądarki

Wersja z asynchronicznymi funkcjami jest dostępna tutaj.

Ogólnie rzecz biorąc, podejście z RPC i strumieniami wydaje się być dość elastyczne: możemy używać multiplexingu strumieniowego i tworzyć różne API do różnych zadań. W zasadzie, dnode można używać wszędzie, ważne jest, aby owinąć transport w formie strumienia nodejs.

Alternatywą jest format JSON, który realizuje protokół JSON RPC 2. Jednakże działa on z konkretne transporty (TCP i HTTP(S)), co w naszym przypadku nie ma zastosowania.

Wewnętrzny stan i localStorage

Będziemy potrzebować przechowywać wewnętrzny stan aplikacji — co najmniej klucze do podpisu. Możemy dosyć łatwo dodać stan aplikacji i metody do jego zmiany w 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)
        }
    }

    ...

} 

W background owinę wszystko w funkcję i zapiszę obiekt aplikacji w window, aby można było z nim pracować z konsoli:

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

Dodajmy kilka kluczy z konsoli UI i sprawdźmy, co się stało z stanem:

Pisanie bezpiecznego rozszerzenia przeglądarki

Stan musi być trwały, aby przy ponownym uruchomieniu klucze się nie zgubiły.

Będziemy przechowywać w localStorage, nadpisując go przy każdej zmianie. W przyszłości dostęp do niego będzie również potrzebny dla UI, a także chcemy subskrybować zmiany. W związku z tym wygodne będzie stworzenie obserwowalnego magazynu (observable storage) i subskrybowanie jego zmian.

Będziemy używać biblioteki mobx (https://github.com/mobxjs/mobx). Wybór padł na nią, ponieważ wcześniej nie mieliśmy z nią do czynienia, a chcieliśmy ją poznać.

Dodamy inicjalizację początkowego stanu i uczynimy store obserwowalnym:

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

export class SignerApp {

    constructor(initState = {}) {
        // Zewnętrznie store pozostanie tym samym obiektem, tylko teraz wszystkie jego pola stały się proxy, które śledzą dostęp do nich
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Metody, które zmieniają observable, powinny być opakowane dekoratorem
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

"Pod maską" mobx zastąpił wszystkie pola store na proxy i przechwytuje wszystkie odwołania do nich. Do tych odwołań będzie można się subskrybować.

Później często będę używać terminu "przy zmianie", chociaż to nie jest do końca poprawne. Mobx śledzi mianowicie dostęp do pól. Używane są gettery i settery obiektów proxy, które tworzy biblioteka.

Dekoratory action mają dwa cele:

  1. W trybie surowym z flagą enforceActions mobx zabrania bezpośredniej zmiany stanu. Dobrym tonem jest praca właśnie w trybie surowym.
  2. Nawet jeśli funkcja zmienia stan kilka razy - na przykład zmieniamy kilka pól w kilku liniach kodu - obserwatorzy są powiadamiani tylko po jej zakończeniu. Jest to szczególnie ważne w przypadku front-endu, gdzie zbędne aktualizacje stanu prowadzą do niepotrzebnego renderowania elementów. W naszym przypadku ani pierwsze, ani drugie nie są szczególnie istotne, jednak będziemy stosować się do najlepszych praktyk. Dekoratory zaleca się umieszczać na wszystkich funkcjach, które zmieniają stan obserwowanych pól.

W tle dodamy inicjalizację i zapis stanu w localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Metody pomocnicze. Zapisują/odczytują obiekt z/do localStorage w postaci JSON-a pod kluczem '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;
    }

    // Ustawienie trwałości stanu

    // Wynik reaction jest przypisany do zmiennej, aby subskrypcję można było anulować. Nie jest to potrzebne, pozostawione dla przykładu
    const localStorageReaction = reaction(
        () => toJS(app.store), // Funkcja selektora danych
        saveState // Funkcja, która będzie wywoływana przy zmianie danych, które zwraca 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);
        }
    }
}

Interesująca jest tutaj funkcja reaction. Ma dwa argumenty:

  1. Selektor danych.
  2. Handler, który będzie wywoływany z tymi danymi za każdym razem, gdy zostaną one zmienione.

W przeciwieństwie do redux, gdzie wyraźnie otrzymujemy stan jako argument, mobx zapamiętuje, do których dokładnie obserwowalnych pól odnosimy się wewnątrz selektora, i tylko przy ich zmianie wywołuje handler.

WaŜne jest zrozumienie, jak konkretnie mobx decyduje, do których observable się subskrybujemy. Gdybym w kodzie napisał selektor w ten sposób() => app.store, to reaction nigdy nie zostałby wywołany, ponieważ samo w sobie przechowywanie nie jest obserwowalne, obserwowalne są tylko jego pola.

Gdybym napisał to w ten sposób () => app.store.keys, to znowu nic by się nie wydarzyło, ponieważ przy dodawaniu/usuwaniu elementów z tablicy odniesienie do niej się nie zmienia.

Mobx po raz pierwszy wykonuje funkcję selektora i monitoruje tylko te obserwowalne, do których mieliśmy dostęp. Zrealizowano to za pomocą getterów proxy. Dlatego tutaj użyto wbudowanej funkcji toJS. Zwraca nowy obiekt, w którym wszystkie proxy zostały zastąpione oryginalnymi polami. W trakcie działania odczytuje wszystkie pola obiektu – w związku z tym uruchamiane są gettery.

W konsoli popup ponownie dodamy kilka kluczy. Tym razem znalazły się one także w localStorage:

Pisanie bezpiecznego rozszerzenia przeglądarki

Po ponownym załadowaniu strony background informacje pozostają na miejscu.

Cały kod aplikacji do tego momentu można zobaczyć tutaj.

Bezpieczne przechowywanie prywatnych kluczy

Przechowywanie prywatnych kluczy w postaci jawnej jest niebezpieczne: zawsze istnieje ryzyko, że ktoś włami się do nas, uzyska dostęp do naszego komputera itd. Dlatego w localStorage będziemy przechowywać klucze w postaci zaszyfrowanej hasłem.

Aby zwiększyć bezpieczeństwo, dodamy aplikacji stan locked, w którym dostęp do kluczy nie będzie możliwy w ogóle. Automatycznie przełączymy rozszerzenie w stan locked po upływie czasu.

Mobx pozwala przechowywać tylko minimalny zestaw danych, a resztę automatycznie obliczać na ich podstawie. Nazywa się to tzw. computed properties. Można je porównać do widoków w bazach danych:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Narzędzia do bezpiecznego szyfrowania łańcuchów. Używają crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Przechowujemy hasło i zaszyfrowane klucze. Jeśli hasło jest null - aplikacja jest zablokowana
            password: null,
            vault: initState.vault,

            // Gettery dla pól obliczeniowych. Można to porównać z widokiem w bazie danych.
            get locked() {
                return this.password == null
            },
            get keys() {
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized() {
                return this.vault !== undefined
            }
        })
    }
    // Inicjalizacja pustego skarbca z nowym hasłem
    @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
        )
    }

    ... // kod połączenia i api

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

    _checkLocked() {
        if (this.store.locked) {
            throw new Error('Aplikacja jest zablokowana')
        }
    }

    // Metody do szyfrowania / deszyfrowania skarbca
    static _encryptVault(obj, pass) {
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass) {
        if (str === undefined) {
            throw new Error('Skarbiec nie zainicjalizowany')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        } catch (e) {
            throw new Error('Nieprawidłowe hasło')
        }
    }
}

Teraz przechowujemy tylko zaszyfrowane klucze i hasło. Wszystko inne jest obliczane. Przejście do stanu zablokowanego realizujemy poprzez usunięcie hasła ze stanu. W publicznym API pojawiła się metoda do inicjalizacji skarbca.

Do szyfrowania napisano narzędzia z użyciem crypto-js:

import CryptoJS from 'crypto-js'

// Używane do utrudnienia łamania hasła przez brute force. Na każdy wariant hasła, przestępca musi wykonać 5000 hashy
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)
}

Przeglądarka ma API bezczynności, dzięki któremu można subskrybować zdarzenie — zmiany stanu. Stan, odpowiednio, może być bezczynność, active i zablokowany. Dla bezczynności można ustawić czas oczekiwania, a zablokowany jest ustawiany, gdy sama OS jest blokowana. Zmienimy również selektor do zapisu w 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 wyraźnie wywołujemy pole, do którego będzie dostęp, reakcja będzie działać poprawnie
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Czas oczekiwania bezczynności, kiedy zdarzenie zostanie wywołane
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Jeśli użytkownik zablokował ekran lub nie działał przez określony czas, blokujemy aplikację
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Połączenie z innymi kontekstami
    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)
        }
    }
}

Kod do tego kroku znajduje się tutaj.

Transakcje

Zatem doszliśmy do najważniejszego: tworzenia i podpisywania transakcji w blockchainie. Będziemy używać blockchaina WAVES i biblioteki waves-transactions.

Na początku dodamy do stanu tablicę wiadomości, które należy podpisać, a następnie — metody dodawania nowej wiadomości, potwierdzania podpisu i odmowy:

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) {
        // Dla każdej wiadomości tworzymy metadane z id, statusem, czasem utworzenia itd.
        const message = observable.object({
            id: uuid(), // Identyfikator, używam uuid
            origin, // Origin będziemy później wyświetlać w interfejsie
            data, //
            status: 'new', // Statusów będzie cztery: new, signed, rejected i failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Zwracamy promesę, wewnątrz której mobx monitoruje zmiany statusu wiadomości. Gdy tylko status się zmieni, wykonamy resolve
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Obserwujemy status wiadomości
                (status, reaction) => { // drugą argument to referencja do samej reakcji, aby można było ją zniszczyć wewnątrz wywołania
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Użytkownik odrzucił wiadomość'));
                            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(`Nie znaleziono wiadomości z 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(`Nie znaleziono wiadomości z id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Podczas otrzymywania nowej wiadomości dodajemy do niej metadane, tworzymy observable i dodajemy do store.messages.

Jeśli nie zrobisz tego ręcznie, mobx zrobi to sam przy dodawaniu do tablicy messages. Jednak utworzy nowy obiekt, do którego nie będziemy mieć odniesienia, a będzie nam potrzebna do następnego kroku. observable Następnie zwracamy promesę, która realizuje się przy zmianie statusu wiadomości. Za status odpowiada reakcja, która sama się „zabije” przy zmianie statusu.

Kod metod

reject approve i jest bardzo prosty: po prostu zmieniamy status wiadomości, wcześniej ją podpisując, jeśli to konieczne. bardzo prosto: po prostu zmieniamy status wiadomości, wcześniej ją podpisując, jeśli to konieczne.

Akceptację i odrzucenie przenosimy do interfejsu API, newMessage — na stronę API:

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

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Teraz spróbujemy podpisać transakcję za pomocą rozszerzenia:

Pisanie bezpiecznego rozszerzenia przeglądarki

W zasadzie wszystko gotowe, zostało dodać prosty interfejs użytkownika.

UI

Interfejs potrzebuje dostępu do stanu aplikacji. Po stronie UI stworzymy observable stan i dodamy do API funkcję, która będzie zmieniać ten stan. Dodamy observable do obiektu API, uzyskanego z background:

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() {
    // Łączymy się z portem, tworzymy z niego strumień
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Tworzymy pusty observable dla stanu background'a
    let backgroundState = observable.object({});
    const api = {
        // Przekazujemy backgroundowi funkcję, która będzie aktualizować observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Dodajemy do background observable ze stanem
    background.state = backgroundState;

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

    // Uruchamiamy interfejs
    await initApp(background)
}

Na końcu uruchamiamy renderowanie interfejsu aplikacji. To aplikacja react. Obiekt background jest po prostu przekazywany za pomocą props. Oczywiście lepiej byłoby stworzyć osobny serwis dla metod i store dla stanu, ale w ramach tego artykułu to wystarczy:

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

// Inicjalizujemy aplikację z obiektem background w postaci props
export async function initApp(background){
    render(
        , 
        document.getElementById('app-content')
    );
}

Z pomocą mobx bardzo łatwo uruchomić renderowanie przy zmianie danych. Po prostu używamy dekoratora observer z pakietu mobx-react komponentu, a renderowanie będzie automatycznie wywoływane po zmianie jakichkolwiek observable, do których odnosi się komponent. Nie są potrzebne żadne mapStateToProps ani connect, jak w redux. Wszystko działa od razu «z pudełka»:

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 z tym dekoratorem automatycznie wywoła metodę render, jeśli zmienią się observable, do których się odnosi
export default class App extends Component {

    // Oczywiście poprawnie wyodrębnić logikę renderowania stron do routingu i nie używać zagnieżdżonych operatorów ternarnych,
    // oraz powiązać observable i metody background bezpośrednio z tymi komponentami, które ich używają
    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;Zablokuj aplikację</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Usuń wszystkie klucze i zainicjalizuj</button>}
            </div>
        </Fragment>
    }
}

Inne komponenty można zobaczyć w kodzie w folderze UI.

Teraz w klasie aplikacji należy stworzyć selektor stanu dla UI i powiadomić UI o jego zmianie. W tym celu dodamy metodę getState i reaction, wywołującą 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) => {
            // Tworzymy reakcję na zmiany stanu, która wywoła zdalną procedurę oraz zaktualizuje stan w procesie ui
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Trzecim argumentem można przekazywać parametry. fireImmediatly oznacza, że reakcja wykona się po raz pierwszy od razu.
                // Jest to konieczne, aby uzyskać początkowy stan. Delay pozwala ustawić debounce
                {fireImmediately: true, delay: 500}
            );
            // Usuniemy subskrypcję po odłączeniu klienta
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Po otrzymaniu obiektu remote tworzy się reaction na zmianę stanu, która wywołuje funkcję po stronie UI.

Ostatni szlif — dodamy wyświetlanie nowych wiadomości na ikonie rozszerzenia:

function setupApp() {
...

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

...
}

Więc aplikacja jest gotowa. Strony internetowe mogą żądać podpisania transakcji:

Pisanie bezpiecznego rozszerzenia przeglądarki

Pisanie bezpiecznego rozszerzenia przeglądarki

Kod dostępny pod tym linkiem.

Podsumowanie

Jeśli dotarłeś do końca artykułu, ale masz pytania, możesz je zadać w repozytorium z rozszerzeniem. Tam również znajdziesz commity dla każdego zaznaczonego kroku.

A jeśli chcesz zobaczyć kod prawdziwego rozszerzenia, możesz go znaleźć tutaj.

Kod, repozytorium i opis działania od siemarell

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster