Nous développons une extension de navigateur sécurisée

Nous développons une extension de navigateur sécurisée

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

  • L'absence de nécessité de stocker une base de données avec les identifiants et mots de passe des utilisateurs. Les informations d'accès sont conservées exclusivement par les utilisateurs eux-mêmes, tandis que la vérification de leur validité se fait au niveau du protocole.
  • L'absence de nécessité d'utiliser un serveur. La logique de l'application peut être exécutée dans le réseau blockchain, où il est également possible de stocker la quantité de données requise.

Il existe deux types de stockage relativement sûrs pour les clés des utilisateurs : les portefeuilles matériels et les extensions de navigateur. Les portefeuilles matériels sont dans leur majorité très sûrs, mais complexes à utiliser et loin d'être gratuits, tandis que les extensions de navigateur offrent un équilibre idéal entre sécurité et facilité d'utilisation, et peuvent même être complètement gratuites pour les utilisateurs finaux.

En tenant compte de tout cela, nous avons souhaité créer une extension aussi sécurisée que possible, qui simplifie le développement d'applications décentralisées en fournissant une API simple pour travailler avec les transactions et les signatures.
C'est de cette expérience que nous allons vous parler ci-dessous.

L'article contiendra un guide étape par étape sur la façon d'écrire une extension de navigateur, avec des exemples de code et des captures d'écran. Tout le code peut être trouvé dans dépôts. Chaque commit correspond logiquement à une section de cet article.

Bref historique des extensions de navigateur

Les extensions de navigateur existent depuis assez longtemps. Elles sont apparues dans Internet Explorer dès 1999, dans Firefox en 2004. Cependant, il n'y avait pas de norme unique pour les extensions pendant longtemps.

On peut dire qu'elle est apparue 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 l'API de Chrome en est devenu la base : conquérant une grande partie du marché des navigateurs et ayant un magasin d'applications intégré, Chrome a en fait établi la norme pour les extensions de navigateur.

Mozilla avait sa propre norme, mais voyant la popularité des extensions pour Chrome, l'entreprise a décidé de créer une API compatible. En 2015, à l'initiative de Mozilla, un groupe spécial a été formé dans le cadre du World Wide Web Consortium (W3C) pour travailler sur les spécifications des extensions multiplateformes.

L'API des extensions pour Chrome existante a été utilisée comme base. Le travail a été réalisé avec le soutien de Microsoft (Google a refusé de participer au développement de la norme), et un brouillon a été créé. la spécification.

