Écrire une extension de navigateur sécurisée

Écrire une extension de navigateur sécurisée

Contrairement à l’architecture « client-serveur » courante, les applications décentralisées se caractérisent par :

  • Il n'est pas nécessaire de stocker une base de données avec les identifiants et mots de passe des utilisateurs. Les informations d'accès sont stockées exclusivement par les utilisateurs eux-mêmes et la confirmation de leur authenticité s'effectue au niveau du protocole.
  • Pas besoin d'utiliser un serveur. La logique applicative peut être exécutée sur un réseau blockchain, où il est possible de stocker la quantité de données requise.

Il existe 2 stockages relativement sûrs pour les clés utilisateur : les portefeuilles matériels et les extensions de navigateur. Les portefeuilles matériels sont pour la plupart extrêmement sécurisés, mais difficiles à utiliser et loin d'être gratuits, mais les extensions de navigateur sont la combinaison parfaite de sécurité et de facilité d'utilisation, et peuvent également être totalement gratuites pour les utilisateurs finaux.

En tenant compte de tout cela, nous avons voulu créer l'extension la plus sécurisée qui simplifie le développement d'applications décentralisées en fournissant une API simple pour travailler avec les transactions et les signatures.
Nous vous raconterons cette expérience ci-dessous.

L'article contiendra des instructions étape par étape sur la façon d'écrire une extension de navigateur, avec des exemples de code et des captures d'écran. Vous pouvez trouver tout le code dans référentiels. Chaque commit correspond logiquement à une section de cet article.

Un bref historique des extensions de navigateur

Les extensions de navigateur existent depuis longtemps. Ils sont apparus dans Internet Explorer en 1999, dans Firefox en 2004. Cependant, pendant très longtemps, il n’existait pas de norme unique en matière d’extensions.

On peut dire qu'il est apparu avec les extensions dans la quatrième version de Google Chrome. Bien sûr, il n'y avait pas de spécification à l'époque, mais c'est l'API Chrome qui est devenue sa base : après avoir conquis la majeure partie du marché des navigateurs et disposant d'un magasin d'applications intégré, Chrome a en fait établi la norme en matière d'extensions de navigateur.

Mozilla avait son propre standard, mais vu la popularité des extensions Chrome, la société a décidé de créer une API compatible. En 2015, à l'initiative de Mozilla, un groupe spécial a été créé au sein du World Wide Web Consortium (W3C) pour travailler sur les spécifications d'extensions multi-navigateurs.

