Escriure una extensió de navegador segura

Escriure una extensió de navegador segura

A diferència de l'arquitectura comuna "client-servidor", les aplicacions descentralitzades es caracteritzen per:

  • No cal emmagatzemar una base de dades amb inicis de sessió i contrasenyes d'usuari. La informació d'accés l'emmagatzemen exclusivament els mateixos usuaris, i la confirmació de la seva autenticitat es produeix a nivell de protocol.
  • No cal utilitzar un servidor. La lògica de l'aplicació es pot executar en una xarxa blockchain, on és possible emmagatzemar la quantitat de dades requerida.

Hi ha 2 emmagatzematges relativament segurs per a les claus d'usuari: carteres de maquinari i extensions de navegador. Les carteres de maquinari són majoritàriament extremadament segures, però difícils d'utilitzar i lluny de ser gratuïtes, però les extensions del navegador són la combinació perfecta de seguretat i facilitat d'ús, i també poden ser completament gratuïtes per als usuaris finals.

Tenint en compte tot això, hem volgut fer l'extensió més segura que simplifiqui el desenvolupament d'aplicacions descentralitzades proporcionant una API senzilla per treballar amb transaccions i signatures.
A continuació us explicarem aquesta experiència.

L'article contindrà instruccions pas a pas sobre com escriure una extensió del navegador, amb exemples de codi i captures de pantalla. Podeu trobar tot el codi a repositoris. Cada commit correspon lògicament a una secció d'aquest article.

Una breu història de les extensions del navegador

Les extensions del navegador existeixen des de fa molt de temps. Van aparèixer a Internet Explorer el 1999, al Firefox el 2004. Tanmateix, durant molt de temps no hi havia un estàndard únic per a les extensions.

Podem dir que va aparèixer juntament amb les extensions a la quarta versió de Google Chrome. Per descomptat, aleshores no hi havia cap especificació, però va ser l'API de Chrome la que es va convertir en la seva base: després d'haver conquerit la major part del mercat dels navegadors i amb una botiga d'aplicacions integrada, Chrome en realitat va establir l'estàndard per a les extensions del navegador.

Mozilla tenia el seu propi estàndard, però veient la popularitat de les extensions de Chrome, l'empresa va decidir fer una API compatible. El 2015, per iniciativa de Mozilla, es va crear un grup especial dins del World Wide Web Consortium (W3C) per treballar en les especificacions d'extensió entre navegadors.

Les extensions de l'API existents per a Chrome es van prendre com a base. El treball es va dur a terme amb el suport de Microsoft (Google es va negar a participar en el desenvolupament de l'estàndard), i com a resultat va aparèixer un esborrany especificacions.

Formalment, l'especificació és compatible amb Edge, Firefox i Opera (tingueu en compte que Chrome no està en aquesta llista). Però, de fet, l'estàndard és en gran part compatible amb Chrome, ja que en realitat està escrit en funció de les seves extensions. Podeu llegir més informació sobre l'API de WebExtensions aquí.

Estructura d'ampliació

L'únic fitxer que es requereix per a l'extensió és el manifest (manifest.json). També és el "punt d'entrada" a l'expansió.

Manifest

Segons l'especificació, el fitxer de manifest és un fitxer JSON vàlid. Una descripció completa de les claus de manifest amb informació sobre quines claus són compatibles amb quin navegador es pot visualitzar aquí.

Les claus que no es troben a l'especificació "poden" ser ignorades (tant Chrome com Firefox informen d'errors, però les extensions continuen funcionant).

I m'agradaria cridar l'atenció sobre alguns punts.

  1. fons — un objecte que inclou els camps següents:
    1. scripts — una sèrie d'scripts que s'executaran en el context de fons (d'això en parlarem una mica més endavant);
    2. pàgina - en lloc dels scripts que s'executaran en una pàgina buida, podeu especificar html amb contingut. En aquest cas, el camp de l'script s'ignorarà i caldrà inserir els scripts a la pàgina de contingut;
    3. persistir — un indicador binari, si no s'especifica, el navegador "matarà" el procés en segon pla quan consideri que no està fent res, i el reiniciarà si cal. En cas contrari, la pàgina només es descarregarà quan es tanqui el navegador. No és compatible amb Firefox.
  2. contingut_scripts — una sèrie d'objectes que us permeten carregar diferents scripts a diferents pàgines web. Cada objecte conté els següents camps importants:
    1. llumins - URL del patró, que determina si s'inclourà o no un script de contingut concret.
    2. js — una llista d'scripts que es carregaran en aquesta coincidència;
    3. exclude_matches - exclou del camp match URL que coincideixen amb aquest camp.
  3. pàgina_acció - en realitat és un objecte responsable de la icona que es mostra al costat de la barra d'adreces al navegador i de la interacció amb ella. També us permet mostrar una finestra emergent, que es defineix mitjançant el vostre propi HTML, CSS i JS.
    1. emergent_predeterminat — camí al fitxer HTML amb la interfície emergent, pot contenir CSS i JS.
  4. permisos — una matriu per gestionar els drets d'extensió. Hi ha 3 tipus de drets, que es descriuen detalladament aquí
  5. recursos_accessibles_web — recursos d'extensió que una pàgina web pot sol·licitar, per exemple, imatges, fitxers JS, CSS, HTML.
  6. connectable_externament — aquí podeu especificar explícitament els ID d'altres extensions i dominis de pàgines web des dels quals us podeu connectar. Un domini pot ser de segon nivell o superior. No funciona al Firefox.

Context d'execució

L'extensió té tres contextos d'execució de codi, és a dir, l'aplicació consta de tres parts amb diferents nivells d'accés a l'API del navegador.

Context d'extensió

La majoria de l'API està disponible aquí. En aquest context "viuen":

  1. Pàgina de fons — part "backend" de l'extensió. El fitxer s'especifica al manifest mitjançant la clau "de fons".
  2. Pàgina emergent — una pàgina emergent que apareix quan feu clic a la icona de l'extensió. En el manifest browser_action -> default_popup.
  3. Pàgina personalitzada — pàgina d'extensió, "viure" en una pestanya independent de la vista chrome-extension://<id_расширения>/customPage.html.

Aquest context existeix independentment de les finestres i pestanyes del navegador. Pàgina de fons existeix en una única còpia i sempre funciona (l'excepció és la pàgina d'esdeveniments, quan l'script de fons és llançat per un esdeveniment i "mor" després de la seva execució). Pàgina emergent existeix quan la finestra emergent està oberta, i Pàgina personalitzada — mentre la pestanya amb ella està oberta. No hi ha accés a altres pestanyes i al seu contingut des d'aquest context.

Context de l'script de contingut

El fitxer d'script de contingut s'inicia juntament amb cada pestanya del navegador. Té accés a part de l'API de l'extensió i a l'arbre DOM de la pàgina web. Els scripts de contingut són els responsables de la interacció amb la pàgina. Les extensions que manipulen l'arbre DOM ho fan als scripts de contingut, per exemple, els bloquejadors d'anuncis o els traductors. A més, l'script de contingut es pot comunicar amb la pàgina mitjançant estàndard postMessage.

Context de la pàgina web

Aquesta és la pròpia pàgina web. No té res a veure amb l'extensió i no hi té accés, excepte en els casos en què el domini d'aquesta pàgina no s'indica explícitament al manifest (més informació a continuació).

Intercanvi de missatges

Les diferents parts de l'aplicació han d'intercanviar missatges entre elles. Hi ha una API per a això runtime.sendMessage per enviar un missatge background и tabs.sendMessage per enviar un missatge a una pàgina (script de contingut, finestra emergent o pàgina web si està disponible externally_connectable). A continuació es mostra un exemple quan s'accedeix a l'API de 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 a una comunicació completa, podeu crear connexions mitjançant runtime.connect. En resposta rebrem runtime.Port, al qual, mentre estigui obert, podeu enviar qualsevol nombre de missatges. Pel costat del client, per exemple, contentscript, es veu així:

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

Servidor o fons:

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

També hi ha un esdeveniment onDisconnect i mètode disconnect.

Diagrama d'aplicació

Fem una extensió del navegador que emmagatzemi claus privades, proporcioni accés a la informació pública (adreça, clau pública es comunica amb la pàgina i permet que aplicacions de tercers sol·licitin una signatura per a les transaccions.

Desenvolupament d'aplicacions

La nostra aplicació ha d'interaccionar amb l'usuari i proporcionar a la pàgina una API per trucar a mètodes (per exemple, per signar transaccions). Comproveu-vos només amb un contentscript no funcionarà, ja que només té accés al DOM, però no al JS de la pàgina. Connecteu-vos mitjançant runtime.connect no podem, perquè l'API és necessària a tots els dominis i només es poden especificar uns específics al manifest. Com a resultat, el diagrama tindrà aquest aspecte:

Escriure una extensió de navegador segura

Hi haurà un altre guió... inpage, que injectarem a la pàgina. S'executarà en el seu context i proporcionarà una API per treballar amb l'extensió.

Начало

Tot el codi d'extensió del navegador està disponible a GitHub. Durant la descripció hi haurà enllaços a commits.

Comencem pel manifest:

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

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

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

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

Creeu background.js, popup.js, inpage.js i contentscript.js buits. Afegim popup.html - i la nostra aplicació ja es pot carregar a Google Chrome i assegurar-nos que funciona.

Per verificar-ho, podeu agafar el codi per tant. A més del que vam fer, l'enllaç va configurar el muntatge del projecte mitjançant webpack. Per afegir una aplicació al navegador, a chrome://extensions cal seleccionar load unpacked i la carpeta amb l'extensió corresponent, en el nostre cas dist.

Escriure una extensió de navegador segura

Ara la nostra extensió està instal·lada i funciona. Podeu executar les eines de desenvolupament per a diferents contextos de la següent manera:

emergent ->

Escriure una extensió de navegador segura

L'accés a la consola de l'script de contingut es realitza a través de la consola de la pròpia pàgina on s'inicia.Escriure una extensió de navegador segura

Intercanvi de missatges

Per tant, hem d'establir dos canals de comunicació: inpage <-> background i popup <-> background. Per descomptat, podeu enviar missatges al port i inventar el vostre propi protocol, però prefereixo l'enfocament que vaig veure al projecte de codi obert metamask.

Aquesta és una extensió del navegador per treballar amb la xarxa Ethereum. En ell, diferents parts de l'aplicació es comuniquen mitjançant RPC mitjançant la biblioteca dnode. Us permet organitzar un intercanvi de manera bastant ràpida i còmoda si li proporcioneu un flux de nodejs com a transport (és a dir, un objecte que implementa la mateixa interfície):

import Dnode from "dnode/browser";

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

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

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

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

Ara crearem una classe d'aplicació. Crearà objectes API per a la finestra emergent i la pàgina web, i crearà un dnode per a ells:

import Dnode from 'dnode/browser';

export class SignerApp {

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

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

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

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

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

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

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

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

Aquí i a continuació, en comptes de l'objecte global de Chrome, fem servir extensionApi, que accedeix a Chrome al navegador de Google i al navegador en altres. Això es fa per a la compatibilitat entre navegadors, però per als propòsits d'aquest article es podria utilitzar simplement "chrome.runtime.connect".

Creem una instància d'aplicació a l'script de fons:

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

const app = new SignerApp();

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

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

Com que dnode funciona amb fluxos i rebem un port, es necessita una classe d'adaptador. Es fa mitjançant la biblioteca de flux llegible, que implementa fluxos de nodejs al navegador:

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

Ara creem una connexió a la interfície d'usuari:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

A continuació, creem la connexió a l'script de contingut:

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

setupConnection();
injectScript();

function setupConnection(){
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

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

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

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

Com que necessitem l'API no a l'script de contingut, sinó directament a la pàgina, fem dues coses:

  1. Creem dos corrents. Un - cap a la pàgina, a la part superior del missatge. Per a això fem servir això aquest paquet dels creadors de metamask. El segon flux és en segon pla sobre el port rebut runtime.connect. Anem a comprar-los. Ara la pàgina tindrà un flux en segon pla.
  2. Injecteu l'script al DOM. Baixeu l'script (l'accés es va permetre al manifest) i creeu una etiqueta script amb el seu contingut dins:

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

setupConnection();
injectScript();

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

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

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

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

Ara creem un objecte API a inpage i el configurem com a global:

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

setupInpageApi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Estem preparats Trucada de procediment remot (RPC) amb API separada per a pàgina i IU. Quan connectem una pàgina nova al fons, podem veure això:

Escriure una extensió de navegador segura

API i origen buits. Al costat de la pàgina, podem cridar a la funció hola així:

Escriure una extensió de navegador segura

Treballar amb funcions de devolució de trucada en JS modern és una mala educació, així que escrivim un petit ajudant per crear un dnode que us permeti passar un objecte API a utils.

Els objectes de l'API ara es veuran així:

export class SignerApp {

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

...

}

Obtenir un objecte des del control remot com aquest:

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

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

I cridar a funcions retorna una promesa:

Escriure una extensió de navegador segura

Versió amb funcions asíncrones disponibles aquí.

En general, l'enfocament RPC i stream sembla força flexible: podem utilitzar la multiplexació de vapor i crear diverses API diferents per a diferents tasques. En principi, dnode es pot utilitzar a qualsevol lloc, el més important és embolicar el transport en forma de flux de nodejs.

Una alternativa és el format JSON, que implementa el protocol JSON RPC 2. Tanmateix, funciona amb transports específics (TCP i HTTP(S)), que no és aplicable en el nostre cas.

Emmagatzematge local i estatal intern

Haurem d'emmagatzemar l'estat intern de l'aplicació, almenys les claus de signatura. Podem afegir fàcilment un estat a l'aplicació i mètodes per canviar-lo a l'API emergent:

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

    ...

} 

En segon pla, embolicarem tot en una funció i escriurem l'objecte de l'aplicació a la finestra perquè puguem treballar-hi des de la consola:

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

Afegim unes quantes claus des de la consola d'IU i veiem què passa amb l'estat:

Escriure una extensió de navegador segura

L'estat s'ha de fer persistent perquè les claus no es perdin en reiniciar.

L'emmagatzemarem a localStorage, sobreescrivint-lo amb cada canvi. Posteriorment, també serà necessari accedir-hi per a la IU, i també m'agradaria subscriure'm als canvis. A partir d'això, serà convenient crear un emmagatzematge observable i subscriure's als seus canvis.

Utilitzarem la biblioteca mobx (https://github.com/mobxjs/mobx). L'elecció va recaure en ella perquè no hi havia de treballar, però tenia moltes ganes d'estudiar-la.

Afegim la inicialització de l'estat inicial i fem que la botiga sigui observable:

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

export class SignerApp {

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

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

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

    ...

}

"Sota el capó", mobx ha substituït tots els camps de la botiga amb proxy i intercepta totes les trucades a ells. Es podrà subscriure a aquests missatges.

A continuació faré servir sovint el terme "en canviar", tot i que això no és del tot correcte. Mobx rastreja l'accés als camps. S'utilitzen captadors i configuradors d'objectes proxy que crea la biblioteca.

Els decoradors d'acció tenen dos propòsits:

  1. En mode estricte amb la marca enforceActions, mobx prohibeix canviar l'estat directament. Es considera una bona pràctica treballar en condicions estrictes.
  2. Fins i tot si una funció canvia l'estat diverses vegades (per exemple, canviem diversos camps en diverses línies de codi), els observadors només reben una notificació quan es completa. Això és especialment important per a la interfície, on les actualitzacions d'estat innecessàries condueixen a una representació innecessària dels elements. En el nostre cas, ni el primer ni el segon són especialment rellevants, però seguirem les bones pràctiques. És habitual adjuntar decoradors a totes les funcions que canvien l'estat dels camps observats.

En segon pla afegirem la inicialització i desar l'estat a localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Setup state persistence

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

La funció de reacció és interessant aquí. Té dos arguments:

  1. Selector de dades.
  2. Un gestor que es cridarà amb aquestes dades cada vegada que canviï.

A diferència de redux, on rebem explícitament l'estat com a argument, mobx recorda a quins observables accedim dins del selector i només crida al controlador quan canvien.

És important entendre exactament com decideix mobx a quins observables ens subscrivim. Si he escrit un selector en codi com aquest() => app.store, aleshores mai s'anomenarà reacció, ja que l'emmagatzematge en si no és observable, només ho són els seus camps.

Si ho vaig escriure així () => app.store.keys, de nou no passaria res, ja que en afegir/eliminar elements de matriu, la referència a aquest no canviarà.

Mobx actua com a selector per primera vegada i només fa un seguiment dels observables als quals hem accedit. Això es fa mitjançant captadors de proxy. Per tant, aquí s'utilitza la funció integrada toJS. Retorna un objecte nou amb tots els proxies substituïts pels camps originals. Durant l'execució, llegeix tots els camps de l'objecte; per tant, s'activen els captadors.

A la consola emergent tornarem a afegir diverses tecles. Aquesta vegada també van acabar a localStorage:

Escriure una extensió de navegador segura

Quan es torna a carregar la pàgina de fons, la informació es manté al seu lloc.

Es pot veure tot el codi de l'aplicació fins a aquest punt aquí.

Emmagatzematge segur de claus privades

Emmagatzemar claus privades en text clar no és segur: sempre hi ha la possibilitat que us pirategin, tingueu accés al vostre ordinador, etc. Per tant, a localStorage emmagatzemarem les claus en forma xifrada amb contrasenya.

Per a més seguretat, afegirem un estat de bloqueig a l'aplicació, en el qual no hi haurà accés a les claus. Transferirem automàticament l'extensió a l'estat bloquejat a causa d'un temps d'espera.

Mobx us permet emmagatzemar només un conjunt mínim de dades, i la resta es calcula automàticament en funció d'això. Aquestes són les anomenades propietats calculades. Es poden comparar amb les vistes de les bases de dades:

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

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

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

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

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

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

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

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

Ara només emmagatzemem les claus xifrades i la contrasenya. Tota la resta està calculada. Fem la transferència a un estat bloquejat eliminant la contrasenya de l'estat. L'API pública ara té un mètode per inicialitzar l'emmagatzematge.

Escrit per a xifratge utilitats que utilitzen crypto-js:

import CryptoJS from 'crypto-js'

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

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

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

El navegador té una API inactiva mitjançant la qual us podeu subscriure a un esdeveniment: canvis d'estat. Estat, en conseqüència, pot ser idle, active и locked. Per a l'inactivitat, podeu establir un temps d'espera i el bloqueig s'estableix quan el propi sistema operatiu està bloquejat. També canviarem el selector per desar a localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

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

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

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

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

El codi abans d'aquest pas és aquí.

Transaccions

Per tant, arribem al més important: crear i signar transaccions a la cadena de blocs. Utilitzarem la cadena de blocs i la biblioteca WAVES ones-transaccions.

Primer, afegim a l'estat una sèrie de missatges que s'han de signar i, a continuació, afegim mètodes per afegir un missatge nou, confirmar la signatura i rebutjar:

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

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

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

    ...
}

Quan rebem un missatge nou, hi afegim metadades, fes observable i afegir a store.messages.

Si no ho fas observable manualment, aleshores mobx ho farà ell mateix quan afegeixi missatges a la matriu. Tanmateix, crearà un objecte nou al qual no tindrem referència, però el necessitarem per al següent pas.

A continuació, tornem una promesa que es resol quan l'estat del missatge canvia. L'estat es controla mitjançant la reacció, que es "suïcida" quan l'estat canvia.

Codi del mètode approve и reject molt senzill: simplement canviem l'estat del missatge, després de signar-lo si cal.

Posem Aprovar i rebutjar a l'API de la IU, newMessage a l'API de la pàgina:

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

    ...
}

Ara intentem signar la transacció amb l'extensió:

Escriure una extensió de navegador segura

En general, tot està preparat, només queda afegir una interfície d'usuari senzilla.

UI

La interfície necessita accedir a l'estat de l'aplicació. Al costat de la IU ho farem observable state i afegiu una funció a l'API que canviarà aquest estat. Afegim observable a l'objecte API rebut de fons:

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

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

setupUi().catch(console.error);

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

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

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

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

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

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

Al final comencem a renderitzar la interfície de l'aplicació. Aquesta és una aplicació de reacció. L'objecte de fons simplement es passa amb accessoris. Seria correcte, per descomptat, fer un servei separat de mètodes i una botiga per a l'estat, però per als propòsits d'aquest article n'hi ha prou:

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

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

Amb mobx és molt fàcil començar a renderitzar quan les dades canvien. Simplement pengem el decorador observador del paquet mobx-react al component, i es cridarà automàticament el render quan canviï qualsevol observable a què fa referència el component. No necessiteu cap mapStateToProps ni connecteu-vos com a redux. Tot funciona des de la caixa:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

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

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

La resta de components es poden veure al codi a la carpeta de la IU.

Ara, a la classe d'aplicació, heu de fer un selector d'estat per a la IU i notificar-la quan canviï. Per fer-ho, afegim un mètode getState и reactiontrucant remote.updateState:

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

export class SignerApp {

    ...

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

    ...

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

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

        })
    }

    ...
}

En rebre un objecte remote s'està creant reaction per canviar l'estat que crida la funció al costat de la IU.

El toc final és afegir la visualització de missatges nous a la icona de l'extensió:

function setupApp() {
...

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

...
}

Per tant, l'aplicació està llesta. Les pàgines web poden sol·licitar una signatura per a les transaccions:

Escriure una extensió de navegador segura

Escriure una extensió de navegador segura

El codi està disponible aquí enllaç.

Conclusió

Si heu llegit l'article fins al final, però encara teniu preguntes, podeu fer-les a repositoris amb extensió. També hi trobareu commits per a cada pas designat.

I si esteu interessats a veure el codi de l'extensió real, podeu trobar això aquí.

Codi, repositori i descripció de la feina de siemarell

Font: www.habr.com

Afegeix comentari