Estamos escribiendo una extensión de navegador segura

Estamos escribiendo una extensión de navegador segura

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

  • La ausencia de la necesidad de almacenar una base de datos con los nombres de usuario y contraseñas. La información de acceso se almacena exclusivamente en los propios usuarios, y la verificación de su validez ocurre a nivel de protocolo.
  • La ausencia de la necesidad de utilizar un servidor. La lógica de la aplicación puede ejecutarse en la red blockchain, donde también es posible almacenar la cantidad necesaria de datos.

Existen dos almacenes relativamente seguros para las claves de los usuarios: monederos de hardware y extensiones de navegador. Los monederos de hardware son, en su mayoría, muy seguros, pero son complicados de usar y no son baratos, mientras que las extensiones de navegador son la combinación ideal de seguridad y facilidad de uso, y además pueden ser completamente gratuitas para los usuarios finales.

Teniendo en cuenta todo esto, quisimos crear una extensión que sea lo más segura posible y que facilite el desarrollo de aplicaciones descentralizadas, proporcionando una API simple para trabajar con transacciones y firmas.
Sobre esta experiencia te hablaremos a continuación.

En este artículo encontrarás una guía paso a paso sobre cómo escribir una extensión de navegador, con ejemplos de código y capturas de pantalla. Todo el código lo puedes encontrar en el repositorio. Cada commit corresponde lógicamente a una sección de este artículo.

Breve historia de las extensiones de navegador

Las extensiones de navegador existen desde hace bastante tiempo. Aparecieron en Internet Explorer en 1999 y en Firefox en 2004. Sin embargo, durante mucho tiempo no hubo un estándar unificado para las extensiones.

Se puede decir que el estándar apareció junto con las extensiones en la cuarta versión de Google Chrome. Por supuesto, no había ninguna especificación entonces, pero precisamente la API de Chrome se convirtió en su base: al conquistar la mayor parte del mercado de navegadores y tener una tienda de aplicaciones integrada, Chrome estableció efectivamente el estándar para las extensiones de navegador.

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

Se basó en una API de extensiones ya existente para Chrome. El trabajo se realizó con el apoyo de Microsoft (Google se negó a participar en el desarrollo del estándar), y como resultado se creó un borrador la especificación.

Formalmente, Edge, Firefox y Opera respaldan la especificación (observe que Chrome no está en esta lista). Pero en realidad, el estándar es en gran medida compatible con Chrome, ya que se basa efectivamente en sus extensiones. Puede leer más sobre la API de WebExtensions aquí.

Estructura de la extensión

El único archivo que se necesita obligatoriamente para la extensión es el manifiesto (manifest.json). Este también es el "punto de entrada" de la extensión.

Manifiesto

Según la especificación, el archivo del manifiesto es un archivo JSON válido. La descripción completa de las claves del manifiesto, junto con información sobre qué claves son compatibles en qué navegador, se puede consultar aquí.

Las claves que no están en la especificación "pueden" ser ignoradas (tanto Chrome como Firefox informan sobre errores, pero las extensiones siguen funcionando).