Les extensions API existantes pour Chrome ont été prises comme base. Le travail a été réalisé avec le soutien de Microsoft (Google a refusé de participer à l'élaboration de la norme), et en conséquence un projet est apparu spécifications.

Formellement, la spécification est prise en charge par Edge, Firefox et Opera (notez que Chrome ne figure pas sur cette liste). Mais en fait, la norme est largement compatible avec Chrome, puisqu’elle est en réalité écrite à partir de ses extensions. Vous pouvez en savoir plus sur l'API WebExtensions ici.

Structure d'extension

Le seul fichier requis pour l’extension est le manifeste (manifest.json). C’est aussi le « point d’entrée » de l’expansion.

Manifeste

Selon la spécification, le fichier manifeste est un fichier JSON valide. Une description complète des clés du manifeste avec des informations sur les clés prises en charge dans quel navigateur peut être consulté ici.

Les clés qui ne figurent pas dans la spécification « peuvent » être ignorées (Chrome et Firefox signalent des erreurs, mais les extensions continuent de fonctionner).

Et je voudrais attirer l'attention sur certains points.

  1. fond — un objet qui comprend les champs suivants :
    1. scripts — un tableau de scripts qui seront exécutés en arrière-plan (nous en reparlerons un peu plus tard) ;
    2. page - au lieu de scripts qui seront exécutés dans une page vide, vous pouvez spécifier du HTML avec du contenu. Dans ce cas, le champ script sera ignoré et les scripts devront être insérés dans la page de contenu ;
    3. persistant — un indicateur binaire, s'il n'est pas spécifié, le navigateur « tuera » le processus en arrière-plan lorsqu'il considérera qu'il ne fait rien, et le redémarrera si nécessaire. Sinon, la page ne sera déchargée qu'à la fermeture du navigateur. Non pris en charge dans Firefox.
  2. contenu_scripts — un tableau d'objets qui vous permet de charger différents scripts sur différentes pages Web. Chaque objet contient les champs importants suivants :
    1. allumettes - URL du modèle, qui détermine si un script de contenu particulier sera inclus ou non.
    2. js — une liste des scripts qui seront chargés dans ce match ;
    3. exclure_matches - exclut du champ match URL qui correspondent à ce champ.
  3. page_action - est en fait un objet responsable de l'icône qui s'affiche à côté de la barre d'adresse dans le navigateur et de l'interaction avec celle-ci. Il vous permet également d'afficher une fenêtre contextuelle définie à l'aide de vos propres HTML, CSS et JS.
    1. default_popup — chemin d'accès au fichier HTML avec l'interface popup, peut contenir du CSS et du JS.
  4. autorisations — un tableau de gestion des droits d'extension. Il existe 3 types de droits, qui sont décrits en détail ici
  5. ressources_accessibles_web — des ressources d'extension qu'une page Web peut demander, par exemple des images, des fichiers JS, CSS, HTML.
  6. externe_connectable — ici, vous pouvez spécifier explicitement les identifiants d'autres extensions et domaines de pages Web à partir desquels vous pouvez vous connecter. Un domaine peut être de deuxième niveau ou supérieur. Ne fonctionne pas dans Firefox.

Contexte d'exécution

L'extension comporte trois contextes d'exécution de code, c'est-à-dire que l'application se compose de trois parties avec différents niveaux d'accès à l'API du navigateur.

Contexte d'extension

La plupart de l'API est disponible ici. Dans ce contexte, ils « vivent » :

  1. Page de fond — partie « backend » de l'extension. Le fichier est spécifié dans le manifeste à l'aide de la clé « background ».
  2. Page contextuelle — une page contextuelle qui apparaît lorsque vous cliquez sur l'icône d'extension. Dans le manifeste browser_action -> default_popup.
  3. Page personnalisée — page d'extension, « vivante » dans un onglet séparé de la vue chrome-extension://<id_расширения>/customPage.html.

Ce contexte existe indépendamment des fenêtres et des onglets du navigateur. Page de fond existe en un seul exemplaire et fonctionne toujours (l'exception est la page d'événement, lorsque le script d'arrière-plan est lancé par un événement et « meurt » après son exécution). Page contextuelle existe lorsque la fenêtre contextuelle est ouverte, et Page personnalisée - alors que l'onglet avec celui-ci est ouvert. Il n'y a pas d'accès aux autres onglets et à leur contenu à partir de ce contexte.

Contexte du script de contenu

Le fichier de script de contenu est lancé avec chaque onglet du navigateur. Il a accès à une partie de l'API de l'extension et à l'arborescence DOM de la page web. Ce sont les scripts de contenu qui sont responsables de l'interaction avec la page. Les extensions qui manipulent l'arborescence DOM le font dans des scripts de contenu - par exemple, des bloqueurs de publicités ou des traducteurs. En outre, le script de contenu peut communiquer avec la page via la norme postMessage.

Contexte de la page Web

Il s'agit de la page Web elle-même. Il n'a rien à voir avec l'extension et n'y a pas accès, sauf dans les cas où le domaine de cette page n'est pas explicitement indiqué dans le manifeste (plus de détails ci-dessous).

Messagerie

Différentes parties de l'application doivent échanger des messages entre elles. Il existe une API pour cela runtime.sendMessage envoyer un message background и tabs.sendMessage pour envoyer un message à une page (script de contenu, popup ou page Web si disponible externally_connectable). Vous trouverez ci-dessous un exemple d'accès à l'API 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))
    }
)

Pour une communication complète, vous pouvez créer des connexions via runtime.connect. En réponse, nous recevrons runtime.Port, auquel, tant qu'il est ouvert, vous pouvez envoyer n'importe quel nombre de messages. Du côté client, par exemple, contentscript, ça ressemble à ça :

// Опять же 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"});

Serveur ou arrière-plan :

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

Il y a aussi un événement onDisconnect et méthode disconnect.

Schéma d'application

Créons une extension de navigateur qui stocke les clés privées, donne accès aux informations publiques (adresse, clé publique communique avec la page et permet aux applications tierces de demander une signature pour les transactions.

Développement d'applications

Notre application doit à la fois interagir avec l'utilisateur et fournir à la page une API pour appeler des méthodes (par exemple, pour signer des transactions). Contentez-vous d'un seul contentscript ne fonctionnera pas, puisqu'il n'a accès qu'au DOM, mais pas au JS de la page. Connectez-vous via runtime.connect nous ne pouvons pas, car l'API est nécessaire sur tous les domaines et seuls des domaines spécifiques peuvent être spécifiés dans le manifeste. En conséquence, le schéma ressemblera à ceci :

Écrire une extension de navigateur sécurisée

Il y aura un autre script - inpage, que nous injecterons dans la page. Il s'exécutera dans son contexte et fournira une API pour travailler avec l'extension.

début

Tout le code de l'extension du navigateur est disponible sur GitHub. Pendant la description, il y aura des liens vers des commits.

Commençons par le manifeste :

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

Créez background.js, popup.js, inpage.js et contentscript.js vides. Nous ajoutons popup.html - et notre application peut déjà être chargée dans Google Chrome et nous assurer qu'elle fonctionne.

Pour vérifier cela, vous pouvez prendre le code par conséquent,. En plus de ce que nous avons fait, le lien a configuré l'assemblage du projet à l'aide de webpack. Pour ajouter une application au navigateur, dans chrome://extensions, vous devez sélectionner charger unpacked et le dossier avec l'extension correspondante - dans notre cas dist.

Écrire une extension de navigateur sécurisée

Notre extension est maintenant installée et fonctionne. Vous pouvez exécuter les outils de développement pour différents contextes comme suit :

pop-up ->

Écrire une extension de navigateur sécurisée

L'accès à la console du script de contenu s'effectue via la console de la page elle-même sur laquelle il est lancé.Écrire une extension de navigateur sécurisée

Messagerie

Nous devons donc établir deux canaux de communication : inpage <-> background et popup <-> background. Vous pouvez bien sûr simplement envoyer des messages au port et inventer votre propre protocole, mais je préfère l'approche que j'ai vue dans le projet open source métamask.

Il s'agit d'une extension de navigateur permettant de travailler avec le réseau Ethereum. Dans celui-ci, différentes parties de l'application communiquent via RPC en utilisant la bibliothèque dnode. Il permet d'organiser un échange assez rapidement et facilement si vous lui fournissez un flux nodejs comme transport (c'est-à-dire un objet qui implémente la même interface) :

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Nous allons maintenant créer une classe d'application. Il créera des objets API pour la popup et la page Web, et créera un nœud pour eux :

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

Ici et ci-dessous, au lieu de l'objet Chrome global, nous utilisons extensionApi, qui accède à Chrome dans le navigateur de Google et dans les autres navigateurs. Ceci est fait pour des raisons de compatibilité entre navigateurs, mais pour les besoins de cet article, on pourrait simplement utiliser « chrome.runtime.connect ».

Créons une instance d'application dans le script d'arrière-plan :

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

Puisque dnode fonctionne avec des flux et que nous recevons un port, une classe d'adaptateur est nécessaire. Il est réalisé à l'aide de la bibliothèque readable-stream, qui implémente les flux nodejs dans le navigateur :

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

Créons maintenant une connexion dans l'interface utilisateur :

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

Ensuite, nous créons la connexion dans le script de contenu :

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

Puisque nous avons besoin de l'API non pas dans le script de contenu, mais directement sur la page, nous faisons deux choses :

  1. Nous créons deux flux. Un - vers la page, en haut du postMessage. Pour cela, nous utilisons ceci ce paquet des créateurs de métamask. Le deuxième flux consiste à effectuer l'arrière-plan sur le port reçu de runtime.connect. Allons les acheter. La page aura désormais un flux en arrière-plan.
  2. Injectez le script dans le DOM. Téléchargez le script (l'accès à celui-ci était autorisé dans le manifeste) et créez une balise script avec son contenu à l'intérieur :

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

Maintenant, nous créons un objet API dans inpage et le définissons sur 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;
}

Nous sommes prêts Appel de procédure à distance (RPC) avec API distincte pour la page et l'interface utilisateur. Lors de la connexion d'une nouvelle page à l'arrière-plan, nous pouvons voir ceci :

Écrire une extension de navigateur sécurisée

API et origine vides. Côté page, on peut appeler la fonction hello comme ceci :

Écrire une extension de navigateur sécurisée

Travailler avec des fonctions de rappel dans le JS moderne est une mauvaise manière, écrivons donc un petit assistant pour créer un dnode qui vous permet de transmettre un objet API aux utils.

Les objets API ressembleront désormais à ceci :

export class SignerApp {

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

...

}

Récupérer un objet à distance comme ceci :

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

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

Et appeler des fonctions renvoie une promesse :

Écrire une extension de navigateur sécurisée

Version avec fonctions asynchrones disponible ici.

Dans l’ensemble, l’approche RPC et flux semble assez flexible : nous pouvons utiliser le multiplexage Steam et créer plusieurs API différentes pour différentes tâches. En principe, dnode peut être utilisé n'importe où, l'essentiel est d'encapsuler le transport sous la forme d'un flux nodejs.

Une alternative est le format JSON, qui implémente le protocole JSON RPC 2. Cependant, il fonctionne avec des transports spécifiques (TCP et HTTP(S)), ce qui n'est pas applicable dans notre cas.

État interne et stockage local

Nous devrons stocker l'état interne de l'application - au moins les clés de signature. On peut assez facilement ajouter un état à l'application et des méthodes pour le modifier dans l'API popup :

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

export class SignerApp {

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

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

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

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

    ...

} 

En arrière-plan, nous allons tout envelopper dans une fonction et écrire l'objet application dans la fenêtre afin de pouvoir travailler avec lui depuis la console :

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

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

setupApp();

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

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Ajoutons quelques clés de la console de l'interface utilisateur et voyons ce qui se passe avec l'état :

Écrire une extension de navigateur sécurisée

L'état doit être rendu persistant afin que les clés ne soient pas perdues lors du redémarrage.

Nous le stockerons dans localStorage, en l'écrasant à chaque modification. Par la suite, l'accès à celui-ci sera également nécessaire pour l'UI, et je souhaite également m'abonner aux modifications. Sur cette base, il conviendra de créer un stockage observable et de s'abonner à ses modifications.

Nous utiliserons la bibliothèque mobx (https://github.com/mobxjs/mobx). Le choix s’est porté sur lui parce que je n’avais pas besoin de travailler avec, mais je voulais vraiment l’étudier.

Ajoutons l'initialisation de l'état initial et rendons le magasin 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)
    }

    ...

}

« Sous le capot », mobx a remplacé tous les champs du magasin par un proxy et intercepte tous les appels vers ceux-ci. Il sera possible de s'abonner à ces messages.

Ci-dessous, j'utiliserai souvent le terme « lors du changement », bien que ce ne soit pas tout à fait correct. Mobx suit l'accès aux champs. Les getters et setters des objets proxy créés par la bibliothèque sont utilisés.

Les décorateurs d’action ont deux objectifs :

  1. En mode strict avec l'indicateur applyActions, mobx interdit de changer directement l'état. Il est considéré comme une bonne pratique de travailler dans des conditions strictes.
  2. Même si une fonction change d'état plusieurs fois - par exemple, on change plusieurs champs dans plusieurs lignes de code - les observateurs ne sont avertis que lorsqu'elle est terminée. Ceci est particulièrement important pour le frontend, où les mises à jour d’état inutiles entraînent un rendu inutile des éléments. Dans notre cas, ni la première ni la seconde ne sont particulièrement pertinentes, mais nous suivrons les meilleures pratiques. Il est d'usage d'attacher des décorateurs à toutes les fonctions qui modifient l'état des champs observés.

En arrière-plan, nous ajouterons l'initialisation et la sauvegarde de l'état dans 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 fonction de réaction est ici intéressante. Il a deux arguments :

  1. Sélecteur de données.
  2. Un gestionnaire qui sera appelé avec ces données à chaque fois qu'elles changent.

Contrairement à Redux, où nous recevons explicitement l'état comme argument, mobx se souvient des observables auxquels nous accédons à l'intérieur du sélecteur et n'appelle le gestionnaire que lorsqu'ils changent.

Il est important de comprendre exactement comment mobx décide à quels observables nous souscrivons. Si j'écrivais un sélecteur dans un code comme celui-ci() => app.store, alors la réaction ne sera jamais appelée, puisque le stockage lui-même n'est pas observable, seuls ses champs le sont.

Si je l'écrivais comme ça () => app.store.keys, là encore, rien ne se passerait, car lors de l'ajout/suppression d'éléments du tableau, la référence à celui-ci ne changera pas.

Mobx agit pour la première fois comme un sélecteur et ne garde la trace que des observables auxquels nous avons accédé. Cela se fait via des getters proxy. Par conséquent, la fonction intégrée est utilisée ici toJS. Il renvoie un nouvel objet avec tous les proxys remplacés par les champs d'origine. Pendant l'exécution, il lit tous les champs de l'objet - les getters sont donc déclenchés.

Dans la console contextuelle, nous ajouterons à nouveau plusieurs clés. Cette fois, ils se sont également retrouvés dans localStorage :

Écrire une extension de navigateur sécurisée

Lorsque la page d'arrière-plan est rechargée, les informations restent en place.

Tout le code de l'application jusqu'à présent peut être consulté ici.

Stockage sécurisé des clés privées

Le stockage de clés privées en texte clair n'est pas sûr : il y a toujours un risque que vous soyez piraté, que vous accédiez à votre ordinateur, etc. Par conséquent, dans localStorage, nous stockerons les clés sous une forme cryptée par mot de passe.

Pour plus de sécurité, nous ajouterons un état verrouillé à l'application, dans lequel il n'y aura aucun accès aux clés. Nous transférerons automatiquement l'extension à l'état verrouillé en raison d'un délai d'attente.

Mobx vous permet de stocker uniquement un ensemble minimum de données, et le reste est automatiquement calculé en fonction de celui-ci. Ce sont ce qu'on appelle les propriétés calculées. Elles peuvent être comparées aux vues des bases de données :

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

Désormais, nous stockons uniquement les clés cryptées et le mot de passe. Tout le reste est calculé. Nous effectuons le transfert vers un état verrouillé en supprimant le mot de passe de l'état. L'API publique dispose désormais d'une méthode pour initialiser le stockage.

Écrit pour le cryptage utilitaires utilisant 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)
}

Le navigateur dispose d'une API inactive grâce à laquelle vous pouvez vous abonner à un événement - changements d'état. L'État peut donc être idle, active и locked. Pour l'inactivité, vous pouvez définir un délai d'attente et le verrouillage est défini lorsque le système d'exploitation lui-même est bloqué. Nous modifierons également le sélecteur de sauvegarde dans 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)
        }
    }
}

Le code avant cette étape est ici.

Transactions

Nous arrivons donc au plus important : créer et signer des transactions sur la blockchain. Nous utiliserons la blockchain et la bibliothèque WAVES vagues-transactions.

Tout d’abord, ajoutons à l’état un tableau de messages qui doivent être signés, puis ajoutons des méthodes pour ajouter un nouveau message, confirmer la signature et refuser :

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

    ...
}

Lorsque nous recevons un nouveau message, nous y ajoutons des métadonnées, faites-le observable et ajouter à store.messages.

Si tu ne le fais pas observable manuellement, alors mobx le fera lui-même lors de l'ajout de messages au tableau. Cependant, cela créera un nouvel objet auquel nous n'aurons pas de référence, mais nous en aurons besoin pour l'étape suivante.

Ensuite, nous renvoyons une promesse qui se résout lorsque l'état du message change. Le statut est surveillé par une réaction, qui se « tuera » lorsque le statut change.

Code de méthode approve и reject très simple : on change simplement le statut du message, après l'avoir signé si nécessaire.

Nous mettons Approuver et Rejeter dans l'API de l'interface utilisateur, newMessage dans l'API de la page :

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

    ...
}

Essayons maintenant de signer la transaction avec l'extension :

Écrire une extension de navigateur sécurisée

En général, tout est prêt, il ne reste plus que ajouter une interface utilisateur simple.

UI

L'interface doit accéder à l'état de l'application. Du côté de l'interface utilisateur, nous ferons observable state et ajoutez une fonction à l’API qui changera cet état. Ajoutons observable à l'objet API reçu en arrière-plan :

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

À la fin, nous commençons le rendu de l'interface de l'application. Ceci est une application de réaction. L'objet d'arrière-plan est simplement transmis à l'aide d'accessoires. Il serait bien sûr correct de créer un service séparé pour les méthodes et un magasin pour l'État, mais pour les besoins de cet article, cela suffit :

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

Avec mobx, il est très facile de démarrer le rendu lorsque les données changent. Nous accrochons simplement l'observateur décorateur à l'emballage mobx-réagir sur le composant, et le rendu sera automatiquement appelé lorsque des observables référencés par le composant changent. Vous n'avez pas besoin de mapStateToProps ni de connexion comme dans Redux. Tout fonctionne dès la sortie de la boîte :

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

Les composants restants peuvent être visualisés dans le code dans le dossier de l'interface utilisateur.

Maintenant, dans la classe d'application, vous devez créer un sélecteur d'état pour l'interface utilisateur et avertir l'interface utilisateur lorsqu'elle change. Pour ce faire, ajoutons une méthode getState и reactionappel 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())

        })
    }

    ...
}

Lors de la réception d'un objet remote est créé reaction pour changer l'état qui appelle la fonction côté interface utilisateur.

La touche finale est d'ajouter l'affichage des nouveaux messages sur l'icône de l'extension :

function setupApp() {
...

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

...
}

Voilà, la candidature est prête. Les pages Web peuvent demander une signature pour les transactions :

Écrire une extension de navigateur sécurisée

Écrire une extension de navigateur sécurisée

Le code est disponible ici lien.

Conclusion

Si vous avez lu l'article jusqu'au bout, mais que vous avez encore des questions, vous pouvez les poser à référentiels avec extension. Vous y trouverez également des commits pour chaque étape désignée.

Et si vous souhaitez consulter le code de l'extension réelle, vous pouvez trouver ceci ici.

Code, référentiel et description de poste de siemarell

Source: habr.com

Ajouter un commentaire