Desenvolvendo uma extensão de navegador segura

Desenvolvendo uma extensão de navegador segura

Ao contrário da arquitetura "cliente-servidor" comum, aplicações descentralizadas caracterizam-se por:

  • A ausência da necessidade de armazenar um banco de dados com os logins e senhas dos usuários. As informações de acesso são mantidas exclusivamente pelos usuários, e a validação de sua veracidade ocorre a nível de protocolo.
  • A ausência da necessidade de usar um servidor. A lógica da aplicação pode ser executada na rede blockchain, onde também é possível armazenar a quantidade necessária de dados.

Existem 2 repositórios relativamente seguros para chaves de usuários — carteiras de hardware e extensões de navegador. As carteiras de hardware são, na sua maioria, extremamente seguras, mas difíceis de usar e não são gratuitas, enquanto as extensões de navegador são uma combinação ideal de segurança e facilidade de uso, e podem ser completamente gratuitas para os usuários finais.

Considerando tudo isso, decidimos criar uma extensão que é o mais segura possível, simplificando o desenvolvimento de aplicações descentralizadas, fornecendo uma API simples para trabalhar com transações e assinaturas.
Sobre essa experiência, falaremos mais adiante.

O artigo incluirá um guia passo a passo sobre como escrever uma extensão de navegador, com exemplos de código e capturas de tela. Todo o código pode ser encontrado em repositório. Cada commit corresponde logicamente a uma seção deste artigo.

Uma breve história das extensões de navegador

As extensões de navegador existem há bastante tempo. No Internet Explorer, surgiram em 1999, e no Firefox, em 2004. No entanto, durante muito tempo não houve um padrão unificado para extensões.

Pode-se dizer que esse padrão surgiu junto com as extensões na quarta versão do Google Chrome. Claro que não havia especificações na época, mas a API do Chrome tornou-se sua base: conquistando a maior parte do mercado de navegadores e com uma loja de aplicativos integrada, o Chrome efetivamente definiu o padrão para extensões de navegador.

A Mozilla tinha seu próprio padrão, mas, ao ver a popularidade das extensões para Chrome, decidiu fazer uma API compatível. Em 2015, por iniciativa da Mozilla, foi formada, no âmbito do World Wide Web Consortium (W3C), um grupo especial para trabalhar nas especificações de extensões cross-browser.

A base foi o API existente de extensões para Chrome. O trabalho foi realizado com o apoio da Microsoft (a Google se recusou a participar do desenvolvimento do padrão), e como resultado, um rascunho foi criado. especificações.

Formalmente, a especificação é suportada pelo Edge, Firefox e Opera (note que o Chrome está ausente desta lista). Mas, na verdade, o padrão é em grande parte compatível com o Chrome, pois foi efetivamente escrito com base em suas extensões. Mais informações sobre a API WebExtensions podem ser lidas. aqui.

Estrutura da extensão

O único arquivo que é absolutamente necessário para a extensão é o manifesto (manifest.json). Ele também é o "ponto de entrada" na extensão.

Manifesto

De acordo com a especificação, o arquivo do manifesto é um arquivo JSON válido. Uma descrição completa das chaves do manifesto, com informações sobre quais chaves são suportadas em qual navegador, pode ser vista. aqui.

As chaves que não estão na especificação "podem" ser ignoradas (tanto o Chrome quanto o Firefox relatam erros, mas as extensões continuam funcionando).

Gostaria de chamar a atenção para alguns pontos.

  1. background — objeto que inclui os seguintes campos:
    1. scripts — array de scripts que serão executados no contexto de background (falaremos sobre isso um pouco mais tarde);
    2. page — em vez de scripts que serão executados em uma página vazia, é possível definir HTML com conteúdo. Nesse caso, o campo script será ignorado, e os scripts precisarão ser inseridos na página com conteúdo;
    3. persistente — um flag binário, se não for especificado, o navegador "matará" o processo de background quando considerar que não está fazendo nada, e reiniciará quando necessário. Caso contrário, a página será descarregada apenas ao fechar o navegador. Não é suportado no Firefox.
  2. content_scripts — array de objetos, permitindo carregar diferentes scripts em diferentes páginas da web. Cada objeto contém os seguintes campos importantes:
    1. matches — padrão de URL, que determina se um determinado script de conteúdo será incluído ou não.
    2. js — lista de scripts que serão carregados na correspondência atual;
    3. exclude_matches — exclui do campo match URLs que atendem a este campo.
  3. page_action — é, na verdade, um objeto que responde pelo ícone que é exibido ao lado da barra de endereços do navegador e sua interação. Também permite mostrar uma janela popup, que é definida através de seu próprio HTML, CSS e JS.
    1. default_popup — caminho para o arquivo HTML com a interface popup, pode conter CSS e JS.
  4. permissions — array for managing extension permissions. There are 3 types of permissions, which are described in detail. aqui
  5. web_accessible_resources — resources of the extension that can be requested by a web page, such as images, JS files, CSS, HTML.
  6. externally_connectable — this is where you can explicitly specify the IDs of other extensions and the domains of the web pages that can connect. The domain can be a second-level domain or higher. It does not work in Firefox.

