Scriviamo un'estensione del browser sicura

Scriviamo un'estensione del browser sicura

A differenza dell'architettura 'client-server' comune, per le applicazioni decentralizzate è caratteristico:

  • L'assenza della necessità di memorizzare un database con login e password degli utenti. Le informazioni di accesso sono conservate esclusivamente dagli utenti stessi, mentre la verifica della loro autenticità avviene a livello di protocollo.
  • Non è necessario utilizzare un server. La logica dell'applicazione può essere eseguita nella rete blockchain, dove è possibile anche memorizzare la quantità necessaria di dati.

Esistono 2 depositi relativamente sicuri per le chiavi degli utenti: portafogli hardware ed estensioni del browser. I portafogli hardware sono perlopiù estremamente sicuri, ma complessi da utilizzare e non sempre gratuiti; le estensioni del browser, invece, rappresentano un perfetto equilibrio tra sicurezza e facilità d'uso, e possono anche essere completamente gratuite per gli utenti finali.

Tenendo conto di tutto ciò, abbiamo voluto creare un'estensione il più sicura possibile, che semplifica lo sviluppo di applicazioni decentralizzate, offrendo un'API semplice per gestire transazioni e firme.
Di questa esperienza parleremo di seguito.

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

Breve storia delle estensioni per browser

Le estensioni per browser esistono da abbastanza tempo. In Internet Explorer sono state introdotte già nel 1999, mentre in Firefox nel 2004. Tuttavia, per molto tempo non c'è stato uno standard unico per le estensioni.

Si può dire che sia emerso insieme alle estensioni nella quarta versione di Google Chrome. Certo, all'epoca non c'era alcuna specifica, ma proprio l'API di Chrome ne è diventata la base: conquistando la maggior parte del mercato dei browser e avendo un negozio di applicazioni integrato, Chrome ha di fatto stabilito lo standard per le estensioni per browser.

Mozilla aveva il proprio standard, ma, vedendo la popolarità delle estensioni per 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.

È stata presa come base l'API esistente delle estensioni per Chrome. Il lavoro è stato svolto con il supporto di Microsoft (Google ha rifiutato di partecipare allo sviluppo dello standard), e da questo è emerso un progetto iniziale. specifiche.

Formalmente, la specifica è supportata da Edge, Firefox e Opera (notate che Chrome non è presente in questo elenco). Ma in realtà, lo standard è in gran parte compatibile anche con Chrome, poiché è stato essenzialmente scritto basandosi sulle sue estensioni. Maggiori informazioni sull'API WebExtensions possono essere lette. qui.

Struttura dell'estensione

L'unico file indispensabile per l'estensione è il manifesto (manifest.json). Esso rappresenta anche il "punto d'ingresso" nell'estensione.

Manifesto

Secondo le specifiche, il file manifesto deve essere un file JSON valido. È possibile consultare la descrizione completa delle chiavi del manifesto con informazioni su quali chiavi sono supportate in quale browser. qui.

Le chiavi non presenti nella specifica "possono" essere ignorate (sia Chrome che Firefox segnalano errori, ma le estensioni continuano a funzionare).