Officiellement, la spécification est soutenue par Edge, Firefox et Opera (notez que Chrome n'est pas dans cette liste). Mais en réalité, la norme est largement compatible avec Chrome, car elle est en fait écrite sur la base de ses extensions. Vous pouvez en savoir plus sur l'API WebExtensions. ici.

Structure de l'extension

Le seul fichier nécessaire pour l'extension est le manifeste (manifest.json). Il constitue également le « point d'entrée » de l'extension.

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 par chaque navigateur, est disponible. ici.

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

Je voudrais attirer l'attention sur certains points.

  1. arrière-plan — un objet qui inclut les champs suivants :
    1. scripts — un tableau de scripts qui seront exécutés dans le contexte de l'arrière-plan (nous en parlerons un peu plus tard) ;
    2. page — au lieu de scripts qui seront exécutés sur une page blanche, 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 contenant le contenu ;
    3. persistent — un drapeau binaire, s'il n'est pas spécifié, le navigateur « tuera » le processus d'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. Pas pris en charge dans Firefox.
  2. content_scripts — un tableau d'objets permettant de charger différents scripts sur différentes pages web. Chaque objet contient les champs importants suivants :
    1. matches — un modèle d'URL, qui détermine si un script de contenu spécifique sera inclus ou non.
    2. js — une liste de scripts qui seront chargés dans ce match ;
    3. exclude_matches — exclut du champ match les URL qui correspondent à ce champ.
  3. page_action — est en fait un objet qui gère l'icône affichée à côté de la barre d'adresse du navigateur, ainsi que l'interaction avec celle-ci. Il permet également d'afficher une fenêtre popup, qui est définie à l'aide de son propre HTML, CSS et JS.
    1. default_popup — le chemin vers le fichier HTML avec l'interface popup, qui peut contenir du CSS et du JS.
  4. permissions — tableau pour gérer les droits d'extension. Il existe 3 types de droits, décrits en détail. ici
  5. web_accessible_resources — ressources d'extension pouvant être demandées par une page web, telles que des images, des fichiers JS, CSS, HTML.
  6. externally_connectable — vous pouvez y spécifier explicitement les ID d'autres extensions et les domaines de pages web avec lesquels vous pouvez vous connecter. Le domaine peut être de second niveau ou plus. Ne fonctionne pas sur Firefox.

Contexte d'exécution

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

Contexte de l'extension

La plupart des API y sont accessibles. Dans ce contexte, se trouvent :

  1. Page d'arrière-plan — la partie « backend » de l'extension. Le fichier est spécifié dans le manifeste par la clé « background ».
  2. Page contextuelle — page contextuelle qui apparaît lorsque l'icône de l'extension est cliquée. Dans le manifeste, browser_action -> default_popup.
  3. Page personnalisée — page de l'extension, « vivant » dans un onglet séparé sous la forme chrome-extension:///customPage.html.

Ce contexte existe indépendamment des fenêtres et des onglets du navigateur. Page d'arrière-plan il existe en un seul exemplaire et fonctionne toujours (exception : event page, lorsque le script d'arrière-plan est déclenché par un événement et « meurt » après son exécution). Page contextuelle il existe lorsqu'une fenêtre contextuelle est ouverte, et Page personnalisée — tant qu'un onglet avec elle est ouvert. Aucun accès à d'autres onglets et leurs contenus depuis ce contexte.

Contexte du script de contenu

Le fichier du script de contenu s'exécute avec chaque onglet du navigateur. Il a accès à une partie de l'API de l'extension et à l'arbre DOM de la page web. Ce sont les scripts de contenu qui gèrent l'interaction avec la page. Les extensions manipulant l'arbre DOM le font dans les scripts de contenu – par exemple, les bloqueurs de publicité ou les traducteurs. De plus, le script de contenu peut communiquer avec la page via le standard postMessage.

Contexte de la page web

C'est la page web elle-même. Elle n'a aucun rapport avec l'extension et n'y accède pas, sauf dans les cas où le domaine de cette page est explicitement indiqué dans le manifeste (à ce sujet, ci-dessous).

Échange de messages

Différentes parties de l'application doivent échanger des messages entre elles. Pour cela, il existe l'API runtime.sendMessage pour envoyer un message arrière-plan et tabs.sendMessage pour envoyer un message à la page (script de contenu, popup ou page web si externally_connectable). Voici un exemple lors de l'appel à 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, il est possible de créer des connexions via runtime.connect. En réponse, nous recevrons runtime.Port, dans lequel, tant qu'il est ouvert, nous pouvons envoyer un nombre illimité de messages. Du côté client, par exemple, contentscript, cela ressemble à :

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

Il y a aussi un événement onDisconnect et une méthode déconnexion.

Schéma de l'application

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

Développement d'application

Notre application doit à la fois interagir avec l'utilisateur et fournir une API à la page pour appeler des méthodes (par exemple, pour signer des transactions). Se contenter d'un simple contentscript ne fonctionnera pas, car il n'a accès qu'au DOM, mais pas au JS de la page. Se connecter via runtime.connect nous ne pouvons pas, car l'API est nécessaire sur tous les domaines, tandis que dans le manifeste, seuls des domaines spécifiques peuvent être spécifiés. Au final, le schéma sera comme suit :

Nous développons une extension de navigateur sécurisée