Execution context

The extension has three code execution contexts, meaning it consists of three parts with different levels of access to the browser API.

Extension context

Most of the API is available here. In this context, the following 'live':

  1. Background page — the 'backend' part of the extension. The file is specified in the manifest by the key 'background'.
  2. Popup page — the popup page that appears when you click on the extension icon. In the manifest, browser_action -> default_popup.
  3. Custom page — the extension page, 'living' in a separate tab like chrome-extension:///customPage.html.

This context exists independently of browser windows and tabs. Background page exists as a single instance and works all the time (except for the event page, when the background script runs on an event and 'dies' after execution). Popup page exists when a popup window is open, while Custom page — a tab with it is open. There is no access to other tabs and their content from this context.

Content script context

The content script file runs with each browser tab. It has access to part of the extension API and the DOM tree of the web page. Content scripts are responsible for interacting with the page. Extensions that manipulate the DOM tree do this in content scripts – for example, ad blockers or translators. The content script can also communicate with the page through standard postMessage.

Web page context

This is actually the web page itself. It has no relation to the extension and does not have access to it, except in cases where the domain of this page is explicitly specified in the manifest (more on this below).

Message exchange

Different parts of the application must exchange messages with each other. For this, there is the API runtime.sendMessage to send a message background e tabs.sendMessage to send a message to the page (content script, popup, or web page if available externally_connectable). Below is an example of how to access the Chrome API.

// Сообщением может быть любой 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 comunicação completa, é possível criar conexões através de runtime.connect. Em resposta, receberemos runtime.Port, no qual, enquanto estiver aberto, podemos enviar qualquer quantidade de mensagens. Do lado do cliente, por exemplo, contentscript, isso se parece com 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 background:

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

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

Também existe o evento onDisconnect e o método desconexão.

Esquema do aplicativo

Vamos criar uma extensão de navegador que armazena chaves privadas, fornece acesso a informações públicas (endereço, chave pública se comunica com a página e permite que aplicativos externos solicitem a assinatura de transações.

Desenvolvendo um aplicativo

Nosso aplicativo deve interagir com o usuário e fornecer à página uma API para chamar métodos (por exemplo, para assinar transações). Não podemos contar apenas com contentscript porque ele tem acesso apenas ao DOM, mas não ao JS da página. Não podemos nos conectar através de runtime.connect porque a API é necessária em todos os domínios, e no manifesto podemos especificar apenas alguns específicos. No final, o esquema ficará assim:

Desenvolvendo uma extensão de navegador segura

Haverá um outro script — inpage, que vamos injetar na página. Ele será executado em seu contexto e fornecerá uma API para trabalhar com a extensão.

Início

Todo o código da extensão do navegador está disponível em GitHub. Durante a descrição, haverá links para os commits.

Vamos começar com o manifesto:

{
  // Nome e descrição, versão. Tudo isso será visível no navegador em chrome://extensions/?id=<id da extensão>
  "name": "Signer",
  "description": "Demonstração da extensão",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts que serão executados em background, pode haver vários
  "background": {
    "scripts": ["background.js"]
  },

  // Qual html usar para o popup
  "browser_action": {
    "default_title": "Minha Extensão",
    "default_popup": "popup.html"
  },

  // Scripts de conteúdo.
  // Temos um objeto: para todos os url que começam com http ou https, executamos
  // context do contentscript com o script contentscript.js. Executa imediatamente após receber o documento para todos os frames
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Acesso permitido ao localStorage e idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Aqui são especificados os recursos a que a página web terá acesso. Ou seja, eles podem ser solicitados através de fetch ou simplesmente xhr
  "web_accessible_resources": ["inpage.js"]
}

Criamos arquivos vazios background.js, popup.js, inpage.js e contentscript.js. Adicionamos popup.html — e já podemos carregar nosso aplicativo no Google Chrome e verificar se ele está funcionando.

Para verificar isso, podemos pegar o código daqui. Além do que fizemos, o link está configurado para a construção do projeto com webpack. Para adicionar o aplicativo ao navegador, em chrome://extensions, é necessário escolher carregar empacotado e a pasta com a extensão correspondente — no nosso caso, dist.

Desenvolvendo uma extensão de navegador segura

Agora, nossa extensão está instalada e funcionando. Para abrir as ferramentas de desenvolvedor em diferentes contextos, pode-se fazer da seguinte forma:

popup ->

Desenvolvendo uma extensão de navegador segura

O acesso ao console do script de conteúdo é feito através do console da própria página onde está sendo executado.Desenvolvendo uma extensão de navegador segura

Message exchange

Então, precisamos estabelecer dois canais de comunicação: inpage <-> background e popup <-> background. Claro, podemos simplesmente enviar mensagens para a porta e inventar nosso próprio protocolo, mas prefiro a abordagem que vi no projeto de código aberto metamask.

Esta é uma extensão de navegador para trabalhar com a rede Ethereum. Nela, diferentes partes do aplicativo se comunicam via RPC usando a biblioteca dnode. Ela permite organizar a troca de forma rápida e conveniente, se fornecermos um stream nodejs como transporte (referindo-se a um objeto que implementa a mesma interface):

import Dnode from "dnode/browser";

// Neste exemplo, concordamos que o cliente chama funções no servidor remotamente, embora nada nos impeça de tornar isso bidirecional

// Servidor
// API que queremos fornecer
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transporte sobre o qual o dnode funcionará. Qualquer stream nodejs. No navegador, existe a biblioteca 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Cliente
const dnodeClient = Dnode() // Chamada sem argumento significa que não estamos fornecendo API do outro lado

// Isso irá imprimir no console world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Agora vamos criar a classe do aplicativo. Ela criará objetos de API para o popup e a página da web, bem como criará o dnode para eles:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Retorna o objeto API para ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Retorna o objeto API para a página
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Conecta o popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Conecta a página
    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)
        })
    }
}

Aqui e adiante, em vez do objeto global Chrome, usamos extentionApi, que se conecta ao Chrome no navegador do Google e ao browser em outros. Isso é feito para compatibilidade entre navegadores, mas no âmbito deste artigo poderia ser utilizado apenas 'chrome.runtime.connect'.

Vamos criar uma instância do aplicativo no script de background:

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

const app = new SignerApp();

// onConnect é acionado ao conectar 'processos' (contentscript, popup, ou página de extensão)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Ao estabelecer a conexão, você pode especificar um nome, por esse nome definimos quem se conectou, script de conteúdo ou ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Como dnode trabalha com streams, e nós recebemos uma porta, é necessária uma classe adaptadora. Ela é feita usando a biblioteca readable-stream, que implementa streams do 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 - desconectado'))
        }
        cb()
    }
}

Agora vamos criar a conexão na UI:

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

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

setupUi().catch(console.error);

async function setupUi(){
    // Assim como no classe do aplicativo, criamos a porta, encapsulamos em stream, fazemos 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)
        })
    });

    // Tornamos o objeto API acessível a partir do console
    if (DEV_MODE){
        global.background = background;
    }
}

Em seguida, criamos a conexão no script de conteúdo:

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 {
        // injeta o script na página
        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('Falha na injeção.', e);
    }
}

Como precisamos da API não no script de conteúdo, mas diretamente na página, fazemos duas coisas:

  1. Criamos dois streams. Um — em direção à página, sobre o postMessage. Para isso, usamos este pacote dos criadores do metamask. O segundo stream — para o background sobre a porta recebida de runtime.connect. Conectamos eles. Agora a página terá uma conexão com o background.
  2. Injetamos o script no DOM. Baixamos o script (o acesso a ele foi permitido no manifesto) e criamos uma tag script com seu conteúdo dentro:

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

setupConnection();
injectScript();

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

    // Stream para a página
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // injeta o script na página
        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('Falha na injeção.', e);
    }
}

Agora criamos o objeto api no inpage e o tornamos global:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Fluxo para o script de conteúdo
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Obtendo o objeto API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Acesso através da janela
    global.SignerApp = pageApi;
}

Estamos prontos Chamada de Procedimento Remoto (RPC) com uma API separada para a página e a interface do usuário. Ao conectar uma nova página ao background, podemos ver isso:

Desenvolvendo uma extensão de navegador segura

API vazia e origem. Do lado da página, podemos chamar a função hello assim:

Desenvolvendo uma extensão de navegador segura

Trabalhar com funções de callback no JS moderno é ultrapassado, então vamos escrever um pequeno helper para criar dnode, que permite passar para o objeto API em utils.

Os objetos API agora vão parecer assim:

export class SignerApp {

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

...

}

Recebendo objeto do remote da seguinte forma:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Utilizando as utilidades para mudar todos os callbacks para promises
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

E a chamada de funções retorna uma promise:

Desenvolvendo uma extensão de navegador segura

A versão com funções assíncronas está disponível aqui.

No geral, a abordagem com RPC e streams parece bastante flexível: podemos usar multiplexação de streams e criar várias APIs diferentes para diferentes tarefas. Em princípio, dnode pode ser usado em qualquer lugar, o principal é envolver o transporte na forma de stream do nodejs.

Uma alternativa é o formato JSON, que implementa o protocolo JSON RPC 2. No entanto, ele funciona com transportes específicos (TCP e HTTP(S)), o que não se aplica ao nosso caso.

Estado interno e localStorage

Precisamos armazenar o estado interno do aplicativo — pelo menos, as chaves para assinatura. Podemos facilmente adicionar estado ao aplicativo e métodos para alterá-lo na API do popup:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor() {
        this.store = {
            keys: [],
        };
    }

    addKey(key) {
        this.store.keys.push(key)
    }

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

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

    ...

} 

No background, vamos envolver tudo em uma função e registrar o objeto do aplicativo na janela, para que possamos interagir com ele a partir do console:

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

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

setupApp();

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

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Vamos adicionar algumas chaves via console UI e ver como ficou o estado:

Desenvolvendo uma extensão de navegador segura

O estado precisa ser persistente para que as chaves não sejam perdidas ao reiniciar.

Vamos armazenar no localStorage, sobrescrevendo a cada alteração. Posteriormente, o acesso a ele também será necessário para o UI, e também queremos nos inscrever nas alterações. Com isso em mente, será conveniente criar um armazenamento observável e nos inscrever nas suas mudanças.

Usaremos a biblioteca mobx (https://github.com/mobxjs/mobx). A escolha caiu sobre ela, pois não tínhamos trabalhado com ela antes e queríamos muito estudá-la.

Vamos adicionar a inicialização do estado inicial e tornar o store observável:

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

export class SignerApp {

    constructor(initState = {}) {
        // Externamente, o store continuará sendo o mesmo objeto, mas agora todos os seus campos se tornaram proxies que rastreiam o acesso a eles
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Métodos que alteram o observable costumam ser decorados
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

"Por trás das câmaras", o mobx substituiu todos os campos do store por proxies e intercepta todos os acessos a eles. Será possível se inscrever nesses acessos.

A partir de agora, estarei usando frequentemente o termo "ao alterar", embora isso não seja completamente correto. O mobx rastreia exatamente o acesso aos campos. Ele usa getters e setters dos objetos proxy que a biblioteca cria.

Os decoradores action têm dois propósitos:

  1. No modo estrito com a flag enforceActions, o mobx proíbe a alteração do estado diretamente. É considerado uma boa prática trabalhar exatamente no modo estrito.
  2. Mesmo que a função altere o estado várias vezes - por exemplo, mudamos vários campos em várias linhas de código - os observadores são notificados apenas após a conclusão dela. Isso é especialmente importante para o frontend, onde atualizações desnecessárias de estado levam a renderizações indesejadas de elementos. No nosso caso, nem uma nem outra são particularmente relevantes, mas seguiremos as melhores práticas. Decoradores são comumente aplicados a todas as funções que alteram o estado dos campos observáveis.

No background, adicionaremos a inicialização e a persistência do estado no localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Métodos auxiliares. Gravem/leiam o objeto no/para o localStorage no formato de string JSON pela chave '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;
    }

    // Configuração de persistência de estado

    // O resultado da reação é atribuído a uma variável para que a assinatura possa ser cancelada. Não precisamos disso, mas foi deixado como exemplo
    const localStorageReaction = reaction(
        () => toJS(app.store), // Função-seletor de dados
        saveState // Função que será chamada quando os dados retornados pelo seletor mudarem
    );

    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 função reaction é interessante aqui. Ela tem dois argumentos:

  1. Um seletor de dados.
  2. Um manipulador que será chamado com esses dados toda vez que eles mudarem.

Ao contrário do redux, onde obtemos claramente o estado como argumento, o mobx memoriza quais observáveis estamos acessando dentro do seletor e chama o manipulador apenas quando estes mudam.

É importante entender como exatamente o mobx decide a quais observáveis nos inscrevemos. Se no código eu escrevesse o seletor assim() => app.store, a reação nunca seria chamada, pois o próprio armazenamento não é observável; apenas seus campos são.

Se eu escrevesse assim () => app.store.keys, novamente nada aconteceria, pois ao adicionar/remover elementos do array, a referência a ele não mudaria.

Mobx executa a função seletora pela primeira vez e monitora apenas aqueles observáveis aos quais tivemos acesso. Isso é feito por meio de getters proxy. Portanto, aqui foi utilizada a função embutida toJS. Ela retorna um novo objeto no qual todos os proxies são substituídos pelos campos originais. Durante a execução, ela lê todos os campos do objeto – consequentemente, os getters são acionados.

No console, adicionaremos novamente algumas chaves. Dessa vez, elas também foram parar no localStorage:

Desenvolvendo uma extensão de navegador segura

Após recarregar a página de fundo, as informações permanecem no lugar.

Todo o código do aplicativo até este momento pode ser visualizado aqui.

Armazenamento seguro de chaves privadas

Armazenar chaves privadas em texto claro não é seguro: sempre há a chance de que você seja invadido, que tenham acesso ao seu computador e assim por diante. Portanto, no localStorage, armazenaremos as chaves de forma criptografada com senha.

Para maior segurança, adicionaremos ao aplicativo um estado bloqueado, no qual não haverá acesso às chaves. Vamos automaticamente converter a extensão para o estado bloqueado após um tempo limite.

Mobx permite armazenar apenas o conjunto mínimo de dados, enquanto o restante é calculado automaticamente com base nesse conjunto. Essas são as chamadas propriedades computadas. Elas podem ser comparadas a views em bancos de dados:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utilitários para criptografia segura de strings. Usam crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Armazenamos a senha e as chaves criptografadas. Se a senha for null - o aplicativo está bloqueado
            password: null,
            vault: initState.vault,

            // Getters para campos computáveis. Pode-se fazer uma analogia com a view em um banco de dados.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Inicializa o armazenamento vazio com uma nova senha
    @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
        )
    }

    ... // código de conexão e api

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

    _checkLocked() {
        if (this.store.locked){
            throw new Error('O aplicativo está bloqueado')
        }
    }

    // Métodos para criptografar/descriptografar o armazenamento
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Armazenamento não inicializado')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Senha incorreta')
        }
    }
}

Agora estamos armazenando apenas chaves criptografadas e a senha. Todo o resto é calculado. A transição para o estado bloqueado é feita removendo a senha do estado. Um método foi adicionado à API pública para inicializar o armazenamento.

Para criptografia, foram escritos utilitários utilizando crypto-js:

import CryptoJS from 'crypto-js'

// Usado para dificultar a tentativa de força bruta da senha. Para cada tentativa de senha, o invasor terá que fazer 5000 hashes
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 possui uma API de inatividade, através da qual é possível se inscrever para o evento de alteração de estado. O estado, consequentemente, pode ser ocioso, ativo e bloqueado. Para inatividade, é possível configurar um tempo limite, e bloqueado é definido quando o próprio sistema operacional está bloqueado. Além disso, mudaremos o seletor para salvar no 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;
    }

    // Agora chamamos explicitamente o campo ao qual será feito o acesso, a reação funcionará corretamente
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Tempo limite de inatividade, quando o evento é acionado
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Se o usuário bloqueou a tela ou não atuou durante o intervalo especificado, bloqueamos o aplicativo
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Conectar a outros contextos
    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 até este ponto está aqui.

Transações

Então, chegamos ao mais importante: a criação e assinatura de transações na blockchain. Vamos usar a blockchain WAVES e a biblioteca waves-transactions.

Para começar, adicionaremos ao estado um array de mensagens que precisam ser assinadas, e em seguida — métodos para adicionar uma nova mensagem, confirmar a assinatura e recusar:

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) {
        // Para cada mensagem, criamos metadados com id, status, data de criação, etc.
        const message = observable.object({
            id: uuid(), // Identificador, usando uuid
            origin, // Mostraremos a origem na interface posteriormente
            data, //
            status: 'new', // Os status são: new, signed, rejected e failed
            timestamp: Date.now()
        });
        console.log(`nova mensagem: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Retornamos uma promessa dentro da qual o mobx monitora mudanças na mensagem. Assim que o status mudar, resolveremos isso
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Vamos observar o status da mensagem
                (status, reaction) => { // o segundo argumento é uma referência à própria reação, para que possamos destruí-la dentro da chamada
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Usuário rejeitou a mensagem'));
                            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(`Nenhuma msg com 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(`Nenhuma msg com id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Ao receber uma nova mensagem, adicionamos metadados a ela, fazendo observável e adicionamos em store.messages.

Se não fizermos isso observável manualmente, o mobx fará isso ao adicionar ao array de messages. No entanto, ele criará um novo objeto, do qual não teremos uma referência, e precisaremos dela para o próximo passo.

Em seguida, retornamos uma promessa que se resolve quando o status da mensagem mudar. O status é monitorado por uma reação, que se "destruirá" automaticamente ao mudar de status.

O código dos métodos approve e reject é muito simples: apenas mudamos o status da mensagem, previamente assinando-a, se necessário.

Aprovar e rejeitar são disponibilizados na UI da API, newMessage — na página da API:

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

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

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

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

    ...
}

Agora vamos tentar assinar a transação com a extensão:

Desenvolvendo uma extensão de navegador segura

No geral, tudo está pronto, só falta adicionar uma UI simples.

UI

A interface precisa de acesso ao estado da aplicação. No lado da UI, faremos observável o estado e adicionaremos uma função na API que mudará esse estado. Vamos adicionar observável no objeto da API, obtido do background:

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

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

setupUi().catch(console.error);

async function setupUi() {
    // Conectando à porta, criando um stream a partir dela
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Criando um observable vazio para o estado do background
    let backgroundState = observable.object({});
    const api = {
        // Damos ao background uma função que atualizará o observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Adicionamos ao background o observable com o estado
    background.state = backgroundState;

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

    // Iniciando a interface
    await initApp(background)
}

No final, iniciamos o render da interface da aplicação. Este é um aplicativo React. O objeto do background é simplesmente passado através de props. Claro que seria melhor criar um serviço separado para os métodos e um store para o estado, mas dentro do escopo deste artigo, isso é suficiente:

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

// Inicializamos o aplicativo com o objeto background como props
export async function initApp(background){
    render(
        ,
        document.getElementById('app-content')
    );
}

Com o mobx, é muito fácil iniciar o render quando os dados mudam. Nós simplesmente usamos o decorador observer do pacote mobx-react O componente será chamado automaticamente ao alterar qualquer observable ao qual o componente se refere. Não são necessários mapStateToProps ou connect, como no Redux. Tudo funciona imediatamente "fora da 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 // O componente com este decorator chamará automaticamente o método render se houver alterações nos observáveis aos quais ele se refere
export default class App extends Component {

    // É correto, claro, extrair a lógica de renderização das páginas na roteirização e não usar operadores ternários aninhados,
    // e vincular observáveis e métodos de fundo diretamente aos componentes que os utilizam
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

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

Os outros componentes podem ser visualizados no código na pasta UI.

Agora, na classe do aplicativo, é necessário criar um seletor de estado para a UI e notificar a UI quando houver uma alteração. Para isso, adicionaremos o método getState e reaction, chamando 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 {

    ...

    // público
    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) => {
            // Criamos uma reação nas mudanças de estado, que chamará a função remota e atualizará o estado no processo da UI
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // O terceiro argumento pode receber parâmetros. fireImmediately significa que a reação será executada imediatamente da primeira vez.
                // Isso é necessário para obter o estado inicial. Delay permite configurar o debounce
                {fireImmediately: true, delay: 500}
            );
            // Removeremos a assinatura ao desconectar o cliente
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Ao receber o objeto remoto uma alteração é criada reaction no estado, que chama a função do lado da UI.

O último toque — vamos adicionar a exibição de novas mensagens no ícone da extensão:

function setupApp() {
...

    // Reação para definir o texto da badge.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Então, o aplicativo está pronto. As páginas web podem solicitar a assinatura de transações:

Desenvolvendo uma extensão de navegador segura

Desenvolvendo uma extensão de navegador segura

O código está disponível neste link.

Conclusão

Se você leu o artigo até o final, mas ainda tem perguntas, pode fazê-las no repositório da extensão. Lá você encontrará commits para cada passo indicado.

E se você tiver interesse em ver o código de uma extensão real, você poderá encontrar isso aqui.

Código, repositório e descrição do funcionamento de siemarell

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster