Scriviamo un'estensione del browser sicura

Scriviamo un'estensione del browser sicura

A differenza della comune architettura "client-server", le applicazioni decentralizzate si caratterizzano per:

  • L'assenza della necessità di conservare un database con i nomi utente e le password. Le informazioni per l'accesso sono conservate esclusivamente dagli utenti stessi, mentre la loro autenticità viene confermata a livello del protocollo.
  • L'assenza della necessità di utilizzare un server. La logica dell'applicazione può essere eseguita nella rete blockchain, dove è possibile anche memorizzare la quantità necessaria di dati.

Esistono 2 archiviazioni relativamente sicure per le chiavi degli utenti: i portafogli hardware e le estensioni per browser. I portafogli hardware sono per la maggior parte estremamente sicuri, tuttavia complessi da utilizzare e lontani dall'essere gratuiti, mentre le estensioni per browser rappresentano un'ideale combinazione di sicurezza e facilità d'uso, e possono anche essere completamente gratuite per gli utenti finali.

Tenendo conto di tutto ciò, abbiamo desiderato creare un'estensione massimamente sicura che semplifichi lo sviluppo di applicazioni decentralizzate, fornendo un'API semplice per lavorare con le transazioni e le firme.
Di questa esperienza vi parleremo qui di seguito.

Nell'articolo ci sarà una guida passo-passo su come scrivere un'estensione per browser, con esempi di codice e schermate. Tutto il codice può essere trovato 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 apparse nel 1999, in Firefox nel 2004. Tuttavia, a lungo non è esistito uno standard unico per le estensioni.

Si può dire che esso è nato insieme alle estensioni nella quarta versione di Google Chrome. Certamente, allora non esisteva alcuna specifica, ma proprio l'API di Chrome ne è diventata la base: conquistando gran 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, la compagnia decise 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.

È stato preso come base un API esistente per le estensioni di Chrome. Il lavoro è stato svolto con il supporto di Microsoft (Google ha rifiutato di partecipare allo sviluppo dello standard) e come risultato è stata creata una bozza. specifica.

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

Struttura dell'estensione

L'unico file necessario per l'estensione è il manifesto (manifest.json). Questo file è anche il "punto di ingresso" dell'estensione.

Manifesto

Secondo la specifica, il file del manifesto è un file JSON valido. Una descrizione completa delle chiavi del manifesto con informazioni su quali chiavi sono supportate in quale browser può essere consultata. qui.

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

Vorrei sottolineare 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 tra poco);
    2. page anziché gli script che vengono 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 il contenuto;
    3. persistent un flag binario; se non specificato, il browser "terminerà" il processo di background quando penserà che non stia facendo nulla e lo riavvierà se necessario. Altrimenti, la pagina verrà scaricata solo alla chiusura del browser. Non è supportato in Firefox.
  2. content_scripts è un array di oggetti che permette di caricare diversi script in 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 verranno caricati in questa corrispondenza;
    3. exclude_matches esclude dallo campo match URL che soddisfano questo campo.
  3. page_action è fondamentalmente un oggetto che gestisce l'icona visualizzata accanto alla barra degli indirizzi del browser e l'interazione con essa. Permette anche di mostrare una popup che viene definita tramite il proprio HTML, CSS e JS.
    1. default_popup è il percorso verso il file HTML con l'interfaccia popup, può contenere CSS e JS.
  4. permissions — un insieme per gestire i diritti dell'estensione. Ci sono 3 tipi di diritti, che sono descritti in dettaglio qui
  5. web_accessible_resources — risorse dell'estensione che possono essere richieste da una pagina web, ad esempio immagini, file JS, CSS, HTML.
  6. externally_connectable — qui è possibile specificare esplicitamente gli ID di altre estensioni e i domini delle pagine web a cui ci si può connettere. Il dominio può essere di secondo livello o superiore. Non funziona in 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 dell'estensione

Qui è disponibile gran parte delle API. In questo contesto “vivono”:

  1. Pagina di sfondo — la parte “backend” dell'estensione. Il file è specificato nel manifesto con la chiave “background”.
  2. Pagina popup — la 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 sfondo esiste in un'unica copia e funziona sempre (eccezione — pagina evento, quando lo script di background si avvia in base a un evento e “muore” dopo la sua esecuzione). Pagina popup esiste quando è aperta una finestra popup, e Pagina personalizzata — finché è aperta una scheda con essa. Non c'è accesso ad altre schede e ai loro contenuti da questo contesto.

Contesto dello script di contenuto

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

Contesto della pagina web

Questa è in effetti la pagina web stessa. Non ha alcuna relazione con l'estensione e non ha accesso ad essa, tranne nei casi in cui nel manifesto non è esplicitamente specificato il dominio di questa pagina (di questo — più avanti).

Scambio di messaggi

Diverse parti dell'applicazione devono scambiarsi messaggi tra loro. A tal fine, esiste l'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 externally_connectable). Di seguito 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, nel quale, finché è aperto, è possibile inviare un numero qualsiasi di messaggi. Dalla parte del client, per esempio, contentscript, appare 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 un evento onDisconnect e il metodo disconnessione.

Schema dell'app

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

Sviluppo dell'app

La nostra applicazione deve sia interagire con l'utente, sia fornire allapagina un'API per chiamare metodi (ad esempio, per firmare transazioni). Non possiamo fare solo con contentscript perché ha accesso solo al DOM, ma non al JS della pagina. Non possiamo connetterci tramite runtime.connect perché l'API è necessaria su tutti i domini, mentre nel manifesto è possibile specificare solo quelli specifici. Di conseguenza, lo schema sarà il seguente:

Scriviamo un'estensione del browser sicura

Ci sarà un altro script — inpage, che inietteremo nella pagina. 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. Nella descrizione saranno forniti 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, ce ne possono essere più di uno
  "background": {
    "scripts": ["background.js"]
  },

  // Quale html utilizzare 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. Eseguiamo subito dopo aver ricevuto il 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 avrà accesso la pagina web. Quindi sarà possibile richiederle tramite fetch o semplicemente xhr
  "web_accessible_resources": ["inpage.js"]
}

Creiamo i file vuoti background.js, popup.js, inpage.js e contentscript.js. Aggiungiamo popup.html e la nostra applicazione è già pronta per essere caricata su Google Chrome per verificarne il funzionamento.

Per essere certi di ciò, possiamo prendere il codice da qui. Oltre a quello che abbiamo fatto, il progetto è configurato tramite il link di build usando webpack. Per aggiungere l'applicazione al browser, in chrome://extensions bisogna 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 funziona. Gli strumenti per sviluppatori possono essere avviati per diversi contesti come segue:

popup ->

Scriviamo un'estensione del browser sicura

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

Scambio di messaggi

Quindi, è necessario stabilire due canali di comunicazione: inpage <-> background e popup <-> background. Si può, ovviamente, semplicemente inviare messaggi al porto e inventare il 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. Essa consente una gestione rapida e comoda dello scambio, se le si fornisce un nodo stream di nodejs:

import Dnode from "dnode/browser";

// In questo esempio conveniamo che il client chiami funzioni sul server in remoto, anche se nulla ci impedisce di rendere questo bidirezionale

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

// Client
const dnodeClient = Dnode() // La chiamata senza argomento significa che non stiamo fornendo un'API dall'altra parte

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

Ora creeremo la classe dell'applicazione. Essa creerà oggetti API per il popup e la pagina web, oltre a creare dnode per loro:

import Dnode from 'dnode/browser';

export class SignerApp {

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

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

    // Collega l'ui del 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)
        })
    }
}

Qui e oltre, invece dell'oggetto globale Chrome, utilizziamo extentionApi, che interagisce con Chrome nel browser di Google e con browser negli altri. Questo viene fatto per garantire la compatibilità tra i vari browser, ma nell'ambito di questo articolo si potrebbe utilizzare semplicemente '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 si attiva durante la connessione di 'processi' (contentscript, popup o pagina dell'estensione)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Durante l'instaurazione della connessione, è possibile specificare un nome; in base a questo nome definiamo chi si è connesso, il contentscript o l'ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url;
        app.connectPage(portStream, origin);
    }else{
        app.connectPopup(portStream);
    }
}

Poiché dnode lavora con gli stream e noi otteniamo un porto, è necessario un classe-adattatore. È stato creato utilizzando la libreria readable-stream, che implementa gli stream di 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'UI:

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

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

setupUi().catch(console.error);

async function setupUi(){
    // Creiamo il port, avvolgiamo in 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)
        })
    });

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

Successivamente, 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 {
        // iniettiamo 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('L'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 creato dai creatori di metamask. Il secondo stream — verso il background sopra il port, ottenuto da runtime.connect. Facciamo il pipe tra loro. Ora la pagina avrà uno stream verso il background.
  2. Iniettiamo uno script nel DOM. Preleviamo 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 verso il background
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

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

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

function injectScript(){
    try {
        // iniettiamo 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('L'iniezione è fallita.', 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 di 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;
}

Siamo pronti Chiamata a procedura remota (RPC) con un'API separata per la pagina e l'interfaccia utente. Quando una nuova pagina si collega al background, possiamo vederlo:

Scriviamo un'estensione del browser sicura

API vuota e origine. Dalla pagina possiamo chiamare la funzione hello in questo modo:

Scriviamo un'estensione del browser sicura

Lavorare con funzioni di callback in JS moderno è superato, quindi scriviamo un piccolo helper per creare dnode, che permette di passare un oggetto API in utils.

Gli oggetti API ora appariranno in questo modo:

export class SignerApp {

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

...

}

Riceviamo 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 tutti i callback in promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

E la chiamata delle funzioni restituisce una promessa:

Scriviamo un'estensione del browser sicura

È disponibile la versione con funzioni asincrone qui.

In generale, l'approccio con RPC e stream sembra piuttosto flessibile: possiamo utilizzare lo stream multiplexing e creare diverse API per diverse attività. In linea di principio, dnode può essere utilizzato ovunque, l'importante è avvolgere il trasporto in forma di stream nodejs.

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

Stato interno e localStorage

Avremo bisogno di memorizzare lo stato interno dell'applicazione, almeno le chiavi per la firma. Possiamo aggiungere abbastanza facilmente lo stato all'app e i 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)
        }
    }

    ...

} 

Nel background, avvolgeremo tutto in una funzione e registreremo l'oggetto dell'applicazione in window, 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 dalla console UI alcune chiavi e vediamo cosa è successo con lo stato:

Scriviamo un'estensione del browser sicura

Lo stato deve essere reso persistente per non perdere le chiavi al riavvio.

Lo conserveremo in localStorage, sovrascrivendolo ad ogni modifica. In seguito, sarà necessario accedervi anche per l'UI e sarà utile iscriversi ai cambiamenti. Da ciò, sarà conveniente creare uno store osservabile (observable storage) e iscriversi alle sue modifiche.

Utilizzeremo la libreria mobx (https://github.com/mobxjs/mobx). È stata scelta perché non avevo mai lavorato con essa e volevo impararne il funzionamento.

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 monitorano l'accesso a essi
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    \/\/ I metodi che modificano gli observable dovrebbero essere avvolti in un decoratore
    @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 dello store con proxy e intercetta tutti gli accessi a essi. Sarà possibile iscriversi a questi accessi.

D'ora in avanti utilizzerò spesso il termine “al cambiamento”, sebbene non sia del tutto corretto. Mobx monitora precisamente l'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 una buona pratica lavorare proprio in modalità rigorosa.
  2. Anche se la funzione modifica lo stato più volte - ad esempio, modifichiamo diversi campi in diverse righe di codice - gli osservatori vengono avvisati solo al termine. Questo è particolarmente importante per il frontend, dove aggiornamenti non necessari dello stato provocano un rendering superfluo degli elementi. Nel nostro caso, né il primo né il secondo sono particolarmente rilevanti, tuttavia seguiremo le migliori pratiche. È consuetudine applicare i decoratori 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 un oggetto in/da localStorage in formato JSON stringa 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 viene assegnato a una variabile, in modo che l'iscrizione possa essere annullata. Non abbiamo bisogno di questo, è lasciato come esempio
    const localStorageReaction = reaction(
        () => toJS(app.store), // Funzione selettore di dati
        saveState // Funzione che sarà chiamata quando i dati restituiti dal selettore vengono modificati
    );

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

Interessante qui è la funzione reaction. Ha due argomenti:

  1. Selettore di dati.
  2. Gestore che sarà chiamato con questi dati ogni volta che vengono modificati.

A differenza di redux, dove otteniamo esplicitamente lo stato come argomento, mobx ricorda quali observable stiamo riferendoci all'interno del selettore e chiama il gestore solo quando questi vengono modificati.

È importante capire come mobx decida quali observable ci stiamo iscrivendo. 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; solo i suoi campi lo sono.

Se scrivessi in questo modo () => app.store.keys, di nuovo non succederebbe nulla, poiché l'aggiunta/rimozione di elementi dall'array non cambierebbe il suo riferimento.

Mobx esegue per la prima volta la funzione di selettore e tiene traccia solo di quelli observable a cui abbiamo avuto accesso. Questo è stato fatto tramite getter proxy. Pertanto, qui è stata utilizzata la funzione integrata toJS. Essa restituisce un nuovo oggetto in cui tutti i proxy sono sostituiti dai campi originali. Durante l'esecuzione, legge tutti i campi dell'oggetto: di conseguenza, si attivano i getter.

Nel popup della console aggiungeremo di nuovo alcune chiavi. Questa volta sono finite anche nel localStorage:

Scriviamo un'estensione del browser sicura

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

Tutto il codice dell'applicazione fino a questo punto può essere visualizzato qui.

Salvataggio sicuro delle chiavi private

Conservare le chiavi private in chiaro non è sicuro: c'è sempre la possibilità che il tuo computer possa essere violato e che si possa accedere ad esse. Pertanto, in localStorage conserveremo le chiavi in forma crittografata con password.

Per maggiore sicurezza, aggiungeremo allo stato dell'applicazione locked, nel quale non sarà affatto possibile accedere alle chiavi. Tradurremo automaticamente l'estensione nello stato locked dopo un timeout.

Mobx consente di conservare solo il minimo insieme di dati, mentre il resto viene calcolato automaticamente sulla base di essi. Questi sono noti come proprietà calcolate. Possono essere paragonati a una 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'app è bloccata
            password: null,
            vault: initState.vault,

            // Getter per i campi calcolabili. Si può fare un'analogia con la 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('L'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('Archivio non inizializzato')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        } catch (e) {
            throw new Error('Password errata')
        }
    }
}

Ora memorizziamo solo chiavi crittografate e password. Tutto il resto viene calcolato. La transizione allo stato bloccato avviene rimuovendo la password dallo stato. È stato aggiunto un metodo per inizializzare l'archivio nell'API pubblica.

Per la crittografia sono state scritte utilità utilizzando crypto-js:

import CryptoJS from 'crypto-js'

// Utilizzato per rendere più difficile la ricerca della password. Per ogni possibile password, un aggressore dovrà fare 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 API idle, tramite la quale è possibile iscriversi a un evento: cambiamento di stato. Lo stato, di conseguenza, può essere idle, active e bloccato. Per l'idle è possibile impostare un timeout, mentre 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;
    }

    // Ora chiamiamo esplicitamente il campo a cui sarà possibile accedere, reaction funzionerà correttamente
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Timeout di inattività, quando scatta l'evento
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Se l'utente blocca lo schermo o non interagisce per l'intervallo di tempo specificato, blocchiamo l'applicazione
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['bloccato', '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 cruciale: la creazione e la firma delle transazioni nel blockchain. Utilizzeremo il blockchain WAVES e la libreria waves-transactions.

Per iniziare, aggiungiamo allo stato un array di messaggi che devono essere firmati, poi 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) {
        // Per ogni messaggio creiamo metadati con id, stato, data di creazione, ecc.
        const message = observable.object({
            id: uuid(), // Identificatore, utilizzo uuid
            origin, // L'origine sarà mostrata successivamente nell'interfaccia
            data, //
            status: 'new', // Gli stati saranno quattro: new, signed, rejected e failed
            timestamp: Date.now()
        });
        console.log(`nuovo messaggio: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Ritorniamo una promessa all'interno della quale mobx monitora le modifiche del messaggio. Non appena lo stato cambia, lo risolveremo
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Osserveremo lo stato del messaggio
                (status, reaction) => { // Il secondo argomento è un riferimento alla stessa reazione, per poterla 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 msg 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 msg con id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Quando riceviamo un nuovo messaggio, aggiungiamo metadati a esso, creando osservabile e aggiungiamo a store.messages.

Se non lo facciamo osservabile manualmente, mobx lo farà automaticamente quando verrà aggiunto all'array dei messaggi. Tuttavia, creerà un nuovo oggetto, al quale non avremo riferimento, e questo sarà necessario per il passaggio successivo.

Successivamente, restituiamo una promessa che viene risolta quando cambia lo stato del messaggio. La reazione monitora lo stato e si "distruggerà" automaticamente al cambiamento dello stato.

Il codice dei metodi approve e rifiuta è molto semplice: cambiamo semplicemente lo stato del messaggio, sottoscrivendolo in anticipo, se necessario.

Le approvazioni e le disapprovazioni vengono gestite nell'interfaccia API UI, newMessage — nella pagina API:

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

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

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

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

    ...
}

Ora proveremo a firmare la transazione con l'estensione:

Scriviamo un'estensione del browser sicura

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

UI

L'interfaccia ha bisogno di accesso allo stato dell'applicazione. Dalla parte UI, faremo osservabile lo stato e aggiungeremo una funzione all'API che cambierà questo stato. Aggiungeremo osservabile all'oggetto API, ricevuto 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() {
    // Ci connettiamo alla porta, creando uno stream
    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 = {
        // Diamo 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 all'oggetto background un observable con lo stato
    background.state = backgroundState;

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

    // Avviamo l'interfaccia
    await initApp(background)
}

Alla fine avviamo il rendering dell'interfaccia dell'applicazione. Questa è un'applicazione React. L'oggetto Background viene semplicemente passato tramite le props. In effetti, sarebbe corretto creare un servizio separato per i metodi e uno store per lo stato, ma all'interno 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 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 al cambiamento di qualsiasi observable a cui si riferisce il componente. Non è necessario alcun mapStateToProps o connect, come in redux. Funziona tutto immediatamente "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 chiamato il metodo render, se gli observable a cui si riferisce cambiano
export default class App extends Component {

    \/\/ È giusto ovviamente esternalizzare la logica di rendering delle pagine nel routing e non utilizzare operatori ternari annidati,
    \/\/ e collegare observable e 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 inizializza</button>}
            </div>
        </Fragment>
    }
}

Gli altri componenti possono essere visualizzati nel codice nella cartella UI.

Ora nella classe dell'applicazione è necessario creare un selettore di stato per l'UI e, al suo cambiamento, notificare l'UI. Per fare ciò, aggiungeremo il metodo getState e reaction, che invocherà 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 reaction per il cambiamento di stato, che chiamerà la procedura remota e aggiornerà lo stato nel processo UI
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Terzo argomento può passare parametri. fireImmediately significa che la reaction 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'iscrizione alla disconnessione del client
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Al ricevimento dell'oggetto remote viene creata reaction per il cambiamento di stato, che richiama una funzione sul lato UI.

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

function setupApp() {
...

    // Reaction per impostare il testo del badge.
    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 la firma delle transazioni:

Scriviamo un'estensione del browser sicura

Scriviamo un'estensione del browser sicura

Il codice è disponibile a questa link.

Conclusione

Se hai letto l'articolo fino alla fine, ma hai ancora domande, puoi porle nel repository dell'estensione. Qui troverai anche i commit per ciascun passaggio indicato.

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

Il codice, il repository e la descrizione del lavoro 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