Il y aura un autre script — inpage, que nous allons injecter dans la page. Il sera exécuté dans son contexte et fournira une API pour fonctionner avec l'extension.

Début

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

Commençons par le manifeste :

{
  // Nom et description, version. Tout cela sera visible dans le navigateur à chrome://extensions/?id=
  "name": "Signer",
  "description": "Démonstration de l'extension",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts qui seront exécutés dans le background, il peut y en avoir plusieurs
  "background": {
    "scripts": ["background.js"]
  },

  // Quel html utiliser pour le popup
  "browser_action": {
    "default_title": "Mon Extension",
    "default_popup": "popup.html"
  },

  // Scripts de contenu.
  // Nous avons un objet : pour toutes les URL commençant par http ou https, nous lançons
  // le contexte du contenscript avec le script contentscript.js. Lancer immédiatement à la réception du document pour tous les frames
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Accès autorisé à localStorage et à l'api idle
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Ici, les ressources auxquelles la page Web aura accès sont spécifiées. C'est-à-dire qu'elles pourront être demandées par fetche' ou simplement par xhr
  "web_accessible_resources": ["inpage.js"]
}

Nous créons des fichiers vides background.js, popup.js, inpage.js et contentscript.js. Nous ajoutons popup.html — et notre application peut déjà être chargée dans Google Chrome pour vérifier son bon fonctionnement.

Pour en être sûr, on peut prendre le code d'ici. En plus de ce que nous avons fait, le lien configure la construction du projet à l'aide de webpack. Pour ajouter l'application au navigateur, il faut aller sur chrome://extensions et sélectionner charger une extension non empaquetée, puis le dossier de l'extension correspondante — dans notre cas, dist.

Nous développons une extension de navigateur sécurisée

Maintenant, notre extension est installée et fonctionne. Pour ouvrir les outils de développement dans différents contextes, on peut procéder comme suit :

popup ->

Nous développons une extension de navigateur sécurisée

L'accès à la console du script de contenu se fait via la console de la page elle-même sur laquelle il est exécuté.Nous développons une extension de navigateur sécurisée

Échange de messages

Ainsi, nous devons établir deux canaux de communication : inpage background et popup background. Bien sûr, on peut simplement envoyer des messages dans le port et inventer notre propre protocole, mais je préfère l'approche que j'ai vue dans le projet open source metamask.

C'est une extension de navigateur pour interagir avec le réseau Ethereum. Dans celle-ci, différentes parties de l'application communiquent via RPC à l'aide de la bibliothèque dnode. Elle permet d'organiser rapidement et facilement des échanges, si l'on fournit à nodejs stream comme transport (en référence à un objet qui implemente la même interface) :

import Dnode from "dnode/browser";

// Dans cet exemple, supposons que le client appelle des fonctions à distance sur le serveur, bien que rien ne nous empêche de rendre cela bidirectionnel

// Serveur
// API que nous voulons fournir
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transport sur lequel fonctionnera dnode. N'importe quel stream nodejs. Dans le navigateur, il existe la bibliothèque 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Client
const dnodeClient = Dnode() // Un appel sans argument signifie que nous ne fournissons pas d'API de l'autre côté

// Affichera "world" dans la console
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Nous allons maintenant créer une classe d'application. Elle créera des objets API pour le popup et la page web, ainsi que dnode pour eux :

import Dnode from 'dnode/browser';

export class SignerApp {

    // Renvoie un objet API pour l'interface utilisateur
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Renvoie un objet API pour la page
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Connecte l'interface utilisateur popup
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Connecte la page
    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-après, au lieu de l'objet global Chrome, nous utilisons extentionApi, qui interagit avec Chrome dans le navigateur de Google et avec browser dans d'autres navigateurs. Cela se fait pour la compatibilité multiplateforme, mais dans le cadre de cet article, on aurait pu utiliser simplement 'chrome.runtime.connect'.

Créons une instance de l'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 déclenche lors de la connexion des 'processus' (contentscript, popup ou page d'extension)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Lors de l'établissement de la connexion, on peut spécifier un nom, et selon ce nom, nous déterminons qui s'est connecté, le contenu ou l'interface utilisateur
    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 obtenons un port, une classe d'adaptateur est nécessaire. Elle a été réalisée à l'aide de la bibliothèque readable-stream, qui implémente les flux Node.js 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 - déconnecté'));
        }
        cb();
    }
}

