Napisanie bezpiecznego rozszerzenia przeglądarki

Napisanie bezpiecznego rozszerzenia przeglądarki

W odróżnieniu od powszechnej architektury „klient-serwer”, aplikacje zdecentralizowane charakteryzują się:

  • Nie ma potrzeby przechowywania bazy danych z loginami i hasłami użytkowników. Informacje dostępowe przechowywane są wyłącznie przez samych użytkowników, a potwierdzenie ich autentyczności następuje na poziomie protokołu.
  • Nie ma potrzeby korzystania z serwera. Logika aplikacji może być realizowana w sieci blockchain, gdzie możliwe jest przechowywanie wymaganej ilości danych.

Istnieją 2 stosunkowo bezpieczne miejsca przechowywania kluczy użytkownika – portfele sprzętowe i rozszerzenia przeglądarki. Portfele sprzętowe są w większości wyjątkowo bezpieczne, ale trudne w użyciu i wcale nie darmowe, ale rozszerzenia przeglądarki stanowią idealne połączenie bezpieczeństwa i łatwości użytkowania, a także mogą być całkowicie bezpłatne dla użytkowników końcowych.

Biorąc to wszystko pod uwagę, chcieliśmy stworzyć najbezpieczniejsze rozszerzenie, które uprości rozwój zdecentralizowanych aplikacji, udostępniając proste API do pracy z transakcjami i podpisami.
O tym doświadczeniu opowiemy poniżej.

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

Krótka historia rozszerzeń przeglądarki

Rozszerzenia przeglądarki istnieją już od dłuższego czasu. Pojawiły się w przeglądarce Internet Explorer w 1999 r., w przeglądarce Firefox w 2004 r. Jednak przez bardzo długi czas nie było jednego standardu rozszerzeń.

Można powiedzieć, że pojawił się wraz z rozszerzeniami w czwartej wersji przeglądarki Google Chrome. Oczywiście nie było wtedy specyfikacji, ale to właśnie Chrome API stało się jej podstawą: podbijając większość rynku przeglądarek i mając wbudowany sklep z aplikacjami, Chrome faktycznie wyznaczył standard rozszerzeń przeglądarek.

Mozilla miała swój własny standard, ale widząc popularność rozszerzeń do Chrome, firma zdecydowała się stworzyć kompatybilne API. W 2015 roku z inicjatywy Mozilli w ramach konsorcjum World Wide Web Consortium (W3C) utworzono specjalną grupę, która ma pracować nad specyfikacjami rozszerzeń dla różnych przeglądarek.

Za podstawę przyjęto istniejące rozszerzenia API dla przeglądarki Chrome. Prace prowadzono przy wsparciu firmy Microsoft (Google odmówił udziału w opracowaniu standardu), w wyniku czego pojawił się projekt specyfikacje.

Formalnie specyfikację obsługują Edge, Firefox i Opera (uwaga, Chrome nie znalazł się na tej liście). Ale w rzeczywistości standard jest w dużej mierze kompatybilny z Chrome, ponieważ faktycznie jest napisany w oparciu o jego rozszerzenia. Możesz przeczytać więcej o API WebExtensions tutaj.

Struktura rozszerzenia

Jedynym plikiem wymaganym do rozszerzenia jest manifest (manifest.json). Jest to także „punkt wejścia” do ekspansji.

Manifest

Zgodnie ze specyfikacją plik manifestu jest prawidłowym plikiem JSON. Pełny opis kluczy manifestu z informacją o tym, które klucze są obsługiwane w jakiej przeglądarce można je przeglądać tutaj.

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

I chciałbym zwrócić uwagę na pewne kwestie.

  1. tło — obiekt zawierający następujące pola:
    1. skrypty — tablica skryptów, które zostaną wykonane w kontekście tła (porozmawiamy o tym nieco później);
    2. strona - zamiast skryptów, które będą wykonywane na pustej stronie, możesz określić html z treścią. W takim przypadku pole skryptu zostanie zignorowane i konieczne będzie wstawienie skryptów do strony z treścią;
    3. uporczywy — flaga binarna, jeśli nie zostanie określona, ​​przeglądarka „zabije” proces działający w tle, gdy uzna, że ​​nic nie robi, i w razie potrzeby uruchomi go ponownie. W przeciwnym razie strona zostanie wyładowana dopiero po zamknięciu przeglądarki. Nieobsługiwane w przeglądarce Firefox.
  2. skrypty treści — tablica obiektów, która umożliwia ładowanie różnych skryptów na różne strony internetowe. Każdy obiekt zawiera następujące ważne pola:
    1. zapałki - adres URL wzoru, który określa, czy określony skrypt treści zostanie uwzględniony, czy nie.
    2. js — lista skryptów, które zostaną załadowane do tego meczu;
    3. wyklucz_dopasowania - wyklucza z pola match Adresy URL pasujące do tego pola.
  3. strona_akcja - to tak naprawdę obiekt odpowiedzialny za ikonę wyświetlaną obok paska adresu w przeglądarce i interakcję z nią. Umożliwia także wyświetlenie wyskakującego okna, które jest zdefiniowane przy użyciu własnego kodu HTML, CSS i JS.
    1. domyślne_wyskakujące okienko — ścieżka do pliku HTML z wyskakującym interfejsem, może zawierać CSS i JS.
  4. uprawnienia — tablica do zarządzania prawami rozszerzeń. Istnieją 3 rodzaje uprawnień, które zostały szczegółowo opisane tutaj
  5. zasoby_dostępne_internetowo — zasoby rozszerzeń, o które może poprosić strona internetowa, na przykład obrazy, pliki JS, CSS, HTML.
  6. możliwość podłączenia zewnętrznego — tutaj możesz jednoznacznie określić identyfikatory innych rozszerzeń i domen stron internetowych, z których możesz się połączyć. Domena może być poziomu drugiego lub wyższego. Nie działa w przeglądarce Firefox.

Kontekst wykonania

Rozszerzenie posiada trzy konteksty wykonania kodu, czyli aplikacja składa się z trzech części o różnym poziomie dostępu do API przeglądarki.

Kontekst rozszerzenia

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

  1. Strona w tle — „backendowa” część rozszerzenia. Plik jest określony w manifeście za pomocą klucza „w tle”.
  2. Wyskakująca strona — wyskakująca strona, która pojawia się po kliknięciu ikony rozszerzenia. W manifeście browser_action -> default_popup.
  3. Strona niestandardowa — strona rozszerzenia, „życie” w osobnej zakładce widoku chrome-extension://<id_расширения>/customPage.html.

Kontekst ten istnieje niezależnie od okien i kart przeglądarki. Strona w tle istnieje w pojedynczym egzemplarzu i zawsze działa (wyjątkiem jest strona zdarzenia, gdzie skrypt działający w tle jest uruchamiany przez zdarzenie i „umiera” po jego wykonaniu). Wyskakująca strona istnieje, gdy okno wyskakujące jest otwarte, oraz Strona niestandardowa — gdy karta z nim jest otwarta. Z tego kontekstu nie ma dostępu do innych zakładek i ich zawartości.

Kontekst skryptu treści

Plik skryptu treści jest uruchamiany wraz z każdą zakładką przeglądarki. Ma dostęp do części API rozszerzenia oraz do drzewa DOM strony internetowej. Za interakcję ze stroną odpowiadają skrypty treści. Rozszerzenia manipulujące drzewem DOM robią to w skryptach treści - na przykład blokery reklam lub tłumacze. Ponadto skrypt treści może komunikować się ze stroną w standardzie postMessage.

Kontekst strony internetowej

To jest sama strona internetowa. Nie ma to nic wspólnego z rozszerzeniem i nie ma tam dostępu, z wyjątkiem przypadków, gdy domena tej strony nie jest wyraźnie wskazana w manifeście (więcej na ten temat poniżej).

Wymiana wiadomości

Różne części aplikacji muszą wymieniać między sobą wiadomości. Jest do tego API runtime.sendMessage aby wysłać wiadomość background и tabs.sendMessage aby wysłać wiadomość na stronę (skrypt treści, wyskakujące okienko lub stronę internetową, jeśli jest dostępna externally_connectable). Poniżej znajduje się przykład uzyskiwania dostępu do 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 uzyskać pełną komunikację, możesz tworzyć połączenia za pośrednictwem runtime.connect. W odpowiedzi otrzymamy runtime.Port, do którego, gdy jest otwarty, możesz wysłać dowolną liczbę wiadomości. Po stronie klienta np. contentscript, To wygląda 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 tło:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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 też wydarzenie onDisconnect i metoda disconnect.

Schemat zastosowania

Zróbmy rozszerzenie przeglądarki przechowujące klucze prywatne, zapewniające dostęp do informacji publicznych (adres, klucz publiczny komunikuje się ze stroną i umożliwia aplikacjom stron trzecich żądanie podpisu dla transakcji).

Rozwój aplikacji

Nasza aplikacja musi zarówno wchodzić w interakcję z użytkownikiem, jak i udostępniać stronie API umożliwiające wywoływanie metod (np. podpisywanie transakcji). Wystarczy jeden contentscript nie będzie działać, ponieważ ma dostęp tylko do DOM, ale nie do JS strony. Połącz przez runtime.connect nie możemy, bo API jest potrzebne na wszystkich domenach, a w manifeście można wskazać tylko te konkretne. W rezultacie schemat będzie wyglądał następująco:

Napisanie bezpiecznego rozszerzenia przeglądarki

Będzie inny scenariusz - inpage, który wstrzykniemy na stronę. Będzie działać w swoim kontekście i udostępniać interfejs API do pracy z rozszerzeniem.

początek

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

Zacznijmy od manifestu:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Utwórz puste pliki tła.js, popup.js, inpage.js i contentscript.js. Dodajemy popup.html - i naszą aplikację można już załadować do przeglądarki Google Chrome i upewnić się, że działa.

Aby to sprawdzić, możesz wziąć kod stąd. Oprócz tego, co zrobiliśmy, link skonfigurował montaż projektu za pomocą pakietu internetowego. Aby dodać aplikację do przeglądarki, w chrome://extensions należy wybrać wczytaj rozpakowane i folder z odpowiednim rozszerzeniem - w naszym przypadku dist.

Napisanie bezpiecznego rozszerzenia przeglądarki

Teraz nasze rozszerzenie jest zainstalowane i działa. Narzędzia programistyczne można uruchamiać w różnych kontekstach w następujący sposób:

wyskakujące okienko ->

Napisanie bezpiecznego rozszerzenia przeglądarki

Dostęp do konsoli skryptów treści odbywa się poprzez konsolę samej strony, na której jest ona uruchamiana.Napisanie bezpiecznego rozszerzenia przeglądarki

Wymiana wiadomości

Musimy więc ustalić dwa kanały komunikacji: inpage <-> tło i wyskakujące okienko <-> tło. Można oczywiście po prostu wysyłać wiadomości na port i wymyślać własny protokół, ale ja wolę podejście, które widziałem w projekcie open source metamask.

Jest to rozszerzenie przeglądarki umożliwiające pracę z siecią Ethereum. W nim różne części aplikacji komunikują się poprzez RPC z wykorzystaniem biblioteki dnode. Pozwala dość szybko i wygodnie zorganizować wymianę, jeśli zapewnisz jej strumień nodejs jako transport (czyli obiekt implementujący ten sam interfejs):

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Teraz utworzymy klasę aplikacji. Utworzy obiekty API dla wyskakującego okienka i strony internetowej oraz utworzy dla nich dnode:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

Tutaj i poniżej zamiast globalnego obiektu Chrome używamy rozszerzeniaApi, które uzyskuje dostęp do Chrome w przeglądarce Google i przeglądarki w innych. Odbywa się to w celu zapewnienia zgodności z różnymi przeglądarkami, ale na potrzeby tego artykułu można po prostu użyć „chrome.runtime.connect”.

Utwórzmy instancję aplikacji w skrypcie działającym w tle:

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

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Ponieważ dnode współpracuje ze strumieniami, a my otrzymujemy port, potrzebna jest klasa adaptera. Robi się to za pomocą 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()
    }
}

Utwórzmy teraz połączenie w interfejsie użytkownika:

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

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

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

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

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

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Następnie tworzymy połączenie w skrypcie treś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 {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

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

  1. Tworzymy dwa strumienie. Jeden - w stronę strony, na górze wpisuWiadomość. W tym celu używamy tego ten pakiet od twórców metamaski. Drugi strumień jest przesyłany w tle nad portem, z którego otrzymano runtime.connect. Kupmy je. Teraz strona będzie miała strumień w tle.
  2. Wstrzyknij skrypt do DOM. Pobierz skrypt (dostęp do niego był dozwolony w manifeście) i utwórz tag script z zawartością w środku:

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

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Teraz tworzymy obiekt API na stronie i ustawiamy go na globalny:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

Jesteśmy gotowi Zdalne wywołanie procedury (RPC) z oddzielnym interfejsem API dla strony i interfejsu użytkownika. Łącząc nową stronę z tłem możemy zobaczyć coś takiego:

Napisanie bezpiecznego rozszerzenia przeglądarki

Puste API i pochodzenie. Po stronie strony możemy wywołać funkcję hello w następujący sposób:

Napisanie bezpiecznego rozszerzenia przeglądarki

Praca z funkcjami wywołania zwrotnego we współczesnym JS to złe maniery, więc napiszmy małego pomocnika, który stworzy dnode, który pozwoli ci przekazać obiekt API do narzędzi.

Obiekty API będą teraz wyglądać następująco:

export class SignerApp {

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

...

}

Pobieranie obiektu ze pilota w następujący sposób:

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

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

A wywołanie funkcji zwraca obietnicę:

Napisanie bezpiecznego rozszerzenia przeglądarki

Dostępna wersja z funkcjami asynchronicznymi tutaj.

Ogólnie rzecz biorąc, podejście RPC i strumieniowe wydaje się dość elastyczne: możemy użyć multipleksowania parowego i stworzyć kilka różnych interfejsów API do różnych zadań. W zasadzie dnode można używać wszędzie, najważniejsze jest zawinięcie transportu w postaci strumienia nodejs.

Alternatywą jest format JSON, który implementuje protokół JSON RPC 2. Działa on jednak z określonymi transportami (TCP i HTTP(S)), co w naszym przypadku nie ma zastosowania.

Stan wewnętrzny i pamięć lokalna

Będziemy musieli przechowywać stan wewnętrzny aplikacji - przynajmniej klucze podpisujące. W wyskakującym API możemy w dość prosty sposób dodać stan do aplikacji oraz metody jego zmiany:

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 tle zawiniemy wszystko w funkcję i zapiszemy obiekt aplikacji w oknie, abyśmy mogli 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 interfejsu użytkownika i zobaczmy, co stanie się ze stanem:

Napisanie bezpiecznego rozszerzenia przeglądarki

Stan musi być trwały, aby klucze nie zostały utracone podczas ponownego uruchamiania.

Będziemy go przechowywać w localStorage, nadpisując przy każdej zmianie. Później dostęp do niego będzie niezbędny również dla interfejsu użytkownika, a ja też chciałbym subskrybować zmiany. Na tej podstawie wygodnie będzie utworzyć obserwowalny magazyn i subskrybować jego zmiany.

Będziemy korzystać z biblioteki mobx (https://github.com/mobxjs/mobx). Wybór padł na niego, ponieważ nie musiałem z nim pracować, ale bardzo chciałem go przestudiować.

Dodajmy inicjalizację stanu początkowego i sprawmy, aby sklep był obserwowalny:

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

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

„Pod maską” mobx zastąpił wszystkie pola sklepu serwerem proxy i przechwytuje wszystkie połączenia do nich. Będzie można subskrybować te wiadomości.

Poniżej często będę używał określenia „przy zmianie”, choć nie jest to do końca poprawne. Mobx śledzi dostęp do pól. Używane są metody pobierające i ustawiające obiekty proxy tworzone przez bibliotekę.

Dekoratory akcji służą dwóm celom:

  1. W trybie ścisłym z flagąforceActions mobx zabrania bezpośredniej zmiany stanu. Za dobrą praktykę uważa się pracę w ściśle określonych warunkach.
  2. Nawet jeśli funkcja zmieni stan kilkukrotnie – na przykład zmienimy kilka pól w kilku linijkach kodu – obserwatorzy zostaną powiadomieni dopiero wtedy, gdy funkcja się zakończy. Jest to szczególnie ważne w przypadku frontendu, gdzie niepotrzebne aktualizacje stanu prowadzą do niepotrzebnego renderowania elementów. W naszym przypadku ani to pierwsze, ani drugie nie jest szczególnie istotne, ale będziemy kierować się najlepszymi praktykami. Zwyczajowo do wszystkich funkcji zmieniających stan obserwowanych pól dołącza się dekoratory.

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";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

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

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

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

  1. Selektor danych.
  2. Procedura obsługi, która będzie wywoływana z tymi danymi przy każdej zmianie.

W przeciwieństwie do redux, gdzie jawnie otrzymujemy stan jako argument, mobx pamięta, do których obserwacji mamy dostęp wewnątrz selektora i wywołuje procedurę obsługi tylko wtedy, gdy się zmieniają.

Ważne jest, aby dokładnie zrozumieć, w jaki sposób mobx decyduje, które obserwable subskrybujemy. Gdybym napisał selektor w kodzie takim jak ten() => app.store, wówczas reakcja nigdy nie zostanie wywołana, ponieważ sama pamięć nie jest obserwowalna, a jedynie jej pola.

Jeśli napisałem to w ten sposób () => app.store.keys, to znowu nic by się nie stało, ponieważ podczas dodawania/usuwania elementów tablicy odniesienie do niego nie ulegnie zmianie.

Mobx po raz pierwszy pełni rolę selektora i śledzi tylko te obserwowalne, do których uzyskaliśmy dostęp. Odbywa się to za pomocą modułów pobierających proxy. Dlatego zastosowano tutaj wbudowaną funkcję toJS. Zwraca nowy obiekt ze wszystkimi proxy zastąpionymi oryginalnymi polami. Podczas wykonywania odczytuje wszystkie pola obiektu - stąd uruchamiane są gettery.

W wyskakującej konsoli ponownie dodamy kilka kluczy. Tym razem trafiły również do localStorage:

Napisanie bezpiecznego rozszerzenia przeglądarki

Po ponownym załadowaniu strony tła informacje pozostają na swoim miejscu.

Można wyświetlić cały kod aplikacji do tego momentu tutaj.

Bezpieczne przechowywanie kluczy prywatnych

Przechowywanie kluczy prywatnych w postaci zwykłego tekstu jest niebezpieczne: zawsze istnieje ryzyko, że zostaniesz zhakowany, uzyskasz dostęp do swojego komputera itp. Dlatego w localStorage będziemy przechowywać klucze w formie zaszyfrowanej hasłem.

Dla większego bezpieczeństwa dodamy do aplikacji stan zablokowania, w którym nie będzie w ogóle dostępu do kluczy. Automatycznie przeniesiemy rozszerzenie do stanu zablokowanego z powodu przekroczenia limitu czasu.

Mobx pozwala na przechowywanie jedynie minimalnego zestawu danych, a reszta jest automatycznie na jego podstawie obliczana. Są to tak zwane właściwości obliczone. Można je porównać do widoków w bazach danych:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

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

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

Teraz przechowujemy tylko zaszyfrowane klucze i hasło. Wszystko inne jest obliczane. Przeniesienie do stanu zablokowanego wykonujemy usuwając hasło ze stanu. Publiczny interfejs API ma teraz metodę inicjowania magazynu.

Napisane w celu szyfrowania narzędzia korzystające z crypto-js:

import CryptoJS from 'crypto-js'

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

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

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

Przeglądarka posiada bezczynne API poprzez które można subskrybować zdarzenie - zmiany stanu. Stan, odpowiednio, może być idle, active и locked. W przypadku bezczynności można ustawić limit czasu, a zablokowanie jest ustawiane, gdy sam system operacyjny jest zablokowany. Zmienimy także selektor 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;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Kod przed tym krokiem to tutaj.

Transakcje

Dochodzimy więc do najważniejszej rzeczy: tworzenia i podpisywania transakcji na blockchainie. Będziemy używać blockchainu i biblioteki WAVES transakcje falowe.

Najpierw dodajmy do stanu tablicę wiadomości, które należy podpisać, a następnie dodajmy metody dodania nowej wiadomości, potwierdzenia 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) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Gdy otrzymamy nową wiadomość, dodajemy do niej metadane, robimy to observable i dodaj do store.messages.

Jeśli tego nie zrobisz observable ręcznie, wówczas mobx zrobi to sam podczas dodawania wiadomości do tablicy. Stworzy to jednak nowy obiekt, do którego nie będziemy mieli odniesienia, ale będziemy go potrzebować w kolejnym kroku.

Następnie zwracamy obietnicę, która zostanie rozpatrzona, gdy zmieni się status wiadomości. Stan jest monitorowany poprzez reakcję, która „zabije się” w przypadku zmiany statusu.

Kod metody approve и reject bardzo proste: po prostu zmieniamy status wiadomości, w razie potrzeby po jej podpisaniu.

Umieszczamy opcję Zatwierdź i odrzuć w interfejsie API interfejsu użytkownika, nową wiadomość w interfejsie API strony:

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

    ...
}

Spróbujmy teraz podpisać transakcję rozszerzeniem:

Napisanie bezpiecznego rozszerzenia przeglądarki

Ogólnie rzecz biorąc, wszystko jest gotowe, pozostaje tylko dodaj prosty interfejs użytkownika.

UI

Interfejs wymaga dostępu do stanu aplikacji. Po stronie interfejsu użytkownika to zrobimy observable state i dodaj do API funkcję, która zmieni ten stan. Dodajmy observable do obiektu API otrzymanego z tła:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

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

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

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

    // Запуск интерфейса
    await initApp(background)
}

Na koniec zaczynamy renderować interfejs aplikacji. To jest aplikacja reagująca. Obiekt tła jest po prostu przekazywany za pomocą rekwizytów. Poprawne byłoby oczywiście utworzenie osobnej usługi dla metod i magazynu dla stanu, ale na potrzeby tego artykułu wystarczy:

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

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

Dzięki mobx bardzo łatwo jest rozpocząć renderowanie w przypadku zmiany danych. Po prostu zawieszamy dekorator obserwatora na opakowaniu reakcja mobx na komponencie, a render zostanie automatycznie wywołany, gdy jakiekolwiek obserwable, do których odwołuje się komponent, ulegną zmianie. Nie potrzebujesz żadnych mapStateToProps ani nie łączysz się jak w Redux. Wszystko działa od razu po wyjęciu 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 // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

Pozostałe komponenty można zobaczyć w kodzie w folderze interfejsu użytkownika.

Teraz w klasie aplikacji musisz utworzyć selektor stanu dla interfejsu użytkownika i powiadomić interfejs użytkownika o jego zmianach. Aby to zrobić, dodajmy metodę getState и reactionpowołanie remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Podczas odbierania przedmiotu remote jest tworzony reaction aby zmienić stan wywołujący funkcję po stronie interfejsu użytkownika.

Ostatnim akcentem jest dodanie wyświetlania nowych wiadomości na ikonie rozszerzenia:

function setupApp() {
...

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

...
}

Zatem aplikacja jest gotowa. Strony internetowe mogą wymagać podpisu w przypadku transakcji:

Napisanie bezpiecznego rozszerzenia przeglądarki

Napisanie bezpiecznego rozszerzenia przeglądarki

Kod jest dostępny tutaj powiązanie.

wniosek

Jeśli przeczytałeś artykuł do końca, ale nadal masz pytania, możesz je zadać pod adresem repozytoria z rozszerzeniem. Znajdziesz tam również zatwierdzenia dla każdego wyznaczonego kroku.

A jeśli jesteś zainteresowany sprawdzeniem kodu rzeczywistego rozszerzenia, możesz to znaleźć tutaj.

Kod, repozytorium i opis zadania z siemarell

Źródło: www.habr.com

Dodaj komentarz