Scrivere un'estensione del browser sicura

Scrivere un'estensione del browser sicura

A differenza della comune architettura “client-server”, le applicazioni decentralizzate sono caratterizzate da:

  • Non è necessario archiviare un database con accessi e password degli utenti. Le informazioni di accesso vengono memorizzate esclusivamente dagli utenti stessi e la conferma della loro autenticità avviene a livello di protocollo.
  • Non è necessario utilizzare un server. La logica dell'applicazione può essere eseguita su una rete blockchain, dove è possibile archiviare la quantità di dati richiesta.

Esistono 2 archivi relativamente sicuri per le chiavi utente: portafogli hardware ed estensioni del browser. I portafogli hardware sono per lo più estremamente sicuri, ma difficili da usare e tutt'altro che gratuiti, ma le estensioni del browser sono la combinazione perfetta di sicurezza e facilità d'uso e possono anche essere completamente gratuite per gli utenti finali.

Tenendo conto di tutto ciò, abbiamo voluto creare l'estensione più sicura che semplifichi lo sviluppo di applicazioni decentralizzate fornendo una semplice API per lavorare con transazioni e firme.
Di seguito vi racconteremo questa esperienza.

L'articolo conterrà istruzioni dettagliate su come scrivere un'estensione del browser, con esempi di codice e screenshot. Puoi trovare tutto il codice in repository. Ogni commit corrisponde logicamente a una sezione di questo articolo.

Una breve storia delle estensioni del browser

Le estensioni del browser esistono da molto tempo. Sono apparsi in Internet Explorer nel 1999, in Firefox nel 2004. Tuttavia, per molto tempo non è esistito uno standard unico per le estensioni.

Possiamo dire che è apparso insieme alle estensioni nella quarta versione di Google Chrome. Naturalmente, allora non esistevano specifiche, ma è stata l'API di Chrome a diventarne la base: avendo conquistato la maggior parte del mercato dei browser e avendo un negozio di applicazioni integrato, Chrome ha effettivamente stabilito lo standard per le estensioni del browser.

Mozilla aveva il proprio standard, ma vista la popolarità delle estensioni di Chrome, l'azienda ha deciso di creare un'API compatibile. Nel 2015, su iniziativa di Mozilla, è stato creato un gruppo speciale all'interno del World Wide Web Consortium (W3C) per lavorare sulle specifiche delle estensioni cross-browser.

Come base sono state prese le estensioni API esistenti per Chrome. Il lavoro è stato svolto con il supporto di Microsoft (Google ha rifiutato di partecipare allo sviluppo dello standard) e di conseguenza è apparsa una bozza specificazione.

Formalmente, la specifica è supportata da Edge, Firefox e Opera (nota che Chrome non è in questo elenco). Ma in realtà lo standard è ampiamente compatibile con Chrome, poiché è scritto in base alle sue estensioni. Puoi leggere ulteriori informazioni sull'API WebExtensions qui.

Struttura di estensione

L'unico file richiesto per l'estensione è il manifest (manifest.json). È anche il “punto di ingresso” per l’espansione.

Манифест

Secondo le specifiche, il file manifest è un file JSON valido. Una descrizione completa delle chiavi manifest con informazioni su quali chiavi sono supportate in quale browser è possibile visualizzare qui.

Le chiavi che non sono presenti nelle specifiche “potrebbero” essere ignorate (sia Chrome che Firefox segnalano errori, ma le estensioni continuano a funzionare).

E vorrei attirare l'attenzione su alcuni punti.

  1. sfondo — un oggetto che include i seguenti campi:
    1. script — una serie di script che verranno eseguiti nel contesto di background (ne parleremo più avanti);
    2. pagina - invece degli script che verranno eseguiti in una pagina vuota, puoi specificare html con contenuto. In questo caso il campo script verrà ignorato e gli script dovranno essere inseriti nella pagina del contenuto;
    3. persistere — un flag binario, se non specificato, il browser "ucciderà" il processo in background quando riterrà che non stia facendo nulla e lo riavvierà se necessario. In caso contrario la pagina verrà scaricata solo alla chiusura del browser. Non supportato in Firefox.
  2. content_scripts — una serie di oggetti che consente di caricare diversi script su diverse pagine web. Ogni oggetto contiene i seguenti campi importanti:
    1. fiammiferi - URL del modello, che determina se un particolare script di contenuto verrà incluso o meno.
    2. js — un elenco di script che verranno caricati in questa partita;
    3. exclude_matches - esclude dal campo match URL che corrispondono a questo campo.
  3. pagina_azione - è in realtà un oggetto responsabile dell'icona visualizzata accanto alla barra degli indirizzi nel browser e dell'interazione con essa. Ti consente inoltre di visualizzare una finestra popup, definita utilizzando il tuo HTML, CSS e JS.
    1. default_popup — percorso del file HTML con l'interfaccia popup, può contenere CSS e JS.
  4. permessi — un array per la gestione dei diritti di estensione. Esistono 3 tipi di diritti, descritti in dettaglio qui
  5. risorse_accessibili_web — risorse di estensione che una pagina web può richiedere, ad esempio immagini, file JS, CSS, HTML.
  6. collegabile_esternamente — qui puoi specificare esplicitamente gli ID di altre estensioni e domini di pagine web da cui puoi connetterti. Un dominio può essere di secondo livello o superiore. Non funziona con Firefox.

Contesto di esecuzione

L'estensione ha tre contesti di esecuzione del codice, ovvero l'applicazione è composta da tre parti con diversi livelli di accesso all'API del browser.

Contesto di estensione

La maggior parte dell'API è disponibile qui. In questo contesto “vivono”:

  1. Pagina di sfondo - Parte "backend" dell'estensione. Il file viene specificato nel manifest utilizzando la chiave " background ".
  2. Pagina popup - una pagina popup che appare quando fai clic sull'icona dell'estensione. Nel manifesto browser_action -> default_popup.
  3. Pagina personalizzata - pagina di estensione, "vivente" in una scheda separata della vista chrome-extension://<id_расширения>/customPage.html.

Questo contesto esiste indipendentemente dalle finestre e dalle schede del browser. Pagina di sfondo esiste in un'unica copia e funziona sempre (l'eccezione è la pagina dell'evento, quando lo script in background viene lanciato da un evento e “muore” dopo la sua esecuzione). Pagina popup esiste quando la finestra popup è aperta e Pagina personalizzata - mentre la scheda con esso è aperta. Non è possibile accedere ad altre schede e ai relativi contenuti da questo contesto.

Contesto dello script di contenuto

Il file dello script del contenuto viene avviato insieme a ciascuna scheda del browser. Ha accesso a parte dell'API dell'estensione e all'albero DOM della pagina web. Sono gli script di contenuto responsabili dell'interazione con la pagina. Le estensioni che manipolano l'albero DOM lo fanno negli script di contenuto, ad esempio blocchi di annunci o traduttori. Inoltre, lo script del contenuto può comunicare con la pagina tramite standard postMessage.

Contesto della pagina Web

Questa è la pagina web vera e propria. Non ha nulla a che fare con l'estensione e non ha accesso lì, tranne nei casi in cui il dominio di questa pagina non è esplicitamente indicato nel manifest (ne parleremo più avanti).

messaggistica

Parti diverse dell'applicazione devono scambiarsi messaggi tra loro. C'è un'API per questo runtime.sendMessage per inviare un messaggio background и tabs.sendMessage per inviare un messaggio a una pagina (script di contenuto, popup o pagina web se disponibile externally_connectable). Di seguito è riportato un esempio di accesso all'API di 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))
    }
)

Per una comunicazione completa, è possibile creare connessioni tramite runtime.connect. In risposta riceveremo runtime.Port, al quale, mentre è aperto, puoi inviare un numero qualsiasi di messaggi. Lato client, ad esempio, contentscript, assomiglia a questo:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

Server o sfondo:

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

C'è anche un evento onDisconnect e metodo disconnect.

Schema applicativo

Creiamo un'estensione del browser che memorizzi le chiavi private, fornisca accesso alle informazioni pubbliche (indirizzo, chiave pubblica comunica con la pagina e consente ad applicazioni di terze parti di richiedere una firma per le transazioni.

Sviluppo dell'applicazione

La nostra applicazione deve interagire con l'utente e fornire alla pagina un'API per chiamare metodi (ad esempio, per firmare transazioni). Accontentati di uno solo contentscript non funzionerà, poiché ha accesso solo al DOM, ma non al JS della pagina. Connetti tramite runtime.connect non possiamo, perché l'API è necessaria su tutti i domini e solo quelli specifici possono essere specificati nel manifest. Di conseguenza, il diagramma sarà simile al seguente:

Scrivere un'estensione del browser sicura

Ci sarà un altro copione - inpage, che inseriremo nella pagina. Verrà eseguito nel suo contesto e fornirà un'API per lavorare con l'estensione.

Inizio

Tutto il codice delle estensioni del browser è disponibile su GitHub. Durante la descrizione saranno presenti collegamenti ai commit.

Partiamo dal manifesto:

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

Crea background.js, popup.js, inpage.js e contentscript.js vuoti. Aggiungiamo popup.html e la nostra applicazione può già essere caricata in Google Chrome e assicurarci che funzioni.

Per verificarlo, puoi prendere il codice quindi. Oltre a ciò che abbiamo fatto, il collegamento ha configurato l'assemblaggio del progetto utilizzando webpack. Per aggiungere un'applicazione al browser, in chrome://extensions è necessario selezionare carica unpacked e la cartella con l'estensione corrispondente, nel nostro caso dist.

Scrivere un'estensione del browser sicura

Ora la nostra estensione è installata e funzionante. È possibile eseguire gli strumenti per sviluppatori per diversi contesti come segue:

pop-up ->

Scrivere un'estensione del browser sicura

L'accesso alla console dello script del contenuto viene effettuato tramite la console della pagina stessa su cui viene avviato.Scrivere un'estensione del browser sicura

messaggistica

Dobbiamo quindi stabilire due canali di comunicazione: inpage <-> background e popup <-> background. Ovviamente puoi semplicemente inviare messaggi alla porta e inventare il tuo protocollo, ma preferisco l'approccio che ho visto nel progetto open source metamask.

Questa è un'estensione del browser per lavorare con la rete Ethereum. In esso, diverse parti dell'applicazione comunicano tramite RPC utilizzando la libreria dnode. Ti consente di organizzare uno scambio in modo abbastanza rapido e conveniente se gli fornisci un flusso nodejs come trasporto (ovvero un oggetto che implementa la stessa interfaccia):

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

Ora creeremo una classe applicativa. Creerà oggetti API per il popup e la pagina web e creerà un dnode per loro:

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

Qui e di seguito, invece dell'oggetto globale Chrome, utilizziamo extensionApi, che accede a Chrome nel browser di Google e ai browser di altri. Questo viene fatto per la compatibilità tra browser, ma ai fini di questo articolo si potrebbe semplicemente utilizzare "chrome.runtime.connect".

Creiamo un'istanza dell'applicazione nello script in background:

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

Poiché dnode funziona con i flussi e riceviamo una porta, è necessaria una classe adattatore. È realizzato utilizzando la libreria readable-stream, che implementa i flussi nodejs nel browser:

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

Ora creiamo una connessione nell'interfaccia utente:

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

Quindi creiamo la connessione nello script del contenuto:

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

Poiché abbiamo bisogno dell'API non nello script del contenuto, ma direttamente nella pagina, facciamo due cose:

  1. Creiamo due flussi. Uno: verso la pagina, sopra postMessage. Per questo usiamo questo questo pacchetto dai creatori di metamask. Il secondo flusso va in background sulla porta da cui si riceve runtime.connect. Compriamoli. Ora la pagina avrà uno streaming in background.
  2. Iniettare lo script nel DOM. Scarica lo script (l'accesso ad esso era consentito nel manifest) e crea un tag script con il suo contenuto all'interno:

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

Ora creiamo un oggetto API in inpage e lo impostiamo su global:

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

setupInpageApi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Siamo pronti Chiamata di procedura remota (RPC) con API separate per pagina e interfaccia utente. Quando colleghiamo una nuova pagina allo sfondo possiamo vedere questo:

Scrivere un'estensione del browser sicura

API e origine vuote. Sul lato della pagina, possiamo chiamare la funzione hello in questo modo:

Scrivere un'estensione del browser sicura

Lavorare con le funzioni di callback nel moderno JS è una cattiva educazione, quindi scriviamo un piccolo helper per creare un dnode che ti permetta di passare un oggetto API alle utils.

Gli oggetti API ora appariranno così:

export class SignerApp {

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

...

}

Ottenere un oggetto dal telecomando in questo modo:

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

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

E chiamare le funzioni restituisce una promessa:

Scrivere un'estensione del browser sicura

Disponibile versione con funzioni asincrone qui.

Nel complesso, l'approccio RPC e stream sembra abbastanza flessibile: possiamo utilizzare il multiplexing di vapore e creare diverse API diverse per compiti diversi. In linea di principio, dnode può essere utilizzato ovunque, l'importante è avvolgere il trasporto sotto forma di flusso nodejs.

Un'alternativa è il formato JSON, che implementa il protocollo JSON RPC 2. Funziona però con trasporti specifici (TCP e HTTP(S)), cosa che nel nostro caso non è applicabile.

Stato interno e localStorage

Dovremo memorizzare lo stato interno dell'applicazione, almeno le chiavi di firma. Possiamo aggiungere abbastanza facilmente uno stato all'applicazione e metodi per modificarlo nell'API popup:

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

    ...

} 

In background, avvolgeremo tutto in una funzione e scriveremo l'oggetto dell'applicazione nella finestra in modo da poter lavorare con esso dalla console:

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

Aggiungiamo alcune chiavi dalla console dell'interfaccia utente e vediamo cosa succede con lo stato:

Scrivere un'estensione del browser sicura

Lo stato deve essere reso persistente in modo che le chiavi non vengano perse al riavvio.

Lo memorizzeremo in localStorage, sovrascrivendolo ad ogni modifica. Successivamente sarà necessario accedervi anche per l'interfaccia utente e vorrei sottoscrivere anche le modifiche. Sulla base di ciò, sarà conveniente creare uno spazio di archiviazione osservabile e sottoscrivere le sue modifiche.

Utilizzeremo la libreria mobx (https://github.com/mobxjs/mobx). La scelta è ricaduta su questo perché non dovevo lavorarci, ma volevo davvero studiarlo.

Aggiungiamo l'inizializzazione dello stato iniziale e rendiamo osservabile il negozio:

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

    ...

}

"Sotto il cofano", mobx ha sostituito tutti i campi del negozio con proxy e intercetta tutte le chiamate ad essi indirizzate. Sarà possibile iscriversi a questi messaggi.

Di seguito utilizzerò spesso il termine “al cambio”, anche se questo non è del tutto corretto. Mobx tiene traccia dell'accesso ai campi. Vengono utilizzati getter e setter di oggetti proxy creati dalla libreria.

I decoratori di azioni hanno due scopi:

  1. In modalità rigorosa con il flag EnforceActions, mobx proibisce di modificare direttamente lo stato. È considerata una buona pratica lavorare in condizioni rigorose.
  2. Anche se una funzione cambia lo stato più volte - ad esempio, modifichiamo più campi in più righe di codice - gli osservatori vengono avvisati solo al suo completamento. Ciò è particolarmente importante per il frontend, dove aggiornamenti di stato non necessari portano a rendering non necessari degli elementi. Nel nostro caso né il primo né il secondo sono particolarmente rilevanti, ma seguiremo le migliori pratiche. È consuetudine associare decoratori a tutte le funzioni che modificano lo stato dei campi osservati.

In background aggiungeremo l'inizializzazione e il salvataggio dello stato in 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)
        }
    }
}

La funzione di reazione è interessante qui. Ha due argomenti:

  1. Selettore dati.
  2. Un gestore che verrà chiamato con questi dati ogni volta che cambiano.

A differenza di redux, dove riceviamo esplicitamente lo stato come argomento, mobx ricorda a quali osservabili accediamo all'interno del selettore e chiama il gestore solo quando cambiano.

È importante capire esattamente come mobx decide a quali osservabili ci iscriviamo. Se scrivessi un selettore in codice come questo() => app.store, la reazione non verrà mai chiamata, poiché la memoria stessa non è osservabile, lo sono solo i suoi campi.

Se lo scrivessi così () => app.store.keys, anche in questo caso non accadrebbe nulla, poiché quando si aggiungono/rimuovono elementi dell'array, il riferimento ad esso non cambierà.

Mobx funge per la prima volta da selettore e tiene traccia solo degli osservabili a cui abbiamo avuto accesso. Questo viene fatto tramite getter proxy. Pertanto, qui viene utilizzata la funzione integrata toJS. Restituisce un nuovo oggetto con tutti i proxy sostituiti con i campi originali. Durante l'esecuzione legge tutti i campi dell'oggetto, quindi vengono attivati ​​i getter.

Nella console popup aggiungeremo nuovamente diversi tasti. Anche questa volta sono finiti in localStorage:

Scrivere un'estensione del browser sicura

Quando la pagina di sfondo viene ricaricata, le informazioni rimangono al loro posto.

È possibile visualizzare tutto il codice dell'applicazione fino a questo punto qui.

Archiviazione sicura delle chiavi private

Conservare le chiavi private in formato testo non crittografato non è sicuro: c'è sempre la possibilità di essere hackerati, di ottenere l'accesso al computer e così via. Pertanto, in localStorage memorizzeremo le chiavi in ​​forma crittografata con password.

Per maggiore sicurezza, aggiungeremo all'applicazione uno stato bloccato, in cui non sarà possibile alcun accesso alle chiavi. Trasferiremo automaticamente l'estensione allo stato bloccato a causa di un timeout.

Mobx ti consente di memorizzare solo un set minimo di dati e il resto viene calcolato automaticamente in base ad esso. Queste sono le cosiddette proprietà calcolate. Possono essere paragonati alle visualizzazioni nei database:

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

Ora memorizziamo solo le chiavi crittografate e la password. Tutto il resto è calcolato. Effettuiamo il trasferimento in uno stato bloccato rimuovendo la password dallo stato. L'API pubblica ora dispone di un metodo per inizializzare l'archiviazione.

Scritto per la crittografia utilità che utilizzano 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)
}

Il browser dispone di un'API inattiva tramite la quale è possibile iscriversi a un evento: modifiche dello stato. Lo Stato, di conseguenza, potrebbe esserlo idle, active и locked. Per inattivo è possibile impostare un timeout e bloccato viene impostato quando il sistema operativo stesso è bloccato. Cambieremo anche il selettore per il salvataggio in 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)
        }
    }
}

Il codice prima di questo passaggio è qui.

Operazioni

Arriviamo quindi alla cosa più importante: creare e firmare transazioni sulla blockchain. Utilizzeremo la blockchain e la libreria WAVES transazioni ondulatorie.

Per prima cosa aggiungiamo allo stato un array di messaggi che devono essere firmati, quindi aggiungiamo i metodi per aggiungere un nuovo messaggio, confermare la firma e rifiutare:

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

    ...
}

Quando riceviamo un nuovo messaggio, gli aggiungiamo i metadati, fallo observable e aggiungere a store.messages.

Se non lo fai observable manualmente, quindi mobx lo farà da solo quando aggiungerà messaggi all'array. Tuttavia creerà un nuovo oggetto a cui non avremo un riferimento, ma ci servirà per il passaggio successivo.

Successivamente, restituiamo una promessa che si risolve quando lo stato del messaggio cambia. Lo stato è monitorato dalla reazione, che si “ucciderà” quando lo stato cambia.

Codice del metodo approve и reject molto semplice: dobbiamo semplicemente modificare lo stato del messaggio, dopo averlo firmato se necessario.

Inseriamo Approva e Rifiuta nell'API dell'interfaccia utente, newMessage nell'API della pagina:

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

    ...
}

Ora proviamo a firmare la transazione con l'estensione:

Scrivere un'estensione del browser sicura

In generale, tutto è pronto, non resta che aggiungi un'interfaccia utente semplice.

UI

L'interfaccia deve accedere allo stato dell'applicazione. Dal lato dell'interfaccia utente lo faremo observable state e aggiungi una funzione all'API che cambierà questo stato. Aggiungiamo observable all'oggetto API ricevuto dallo sfondo:

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

Alla fine iniziamo il rendering dell'interfaccia dell'applicazione. Questa è un'applicazione di reazione. L'oggetto di sfondo viene semplicemente passato utilizzando gli oggetti di scena. Sarebbe corretto, ovviamente, creare un servizio separato per i metodi e un negozio per lo Stato, ma ai fini di questo articolo questo è sufficiente:

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

Con mobx è molto semplice avviare il rendering quando i dati cambiano. Appendiamo semplicemente il decoratore dell'osservatore alla confezione mobx-reagisci sul componente e render verrà chiamato automaticamente quando qualsiasi osservabile a cui fa riferimento il componente cambia. Non hai bisogno di mapStateToProps o di connetterti come in Redux. Tutto funziona immediatamente:

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

I restanti componenti possono essere visualizzati nel codice nella cartella dell'interfaccia utente.

Ora nella classe dell'applicazione è necessario creare un selettore di stato per l'interfaccia utente e notificare all'interfaccia utente quando cambia. Per fare ciò, aggiungiamo un metodo getState и reactionchiamando 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())

        })
    }

    ...
}

Quando si riceve un oggetto remote è stato creato reaction per modificare lo stato che chiama la funzione sul lato dell'interfaccia utente.

Il tocco finale è aggiungere la visualizzazione dei nuovi messaggi sull'icona dell'estensione:

function setupApp() {
...

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

...
}

Quindi, l'applicazione è pronta. Le pagine Web possono richiedere una firma per le transazioni:

Scrivere un'estensione del browser sicura

Scrivere un'estensione del browser sicura

Il codice è disponibile qui collegamento.

conclusione

Se hai letto l'articolo fino alla fine, ma hai ancora domande, puoi farle a repository con estensione. Lì troverai anche i commit per ogni passaggio designato.

E se sei interessato a guardare il codice dell'estensione vera e propria, puoi trovare questo qui.

Codice, repository e descrizione del lavoro da siemarel

Fonte: habr.com

Aggiungi un commento