Créons maintenant la 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(){
    // Comme dans la classe de l'application, nous créons un port, l'enveloppant dans un flux, et faisons un 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)
        })
    });

    // Rendre l'objet API accessible depuis la console
    if (DEV_MODE){
        global.background = background;
    }
}

Ensuite, nous établissons une 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 {
        // injecter le script dans la page
        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('L'injection a échoué.', 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. L'un allant vers la page, en utilisant postMessage. Pour cela, nous utilisons ce package des créateurs de metamask. Le deuxième flux va vers le background via le port reçu de runtime.connect. Nous les relions (pipe). Maintenant, la page aura un flux vers le fond.
  2. Nous injectons un script dans le DOM. Nous extrayons le script (l'accès a été autorisé dans le manifeste) et créons 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(){
    // Flux vers le fond
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Flux vers la page
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // injecter le script dans la page
        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('L'injection a échoué.', e);
    }
}

Nous créons maintenant un objet api dans inpage et le rendons global :

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream vers le script de contenu
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Récupérer l'objet API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Accès via window
    global.SignerApp = pageApi;
}

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

Nous développons une extension de navigateur sécurisée

API vide et origine. Du côté de la page, nous pouvons appeler la fonction hello comme ceci :

Nous développons une extension de navigateur sécurisée

Travailler avec des fonctions de rappel dans le JS moderne est démodé, donc écrivons un petit helper pour créer dnode, qui permet de transmettre dans l'objet API dans utils.

Les objets API auront désormais l'apparence suivante :

export class SignerApp {

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

...

}

Obtenir l'objet de remote de la manière suivante :

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Grâce aux outils, nous modifions tous les rappels en promesses
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

Et l'appel de fonctions retourne une promesse :

Nous développons une extension de navigateur sécurisée

Une version avec des fonctions asynchrones est disponible ici.

Dans l'ensemble, l'approche avec RPC et streams semble suffisamment flexible : nous pouvons utiliser le multiplexage de flux et créer plusieurs API différentes pour différentes tâches. En principe, dnode peut être utilisé partout, il suffit d'encapsuler le transport sous forme de stream 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 localStorage

Nous aurons besoin de stocker l'état interne de l'application — au moins, les clés pour la signature. Nous pouvons facilement ajouter l'état à l'application et les 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)
        }
    }

    ...

} 

Dans l'arrière-plan, nous encapsulerons tout dans une fonction et enregistrerons l'objet application dans window, afin que nous puissions travailler avec 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 depuis la console UI et voyons ce qu'il en est de l'état :

Nous développons une extension de navigateur sécurisée

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

Nous allons le stocker dans localStorage, en le réécrivant à chaque modification. Par la suite, l'accès à celui-ci sera également nécessaire pour l'UI, et nous voudrions pouvoir nous abonner aux changements. Par conséquent, il serait pratique 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 elle car je n'avais jamais eu l'occasion de travailler avec et j'avais vraiment envie de l'explorer.

Ajoutons l'initialisation de l'état initial et rendons le store observable :

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

export class SignerApp {

    constructor(initState = {}) {
        // En externe, le store restera le même objet, mais maintenant tous ses champs sont devenus des proxies, qui surveillent l'accès à ceux-ci
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Les méthodes qui modifient les observables doivent être enveloppées dans un décorateur
    @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 store par des proxies et intercepte toutes les interactions avec ceux-ci. On pourra s'abonner à ces interactions.

Je vais souvent utiliser le terme "lors de la modification", bien que ce ne soit pas tout à fait correct. Mobx surveille en réalité l'accès aux champs. Des getters et setters des objets proxy sont utilisés, qui sont créés par la bibliothèque.

Les décorateurs d'action ont deux objectifs :

  1. En mode strict avec le drapeau enforceActions, mobx interdit de modifier l'état directement. Il est considéré comme de bon ton de travailler en mode strict.
  2. Même si la fonction modifie l'état plusieurs fois – par exemple, nous modifions plusieurs champs dans plusieurs lignes de code – les observateurs ne sont notifiés qu'à la fin. C'est particulièrement important pour le front-end, où des mises à jour d'état inutiles entraînent un rendu superflu des éléments. Dans notre cas, ni l'un ni l'autre n'est particulièrement pertinent, mais nous allons suivre les meilleures pratiques. Les décorateurs sont généralement appliqués à toutes les fonctions qui modifient l'état des champs observés.

Dans l'arrière-plan, nous allons ajouter l'initialisation et la sauvegarde de l'état dans le localStorage :

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Méthodes auxiliaires. Enregistre/lit l'objet dans/le localStorage sous forme de chaîne JSON par la clé '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;
    }

    // Configuration de la persistance de l'état

    // Le résultat de la réaction est attribué à une variable afin que l'abonnement puisse être annulé. Nous n'en avons pas besoin, laissé à titre d'exemple
    const localStorageReaction = reaction(
        () => toJS(app.store), // Fonction sélectrice de données
        saveState // Fonction qui sera appelée lors de la modification des données renvoyées par le sélecteur
    );

    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 reaction est particulièrement intéressante ici. Elle a deux arguments :

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

Contrairement à redux, où nous recevons explicitement l'état en tant qu'argument, mobx mémorise à quels observables nous accédons à l'intérieur du sélecteur, et n'appelle le gestionnaire qu'en cas de modification de ceux-ci.

Il est important de comprendre comment mobx décide sur quels observables nous nous abonnons. Si dans le code j'avais écrit un sélecteur de cette manière() => app.store, alors la réaction ne serait jamais appelée, car le stockage lui-même n'est pas observable, seuls ses champs le sont.

Si j'avais écrit comme ceci () => app.store.keys, alors encore une fois rien ne se passerait, car lors de l'ajout ou de la suppression d'éléments dans le tableau, la référence à celui-ci ne changera pas.

Mobx exécute pour la première fois la fonction de sélecteur et ne surveille que les observable auxquels nous avons accédé. Cela se fait à travers des getters de proxy. C'est pourquoi la fonction intégrée est utilisée ici. toJS. Elle renvoie un nouvel objet dans lequel tous les proxies sont remplacés par les champs originaux. Au cours de l'exécution, elle lit tous les champs de l'objet – par conséquent, les getters s'activent.

Dans la console popup, ajoutons à nouveau quelques clés. Cette fois, elles ont également été ajoutées au localStorage :

Nous développons une extension de navigateur sécurisée

Après le rechargement de la page d'arrière-plan, 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

Stocker les clés privées en clair n'est pas sécurisé : il y a toujours un risque que vous soyez piraté, que quelqu'un accède à votre ordinateur, etc. C'est pourquoi nous allons stocker les clés dans localStorage sous forme chiffrée par mot de passe.

Pour plus de sécurité, nous ajouterons un état 'locked' à l'application, où l'accès aux clés sera complètement interdit. Nous allons automatiquement changer l'extension en état 'locked' après un délai.

Mobx permet de stocker uniquement un ensemble minimal de données, le reste étant calculé automatiquement sur leur base. Ce sont les propriétés calculées, ou 'computed properties'. Elles peuvent être comparées aux vues dans les bases de données :

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Utilitaires pour le chiffrement sécurisé des chaînes. Utilisent crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Stocke le mot de passe et les clés chiffrées. Si le mot de passe est null - application verrouillée
            password: null,
            vault: initState.vault,

            // Getters pour les champs calculés. Peut être comparé aux vues dans une base de données.
            get locked() {
                return this.password == null
            },
            get keys() {
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized() {
                return this.vault !== undefined
            }
        })
    }
    // Initialisation d'un coffre vide avec un nouveau mot de passe
    @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
        )
    }

    ... // code de connexion et api

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

    _checkLocked() {
        if (this.store.locked) {
            throw new Error('L'application est verrouillée')
        }
    }

    // Méthodes pour le chiffrement/déchiffrement du coffre
    static _encryptVault(obj, pass) {
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass) {
        if (str === undefined) {
            throw new Error('Coffre non initialisé')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        } catch (e) {
            throw new Error('Mauvais mot de passe')
        }
    }
}

Nous stockons désormais uniquement des clés chiffrées et un mot de passe. Tout le reste est calculé. Nous passons à l'état verrouillé en supprimant le mot de passe de l'état. Une méthode pour initialiser le coffre a été ajoutée à l'API publique.

Pour le chiffrement, des utilitaires utilisant crypto-js ont été écrits:

import CryptoJS from 'crypto-js'

// Utilisé pour compliquer le craquage du mot de passe par force brute. Pour chaque variante de mot de passe, l'attaquant devra effectuer 5000 hachages
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 idle, via laquelle on peut s'abonner à un événement — les changements d'état. L'état, en conséquence, peut être idle, active et bloqué. Pour idle, on peut définir un délai d'attente, tandis que locked est utilisé lorsque le système d'exploitation lui-même est verrouillé. Nous allons également changer le sélecteur pour enregistrer 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;
    }

    // Maintenant, nous appelons explicitement le champ auquel l'accès aura lieu, la réaction fonctionnera normalement
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Délai d'inactivité lorsque l'événement se déclenche
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Si l'utilisateur a verrouillé l'écran ou est resté inactif pendant l'intervalle spécifié, nous verrouillons l'application
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connectez-vous à d'autres contextes
    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 jusqu'à cette étape se trouve ici.

Transactions

Ainsi, nous avons atteint le point crucial : la création et la signature de transactions sur la blockchain. Nous allons utiliser la blockchain WAVES et la bibliothèque waves-transactions.

Pour commencer, ajoutons à l'état un tableau de messages qui doivent être signés, puis — des méthodes pour ajouter un nouveau message, confirmer la signature et annuler :

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) {
        // Pour chaque message, nous créons des métadonnées avec id, statut, date de création, etc.
        const message = observable.object({
            id: uuid(), // Identifiant, utilisant uuid
            origin, // L'origine sera affichée dans l'interface plus tard
            data, //
            status: 'new', // Les statuts seront au nombre de quatre : new, signed, rejected et failed
            timestamp: Date.now()
        });
        console.log(`nouveau message : ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Nous retournons une promesse à l'intérieur de laquelle mobx surveille les changements du message. Dès que le statut change, nous le résolvons
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Nous surveillons le statut du message
                (status, reaction) => { // Le deuxième argument est une référence à la réaction elle-même, afin de pouvoir la détruire à l'intérieur de l'appel
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Utilisateur a rejeté le 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(`Pas de msg avec 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(`Pas de msg avec id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Lors de la réception d'un nouveau message, nous y ajoutons des métadonnées, nous faisons observable et ajoutons à store.messages.

Si cela n'est pas fait observable manuellement, mobx le fera automatiquement lors de l'ajout au tableau messages. Cependant, il créera un nouvel objet, auquel nous n'aurons pas de lien, et c'est nécessaire pour l'étape suivante.

Ensuite, nous retournons une promesse qui se résout lorsque le statut du message change. Le statut est surveillé par une réaction qui se « tue » elle-même lors du changement de statut.

Le code des méthodes approve et reject est très simple : nous changeons simplement le statut du message, après l'avoir préalablement signé, si besoin est.

Nous mettons en avant l'approbation et le rejet dans l'API UI, newMessage — dans les pages 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)
        }
    }

    ...
}

Essayons maintenant de signer une transaction avec l'extension :

Nous développons une extension de navigateur sécurisée

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

UI

L'interface a besoin d'accès à l'état de l'application. Du côté UI, nous allons créer observable un état et ajouter une fonction dans l'API qui pourra modifier cet état. Nous allons ajouter observable dans l'objet API, obtenu du 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() {
    // Nous nous connectons au port, créons un flux à partir de celui-ci
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Créons un observable vide pour l'état du background
    let backgroundState = observable.object({});
    const api = {
        // Nous renvoyons au background une fonction qui mettra à jour l'observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Ajoutons au background un observable avec l'état
    background.state = backgroundState;

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

    // Démarrage de l'interface
    await initApp(background)
}

À la fin, nous lançons le rendu de l'interface de l'application. C'est une application React. L'objet background est simplement transmis via des props. Il serait en effet correct de créer un service distinct pour les méthodes et un store pour l'état, mais dans le cadre de cet article, cela suffit :

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

// Initialisons l'application avec l'objet background en tant que props
export async function initApp(background) {
    render(
        ,
        document.getElementById('app-content')
    );
}

Avec MobX, il est très simple de lancer un rendu lors de la modification des données. Nous n'avons qu'à appliquer le décorateur observer du package mobx-react Le composant sera automatiquement rendu lors de la modification de tout observable auquel il fait référence. Aucune mapStateToProps ou connect n'est nécessaire, comme avec redux. Tout fonctionne immédiatement « hors 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 // Le composant avec ce décorateur appellera automatiquement la méthode render, si des observables dont il dépend sont modifiés
export default class App extends Component {

    // Il est préférable de sortir la logique de rendu des pages dans le routage et de ne pas utiliser d'opérateurs ternaires imbriqués,
    // et de lier les observables et méthodes de background directement aux composants qui les utilisent
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <fragment>
            {!initialized
                ?
                <init oninit="{initVault}/">
                :
                locked
                    ?
                    <unlock onunlock="{unlock}/">
                    :
                    messages.length &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!locked &amp;&amp; <button onclick="{()" > lock()}&gt;Verrouiller l'application</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Supprimer toutes les clés et initialiser</button>}
            </div>
        </Fragment>
    }
}

Les autres composants peuvent être consultés dans le code dans le dossier UI.

Maintenant, dans la classe de l'application, il est nécessaire de créer un sélecteur d'état pour l'UI et d'avertir l'UI lors de sa modification. Pour cela, ajoutons la méthode getState et reaction, appelant 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) => {
            // Créer une réaction sur les modifications de l'état qui appellera une fonction distante et mettra à jour l'état dans le processus UI
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Le troisième argument permet de passer des paramètres. fireImmediatly signifie que la réaction sera exécutée tout de suite pour la première fois.
                // Cela est nécessaire pour obtenir l'état initial. Delay permet de définir un debounce
                {fireImmediately: true, delay: 500}
            );
            // Supprimer l'abonnement lors de la déconnexion du client
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Lors de la réception de l'objet remote est créé reaction à chaque modification de l'état, qui appelle une fonction du côté UI.

La dernière touche : ajoutons l'affichage de nouveaux messages sur l'icône de l'extension :

function setupApp() {
...

    // Réaction pour définir le texte du badge.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Donc, l'application est prête. Les pages Web peuvent demander une signature pour les transactions :

Nous développons une extension de navigateur sécurisée

Nous développons une extension de navigateur sécurisée

Le code est disponible à ce lien le lien.

Conclusion

Si vous avez lu l'article jusqu'à la fin mais que vous avez encore des questions, vous pouvez les poser dans le dépôt de l'extension. Vous y trouverez également les commits pour chaque étape indiquée.

Et si vous êtes intéressé par le code d'une véritable extension, vous pouvez le trouver ici ici.

Le code, le dépôt et la description du fonctionnement par siemarell

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster