Escribir una extensión de navegador segura

Escribir una extensión de navegador segura

A diferencia de la arquitectura común “cliente-servidor”, las aplicaciones descentralizadas se caracterizan por:

  • No es necesario almacenar una base de datos con nombres de usuario y contraseñas. La información de acceso es almacenada exclusivamente por los propios usuarios y la confirmación de su autenticidad se produce a nivel de protocolo.
  • No es necesario utilizar un servidor. La lógica de la aplicación se puede ejecutar en una red blockchain, donde es posible almacenar la cantidad necesaria de datos.

Hay dos lugares de almacenamiento relativamente seguros para las claves de usuario: carteras de hardware y extensiones de navegador. Las carteras de hardware son en su mayoría extremadamente seguras, pero difíciles de usar y lejos de ser gratuitas, pero las extensiones del navegador son la combinación perfecta de seguridad y facilidad de uso, y también pueden ser completamente gratuitas para los usuarios finales.

Teniendo todo esto en cuenta, queríamos crear la extensión más segura que simplifique el desarrollo de aplicaciones descentralizadas al proporcionar una API simple para trabajar con transacciones y firmas.
Te contamos esta experiencia a continuación.

El artículo contendrá instrucciones paso a paso sobre cómo escribir una extensión de navegador, con ejemplos de código y capturas de pantalla. Puedes encontrar todo el código en repositorios. Cada confirmación corresponde lógicamente a una sección de este artículo.

Una breve historia de las extensiones del navegador

Las extensiones de navegador existen desde hace mucho tiempo. Aparecieron en Internet Explorer en 1999, en Firefox en 2004. Sin embargo, durante mucho tiempo no existió un estándar único para las extensiones.

Podemos decir que apareció junto con extensiones en la cuarta versión de Google Chrome. Por supuesto, entonces no había ninguna especificación, pero fue la API de Chrome la que se convirtió en su base: habiendo conquistado la mayor parte del mercado de navegadores y teniendo una tienda de aplicaciones incorporada, Chrome realmente estableció el estándar para las extensiones de navegador.

Mozilla tenía su propio estándar, pero al ver la popularidad de las extensiones de Chrome, la empresa decidió crear una API compatible. En 2015, por iniciativa de Mozilla, se creó un grupo especial dentro del World Wide Web Consortium (W3C) para trabajar en especificaciones de extensiones para varios navegadores.

Se tomaron como base las extensiones API existentes para Chrome. El trabajo se llevó a cabo con el apoyo de Microsoft (Google se negó a participar en el desarrollo del estándar) y como resultado apareció un borrador. especificaciones.

Formalmente, la especificación es compatible con Edge, Firefox y Opera (tenga en cuenta que Chrome no está en esta lista). Pero, de hecho, el estándar es en gran medida compatible con Chrome, ya que en realidad está escrito en base a sus extensiones. Puede leer más sobre la API de WebExtensions aquí.

Estructura de extensión

El único archivo necesario para la extensión es el manifiesto (manifest.json). También es el “punto de entrada” a la expansión.

Manifiesto

Según la especificación, el archivo de manifiesto es un archivo JSON válido. Se puede ver una descripción completa de las claves de manifiesto con información sobre qué claves son compatibles y en qué navegador. aquí.

Las claves que no están en la especificación "pueden" ignorarse (tanto Chrome como Firefox informan errores, pero las extensiones continúan funcionando).

Y me gustaría llamar la atención sobre algunos puntos.

  1. fondo — un objeto que incluye los siguientes campos:
    1. guiones — una serie de scripts que se ejecutarán en el contexto de fondo (hablaremos de esto un poco más adelante);
    2. página - en lugar de scripts que se ejecutarán en una página vacía, puede especificar html con contenido. En este caso, el campo script se ignorará y será necesario insertar los scripts en la página de contenido;
    3. persistente — una bandera binaria, si no se especifica, el navegador “matará” el proceso en segundo plano cuando considere que no está haciendo nada y lo reiniciará si es necesario. De lo contrario, la página sólo se descargará cuando se cierre el navegador. No es compatible con Firefox.
  2. scripts_de_contenido — una serie de objetos que le permite cargar diferentes scripts en diferentes páginas web. Cada objeto contiene los siguientes campos importantes:
    1. cerillas - URL del patrón, que determina si se incluirá o no un guión de contenido en particular.
    2. js — una lista de scripts que se cargarán en este partido;
    3. excluir_coincidencias - excluye del campo match URL que coinciden con este campo.
  3. página_acción - es en realidad un objeto responsable del icono que se muestra junto a la barra de direcciones en el navegador y de la interacción con él. También le permite mostrar una ventana emergente, que se define utilizando su propio HTML, CSS y JS.
    1. ventana emergente_predeterminada — ruta al archivo HTML con la interfaz emergente, puede contener CSS y JS.
  4. permisos — una matriz para gestionar los derechos de extensión. Existen 3 tipos de derechos, los cuales se describen detalladamente aquí
  5. recursos_accesibles_web — recursos de extensión que una página web puede solicitar, por ejemplo, imágenes, archivos JS, CSS, HTML.
  6. conectable externamente — aquí puede especificar explícitamente los ID de otras extensiones y dominios de páginas web desde las que puede conectarse. Un dominio puede ser de segundo nivel o superior. No funciona en Firefox.

Contexto de ejecución

La extensión tiene tres contextos de ejecución de código, es decir, la aplicación consta de tres partes con diferentes niveles de acceso a la API del navegador.

Contexto de extensión

La mayor parte de la API está disponible aquí. En este contexto “viven”:

  1. Página de antecedentes - Parte "backend" de la extensión. El archivo se especifica en el manifiesto utilizando la clave "fondo".
  2. Página emergente — una página emergente que aparece al hacer clic en el icono de la extensión. en el manifiesto browser_action -> default_popup.
  3. Pagina personalizada — página de extensión, “viviendo” en una pestaña separada de la vista chrome-extension://<id_расширения>/customPage.html.

Este contexto existe independientemente de las ventanas y pestañas del navegador. Página de antecedentes existe en una sola copia y siempre funciona (la excepción es la página del evento, cuando el script en segundo plano es iniciado por un evento y "muere" después de su ejecución). Página emergente existe cuando la ventana emergente está abierta, y Pagina personalizada – mientras la pestaña que lo contiene está abierta. No hay acceso a otras pestañas ni a sus contenidos desde este contexto.

Contexto del guión de contenido

El archivo de secuencia de comandos de contenido se inicia junto con cada pestaña del navegador. Tiene acceso a parte de la API de la extensión y al árbol DOM de la página web. Son los guiones de contenido los responsables de la interacción con la página. Las extensiones que manipulan el árbol DOM hacen esto en scripts de contenido (por ejemplo, bloqueadores de anuncios o traductores). Además, el script de contenido puede comunicarse con la página a través de estándar postMessage.

contexto de la página web

Esta es la página web real. No tiene nada que ver con la extensión y no tiene acceso allí, excepto en los casos en que el dominio de esta página no esté indicado explícitamente en el manifiesto (más sobre esto a continuación).

Mensajería

Las distintas partes de la aplicación deben intercambiar mensajes entre sí. Hay una API para esto runtime.sendMessage para enviar un mensaje background и tabs.sendMessage para enviar un mensaje a una página (script de contenido, ventana emergente o página web si está disponible) externally_connectable). A continuación se muestra un ejemplo de cómo acceder a la API de Chrome.

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

Para una comunicación completa, puede crear conexiones a través de runtime.connect. En respuesta recibiremos runtime.Port, al que, mientras esté abierto, podrás enviar cualquier número de mensajes. Del lado del cliente, por ejemplo, contentscript, se parece a esto:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

Servidor o fondo:

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

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

También hay un evento onDisconnect y metodo disconnect.

Diagrama de aplicación

Creemos una extensión de navegador que almacene claves privadas, brinde acceso a información pública (dirección, la clave pública se comunica con la página y permite que aplicaciones de terceros soliciten una firma para transacciones.

Desarrollo de aplicaciones

Nuestra aplicación debe interactuar con el usuario y proporcionar a la página una API para llamar a métodos (por ejemplo, para firmar transacciones). Confórmate con solo uno contentscript no funcionará, ya que solo tiene acceso al DOM, pero no al JS de la página. Conéctese a través de runtime.connect no podemos, porque la API es necesaria en todos los dominios y solo se pueden especificar algunos específicos en el manifiesto. Como resultado, el diagrama se verá así:

Escribir una extensión de navegador segura

Habrá otro guión. inpage, que inyectaremos en la página. Se ejecutará en su contexto y proporcionará una API para trabajar con la extensión.

principio

Todo el código de extensión del navegador está disponible en GitHub. Durante la descripción habrá enlaces a confirmaciones.

Empecemos con el manifiesto:

{
  // Имя и описание, версия. Все это будет видно в браузере в 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"]
}

Cree background.js, popup.js, inpage.js y contentscript.js vacíos. Agregamos popup.html y nuestra aplicación ya se puede cargar en Google Chrome y asegurarnos de que funciona.

Para verificar esto, puedes tomar el código. por lo tanto. Además de lo que hicimos nosotros, el enlace configuró el montaje del proyecto usando webpack. Para agregar una aplicación al navegador, en chrome://extensiones debe seleccionar cargar descomprimido y la carpeta con la extensión correspondiente, en nuestro caso dist.

Escribir una extensión de navegador segura

Ahora nuestra extensión está instalada y funcionando. Puede ejecutar las herramientas de desarrollador para diferentes contextos de la siguiente manera:

ventana emergente ->

Escribir una extensión de navegador segura

El acceso a la consola del script de contenidos se realiza a través de la consola de la propia página en la que se inicia.Escribir una extensión de navegador segura

Mensajería

Por lo tanto, necesitamos establecer dos canales de comunicación: en la página <-> fondo y en la ventana emergente <-> fondo. Por supuesto, puedes simplemente enviar mensajes al puerto e inventar tu propio protocolo, pero prefiero el enfoque que vi en el proyecto de código abierto de metamask.

Esta es una extensión del navegador para trabajar con la red Ethereum. En él, diferentes partes de la aplicación se comunican vía RPC usando la biblioteca dnode. Le permite organizar un intercambio con bastante rapidez y comodidad si le proporciona un flujo de nodejs como transporte (es decir, un objeto que implementa la misma interfaz):

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

Ahora crearemos una clase de aplicación. Creará objetos API para la ventana emergente y la página web, y creará un dnodo para ellos:

import Dnode from 'dnode/browser';

export class SignerApp {

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

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

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

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

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

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

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

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

Aquí y a continuación, en lugar del objeto global de Chrome, usamos extensionApi, que accede a Chrome en el navegador de Google y al navegador en otros. Esto se hace por compatibilidad entre navegadores, pero para los fines de este artículo, simplemente se podría usar 'chrome.runtime.connect'.

Creemos una instancia de aplicación en el script en segundo plano:

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

const app = new SignerApp();

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

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

Dado que dnode trabaja con transmisiones y recibimos un puerto, se necesita una clase de adaptador. Se crea utilizando la biblioteca readable-stream, que implementa flujos de nodejs en el 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()
    }
}

Ahora creemos una conexión en la interfaz de usuario:

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

Luego creamos la conexión en el script de contenido:

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 necesitamos la API no en el script de contenido, sino directamente en la página, hacemos dos cosas:

  1. Creamos dos corrientes. Uno: hacia la página, encima del mensaje posterior. Para esto usamos esto este paquete de los creadores de metamask. La segunda transmisión es en segundo plano sobre el puerto recibido de runtime.connect. Comprémoslos. Ahora la página tendrá una transmisión en segundo plano.
  2. Inyecte el script en el DOM. Descargue el script (el acceso a él estaba permitido en el manifiesto) y cree una etiqueta script con su contenido en el interior:

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

Ahora creamos un objeto api en inpage y lo configuramos como global:

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

setupInpageApi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Estamos listos Llamada a procedimiento remoto (RPC) con API separada para página y UI. Al conectar una nueva página al fondo podemos ver esto:

Escribir una extensión de navegador segura

API vacía y origen. En el lado de la página, podemos llamar a la función hola de esta manera:

Escribir una extensión de navegador segura

Trabajar con funciones de devolución de llamada en JS moderno es de mala educación, así que escribamos una pequeña ayuda para crear un dnodo que le permita pasar un objeto API a utils.

Los objetos API ahora tendrán este aspecto:

export class SignerApp {

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

...

}

Obtener un objeto desde un control remoto como este:

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

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

Y llamar a funciones devuelve una promesa:

Escribir una extensión de navegador segura

Versión con funciones asíncronas disponibles aquí.

En general, el enfoque de RPC y flujo parece bastante flexible: podemos usar la multiplexación de Steam y crear varias API diferentes para diferentes tareas. En principio, dnode se puede utilizar en cualquier lugar, lo principal es envolver el transporte en forma de flujo de nodejs.

Una alternativa es el formato JSON, que implementa el protocolo JSON RPC 2. Sin embargo, funciona con transportes específicos (TCP y HTTP(S)), lo cual no es aplicable en nuestro caso.

Almacenamiento interno estatal y local

Necesitaremos almacenar el estado interno de la aplicación, al menos las claves de firma. Podemos agregar fácilmente un estado a la aplicación y métodos para cambiarlo en la API emergente:

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

export class SignerApp {

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

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

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

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

    ...

} 

En segundo plano, envolveremos todo en una función y escribiremos el objeto de la aplicación en la ventana para que podamos trabajar con él desde la consola:

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

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

setupApp();

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

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Agreguemos algunas claves desde la consola UI y veamos qué sucede con el estado:

Escribir una extensión de navegador segura

El estado debe hacerse persistente para que las claves no se pierdan al reiniciar.

Lo almacenaremos en localStorage, sobrescribiéndolo con cada cambio. Posteriormente, el acceso a él también será necesario para la interfaz de usuario y también me gustaría suscribirme a los cambios. En base a esto, será conveniente crear un almacenamiento observable y suscribirse a sus cambios.

Usaremos la biblioteca mobx (https://github.com/mobxjs/mobx). La elección recayó en él porque no tenía que trabajar con él, pero tenía muchas ganas de estudiarlo.

Agreguemos la inicialización del estado inicial y hagamos que la tienda sea observable:

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

export class SignerApp {

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

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

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

    ...

}

"Bajo el capó", mobx ha reemplazado todos los campos de la tienda con proxy e intercepta todas las llamadas a ellos. Será posible suscribirse a estos mensajes.

A continuación utilizaré a menudo el término "al cambiar", aunque esto no es del todo correcto. Mobx rastrea el acceso a los campos. Se utilizan captadores y definidores de objetos proxy que crea la biblioteca.

Los decoradores de acciones tienen dos propósitos:

  1. En modo estricto con el indicador enforceActions, mobx prohíbe cambiar el estado directamente. Se considera una buena práctica trabajar en condiciones estrictas.
  2. Incluso si una función cambia de estado varias veces (por ejemplo, cambiamos varios campos en varias líneas de código), los observadores reciben una notificación solo cuando se completa. Esto es especialmente importante para la interfaz, donde las actualizaciones de estado innecesarias conducen a una representación innecesaria de elementos. En nuestro caso ni lo primero ni lo segundo son especialmente relevantes, pero seguiremos las mejores prácticas. Es habitual adjuntar decoradores a todas las funciones que cambian el estado de los campos observados.

En segundo plano agregaremos la inicialización y guardaremos el estado en localStorage:

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

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

setupApp();

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

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

    // Setup state persistence

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

La función de reacción es interesante aquí. Tiene dos argumentos:

  1. Selector de datos.
  2. Un controlador al que se llamará con estos datos cada vez que cambie.

A diferencia de redux, donde recibimos explícitamente el estado como argumento, mobx recuerda a qué observables accedemos dentro del selector y solo llama al controlador cuando cambian.

Es importante comprender exactamente cómo mobx decide a qué observables nos suscribimos. Si escribiera un selector en código como este() => app.store, entonces nunca se llamará a la reacción, ya que el almacenamiento en sí no es observable, solo sus campos lo son.

Si lo escribiera así () => app.store.keys, nuevamente no sucedería nada, ya que al agregar/eliminar elementos de la matriz, la referencia no cambiará.

Mobx actúa como selector por primera vez y solo realiza un seguimiento de los observables a los que hemos accedido. Esto se hace a través de captadores de proxy. Por lo tanto, aquí se utiliza la función incorporada. toJS. Devuelve un nuevo objeto con todos los servidores proxy reemplazados por los campos originales. Durante la ejecución, lee todos los campos del objeto, por lo que se activan los captadores.

En la consola emergente agregaremos nuevamente varias claves. Esta vez también terminaron en localStorage:

Escribir una extensión de navegador segura

Cuando se recarga la página de fondo, la información permanece en su lugar.

Se puede ver todo el código de la aplicación hasta este punto. aquí.

Almacenamiento seguro de claves privadas

Almacenar claves privadas en texto claro no es seguro: siempre existe la posibilidad de que lo pirateen, obtenga acceso a su computadora, etc. Por lo tanto, en localStorage almacenaremos las claves en forma cifrada con contraseña.

Para mayor seguridad, agregaremos un estado bloqueado a la aplicación, en el que no habrá ningún acceso a las claves. Transferiremos automáticamente la extensión al estado bloqueado debido a un tiempo de espera.

Mobx le permite almacenar solo un conjunto mínimo de datos y el resto se calcula automáticamente en función de él. Estas son las llamadas propiedades calculadas. Se pueden comparar con vistas en bases de datos:

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

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

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

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

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

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

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

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

Ahora sólo almacenamos las claves cifradas y la contraseña. Todo lo demás está calculado. Hacemos la transferencia a un estado bloqueado eliminando la contraseña del estado. La API pública ahora tiene un método para inicializar el almacenamiento.

Escrito para cifrado utilidades usando crypto-js:

import CryptoJS from 'crypto-js'

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

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

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

El navegador tiene una API inactiva a través de la cual puede suscribirse a un evento: cambios de estado. En consecuencia, el Estado puede ser idle, active и locked. Para inactivo, puede establecer un tiempo de espera y bloqueado se establece cuando el sistema operativo está bloqueado. También cambiaremos el selector para guardar en localStorage:

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

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

setupApp();

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

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

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

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

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

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

El código antes de este paso es aquí.

Transacciones

Entonces, llegamos a lo más importante: crear y firmar transacciones en blockchain. Usaremos la cadena de bloques y la biblioteca WAVES. transacciones-de-ondas.

Primero, agreguemos al estado una serie de mensajes que deben firmarse, luego agreguemos métodos para agregar un nuevo mensaje, confirmar la firma y rechazar:

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

    ...
}

Cuando recibimos un mensaje nuevo, le agregamos metadatos, ¿no? observable y agregar a store.messages.

Si no lo haces observable manualmente, entonces mobx lo hará solo al agregar mensajes a la matriz. Sin embargo, creará un nuevo objeto al que no tendremos referencia, pero la necesitaremos para el siguiente paso.

A continuación, devolvemos una promesa que se resuelve cuando cambia el estado del mensaje. El estado es monitoreado por la reacción, que se "matará a sí misma" cuando el estado cambie.

Código de método approve и reject muy sencillo: simplemente cambiamos el estado del mensaje, previa firma si es necesario.

Ponemos Aprobar y rechazar en la API de la interfaz de usuario, nuevoMensaje en la API de la página:

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

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

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

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

    ...
}

Ahora intentemos firmar la transacción con la extensión:

Escribir una extensión de navegador segura

En general todo está listo, solo queda agregar interfaz de usuario simple.

UI

La interfaz necesita acceso al estado de la aplicación. En el lado de la interfaz de usuario haremos observable state y agregue una función a la API que cambiará este estado. agreguemos observable al objeto API recibido del fondo:

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

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

setupUi().catch(console.error);

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

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

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

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

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

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

Al final comenzamos a renderizar la interfaz de la aplicación. Esta es una aplicación de reacción. El objeto de fondo simplemente se pasa usando accesorios. Por supuesto, sería correcto crear un servicio separado para métodos y un almacén para el estado, pero para los propósitos de este artículo esto es suficiente:

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

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

Con mobx es muy fácil comenzar a renderizar cuando cambian los datos. Simplemente colgamos el decorador observador del paquete. mobx-reaccionar en el componente, y se llamará automáticamente a render cuando cambie cualquier observable al que haga referencia el componente. No necesita ningún mapStateToProps ni conectarse como en redux. Todo funciona desde el primer momento:

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

Los componentes restantes se pueden ver en el código. en la carpeta de la interfaz de usuario.

Ahora, en la clase de aplicación, debe crear un selector de estado para la interfaz de usuario y notificar a la interfaz de usuario cuando cambie. Para hacer esto, agreguemos un método. getState и reactionvocación 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())

        })
    }

    ...
}

Al recibir un objeto remote se crea reaction para cambiar el estado que llama a la función en el lado de la interfaz de usuario.

El toque final es agregar la visualización de mensajes nuevos en el ícono de la extensión:

function setupApp() {
...

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

...
}

Entonces, la aplicación está lista. Las páginas web podrán solicitar firma para transacciones:

Escribir una extensión de navegador segura

Escribir una extensión de navegador segura

El código está disponible aquí. enlace.

Conclusión

Si ha leído el artículo hasta el final, pero aún tiene preguntas, puede hacerlas en repositorios con extensión. Allí también encontrará confirmaciones para cada paso designado.

Y si está interesado en ver el código de la extensión real, puede encontrar esto aquí.

Código, repositorio y descripción del trabajo de siemarell

Fuente: habr.com

Añadir un comentario