Vorrei richiamare l'attenzione su alcuni punti.

  1. background — un oggetto che include i seguenti campi:
    1. scripts — un array di script che verranno eseguiti nel contesto di background (ne parleremo più tardi);
    2. page — invece di script che verranno eseguiti in una pagina vuota, è possibile specificare html con contenuto. In questo caso, il campo script sarà ignorato e gli script dovranno essere inseriti nella pagina con contenuto;
    3. persistent — un flag binario, se non specificato, il browser "terminerà" il processo background quando riterrà che non stia facendo nulla e lo riavvierà se necessario. Altrimenti, la pagina verrà scaricata solo alla chiusura del browser. Non supportato da Firefox.
  2. content_scripts — un array di oggetti che consente di caricare diversi script per diverse pagine web. Ogni oggetto contiene i seguenti campi importanti:
    1. matchespattern url, che determina se un determinato content script sarà incluso o meno.
    2. js — un elenco di script che saranno caricati in questo match;
    3. exclude_matches — esclude dal campo corrispondenza URL che soddisfano questo campo.
  3. azione_page — è effettivamente un oggetto responsabile dell'icona visualizzata accanto alla barra degli indirizzi nel browser e delle interazioni con essa. Permette anche di mostrare una finestra popup, personalizzabile tramite HTML, CSS e JS.
    1. default_popup — percorso al file HTML dell'interfaccia popup, può contenere CSS e JS.
  4. autorizzazioni — array per gestire i diritti dell'estensione. Ci sono 3 tipi di diritti, descritti in dettaglio qui
  5. risorse_accessibili_in_web — risorse dell'estensione che possono essere richieste da una pagina web, come immagini, file JS, CSS, HTML.
  6. collegabile_esternamente — qui è possibile specificare esplicitamente gli ID di altre estensioni e i domini delle pagine web a cui è possibile collegarsi. Il dominio può essere di secondo livello o superiore. Non funziona in Firefox.

Contesto di esecuzione

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

Contesto dell'estensione

Qui è disponibile la maggior parte delle API. In questo contesto 'vivono':

  1. Pagina di background — parte 'backend' dell'estensione. Il file è specificato nel manifesto con la chiave 'background'.
  2. Pagina popup — pagina popup che appare quando si fa clic sull'icona dell'estensione. Nel manifesto browser_action -> default_popup.
  3. Pagina personalizzata — pagina dell'estensione, 'vissuta' in una scheda separata di tipo chrome-extension:///customPage.html.

Questo contesto esiste indipendentemente dalle finestre e dalle schede del browser. Pagina di background esiste in un'unica copia e funziona sempre (eccezione — event page, quando lo script di background viene avviato da un evento e 'muore' dopo l'esecuzione). Pagina popup esiste quando è aperta una finestra popup, e Pagina personalizzata — finché è aperta la scheda con essa. Non c'è accesso ad altre schede e al loro contenuto da questo contesto.

Content script context

Il file di script di contenuto viene eseguito con ogni scheda del browser. Ha accesso a una parte dell'API dell'estensione e al DOM della pagina web. Sono proprio gli script di contenuto a occuparsi dell'interazione con la pagina. Le estensioni che manipolano il DOM lo fanno negli script di contenuto, ad esempio, i bloccatori di pubblicità o i traduttori. Inoltre, lo script di contenuto può comunicare con la pagina attraverso il standard postMessage.

Web page context

Questa è la pagina web vera e propria. Non ha nulla a che fare con l'estensione e non ha accesso, a meno che il dominio di questa pagina non sia esplicitamente indicato nel manifesto (di questo si parlerà più avanti).

Scambio di messaggi

Diverse parti dell'applicazione devono scambiarsi messaggi tra di loro. A tal fine esiste un'API runtime.sendMessage per inviare un messaggio background e tabs.sendMessage per inviare un messaggio alla pagina (allo script di contenuto, al popup o alla pagina web se presente collegabile_esternamente). Di seguito un esempio di utilizzo dell'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, nel quale, finché è aperto, è possibile inviare un numero qualsiasi di messaggi. Sul lato client, ad esempio, contentscript, si presenta così:

// Опять же 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 background:

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

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

C'è anche l'evento onDisconnect e il metodo disconnect.

Schema dell'applicazione

Creiamo un'estensione per il browser che memorizza chiavi private, fornisce accesso a informazioni pubbliche (indirizzo, chiave pubblica) comunicando con la pagina e consente ad applicazioni di terze parti di richiedere la firma delle transazioni.

Sviluppo dell'applicazione

La nostra applicazione deve sia interagire con l'utente che fornire una API per invocare metodi (ad esempio, per la firma delle transazioni). Non possiamo limitarci a contentscript questo, poiché ha accesso solo al DOM, ma non al JS della pagina. Non possiamo collegarci tramite runtime.connect perché l'API è necessaria su tutti i domini, mentre nel manifesto si possono specificare solo quelli specifici. Di conseguenza, la struttura sarà la seguente:

Scriviamo un'estensione del browser sicura

Ci sarà un ulteriore script — inpage, che inietteremo nella pagina. Questo verrà eseguito nel suo contesto e fornirà un'API per lavorare con l'estensione.

Inizio

Tutto il codice dell'estensione del browser è disponibile su GitHub. Durante la descrizione ci saranno collegamenti ai commit.

Iniziamo con il manifesto:

{
  // Nome e descrizione, versione. Tutto questo sarà visibile nel browser in chrome://extensions/?id=
  "name": "Signer",
  "description": "Demo dell'estensione",
  "version": "0.0.1",
  "manifest_version": 2,

  // Script che verranno eseguiti in background, possono essercene più di uno
  "background": {
    "scripts": ["background.js"]
  },

  // Quale html usare per il popup
  "browser_action": {
    "default_title": "La mia estensione",
    "default_popup": "popup.html"
  },

  // Script di contenuto.
  // Abbiamo un oggetto: per tutti gli url che iniziano con http o https eseguiamo
  // il contenscript context con lo script contentscript.js. Eseguire immediatamente al momento del caricamento del documento per tutti i frame
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Accesso consentito a localStorage e idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Qui vengono specificate le risorse a cui la pagina web avrà accesso. Cioè, possono essere richieste tramite fetch o semplicemente xhr
  "web_accessible_resources": ["inpage.js"]
}

Creiamo file vuoti per background.js, popup.js, inpage.js e contentscript.js. Aggiungiamo popup.html — e la nostra applicazione può già essere caricata in Google Chrome per verificare che funzioni.

Per esserne certi, è possibile prendere il codice da qui. Inoltre, oltre a quanto abbiamo fatto, è stata configurata una build del progetto tramite webpack. Per aggiungere l'applicazione al browser, in chrome://extensions è necessario selezionare load unpacked e la cartella con l'estensione corrispondente — nel nostro caso dist.

Scriviamo un'estensione del browser sicura

Ora la nostra estensione è installata e funzionante. Gli strumenti per sviluppatori possono essere avviati per diversi contesti nel seguente modo:

popup ->

Scriviamo un'estensione del browser sicura

L'accesso alla console dello script di contenuto avviene attraverso la console della pagina stessa su cui è in esecuzione.Scriviamo un'estensione del browser sicura

Scambio di messaggi

Quindi, dobbiamo stabilire due canali di comunicazione: inpage background e popup background. Certo, si possono semplicemente inviare messaggi al porto e inventare un proprio protocollo, ma a me piace di più l'approccio che ho osservato nel progetto open source metamask.

Questa è un'estensione del browser per lavorare con la rete Ethereum. In essa, le diverse parti dell'applicazione comunicano tramite RPC utilizzando la libreria dnode. Questa consente di organizzare lo scambio in modo abbastanza rapido e conveniente, se le viene fornito come trasporto un flusso nodejs (si intende un oggetto che implementa la stessa interfaccia):

import Dnode from "dnode/browser";

// In questo esempio, supponiamo che il cliente chiami funzioni sul server in remoto, anche se non c'è nulla che ci impedisca di farlo bidirezionale

// Server
// API che vogliamo fornire
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Trasporto su cui funzionerà dnode. Qualsiasi stream nodejs. Nel browser esiste una libreria 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Cliente
const dnodeClient = Dnode() // La chiamata senza argomento significa che non stiamo fornendo API dall'altro lato

// Mosterà in console world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Ora creiamo una classe applicativa. Essa creerà oggetti API per il popup e la pagina web, oltre a creare dnode per essi:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Restituisce l'oggetto API per l'interfaccia popup
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Restituisce l'oggetto API per la pagina
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Collega l'interfaccia popup
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Collega la pagina
    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)
        })
    }
}

Da questo punto in poi, invece dell'oggetto globale Chrome, utilizziamo extentionApi, che fa riferimento a Chrome nel browser di Google e a browser in altri. Questo viene fatto per la compatibilità tra i browser, ma per il contesto di questo articolo si potrebbe semplicemente utilizzare 'chrome.runtime.connect'.

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

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

const app = new SignerApp();

// onConnect viene attivato quando si collegano 'processi' (contentscript, popup o pagina dell'estensione)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Quando la connessione è stabilita, è possibile specificare un nome, che usiamo per identificare chi si è collegato, il contentscript o l'interfaccia utente
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Poiché dnode lavora con flussi e noi riceviamo una porta, è necessario un classe-adattatore. È stato realizzato utilizzando la libreria readable-stream, che implementa 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(){
    // Come nella classe dell'app, creiamo un porto, lo avvolgiamo nello stream, facciamo 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)
        })
    });

    // Rendi disponibile l'oggetto API dalla console
    if (DEV_MODE){
        global.background = background;
    }
}

Poi creiamo una connessione nello script di 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 {
        // inietta lo script nella pagina
        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('Iniezione fallita.', e);
    }
}

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

  1. Creiamo due stream. Uno — verso la pagina, sopra postMessage. Per questo utilizziamo questo pacchetto degli sviluppatori di metamask. Il secondo stream — verso il background, sopra la porta ricevuta da runtime.connect. Colleghiamoli. Ora la pagina avrà uno stream verso il background.
  2. Iniettiamo uno script nel DOM. Scarichiamo lo script (l'accesso è stato autorizzato nel manifesto) e creiamo 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(){
    // Stream al background
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Stream alla pagina
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // inietta lo script nella pagina
        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 l'oggetto api in inpage e lo rendiamo globale:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream per lo script contenuto
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

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

    // Accesso tramite window
    global.SignerApp = pageApi;
}

Abbiamo finito Remote Procedure Call (RPC) con API separate per pagina e UI. Collegando una nuova pagina al background possiamo vederlo:

Scriviamo un'estensione del browser sicura

API e origin vuoti. Dalla parte della pagina possiamo chiamare la funzione hello in questo modo:

Scriviamo un'estensione del browser sicura

Lavorare con le callback in JS moderno è obsoleto, quindi scriviamo un piccolo helper per creare dnode, che consente di passare all'oggetto API in utils.

Gli oggetti API ora appariranno in questo modo:

export class SignerApp {

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

...

}

Ottenere l'oggetto da remote nel seguente modo:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Utilizzando le utility cambiamo tutte le callback in promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

E la chiamata delle funzioni restituisce una promessa:

Scriviamo un'estensione del browser sicura

La versione con funzioni asincrone è disponibile qui.

In generale, l'approccio con RPC e stream sembra abbastanza flessibile: possiamo usare multiplexing di stream e creare diverse API per compiti diversi. Fondamentalmente, dnode può essere utilizzato ovunque, l'importante è incapsulare il trasporto come stream di nodejs.

Un'alternativa è il formato JSON, che implementa il protocollo JSON RPC 2. Tuttavia, funziona con trasporti specifici (TCP e HTTP(S)), che nel nostro caso non sono applicabili.

Stato interno e localStorage

Dovremo memorizzare lo stato interno dell'applicazione — almeno le chiavi per la firma. Possiamo facilmente aggiungere lo stato all'app e i metodi per modificarlo nell'API del 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)
        }
    }

    ...

} 

Nel background, incapsuleremo tutto in una funzione e registreremo l'oggetto dell'app nel window, per poterlo utilizzare 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 UI e vediamo cosa è successo con lo stato:

Scriviamo un'estensione del browser sicura

Lo stato deve essere reso persistente affinché le chiavi non vengano perse al riavvio.

Conserveremo in localStorage, sovrascrivendo ad ogni modifica. In seguito, sarà necessario accedervi anche per l'interfaccia utente e vogliamo anche iscriverci alle modifiche. Basandoci su questo, sarà utile creare uno storage osservabile e iscriversi alle sue modifiche.

Useremo la libreria mobx (https://github.com/mobxjs/mobx). La scelta è ricaduta su di essa poiché non abbiamo mai lavorato con essa, ma ci piacerebbe studiarla.

Aggiungeremo l'inizializzazione dello stato iniziale e renderemo lo store osservabile:

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

export class SignerApp {

    constructor(initState = {}) {
        // Esternamente lo store rimarrà lo stesso oggetto, solo che ora tutti i suoi campi sono diventati proxy, che tracciano l'accesso a essi
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // I metodi che modificano un observable dovrebbero essere racchiusi in un decoratore
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

«Sotto la superficie», mobx ha sostituito tutti i campi dello store con proxy e intercetta tutte le chiamate a essi. Si potrà iscrivere a queste chiamate.

In seguito, utilizzerò spesso il termine "quando si modifica", anche se non è del tutto corretto. Mobx tiene traccia dell'accesso ai campi. Vengono utilizzati getter e setter degli oggetti proxy creati dalla libreria.

I decoratori action servono a due scopi:

  1. In modalità rigorosa con il flag enforceActions, mobx vieta di modificare direttamente lo stato. È considerato buona norma lavorare proprio in modalità rigorosa.
  2. Anche se una funzione modifica lo stato più volte – ad esempio, se cambiamo diversi campi in più righe di codice – gli osservatori vengono avvisati solo al termine della funzione. Questo è particolarmente importante per il frontend, dove aggiornamenti di stato superflui portano a rendering non necessari degli elementi. Nel nostro caso, né il primo né il secondo sono particolarmente rilevanti, ma seguiremo le migliori pratiche. I decoratori dovrebbero essere applicati a tutte le funzioni che modificano lo stato dei campi osservabili.

Nel 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";
// Metodi ausiliari. Scrivono/leggono oggetti in/da localStorage in forma di stringa JSON con la chiave '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;
    }

    // Imposta la persistenza dello stato

    // Il risultato della reaction è assegnato a una variabile per poter annullare l'iscrizione. Non ci serve, lasciato come esempio
    const localStorageReaction = reaction(
        () => toJS(app.store), // Funzione selettore dei dati
        saveState // Funzione che verrà chiamata quando i dati restituiti dal selettore cambiano
    );

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

Qui ci interessa la funzione reaction. Ha due argomenti:

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

A differenza di redux, dove otteniamo esplicitamente lo stato come argomento, mobx memorizza a quali observable accediamo all'interno del selettore e solo quando questi cambiano attiva il gestore.

È importante comprendere come mobx determina a quali observable ci iscriviamo. Se nel codice scrivessi il selettore in questo modo() => app.store, la reaction non verrebbe mai chiamata, poiché di per sé il negozio non è osservabile, lo sono solo i suoi campi.

Se lo scrivessi così () => app.store.keys, di nuovo non succederebbe nulla, poiché aggiungendo/rimuovendo elementi dall'array il riferimento ad esso non cambierà.

Mobx esegue la funzione del selettore la prima volta e tiene traccia solo di quegli observable a cui abbiamo avuto accesso. Questo avviene tramite i getter proxy. Perciò qui è stata utilizzata la funzione incorporata toJS. Essa restituisce un nuovo oggetto, in cui tutti i proxy sono sostituiti da campi originali. Durante l'esecuzione legge tutti i campi dell'oggetto, quindi vengono attivati i getter.

Nella console popup aggiungiamo di nuovo alcune chiavi. Questa volta sono finite anche in localStorage:

Scriviamo un'estensione del browser sicura

Al ricaricamento della pagina di background, l'informazione rimane al suo posto.

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

Archiviazione sicura delle chiavi private

Conservare le chiavi private in chiaro non è sicuro: c'è sempre la possibilità di essere violati, di avere accesso al proprio computer e così via. Pertanto, in localStorage conserveremo le chiavi in forma crittografata con una password.

Per una maggiore sicurezza, aggiungeremo allo stato dell'applicazione locked, in cui non ci sarà accesso alle chiavi. Tradurremo automaticamente l'estensione nello stato locked dopo un timeout.

Mobx consente di memorizzare solo il set minimo di dati, e il resto viene calcolato automaticamente sulla base di questi. Queste sono le cosiddette proprietà calcolate. Possono essere paragonate alle view nei database:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Utilità per la crittografia sicura delle stringhe. Utilizza crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Memorizziamo la password e le chiavi crittografate. Se la password è null - l'applicazione è bloccata
            password: null,
            vault: initState.vault,

            // Getter per i campi calcolabili. Può essere paragonato a una vista nel db.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Inizializzazione di un archivio vuoto con una nuova password
    @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
        )
    }

    ... // codice di connessione e api

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

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

    // Metodi per crittografare/decrittografare l'archivio
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault non inizializzato')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Password errata')
        }
    }
}

Ora memorizziamo solo chiavi e password crittografate. Tutto il resto viene calcolato. Effe- tuiamo la transizione nello stato locked rimuovendo la password dallo stato. È stato introdotto un metodo nell'API pubblica per inizializzare il storage.

Per la crittografia sono state scritte utility utilizzando crypto-js:

import CryptoJS from 'crypto-js'

// Utilizzato per complicare l'indovinare la password attraverso tentativi. Ogni possibile password richiede all'attaccante di calcolare 5000 hash.
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 ha un idle API, tramite il quale è possibile iscriversi a un evento: cambiamenti di stato. Lo stato, di conseguenza, può essere idle, active e locked. Per idle è possibile impostare un timeout, mentre locked si attiva quando il sistema operativo stesso viene bloccato. Modificheremo 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;
    }

    // Ora chiamiamo esplicitamente il campo accessibile, la reazione funzionerà correttamente
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Timeout di inattività che attiverà l'evento
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Se l'utente ha bloccato lo schermo o è inattivo per l'intervallo specificato, blocchiamo l'applicazione
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connetti ad altri contesti
    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 fino a questo passo si trova qui.

Transazioni

Quindi, siamo arrivati al punto principale: la creazione e la firma delle transazioni nella blockchain. Useremo la blockchain WAVES e la libreria waves-transactions.

Per prima cosa, aggiungiamo nello stato un array di messaggi da firmare, successivamente — i metodi per aggiungere un nuovo messaggio, confermare la firma e rifiutarla:

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) {
        // Creiamo metadati per ciascun messaggio con id, stato, tempo di creazione, ecc.
        const message = observable.object({
            id: uuid(), // Identificatore, usando uuid
            origin, // L'origine sarà mostrata in seguito nell'interfaccia
            data, //
            status: 'new', // Gli stati saranno quattro: new, signed, rejected e failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Restituiamo una promessa in cui mobx monitora i cambiamenti del messaggio. Non appena lo stato cambia, lo risolveremo
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Moniteremo lo stato del messaggio
                (status, reaction) => { // il secondo argomento è un riferimento alla reazione stessa, da distruggere all'interno della chiamata
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('L'utente ha rifiutato il messaggio'));
                            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(`Nessun messaggio con 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(`Nessun messaggio con id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Quando riceviamo un nuovo messaggio, aggiungiamo i metadati e lo rendiamo osservabile e lo aggiungiamo a store.messages.

Se non lo facciamo osservabile manualmente, mobx lo farà automaticamente all'aggiunta all'array messages. Tuttavia, creerà un nuovo oggetto al quale non avremo riferimento, e ci servirà per il passo successivo.

Successivamente restituiamo una promessa, che si risolve al cambiamento dello stato del messaggio. Lo stato è monitorato da un reaction, che si "eliminerà" automaticamente al cambio di stato.

Il codice dei metodi approvare e rifiutare è molto semplice: cambiamo semplicemente lo stato del messaggio, firmandolo se necessario.

Approve e reject li portiamo nell'API UI, 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 una transazione con l'estensione:

Scriviamo un'estensione del browser sicura

In generale, è tutto pronto, resta solo aggiungere un'interfaccia utente semplice.

UI

L'interfaccia ha bisogno di accesso allo stato dell'applicazione. Sul lato UI, lo faremo osservabile creando uno stato e aggiungendo una funzione API che modificherà questo stato. Aggiungeremo osservabile all'oggetto API, ottenuto dal background:

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

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

setupUi().catch(console.error);

async function setupUi() {
    // Connessione al porto, creiamo uno stream da esso
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Creiamo un observable vuoto per lo stato del background
    let backgroundState = observable.object({});
    const api = {
        // Rilasciamo al background una funzione che aggiornerà l'observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Aggiungiamo al background l'observable con lo stato
    background.state = backgroundState;

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

    // Avvio dell'interfaccia
    await initApp(background)
}

Alla fine, lanciamo il rendering dell'interfaccia dell'applicazione. Si tratta di un'applicazione React. L'oggetto di background viene semplicemente passato tramite i props. Certo, sarebbe corretto creare un servizio separato per i metodi e uno store per lo stato, ma per il contesto di questo articolo va bene così:

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

// Inizializziamo l'applicazione con l'oggetto di background come props
export async function initApp(background){
    render(
        ,
        document.getElementById('app-content')
    );
}

Con MobX, è molto semplice avviare il rendering quando i dati cambiano. Basta applicare il decoratore observer dal pacchetto mobx-react al componente, e il rendering verrà automaticamente attivato quando cambiano qualsiasi observable a cui fa riferimento il componente. Non è necessario alcun mapStateToProps o connect, come in Redux. Funziona tutto subito ‘out of the box’:

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 // Questo componente con questo decoratore avrà automaticamente il metodo render chiamato se ci saranno modifiche agli observable a cui fa riferimento
export default class App extends Component {

    // È importante separare la logica del rendering delle pagine nel routing e non usare operatori ternari annidati,
    // e collegare gli observable e i metodi di background direttamente ai componenti che li utilizzano
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <fragment>
            {!initialized
                ?
                <init oninit="{initVault}/">
                :
                locked
                    ?
                    <unlock onunlock="{unlock}/">
                    :
                    messages.length &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!locked &amp;&amp; <button onclick="{()" > lock()}&gt;Blocca App</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Elimina tutte le chiavi e inizia</button>}
            </div>
        </Fragment>
    }
}

Puoi vedere gli altri componenti nel codice nella cartella UI.

Ora, nella classe dell'applicazione, è necessario fare un selettore dello stato per l'UI e notificare l'UI quando cambia. Aggiungiamo quindi il metodo getState e reaction, che chiama 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 {

    ...

    // pubblico
    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) => {
            // Creiamo una reazione ai cambiamenti dello stato, che chiamerà la procedura remota e aggiornerà lo stato nel processo UI
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Il terzo argomento può passare parametri. fireImmediatly significa che la reazione verrà eseguita subito la prima volta.
                // Questo è necessario per ottenere lo stato iniziale. Delay consente di impostare il debounce
                {fireImmediately: true, delay: 500}
            );
            // Rimuoviamo l'abbonamento alla disconnessione del client
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Quando si riceve l'oggetto remoto viene creato reaction per monitorare i cambiamenti di stato, che richiama una funzione lato UI.

Ultimo ritocco: aggiungiamo la visualizzazione di nuovi messaggi sull'icona dell'estensione:

function setupApp() {
...

    // Reazione all'impostazione del testo del badge.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Quindi, l'app è pronta. Le pagine web possono richiedere la firma delle transazioni:

Scriviamo un'estensione del browser sicura

Scriviamo un'estensione del browser sicura

Il codice è disponibile in questo link.

Conclusione

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

E se sei interessato a vedere il codice di un'autentica estensione, potrai trovarlo qui qui.

Codice, repository e descrizione del funzionamento di siemarell

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster