Escribindo unha extensión de navegador segura

Escribindo unha extensión de navegador segura

A diferenza da arquitectura común "cliente-servidor", as aplicacións descentralizadas caracterízanse por:

  • Non é necesario almacenar unha base de datos con inicios de sesión e contrasinais de usuario. A información de acceso é almacenada exclusivamente polos propios usuarios, e a confirmación da súa autenticidade prodúcese a nivel de protocolo.
  • Non é necesario usar un servidor. A lóxica da aplicación pódese executar nunha rede blockchain, onde é posible almacenar a cantidade necesaria de datos.

Hai dous almacenamentos relativamente seguros para as claves de usuario: carteiras de hardware e extensións de navegador. As carteiras de hardware son na súa maioría extremadamente seguras, pero difíciles de usar e están lonxe de ser gratuítas, pero as extensións do navegador son a combinación perfecta de seguridade e facilidade de uso e tamén poden ser completamente gratuítas para os usuarios finais.

Tendo todo isto en conta, queriamos facer a extensión máis segura que simplifique o desenvolvemento de aplicacións descentralizadas proporcionando unha API sinxela para traballar con transaccións e sinaturas.
Contámosche esta experiencia a continuación.

O artigo conterá instrucións paso a paso sobre como escribir unha extensión de navegador, con exemplos de código e capturas de pantalla. Podes atopar todo o código en repositorios. Cada commit corresponde loxicamente a unha sección deste artigo.

Breve historia das extensións do navegador

As extensións do navegador existen dende hai moito tempo. Apareceron en Internet Explorer en 1999, en Firefox en 2004. Non obstante, durante moito tempo non houbo un estándar único para as extensións.

Podemos dicir que apareceu xunto con extensións na cuarta versión de Google Chrome. Por suposto, non había ningunha especificación entón, pero foi a API de Chrome a que se converteu na súa base: despois de conquistar a maior parte do mercado dos navegadores e ter unha tenda de aplicacións integrada, Chrome en realidade estableceu o estándar para as extensións do navegador.

Mozilla tiña o seu propio estándar, pero ao ver a popularidade das extensións de Chrome, a compañía decidiu facer unha API compatible. En 2015, por iniciativa de Mozilla, creouse un grupo especial dentro do World Wide Web Consortium (W3C) para traballar nas especificacións de extensións entre navegadores.

Tomáronse como base as extensións de API existentes para Chrome. O traballo levouse a cabo co apoio de Microsoft (Google negouse a participar no desenvolvemento do estándar), e como resultado apareceu un borrador especificacións.

Formalmente, a especificación é compatible con Edge, Firefox e Opera (teña en conta que Chrome non está nesta lista). Pero de feito, o estándar é en gran parte compatible con Chrome, xa que en realidade está escrito en función das súas extensións. Podes ler máis sobre a API de WebExtensions aquí.

Estrutura de extensión

O único ficheiro que se require para a extensión é o manifesto (manifest.json). Tamén é o "punto de entrada" á expansión.

Manifesto

Segundo a especificación, o ficheiro de manifesto é un ficheiro JSON válido. Unha descrición completa das claves de manifesto con información sobre as claves compatibles en que navegador se pode ver aquí.

As claves que non están na especificación "poden" ignorarse (tanto Chrome como Firefox informan de erros, pero as extensións seguen funcionando).

E gustaríame chamar a atención sobre algúns puntos.

  1. fondo - un obxecto que inclúa os seguintes campos:
    1. scripts — unha matriz de scripts que se executarán no contexto de fondo (falaremos disto un pouco máis tarde);
    2. páxina - en lugar de scripts que se executarán nunha páxina baleira, pode especificar html con contido. Neste caso, ignorarase o campo do guión e os guións terán que ser inseridos na páxina de contido;
    3. persiste — unha marca binaria, se non se especifica, o navegador “matará” o proceso en segundo plano cando considere que non está facendo nada, e reiniciará se é necesario. En caso contrario, a páxina só se descargará cando o navegador estea pechado. Non é compatible con Firefox.
  2. contido_scripts — unha matriz de obxectos que lle permite cargar diferentes scripts en diferentes páxinas web. Cada obxecto contén os seguintes campos importantes:
    1. fósforo - URL do patrón, que determina se se incluirá ou non un script de contido en particular.
    2. js — unha lista de scripts que se cargarán nesta partida;
    3. exclude_matches - exclúe do campo match URL que coinciden con este campo.
  3. páxina_acción - é en realidade un obxecto que é responsable da icona que se mostra xunto á barra de enderezos no navegador e da interacción con ela. Tamén che permite mostrar unha ventá emerxente, que se define usando o teu propio HTML, CSS e JS.
    1. emerxente_predeterminado — camiño ao ficheiro HTML coa interface emerxente, pode conter CSS e JS.
  4. permisos — unha matriz para xestionar os dereitos de extensión. Hai 3 tipos de dereitos, que se describen en detalle aquí
  5. recursos_accesibles_web — recursos de extensión que pode solicitar unha páxina web, por exemplo, imaxes, ficheiros JS, CSS, HTML.
  6. conectable_externamente — aquí pode especificar explícitamente os ID doutras extensións e dominios de páxinas web desde as que pode conectarse. Un dominio pode ser de segundo nivel ou superior. Non funciona en Firefox.

Contexto de execución

A extensión ten tres contextos de execución de código, é dicir, a aplicación consta de tres partes con diferentes niveis de acceso á API do navegador.

Contexto de extensión

A maior parte da API está dispoñible aquí. Neste contexto "viven":

  1. Páxina de fondo - parte "backend" da extensión. O ficheiro especifícase no manifesto usando a tecla "background".
  2. Páxina emerxente — unha páxina emerxente que aparece cando fai clic na icona da extensión. No manifesto browser_action -> default_popup.
  3. Páxina personalizada — páxina de extensión, "vivindo" nunha pestana separada da vista chrome-extension://<id_расширения>/customPage.html.

Este contexto existe independentemente das fiestras e pestanas do navegador. Páxina de fondo existe nunha única copia e sempre funciona (a excepción é a páxina do evento, cando o script en segundo plano é lanzado por un evento e "morre" despois da súa execución). Páxina emerxente existe cando a ventá emerxente está aberta e Páxina personalizada — mentres a pestana con ela está aberta. Non hai acceso a outras pestanas e aos seus contidos desde este contexto.

Contexto do guión de contido

O ficheiro de script de contido lánzase xunto con cada pestana do navegador. Ten acceso a parte da API da extensión e á árbore DOM da páxina web. Os scripts de contido son os responsables da interacción coa páxina. As extensións que manipulan a árbore DOM fan isto nos scripts de contido, por exemplo, bloqueadores de anuncios ou tradutores. Ademais, o script de contido pode comunicarse coa páxina mediante un estándar postMessage.

Contexto da páxina web

Esta é a propia páxina web. Non ten nada que ver coa extensión e non ten acceso alí, agás nos casos en que o dominio desta páxina non estea indicado explícitamente no manifesto (máis información a continuación).

Intercambio de mensaxes

As diferentes partes da aplicación deben intercambiar mensaxes entre si. Hai unha API para iso runtime.sendMessage para enviar unha mensaxe background и tabs.sendMessage para enviar unha mensaxe a unha páxina (script de contido, emerxente ou páxina web se está dispoñible externally_connectable). A continuación móstrase un exemplo ao acceder á 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))
    }
)

Para unha comunicación completa, podes crear conexións a través runtime.connect. En resposta recibiremos runtime.Port, ao que, mentres estea aberto, pode enviar calquera número de mensaxes. No lado do cliente, por exemplo, contentscript, parece isto:

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

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

Tamén hai un evento onDisconnect e método disconnect.

Diagrama de aplicación

Fagamos unha extensión do navegador que almacene claves privadas, proporcione acceso á información pública (enderezo, chave pública comunícase coa páxina e permita que aplicacións de terceiros soliciten unha sinatura para as transaccións.

Desenvolvemento de aplicacións

A nosa aplicación debe interactuar co usuario e proporcionar á páxina unha API para chamar métodos (por exemplo, para asinar transaccións). Conforme só con un contentscript non funcionará, xa que só ten acceso ao DOM, pero non ao JS da páxina. Conectar mediante runtime.connect non podemos, porque a API é necesaria en todos os dominios e só se poden especificar uns específicos no manifesto. Como resultado, o diagrama terá o seguinte aspecto:

Escribindo unha extensión de navegador segura

Haberá outro guión - inpage, que inxectaremos na páxina. Executarase no seu contexto e proporcionará unha API para traballar coa extensión.

Comezar

Todo o código de extensión do navegador está dispoñible en GitHub. Durante a descrición haberá ligazóns a commits.

Comezamos co manifesto:

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

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

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

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

Crea background.js, popup.js, inpage.js e contentscript.js baleiros. Engadimos popup.html e a nosa aplicación xa se pode cargar en Google Chrome e asegurarse de que funciona.

Para verificalo, podes levar o código por iso. Ademais do que fixemos, a ligazón configurou a montaxe do proxecto mediante webpack. Para engadir unha aplicación ao navegador, en chrome://extensions cómpre seleccionar cargar desempaquetado e o cartafol coa extensión correspondente -no noso caso dist.

Escribindo unha extensión de navegador segura

Agora a nosa extensión está instalada e funcionando. Pode executar as ferramentas de programador para diferentes contextos do seguinte xeito:

emerxente ->

Escribindo unha extensión de navegador segura

O acceso á consola do script de contido realízase a través da consola da propia páxina na que se inicia.Escribindo unha extensión de navegador segura

Intercambio de mensaxes

Polo tanto, necesitamos establecer dúas canles de comunicación: inpage background e popup background. Por suposto, podes enviar mensaxes ao porto e inventar o teu propio protocolo, pero prefiro o enfoque que vin no proxecto de código aberto de metamask.

Esta é unha extensión do navegador para traballar coa rede Ethereum. Nela, diferentes partes da aplicación comunícanse mediante RPC mediante a biblioteca dnode. Permítelle organizar un intercambio de forma bastante rápida e cómoda se lle proporciona un fluxo nodejs como transporte (é dicir, un obxecto que implementa a mesma interface):

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

Agora imos crear unha clase de aplicación. Creará obxectos API para a páxina emerxente e web e creará un dnode para eles:

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í e abaixo, en lugar do obxecto global de Chrome, usamos extensionApi, que accede a Chrome no navegador de Google e ao navegador noutros. Isto faise para compatibilidade entre navegadores, pero para os efectos deste artigo, pode simplemente usar "chrome.runtime.connect".

Imos crear unha instancia de aplicación no script en segundo plano:

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

Dado que dnode funciona con fluxos e recibimos un porto, é necesaria unha clase de adaptador. Faise usando a biblioteca de fluxos lexibles, que implementa fluxos de nodejs no 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()
    }
}

Agora imos crear unha conexión na IU:

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

Despois creamos a conexión no script de contido:

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

Dado que necesitamos a API non no script de contido, senón directamente na páxina, facemos dúas cousas:

  1. Creamos dúas correntes. Un - cara á páxina, na parte superior do postMensaxe. Para iso usamos isto este paquete dos creadores de metamask. O segundo fluxo é o fondo sobre o porto recibido runtime.connect. Imos mercalos. Agora a páxina terá un fluxo en segundo plano.
  2. Inxecta o script no DOM. Descarga o script (o acceso a el estaba permitido no manifesto) e crea unha etiqueta script co seu contido dentro:

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

Agora creamos un obxecto api en inpage e configuramos como 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;
}

Estamos listos Chamada de procedemento remoto (RPC) con API separada para páxina e IU. Ao conectar unha páxina nova ao fondo podemos ver isto:

Escribindo unha extensión de navegador segura

API baleira e orixe. No lado da páxina, podemos chamar á función ola deste xeito:

Escribindo unha extensión de navegador segura

Traballar con funcións de devolución de chamada en JS moderno é de mala educación, así que imos escribir un pequeno axudante para crear un dnode que che permita pasar un obxecto API a utils.

Os obxectos da API agora terán o seguinte aspecto:

export class SignerApp {

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

...

}

Conseguindo un obxecto desde o control remoto como este:

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

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

E chamar a funcións devolve unha promesa:

Escribindo unha extensión de navegador segura

Versión con funcións asíncronas dispoñibles aquí.

En xeral, o enfoque RPC e fluxo parece bastante flexible: podemos usar a multiplexación de vapor e crear varias API diferentes para tarefas diferentes. En principio, dnode pódese usar en calquera lugar, o principal é envolver o transporte en forma de fluxo nodejs.

Unha alternativa é o formato JSON, que implementa o protocolo JSON RPC 2. Non obstante, funciona con transportes específicos (TCP e HTTP(S)), que non é aplicable no noso caso.

Almacenamento local e estatal interno

Teremos que almacenar o estado interno da aplicación, polo menos as claves de sinatura. Podemos engadir facilmente un estado á aplicación e métodos para cambialo na API emerxente:

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 segundo plano, envolveremos todo nunha función e escribiremos o obxecto da aplicación na xanela para que poidamos traballar con el desde a 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)
        }
    }
}

Engademos algunhas claves da consola da IU e vexamos que pasa co estado:

Escribindo unha extensión de navegador segura

O estado debe ser persistente para que as chaves non se perdan ao reiniciar.

Almacenarémolo en localStorage, sobrescribindoo con cada cambio. Posteriormente, o acceso a ela tamén será necesario para a IU, e tamén me gustaría subscribirme aos cambios. En función diso, será conveniente crear un almacenamento observable e subscribirse aos seus cambios.

Usaremos a biblioteca mobx (https://github.com/mobxjs/mobx). A elección recaeu nel porque non tiña que traballar con el, pero tiña moitas ganas de estudalo.

Engadimos a inicialización do estado inicial e fagamos que a tenda sexa 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)
    }

    ...

}

"Debaixo do capó", mobx substituíu todos os campos da tenda por proxy e intercepta todas as chamadas a eles. Será posible subscribirse a estas mensaxes.

A continuación usarei a miúdo o termo "ao cambiar", aínda que isto non é do todo correcto. Mobx rastrexa o acceso aos campos. Utilízanse os captadores e configuradores de obxectos proxy que crea a biblioteca.

Os decoradores de acción teñen dous propósitos:

  1. No modo estrito coa marca enforceActions, mobx prohibe cambiar o estado directamente. Considérase unha boa práctica traballar en condicións estritas.
  2. Aínda que unha función cambie o estado varias veces, por exemplo, cambiamos varios campos en varias liñas de código, os observadores só reciben unha notificación cando se completa. Isto é especialmente importante para o frontend, onde as actualizacións de estado innecesarias conducen a renderización innecesaria dos elementos. No noso caso, nin o primeiro nin o segundo son especialmente relevantes, pero seguiremos as mellores prácticas. É habitual asociar decoradores a todas as funcións que cambian o estado dos campos observados.

En segundo plano engadiremos a inicialización e gardamos o estado en 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)
        }
    }
}

A función de reacción é interesante aquí. Ten dous argumentos:

  1. Selector de datos.
  2. Un manejador que se chamará con estes datos cada vez que cambien.

A diferenza de redux, onde recibimos explícitamente o estado como argumento, mobx lembra a que observables accedemos dentro do selector e só chama ao controlador cando cambian.

É importante comprender exactamente como mobx decide a que observables nos subscribimos. Se escribín un selector en código coma este() => app.store, entón nunca se chamará reacción, xa que o almacenamento en si non é observable, só o son os seus campos.

Se o escribín así () => app.store.keys, entón de novo non pasaría nada, xa que ao engadir/eliminar elementos da matriz, a referencia a ela non cambiará.

Mobx actúa como selector por primeira vez e só fai un seguimento dos observables aos que accedemos. Isto faise a través de captadores de proxy. Polo tanto, aquí úsase a función integrada toJS. Devolve un novo obxecto con todos os proxies substituídos polos campos orixinais. Durante a execución, le todos os campos do obxecto, polo que se activan os getters.

Na consola emerxente engadiremos de novo varias teclas. Nesta ocasión tamén acabaron en localStorage:

Escribindo unha extensión de navegador segura

Cando se recarga a páxina de fondo, a información permanece no seu lugar.

Pódese ver todo o código de aplicación ata este punto aquí.

Almacenamento seguro de claves privadas

Gardar as claves privadas en texto claro non é seguro: sempre existe a posibilidade de que che pirateen, teñas acceso ao teu ordenador, etc. Polo tanto, en localStorage almacenaremos as claves nun formulario cifrado con contrasinal.

Para unha maior seguridade, engadiremos un estado bloqueado á aplicación, no que non haberá acceso ás claves. Transferiremos automaticamente a extensión ao estado bloqueado debido a un tempo de espera.

Mobx permítelle almacenar só un conxunto mínimo de datos, e o resto calcúlase automaticamente en función del. Estas son as chamadas propiedades calculadas. Pódense comparar coas vistas das bases de datos:

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

Agora só almacenamos as claves e o contrasinal cifrados. Todo o demais está calculado. Facemos a transferencia a un estado bloqueado eliminando o contrasinal do estado. A API pública agora ten un método para inicializar o almacenamento.

Escrito para cifrar utilidades usando 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)
}

O navegador ten unha API inactiva a través da cal podes subscribirte a un evento: cambios de estado. Estado, en consecuencia, pode ser idle, active и locked. Para o modo de inactividade, pode establecer un tempo de espera e o bloqueo establécese cando o propio sistema operativo está bloqueado. Tamén cambiaremos o selector para gardar en 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)
        }
    }
}

O código antes deste paso é aquí.

Transaccións

Entón, chegamos ao máis importante: crear e asinar transaccións na cadea de bloques. Usaremos a cadea de bloques e a biblioteca WAVES ondas-transaccións.

En primeiro lugar, engademos ao estado unha serie de mensaxes que hai que asinar, despois engademos métodos para engadir unha nova mensaxe, confirmar a sinatura e rexeitar:

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

    ...
}

Cando recibimos unha nova mensaxe, engadímoslle metadatos, fai observable e engadir a store.messages.

Se non o fas observable manualmente, entón mobx faráo por si mesmo ao engadir mensaxes á matriz. Non obstante, creará un novo obxecto ao que non teremos unha referencia, pero necesitarémolo para o seguinte paso.

A continuación, devolvemos unha promesa que se resolve cando o estado da mensaxe cambia. O estado é supervisado pola reacción, que se "matará" cando o estado cambie.

Código do método approve и reject moi sinxelo: simplemente cambiamos o estado da mensaxe, despois de asinala se é necesario.

Poñemos Aprobar e rexeitar na API da IU, newMessage na API da páxina:

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

    ...
}

Agora imos tentar asinar a transacción coa extensión:

Escribindo unha extensión de navegador segura

En xeral, todo está listo, só queda engadir unha interface de usuario sinxela.

UI

A interface necesita acceso ao estado da aplicación. No lado da IU faremos observable estado e engade unha función á API que cambiará este estado. Engadimos observable ao obxecto API recibido desde segundo plano:

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

Ao final comezamos a renderizar a interface da aplicación. Esta é unha aplicación de reacción. O obxecto de fondo pásase simplemente usando accesorios. Sería correcto, por suposto, facer un servizo separado para métodos e unha tenda para o estado, pero para os efectos deste artigo é suficiente:

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

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

Con mobx é moi sinxelo comezar a renderizar cando os datos cambian. Simplemente colgamos o decorador observador do paquete mobx-react no compoñente e chamarase automaticamente ao render cando cambie calquera observable referenciado polo compoñente. Non necesitas ningún mapStateToProps nin conectarte como en redux. Todo funciona desde a 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>
    }
}

Os compoñentes restantes pódense ver no código no cartafol da IU.

Agora, na clase de aplicación, cómpre facer un selector de estado para a IU e notificarlle cando cambie. Para iso, imos engadir un método getState и reactionchamando 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())

        })
    }

    ...
}

Ao recibir un obxecto remote créase reaction para cambiar o estado que chama a función no lado da IU.

O toque final é engadir a visualización de novas mensaxes na icona da extensión:

function setupApp() {
...

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

...
}

Entón, a aplicación está lista. As páxinas web poden solicitar unha sinatura para as transaccións:

Escribindo unha extensión de navegador segura

Escribindo unha extensión de navegador segura

O código está dispoñible aquí Ligazón.

Conclusión

Se liches o artigo ata o final, pero aínda tes dúbidas, podes preguntalas en repositorios con extensión. Alí tamén atoparás commits para cada paso designado.

E se estás interesado en mirar o código da extensión real, podes atopalo aquí.

Código, repositorio e descrición do traballo de siemarell

Fonte: www.habr.com

Engadir un comentario