Y me gustaría señalar algunos puntos.

  1. background — objeto que incluye los siguientes campos:
    1. scripts — matriz de scripts que se ejecutarán en el contexto de fondo (hablaremos de esto más adelante);
    2. page — en lugar de scripts que se ejecutarán en una página en blanco, se puede especificar HTML con contenido. En este caso, el campo script será ignorado y los scripts deberán insertarse en la página con contenido;
    3. persistentes — un indicador binario, si no se especifica, el navegador "terminará" el proceso de fondo cuando considere que no está haciendo nada y lo reiniciará si es necesario. De lo contrario, la página solo se descargará al cerrar el navegador. No es compatible con Firefox.
  2. content_scripts — matriz de objetos que permite cargar diferentes scripts en diferentes páginas web. Cada objeto contiene los siguientes campos importantes:
    1. matches — patrón url, que determina si se incluirá un script de contenido específico o no.
    2. js — lista de scripts que se cargarán en este partido;
    3. exclude_matches — excluye del campo match URLs que cumplen con este campo.
  3. page_action — en realidad es un objeto que se encarga del ícono que aparece junto a la barra de direcciones en el navegador y la interacción con él. También permite mostrar una ventana emergente que se configura mediante su propio HTML, CSS y JS.
    1. default_popup — ruta al archivo HTML con la interfaz emergente, que puede contener CSS y JS.
  4. permissions — un conjunto para gestionar los permisos de la extensión. Existen 3 tipos de permisos, que se describen en detalle aquí
  5. web_accessible_resources — recursos de la extensión que puede solicitar una página web, como imágenes, archivos JS, CSS, HTML.
  6. externally_connectable — aquí se pueden especificar explícitamente las ID de otras extensiones y los dominios de las páginas web desde las cuales se puede conectar. El 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 la extensión

Aquí está disponible la mayor parte de la API. En este contexto 'viven':

  1. Página de fondo — la parte 'backend' de la extensión. El archivo se especifica en el manifiesto bajo la clave 'background'.
  2. Página emergente — la página emergente que aparece al hacer clic en el icono de la extensión. En el manifiesto browser_action -> default_popup.
  3. Página personalizada — página de la extensión, 'viviendo' en una pestaña separada como chrome-extension:///customPage.html.

Este contexto existe independientemente de las ventanas y pestañas del navegador. Página de fondo existe en una sola instancia y siempre está activo (excepción: página de eventos, cuando el script de fondo se activa por un evento y 'muere' después de su ejecución). Página emergente existe cuando hay una ventana emergente abierta, y Página personalizada — mientras haya una pestaña abierta con ella. No hay acceso a otras pestañas y su contenido desde este contexto.

Contexto de script de contenido

El archivo del script de contenido se ejecuta 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. Los scripts de contenido son los responsables de interactuar con la página. Las extensiones que manipulan el árbol DOM lo hacen en scripts de contenido, por ejemplo, bloqueadores de anuncios o traductores. También el script de contenido puede comunicarse con la página a través del estándar postMessage.

Contexto de la página web

Esta es la propia página web. No tiene relación con la extensión y no tiene acceso a ella, excepto en los casos en que el dominio de esta página no está explícitamente especificado en el manifiesto (más sobre esto abajo).

Intercambio de mensajes

Las diferentes partes de la aplicación deben intercambiar mensajes entre sí. Para esto existe la API runtime.sendMessage para enviar un mensaje background y tabs.sendMessage para enviar un mensaje a la página (al script de contenido, a la ventana emergente o a la página web si hay externally_connectable). A continuación se presenta un ejemplo al llamar 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, se pueden crear conexiones a través de runtime.connect. En respuesta obtendremos runtime.Port, en el cual, mientras esté abierto, se pueden enviar cualquier cantidad de mensajes. En el lado del cliente, por ejemplo, contentscript, se ve así:

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

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

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

También hay un evento onDisconnect y un método desconexión.

Esquema de la aplicación

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

Desarrollo de la aplicación

Nuestra aplicación debe interactuar tanto con el usuario como proporcionar una API a la página para llamar métodos (por ejemplo, para firmar transacciones). No podemos depender solo de contentscript porque solo tiene acceso al DOM, pero no al JS de la página. No podemos conectarnos a través de runtime.connect porque la API necesita estar disponible en todos los dominios, y en el manifiesto solo se pueden especificar dominios específicos. Al final, el esquema se verá así:

Estamos escribiendo una extensión de navegador segura

Habrá otro script — inpage, que vamos a inyectar en la página. Se ejecutará en su contexto y proporcionará una API para trabajar con la extensión.

Introducción

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

Empezaremos con el manifiesto:

{
  // Nombre y descripción, versión. Todo esto será visible en el navegador en chrome://extensions/?id=
  "name": "Signer",
  "description": "Demostración de extensión",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts que se ejecutarán en background, puede haber varios
  "background": {
    "scripts": ["background.js"]
  },

  // Qué HTML usar para popup
  "browser_action": {
    "default_title": "Mi Extensión",
    "default_popup": "popup.html"
  },

  // Scripts de contenido.
  // Tenemos un objeto: para todas las URL que comienzan con http o https, lanzamos
  // el contexto del contenscript con el script contentscript.js. Ejecutar inmediatamente tras recibir el documento para todos los frames
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Se permite el acceso a localStorage y a la API de inactividad
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Aquí se indican los recursos a los que tendrá acceso la página web. Es decir, se podrán solicitar con fetch o simplemente xhr
  "web_accessible_resources": ["inpage.js"]
}

Crearemos los archivos vacíos background.js, popup.js, inpage.js y contentscript.js. Añadimos popup.html y nuestra aplicación ya se puede cargar en Google Chrome para verificar que funciona.

Para asegurarnos de ello, podemos tomar el código desde aquí. Además de lo que hemos hecho, en el enlace se configura la construcción del proyecto utilizando webpack. Para añadir la aplicación al navegador, en chrome://extensions hay que seleccionar 'cargar descomprimido' y la carpeta con la extensión correspondiente — en nuestro caso dist.

Estamos escribiendo una extensión de navegador segura

Ahora nuestra extensión está instalada y funcionando. Se puede iniciar las herramientas para desarrolladores para diferentes contextos de la siguiente manera:

popup ->

Estamos escribiendo una extensión de navegador segura

El acceso a la consola del script de contenido se realiza a través de la consola de la página donde se ejecuta.Estamos escribiendo una extensión de navegador segura

Intercambio de mensajes

Así que necesitamos establecer dos canales de comunicación: inpage <-> background y popup <-> background. Por supuesto, se puede enviar mensajes al puerto y crear nuestro propio protocolo, pero prefiero el enfoque que vi en el proyecto de código abierto metamask.

Esta es una extensión de navegador para trabajar con la red Ethereum. En ella, diferentes partes de la aplicación se comunican a través de RPC utilizando la biblioteca dnode. Esta permite organizar el intercambio de manera bastante rápida y cómoda si se proporciona un stream de nodejs como transporte (se refiere a un objeto que implementa la misma interfaz):

import Dnode from "dnode/browser";

// En este ejemplo asumiremos que el cliente llama remotamente a funciones en el servidor, aunque nada impide que esto sea bidireccional

// Servidor
// API que queremos proporcionar
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transporte, sobre el cual funcionará dnode. Cualquier stream de nodejs. En el navegador existe la biblioteca 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Cliente
const dnodeClient = Dnode() // Llamada sin argumento significa que no estamos proporcionando API en el otro lado

// Mostrará en consola world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Ahora crearemos la clase de la aplicación. Esta creará objetos API para el popup y la página web, así como también creará dnode para ellos:

import Dnode from 'dnode/browser';

export class SignerApp {

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

    // Devuelve el objeto API para la página
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

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

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

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

