Escrevendo uma extensão de navegador segura

Escrevendo uma extensão de navegador segura

Ao contrário da arquitetura comum “cliente-servidor”, as aplicações descentralizadas são caracterizadas por:

  • Não há necessidade de armazenar um banco de dados com logins e senhas de usuários. As informações de acesso são armazenadas exclusivamente pelos próprios usuários e a confirmação de sua autenticidade ocorre em nível de protocolo.
  • Não há necessidade de usar um servidor. A lógica da aplicação pode ser executada em uma rede blockchain, onde é possível armazenar a quantidade necessária de dados.

Existem 2 armazenamentos relativamente seguros para chaves de usuário – carteiras de hardware e extensões de navegador. As carteiras de hardware são, em sua maioria, extremamente seguras, mas difíceis de usar e longe de serem gratuitas, mas as extensões do navegador são a combinação perfeita de segurança e facilidade de uso, e também podem ser totalmente gratuitas para usuários finais.

Levando tudo isso em consideração, queríamos fazer a extensão mais segura que simplificasse o desenvolvimento de aplicações descentralizadas, fornecendo uma API simples para trabalhar com transações e assinaturas.
Contaremos sobre essa experiência a seguir.

O artigo conterá instruções passo a passo sobre como escrever uma extensão de navegador, com exemplos de código e capturas de tela. Você pode encontrar todo o código em repositórios. Cada commit corresponde logicamente a uma seção deste artigo.

Uma breve história das extensões de navegador

As extensões de navegador já existem há muito tempo. Eles apareceram no Internet Explorer em 1999, no Firefox em 2004. No entanto, durante muito tempo não houve um padrão único para extensões.

Podemos dizer que apareceu junto com as extensões na quarta versão do Google Chrome. Claro, não havia especificação na época, mas foi a API do Chrome que se tornou sua base: tendo conquistado a maior parte do mercado de navegadores e tendo uma loja de aplicativos integrada, o Chrome realmente estabeleceu o padrão para extensões de navegador.

A Mozilla tinha seu próprio padrão, mas vendo a popularidade das extensões do Chrome, a empresa decidiu fazer uma API compatível. Em 2015, por iniciativa da Mozilla, foi criado um grupo especial dentro do World Wide Web Consortium (W3C) para trabalhar em especificações de extensões entre navegadores.

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

Formalmente, a especificação é suportada pelo Edge, Firefox e Opera (observe que o Chrome não está nesta lista). Mas, na verdade, o padrão é amplamente compatível com o Chrome, já que na verdade é escrito com base em suas extensões. Você pode ler mais sobre a API WebExtensions aqui.

Estrutura de extensão

O único arquivo necessário para a extensão é o manifesto (manifest.json). É também o “ponto de entrada” para a expansão.

Manifesto

De acordo com a especificação, o arquivo de manifesto é um arquivo JSON válido. Uma descrição completa das chaves do manifesto com informações sobre quais chaves são suportadas e em quais navegadores podem ser visualizadas aqui.

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

E gostaria de chamar a atenção para alguns pontos.

  1. fundo — um objeto que inclui os seguintes campos:
    1. Scripts — um conjunto de scripts que serão executados no contexto de fundo (falaremos sobre isso um pouco mais tarde);
    2. página - em vez de scripts que serão executados em uma página vazia, você pode especificar html com conteúdo. Neste caso, o campo script será ignorado e os scripts precisarão ser inseridos na página de conteúdo;
    3. persistente — um sinalizador binário, se não for especificado, o navegador irá “matar” o processo em segundo plano quando considerar que não está fazendo nada, e reiniciá-lo se necessário. Caso contrário, a página só será descarregada quando o navegador for fechado. Não suportado no Firefox.
  2. scripts de conteúdo — uma matriz de objetos que permite carregar diferentes scripts em diferentes páginas da web. Cada objeto contém os seguintes campos importantes:
    1. fósforos - URL padrão, que determina se um determinado script de conteúdo será incluído ou não.
    2. js — uma lista de scripts que serão carregados nesta partida;
    3. exclude_matches - exclui do campo match URLs que correspondem a este campo.
  3. ação_da_página - é na verdade um objeto responsável pelo ícone exibido próximo à barra de endereço do navegador e pela interação com ele. Também permite exibir uma janela pop-up, que é definida usando seu próprio HTML, CSS e JS.
    1. Default_Popup. — caminho para o arquivo HTML com a interface pop-up, pode conter CSS e JS.
  4. permissões — uma matriz para gerenciar direitos de extensão. Existem 3 tipos de direitos, que são descritos detalhadamente aqui
  5. web_accessible_resources — recursos de extensão que uma página web pode solicitar, por exemplo, imagens, arquivos JS, CSS, HTML.
  6. conectável externamente — aqui você pode especificar explicitamente os IDs de outras extensões e domínios de páginas da web a partir dos quais você pode se conectar. Um domínio pode ser de segundo nível ou superior. Não funciona no Firefox.

Contexto de execução

A extensão possui três contextos de execução de código, ou seja, a aplicação é composta por três partes com diferentes níveis de acesso à API do navegador.

Contexto de extensão

A maior parte da API está disponível aqui. Neste contexto eles “vivem”:

  1. Página de fundo - parte “backend” da extensão. O arquivo é especificado no manifesto usando a chave “background”.
  2. Página pop-up — uma página pop-up que aparece quando você clica no ícone da extensão. No manifesto browser_action -> default_popup.
  3. Página personalizada — página de extensão, “vivendo” em uma guia separada da visualização chrome-extension://<id_расширения>/customPage.html.

Este contexto existe independentemente das janelas e guias do navegador. Página de fundo existe em uma única cópia e sempre funciona (a exceção é a página do evento, quando o script de background é iniciado por um evento e “morre” após sua execução). Página pop-up existe quando a janela pop-up está aberta e Página personalizada - enquanto a guia com ele estiver aberta. Não há acesso a outras guias e seus conteúdos neste contexto.

Contexto do script de conteúdo

O arquivo de script de conteúdo é iniciado junto com cada guia do navegador. Tem acesso a parte da API da extensão e à árvore DOM da página web. São os scripts de conteúdo os responsáveis ​​pela interação com a página. Extensões que manipulam a árvore DOM fazem isso em scripts de conteúdo – por exemplo, bloqueadores de anúncios ou tradutores. Além disso, o script de conteúdo pode se comunicar com a página via padrão postMessage.

Contexto da página da web

Esta é a própria página da web. Não tem nada a ver com a extensão e não tem acesso à mesma, exceto nos casos em que o domínio desta página não esteja explicitamente indicado no manifesto (mais sobre isso abaixo).

Mensagens

Diferentes partes do aplicativo devem trocar mensagens entre si. Existe uma API para isso runtime.sendMessage para enviar uma mensagem background и tabs.sendMessage para enviar uma mensagem para uma página (script de conteúdo, popup ou página web se disponível externally_connectable). Abaixo está um exemplo de acesso à API do 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 uma comunicação completa, você pode criar conexões através runtime.connect. Em resposta receberemos runtime.Port, para o qual, enquanto estiver aberto, você pode enviar qualquer número de mensagens. Do lado do cliente, por exemplo, contentscript, Se parece com isso:

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

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

Há também um evento onDisconnect e método disconnect.

Diagrama de aplicação

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

Desenvolvimento de aplicações

Nosso aplicativo deve interagir com o usuário e fornecer à página uma API para chamar métodos (por exemplo, para assinar transações). Contente-se com apenas um contentscript não funcionará, pois só tem acesso ao DOM, mas não ao JS da página. Conecte-se via runtime.connect não podemos, porque a API é necessária em todos os domínios e apenas domínios específicos podem ser especificados no manifesto. Como resultado, o diagrama ficará assim:

Escrevendo uma extensão de navegador segura

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

começo

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

Comecemos pelo 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"]
}

Crie background.js, popup.js, inpage.js e contentscript.js vazios. Adicionamos popup.html - e nosso aplicativo já pode ser carregado no Google Chrome e ter certeza de que funciona.

Para verificar isso, você pode pegar o código por isso. Além do que fizemos, o link configurou a montagem do projeto utilizando webpack. Para adicionar um aplicativo ao navegador, em chrome://extensions você precisa selecionar carregar descompactado e a pasta com a extensão correspondente - no nosso caso dist.

Escrevendo uma extensão de navegador segura

Agora nossa extensão está instalada e funcionando. Você pode executar as ferramentas do desenvolvedor para diferentes contextos da seguinte maneira:

pop-up ->

Escrevendo uma extensão de navegador segura

O acesso ao console do script de conteúdo é realizado através do console da própria página em que é lançado.Escrevendo uma extensão de navegador segura

Mensagens

Portanto, precisamos estabelecer dois canais de comunicação: inpage <-> background e popup <-> background. Você pode, é claro, simplesmente enviar mensagens para a porta e inventar seu próprio protocolo, mas prefiro a abordagem que vi no projeto de código aberto metamask.

Esta é uma extensão do navegador para trabalhar com a rede Ethereum. Nele, diferentes partes da aplicação se comunicam via RPC utilizando a biblioteca dnode. Ele permite que você organize uma troca de forma bastante rápida e conveniente se você fornecer um fluxo nodejs como transporte (ou seja, um objeto 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 criaremos uma classe de aplicação. Ele criará objetos API para o pop-up e a página da web e criará um 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)
        })
    }
}

Aqui e abaixo, em vez do objeto global Chrome, usamos extensionApi, que acessa o Chrome no navegador do Google e o navegador em outros. Isso é feito para compatibilidade entre navegadores, mas para os fins deste artigo pode-se simplesmente usar 'chrome.runtime.connect'.

Vamos criar uma instância do aplicativo no script em 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)
    }
}

Como o dnode funciona com streams e recebemos uma porta, é necessária uma classe de adaptador. É feito usando a biblioteca readable-stream, que implementa streams 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 vamos criar uma 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(){
    // Также, как и в классе приложения создаем порт, оборачиваем в 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;
    }
}

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

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

  1. Criamos dois fluxos. Um - próximo à página, no topo da postMessage. Para isso usamos isso este pacote dos criadores da metamask. O segundo fluxo é o segundo plano da porta recebida de runtime.connect. Vamos comprá-los. Agora a página terá um stream em segundo plano.
  2. Injete o script no DOM. Baixe o script (o acesso foi permitido no manifesto) e crie 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(){
    // Стрим к бекграунду
    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 criamos um objeto API no inpage e o 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 prontos Chamada de procedimento remoto (RPC) com API separada para página e UI. Ao conectar uma nova página ao plano de fundo, podemos ver o seguinte:

Escrevendo uma extensão de navegador segura

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

Escrevendo uma extensão de navegador segura

Trabalhar com funções de retorno de chamada em JS moderno é falta de educação, então vamos escrever um pequeno auxiliar para criar um dnode que permite passar um objeto API para utilitários.

Os objetos da API agora ficarão assim:

export class SignerApp {

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

...

}

Obtendo um objeto remotamente assim:

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 funções retorna uma promessa:

Escrevendo uma extensão de navegador segura

Versão com funções assíncronas disponíveis aqui.

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

Uma alternativa é o formato JSON, que implementa o protocolo JSON RPC 2. Porém, funciona com transportes específicos (TCP e HTTP(S)), o que não é aplicável no nosso caso.

Estado interno e armazenamento local

Precisaremos armazenar o estado interno do aplicativo – pelo menos as chaves de assinatura. Podemos facilmente adicionar um estado ao aplicativo e métodos para alterá-lo na API pop-up:

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

    ...

} 

Em segundo plano, agruparemos tudo em uma função e escreveremos o objeto da aplicação na janela para que possamos trabalhar 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 do console da IU e ver o que acontece com o estado:

Escrevendo uma extensão de navegador segura

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

Iremos armazená-lo em localStorage, substituindo-o a cada alteração. Posteriormente, o acesso a ele também será necessário para a UI, e também gostaria de assinar as alterações. Com base nisso, será conveniente criar um armazenamento observável e assinar suas alterações.

Usaremos a biblioteca mobx (https://github.com/mobxjs/mobx). A escolha recaiu sobre isso porque eu não precisava trabalhar com isso, mas queria muito estudar.

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

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

    ...

}

“Nos bastidores”, o mobx substituiu todos os campos da loja por proxy e intercepta todas as chamadas para eles. Será possível assinar essas mensagens.

Abaixo usarei frequentemente o termo “ao mudar”, embora isso não seja totalmente correto. Mobx rastreia o acesso aos campos. São usados ​​getters e setters de objetos proxy criados pela biblioteca.

Os decoradores de ação têm dois propósitos:

  1. No modo estrito com o sinalizador EnforceActions, o mobx proíbe a alteração direta do estado. É considerada uma boa prática trabalhar sob condições estritas.
  2. Mesmo que uma função mude de estado várias vezes - por exemplo, alteramos vários campos em várias linhas de código - os observadores são notificados apenas quando ela é concluída. Isto é especialmente importante para o frontend, onde atualizações de estado desnecessárias levam à renderização desnecessária de elementos. No nosso caso, nem o primeiro nem o segundo são particularmente relevantes, mas seguiremos as melhores práticas. É comum anexar decoradores a todas as funções que alteram o estado dos campos observados.

No fundo adicionaremos a inicialização e salvaremos o estado no 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 função de reação é interessante aqui. Tem dois argumentos:

  1. Seletor de dados.
  2. Um manipulador que será chamado com esses dados sempre que eles forem alterados.

Ao contrário do redux, onde recebemos explicitamente o estado como argumento, o mobx lembra quais observáveis ​​acessamos dentro do seletor e só chama o manipulador quando eles mudam.

É importante entender exatamente como o mobx decide quais observáveis ​​assinaremos. Se eu escrevesse um seletor em código como este() => app.store, então a reação nunca será chamada, pois o armazenamento em si 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 será alterada.

Mobx atua como um seletor pela primeira vez e apenas rastreia os observáveis ​​que acessamos. Isso é feito por meio de getters de proxy. Portanto, a função integrada é usada aqui toJS. Ele retorna um novo objeto com todos os proxies substituídos pelos campos originais. Durante a execução, ele lê todos os campos do objeto - daí os getters serem acionados.

No console pop-up adicionaremos novamente várias chaves. Desta vez eles também acabaram no localStorage:

Escrevendo uma extensão de navegador segura

Quando a página de fundo é recarregada, as informações permanecem no lugar.

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

Armazenamento seguro de chaves privadas

Armazenar chaves privadas em texto não criptografado não é seguro: sempre há uma chance de você ser hackeado, obter acesso ao seu computador e assim por diante. Portanto, no localStorage armazenaremos as chaves em um formato criptografado por senha.

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

Mobx permite armazenar apenas um conjunto mínimo de dados, e o restante é calculado automaticamente com base nele. Estas são as chamadas propriedades computadas. Eles podem ser comparados a visualizações em bancos de dados:

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 armazenamos apenas as chaves criptografadas e a senha. Todo o resto é calculado. Fazemos a transferência para um estado bloqueado removendo a senha do estado. A API pública agora possui um método para inicializar o armazenamento.

Escrito para criptografia utilitários 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 possui uma API inativa por meio da qual você pode assinar um evento - alterações de estado. Estado, portanto, pode ser idle, active и locked. Para inativo você pode definir um tempo limite e bloqueado é definido quando o próprio sistema operacional é bloqueado. Também 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;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, 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 desta etapa é aqui.

Transações

Então chegamos ao ponto mais importante: criar e assinar transações no blockchain. Usaremos o blockchain e a biblioteca WAVES transações de ondas.

Primeiro, vamos adicionar ao estado uma matriz de mensagens que precisam ser assinadas e, em seguida, adicionar 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) {
        // Для каждого сообщения создаем метаданные с 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'
    }

    ...
}

Quando recebemos uma nova mensagem, adicionamos metadados a ela, fazemos observable e adicione a store.messages.

Se você não observable manualmente, o mobx fará isso sozinho ao adicionar mensagens ao array. Porém, criará um novo objeto ao qual não teremos referência, mas precisaremos dele para a próxima etapa.

A seguir, retornamos uma promessa que é resolvida quando o status da mensagem muda. O status é monitorado pela reação, que “se matará” quando o status mudar.

Código do método approve и reject muito simples: basta alterar o estado da mensagem, após assiná-la se necessário.

Colocamos Aprovar e rejeitar na API UI, newMessage na API da página:

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

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

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

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

    ...
}

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

Escrevendo uma extensão de navegador segura

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

UI

A interface precisa de acesso ao estado do aplicativo. No lado da UI, faremos observable estado e adicione uma função à API que alterará esse estado. Vamos adicionar observable ao objeto API recebido em 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)
}

No final começamos a renderizar a interface da aplicação. Este é um aplicativo de reação. O objeto de fundo é simplesmente passado usando adereços. Seria correto, claro, fazer um serviço separado para métodos e um armazenamento para o estado, mas para os fins deste artigo isso é 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')
    );
}

Com mobx é muito fácil começar a renderizar quando os dados mudam. Simplesmente penduramos o decorador observador na embalagem reação mobx no componente, e render será chamado automaticamente quando qualquer observável referenciado pelo componente for alterado. Você não precisa de nenhum mapStateToProps ou conectar-se como no redux. Tudo funciona imediatamente:

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 componentes restantes podem ser visualizados no código na pasta da IU.

Agora, na classe do aplicativo, você precisa criar um seletor de estado para a UI e notificar a UI quando ela mudar. Para fazer isso, vamos adicionar um método getState и reactionligando 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 receber um objeto remote é criado reaction para alterar o estado que chama a função no lado da interface do usuário.

O toque final é adicionar a exibição de novas mensagens no ícone da extensão:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    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 da Web podem solicitar uma assinatura para transações:

Escrevendo uma extensão de navegador segura

Escrevendo uma extensão de navegador segura

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

Conclusão

Se você leu o artigo até o fim, mas ainda tem dúvidas, pode perguntar em repositórios com extensão. Lá você também encontrará commits para cada etapa designada.

E se você estiver interessado em ver o código da extensão real, você pode encontrar isto aqui.

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

Fonte: habr.com

Adicionar um comentário