    // Conecta la página
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

De aquí en adelante, en lugar del objeto global Chrome, usamos extentionApi, que accede a Chrome en el navegador de Google y a browser en otros. Esto se hace para la compatibilidad entre navegadores, pero en este artículo se podría haber utilizado simplemente 'chrome.runtime.connect'.

Creamos una instancia de la aplicación en el script de fondo:

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

const app = new SignerApp();

// onConnect se activa al conectar 'procesos' (contentscript, popup o página de la extensión)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Al establecer la conexión, se puede especificar un nombre, por lo que por ese nombre determinamos quién se ha conectado, content script o ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Dado que dnode trabaja con streams y nosotros obtenemos el puerto, se necesita una clase adaptadora. Esta se ha creado utilizando la biblioteca readable-stream, que implementa streams 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 creamos una conexión en el 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(){
    \/\/ También, como en la clase de la aplicación, creamos un puerto, lo envolvemos en stream, hacemos 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)
        })
    });

    \/\/ Hacemos que el objeto API sea accesible desde la consola
    if (DEV_MODE){
        global.background = background;
    }
}

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

  1. Creamos dos streams. Uno hacia la página, sobre postMessage. Para esto usamos este paquete de los creadores de metamask. El segundo stream va hacia el background a través del puerto recibido de runtime.connect. Los pipeamos. Ahora la página tendrá un stream hacia el background.
  2. Inyectamos el script en el DOM. Descargamos el script (el acceso a él fue permitido en el manifiesto) y creamos una etiqueta script con su contenido dentro:

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

setupConnection();
injectScript();

function setupConnection(){
    \/\/ Stream hacia el background
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    \/\/ Stream hacia la página
    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 el objeto api en inpage y lo hacemos global:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Flujo hacia el script de contenido
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

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

    // Acceso a través de window
    global.SignerApp = pageApi;
}

Estamos listos Llamada a Procedimiento Remoto (RPC) con una API separada para la página y la UI. Al conectar una nueva página al fondo, podemos verlo:

Estamos escribiendo una extensión de navegador segura

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

Estamos escribiendo una extensión de navegador segura

Trabajar con funciones de callback en JS moderno es obsoleto, así que escribamos un pequeño helper para crear dnode, que permite pasar al objeto API en utils.

Los objetos API ahora se verán así:

export class SignerApp {

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

...

}

Obteniendo el objeto de remote de la siguiente manera:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Con las utilidades cambiamos todos los callbacks por promesas
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

Y la llamada a funciones devuelve una promesa:

Estamos escribiendo una extensión de navegador segura

La versión con funciones asíncronas está disponible aquí.

En general, el enfoque con RPC y flujos parece lo suficientemente flexible: podemos usar multiplexión de flujo y crear diferentes API para distintas tareas. En principio, dnode se puede usar en cualquier lugar, lo principal es envolver el transporte como un 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.

Estado interno y localStorage

Necesitaremos almacenar el estado interno de la aplicación — al menos, las claves para la firma. Podemos añadir fácilmente el estado a la aplicación y métodos para modificarlo en el popup API:

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 el background, envolveremos todo en una función y escribiremos el objeto de la aplicación en window, para que se pueda 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)
        }
    }
}

Agregaremos algunas claves a través de la consola de UI y veremos cómo quedó el estado:

Estamos escribiendo una extensión de navegador segura

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

Las guardaremos en localStorage, sobrescribiendo en cada cambio. Posteriormente, también será necesario acceder a esto para la UI y queremos subscribirnos a los cambios. Por lo tanto, será útil crear un almacenamiento observable y subscribirnos a sus cambios.

Usaremos la biblioteca mobx (https://github.com/mobxjs/mobx). Se eligió porque no se había trabajado con ella antes y tenía muchas ganas de aprender.

Agregaremos la inicialización del estado inicial y haremos que el store sea observable:

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

export class SignerApp {

    constructor(initState = {}) {
        \/\/ El store se verá igual externamente, pero ahora todos sus campos son proxies que rastrean el acceso a ellos
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    \/\/ Los métodos que cambian el observable deben estar envueltos en un decorador
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

«Bajo el capó» mobx reemplazó todos los campos del store por proxies y intercepta todos los accesos a ellos. Se podrá subscribir a estos accesos.

A partir de aquí, usaré frecuentemente el término “al cambiar”, aunque no es del todo correcto. Mobx rastrea exactamente el acceso a los campos. Se utilizan los getters y setters de los objetos proxy que crea la biblioteca.

Los decoradores action sirven para dos propósitos:

  1. En modo estricto con el flag enforceActions, mobx prohíbe cambiar el estado directamente. Es una buena práctica trabajar precisamente en modo estricto.
  2. Incluso si la función cambia el estado varias veces, por ejemplo, cambiamos varios campos en varias líneas de código, los observadores solo son notificados al finalizar. Esto es especialmente importante para el frontend, donde actualizaciones innecesarias del estado provocan un renderizado superfluo de los elementos. En nuestro caso, ni lo primero ni lo segundo son particularmente relevantes, sin embargo, seguiremos las mejores prácticas. Se recomienda aplicar decoradores a todas las funciones que cambian el estado de los campos observables.

En el background, agregaremos la inicialización y el almacenamiento del estado en el localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Métodos auxiliares. Guardan/leem un objeto en/del localStorage en forma de cadena JSON por la clave '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;
    }

    // Configuración de la persistencia del estado

    // El resultado de reaction se asigna a una variable para poder cancelar la suscripción. No lo necesitamos, se deja como ejemplo
    const localStorageReaction = reaction(
        () => toJS(app.store), // Función seleccionadora de datos
        saveState // Función que se llamará al cambiar los datos que devuelve el selector
    );

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

Es interesante aquí la función reaction. Tiene dos argumentos:

  1. Selector de datos.
  2. Manejador que se llamará con esos datos cada vez que se cambien.

A diferencia de redux, donde obtenemos explícitamente el estado como argumento, mobx recuerda a qué observables específicos nos dirigimos dentro del selector, y solo los llama cuando cambian para ejecutar el manejador.

Es importante entender cómo mobx decide a qué observables nos suscribimos. Si en el código escribiera el selector así() => app.store, entonces reaction nunca se llamaría, ya que el almacenamiento en sí no es observable; solo sus campos lo son.

Si hubiera escrito así () => app.store.keys, entonces nuevamente no pasaría nada, ya que al agregar/eliminar elementos del array, la referencia a este no cambiará.

Mobx ejecuta la función de selector por primera vez y solo supervisa aquellos observables a los que hemos accedido. Esto se logra a través de los getters de proxy. Por lo tanto, se utiliza la función incorporada toJS. Devuelve un nuevo objeto, donde todos los proxies se reemplazan por los campos originales. Durante su ejecución, lee todos los campos del objeto; por lo tanto, se activan los getters.

En la consola popup, agregaremos nuevamente algunas claves. Esta vez también se almacenaron en localStorage:

Estamos escribiendo una extensión de navegador segura

Al recargar la página de fondo, la información permanece en su lugar.

Todo el código de la aplicación hasta este momento se puede ver aquí.

Almacenamiento seguro de claves privadas

Almacenar claves privadas en texto plano no es seguro: siempre existe la posibilidad de que te hackeen, obtengan acceso a tu computadora, etc. Por lo tanto, en localStorage almacenaremos las claves de forma cifrada con una contraseña.

Para mayor seguridad, agregaremos un estado locked a la aplicación, en el que no habrá acceso a las claves. Cambiaremos automáticamente la extensión al estado locked después de un tiempo de espera.

Mobx permite almacenar solo un conjunto mínimo de datos, y el resto se calcula automáticamente en función de estos. Estas se llaman propiedades computadas. Se pueden comparar con las vistas en bases de datos:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utilidades para el cifrado seguro de cadenas. Utilizan crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Almacenamos la contraseña y las claves cifradas. Si la contraseña es null - la aplicación está bloqueada
            password: null,
            vault: initState.vault,

            // Getters para campos computables. Se puede hacer una analogía con view en db.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Inicialización de un almacén vacío con una nueva contraseña
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // código de conexión y api

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

    _checkLocked() {
        if (this.store.locked){
            throw new Error('La aplicación está bloqueada')
        }
    }

    // Métodos para cifrar/desencriptar el almacén
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Almacén no inicializado')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Contraseña incorrecta')
        }
    }
}

Ahora solo almacenamos claves cifradas y la contraseña. Todo lo demás se calcula. La transición al estado de bloqueo se realiza eliminando la contraseña del estado. En la API pública se ha añadido un método para inicializar el almacén.

Para el cifrado se han escrito utilidades que utilizan сrypto-js:

import CryptoJS from 'crypto-js'

// Se utiliza para dificultar el intento de descifrado por fuerza bruta. Para cada posible contraseña, el atacante tendrá que realizar 5000 hash
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 de idle a la que se puede suscribir para el evento: cambios de estado. El estado, por lo tanto, puede ser inactivo, active y bloqueado. Para inactivo se puede configurar un tiempo de espera, y bloqueado se establece cuando el propio 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;
    }

    // Ahora llamamos explícitamente al campo que se accederá, la reacción funcionará correctamente
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Tiempo de espera de inactividad, cuando se dispare el evento
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Si el usuario ha bloqueado la pantalla o ha estado inactivo durante el intervalo especificado, bloqueamos la aplicación
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Conectar a otros contextos
    extensionApi.runtime.onConnect.addListener(connectRemote);

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

El código hasta este paso se encuentra aquí.

Transacciones

Así que hemos llegado a lo más importante: crear y firmar transacciones en la cadena de bloques. Utilizaremos la cadena de bloques WAVES y la biblioteca waves-transactions.

Primero, agregaremos al estado un arreglo de mensajes que necesitan ser firmados, luego, los métodos para agregar un nuevo mensaje, confirmar la firma y rechazarla:

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

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Para cada mensaje, creamos metadatos con id, estado, tiempo de creación, etc.
        const message = observable.object({
            id: uuid(), // Identificador, utilizando uuid
            origin, // Origin que posteriormente mostraremos en la interfaz
            data, //
            status: 'new', // Habrá cuatro estados: new, signed, rejected y failed
            timestamp: Date.now()
        });
        console.log(`nuevo mensaje: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Devolvemos una promesa dentro de la cual mobx monitorea los cambios en el mensaje. Una vez que el estado cambie, lo resolveremos
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Vamos a observar el estado del mensaje
                (status, reaction) => { // El segundo argumento es una referencia a la reacción, para que se pueda eliminar dentro de la llamada
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('El usuario rechazó el mensaje'));
                            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 hay msg con 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 hay msg con id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Al recibir un nuevo mensaje, le agregamos metadatos, hacemos observable y lo agregamos a store.messages.

Si no lo hacemos observable manualmente, mobx lo hará automáticamente al agregarlo al array de messages. Sin embargo, creará un nuevo objeto al que no tendremos referencia, y necesitaremos esa referencia para el siguiente paso.

A continuación, devolvemos una promesa que se resuelve al cambiar el estado del mensaje. Un reaction monitorea el estado, que se eliminará a sí mismo al cambiar.

El código de los métodos approve y reject es muy simple: simplemente cambiamos el estado del mensaje, firmándolo previamente, si es necesario.

Aprobar y rechazar los llevamos a la interfaz de API, newMessage — a la página de API:

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

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

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

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

    ...
}

Ahora intentaremos firmar una transacción con la extensión:

Estamos escribiendo una extensión de navegador segura

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

Interfaz de Usuario

La interfaz necesita acceso al estado de la aplicación. En el lado de la interfaz haremos observable el estado y añadiremos a la API una función que cambiará dicho estado. Agregaremos observable en el objeto API, obtenido del background:

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

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

setupUi().catch(console.error);

async function setupUi() {
    // Conectamos al puerto, creamos un stream a partir de él
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Creamos un observable vacío para el estado del background
    let backgroundState = observable.object({});
    const api = {
        // Le damos al background una función que actualizará el observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Añadimos al background el observable con el estado
    background.state = backgroundState;

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

    // Lanzamos la interfaz
    await initApp(background)
}

Al final, lanzamos el renderizado de la interfaz de la aplicación. Esta es una aplicación react. El objeto de background se pasa simplemente mediante props. Es correcto, por supuesto, hacer un servicio separado para los métodos y un almacén para el estado, pero en el contexto de este artículo es suficiente:

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

// Inicializamos la aplicación con el objeto de background como props
export async function initApp(background){
    render(
        , 
        document.getElementById('app-content')
    );
}

Con mobx es muy fácil iniciar el renderizado cuando los datos cambian. Simplemente usamos el decorador observer del paquete mobx-react en el componente, y el renderizado se llamará automáticamente al modificar cualquier observable al que se refiera el componente. No se necesita ningún mapStateToProps o connect, como en redux. Todo funciona de inmediato "fuera de la caja":

importar React, {Componente, Fragmento} desde 'react'
importar {observador} desde "mobx-react";
importar Init desde '.\/components\/Initialize'
importar Keys desde '.\/components\/Keys'
importar Sign desde '.\/components\/Sign'
importar Unlock desde '.\/components\/Unlock'

@observador \/\/ El componente con este decorador llamará automáticamente al método render si se modifican observable a los que se refiere
export default class App extends Componente {

    \/\/ Correcto, en realidad debería extraerse la lógica de renderizado de páginas en el enrutamiento y no usar operadores ternarios anidados,
    \/\/ y vincular observable y métodos del fondo directamente a los componentes que los utilizan
    renderizar() {
        const {claves, mensajes, inicializado, bloqueado} = this.props.background.state;
        const {bloquear, desbloquear, agregarClave, eliminarClave, initVault, eliminarVault, aprobar, rechazar} = this.props.background;

        return <fragment>
            {!inicializado
                ?
                <init oninit="{initVault}/">
                :
                bloqueado
                    ?
                    <unlock onunlock="{unlock}/">
                    :
                    mensajes.length &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!bloqueado &amp;&amp; <button onclick="{()" > bloquear()}&gt;Bloquear App</button>}
                {inicializado &amp;&amp; <button onclick="{()" > eliminarVault()}&gt;Eliminar todas las claves e inicializar</button>}
            </div>
        </Fragment>
    }
}

Los demás componentes se pueden ver en el código en la carpeta UI.

Ahora en la clase de la aplicación es necesario hacer un selector de estado para la UI y notificar a la UI cuando este cambie. Para esto, agregaremos el método getState y reaction, que llamará a remote.updateState:

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

export class SignerApp {

    ...

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

    ...

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

        dnode.once('remote', (remote) => {
            // Creamos una reacción a los cambios en el estado que llamará a un procedimiento remoto y actualizará el estado en el proceso de UI
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // El tercer argumento puede pasar parámetros. fireImmediately significa que la reacción se ejecutará la primera vez de inmediato.
                // Esto es necesario para obtener el estado inicial. Delay permite establecer el debounce
                {fireImmediately: true, delay: 500}
            );
            // Eliminamos la suscripción al desconectar el cliente
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Al recibir un objeto remoto se crea reaction una reacción al cambio de estado, que llama a la función en el lado de UI.

El último toque: agreguemos la visualización de nuevos mensajes en el icono de extensión:

function setupApp() {
...

    // Reacción a la configuración del texto de la insignia.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Así que, la aplicación está lista. Las páginas web pueden solicitar la firma de transacciones:

Estamos escribiendo una extensión de navegador segura

Estamos escribiendo una extensión de navegador segura

El código está disponible en esta el enlace.

Conclusión

Si has llegado al final del artículo, pero aún tienes preguntas, puedes hacerlas en el repositorio de la extensión. Allí también encontrarás commits para cada paso indicado.

Y si te interesa ver el código de una verdadera extensión, podrás encontrarlo aquí.

El código, el repositorio y la descripción del trabajo de siemarell

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster