Een veilige browserextensie schrijven

Een veilige browserextensie schrijven

In tegenstelling tot de gebruikelijke ‘client-server’-architectuur worden gedecentraliseerde applicaties gekenmerkt door:

  • Het is niet nodig om een ​​database met gebruikersaanmeldingen en wachtwoorden op te slaan. Toegangsinformatie wordt uitsluitend door de gebruikers zelf opgeslagen en de bevestiging van hun authenticiteit vindt plaats op protocolniveau.
  • U hoeft geen server te gebruiken. De applicatielogica kan worden uitgevoerd op een blockchain-netwerk, waar het mogelijk is om de benodigde hoeveelheid gegevens op te slaan.

Er zijn twee relatief veilige opslagplaatsen voor gebruikerssleutels: hardwareportefeuilles en browserextensies. Hardware wallets zijn veelal extreem veilig, maar moeilijk te gebruiken en verre van gratis, maar browserextensies zijn de perfecte combinatie van veiligheid en gebruiksgemak, en kunnen bovendien geheel gratis zijn voor eindgebruikers.

Met dit alles rekening houdend, wilden we de veiligste extensie maken die de ontwikkeling van gedecentraliseerde applicaties vereenvoudigt door een eenvoudige API te bieden voor het werken met transacties en handtekeningen.
Hieronder vertellen wij u over deze ervaring.

Het artikel bevat stapsgewijze instructies voor het schrijven van een browserextensie, met codevoorbeelden en schermafbeeldingen. Je kunt alle code vinden in opslagplaatsen. Elke commit komt logischerwijs overeen met een sectie van dit artikel.

Een korte geschiedenis van browserextensies

Browserextensies bestaan ​​al heel lang. Ze verschenen in 1999 in Internet Explorer en in 2004 in Firefox. Er bestond echter lange tijd niet één standaard voor extensies.

We kunnen zeggen dat het samen met extensies verscheen in de vierde versie van Google Chrome. Natuurlijk was er toen nog geen specificatie, maar het was de Chrome API die de basis werd: nadat Chrome het grootste deel van de browsermarkt had veroverd en een ingebouwde applicatiewinkel had, zette Chrome feitelijk de standaard voor browserextensies.

Mozilla had zijn eigen standaard, maar gezien de populariteit van Chrome-extensies besloot het bedrijf een compatibele API te maken. In 2015 werd op initiatief van Mozilla een speciale groep opgericht binnen het World Wide Web Consortium (W3C) om te werken aan specificaties voor cross-browser extensies.

Als basis zijn de bestaande API-extensies voor Chrome genomen. Het werk werd uitgevoerd met de steun van Microsoft (Google weigerde deel te nemen aan de ontwikkeling van de standaard) en als resultaat verscheen er een concept specificaties.

Formeel wordt de specificatie ondersteund door Edge, Firefox en Opera (let op: Chrome staat niet op deze lijst). Maar in feite is de standaard grotendeels compatibel met Chrome, omdat deze feitelijk is geschreven op basis van de extensies. U kunt meer lezen over de WebExtensions API hier.

Uitbreidingsstructuur

Het enige bestand dat nodig is voor de extensie is het manifest (manifest.json). Het is ook het ‘toegangspunt’ tot de uitbreiding.

Манифест

Volgens de specificatie is het manifestbestand een geldig JSON-bestand. Een volledige beschrijving van manifestsleutels met informatie over welke sleutels in welke browser worden ondersteund, kan worden bekeken hier.

Sleutels die niet in de specificatie staan ​​“mogen” worden genegeerd (zowel Chrome als Firefox melden fouten, maar de extensies blijven werken).

En ik zou op enkele punten de aandacht willen vestigen.

  1. achtergrond — een object dat de volgende velden bevat:
    1. scripts — een reeks scripts die in de achtergrondcontext zullen worden uitgevoerd (we zullen hier later over praten);
    2. pagina - in plaats van scripts die op een lege pagina worden uitgevoerd, kunt u html met inhoud opgeven. In dit geval wordt het scriptveld genegeerd en moeten de scripts in de inhoudspagina worden ingevoegd;
    3. aanhoudend — een binaire vlag. Indien niet gespecificeerd, zal de browser het achtergrondproces “doden” wanneer hij meent dat het niets doet, en indien nodig opnieuw opstarten. Anders wordt de pagina pas verwijderd als de browser wordt gesloten. Niet ondersteund in Firefox.
  2. inhoud_scripts — een reeks objecten waarmee u verschillende scripts naar verschillende webpagina's kunt laden. Elk object bevat de volgende belangrijke velden:
    1. lucifers - patroon-URL, dat bepaalt of een bepaald inhoudsscript wordt opgenomen of niet.
    2. js — een lijst met scripts die in deze match worden geladen;
    3. sluit_overeenkomsten uit - sluit uit van het veld match URL's die overeenkomen met dit veld.
  3. pagina_actie - is eigenlijk een object dat verantwoordelijk is voor het pictogram dat naast de adresbalk in de browser wordt weergegeven en de interactie daarmee. U kunt er ook een pop-upvenster mee weergeven, dat is gedefinieerd met uw eigen HTML, CSS en JS.
    1. standaard_pop-up — pad naar het HTML-bestand met de pop-upinterface, kan CSS en JS bevatten.
  4. permissies — een array voor het beheren van extensierechten. Er zijn 3 soorten rechten, die in detail worden beschreven hier
  5. web_toegankelijke_bronnen — uitbreidingsbronnen waar een webpagina om kan vragen, bijvoorbeeld afbeeldingen, JS, CSS, HTML-bestanden.
  6. extern_aansluitbaar — hier kunt u expliciet de ID's opgeven van andere extensies en domeinen van webpagina's waarmee u verbinding kunt maken. Een domein kan van het tweede niveau of hoger zijn. Werkt niet in Firefox.

Uitvoeringscontext

De extensie heeft drie code-uitvoeringscontexten, dat wil zeggen dat de applicatie uit drie delen bestaat met verschillende toegangsniveaus tot de browser-API.

Extensiecontext

Het grootste deel van de API is hier beschikbaar. In deze context ‘leven’ ze:

  1. Achtergrond pagina — “backend”-gedeelte van de extensie. Het bestand wordt in het manifest gespecificeerd met behulp van de "achtergrond" -sleutel.
  2. Pop-uppagina — een pop-uppagina die verschijnt wanneer u op het extensiepictogram klikt. In het manifest browser_action -> default_popup.
  3. Aangepaste pagina — uitbreidingspagina, “wonen” in een apart tabblad van de weergave chrome-extension://<id_расширения>/customPage.html.

Deze context bestaat onafhankelijk van browservensters en tabbladen. Achtergrond pagina bestaat in één enkele kopie en werkt altijd (de uitzondering is de gebeurtenispagina, wanneer het achtergrondscript door een gebeurtenis wordt gestart en na de uitvoering ervan “sterft”). Pop-uppagina bestaat wanneer het pop-upvenster geopend is, en Aangepaste pagina — terwijl het tabblad ermee open is. Er is vanuit deze context geen toegang tot andere tabbladen en hun inhoud.

Contentscriptcontext

Het inhoudsscriptbestand wordt samen met elk browsertabblad gestart. Het heeft toegang tot een deel van de API van de extensie en tot de DOM-boom van de webpagina. Het zijn inhoudsscripts die verantwoordelijk zijn voor de interactie met de pagina. Extensies die de DOM-boom manipuleren, doen dit in inhoudsscripts, bijvoorbeeld advertentieblokkers of vertalers. Ook kan het contentscript standaard met de pagina communiceren postMessage.

Context van webpagina's

Dit is de eigenlijke webpagina zelf. Het heeft niets te maken met de extensie en heeft daar geen toegang, behalve in gevallen waarin het domein van deze pagina niet expliciet wordt aangegeven in het manifest (meer hierover hieronder).

Berichtuitwisseling

Verschillende delen van de applicatie moeten berichten met elkaar uitwisselen. Hiervoor bestaat een API runtime.sendMessage om een ​​bericht te sturen background и tabs.sendMessage om een ​​bericht naar een pagina te sturen (inhoudsscript, pop-up of webpagina indien beschikbaar). externally_connectable). Hieronder ziet u een voorbeeld van toegang tot de Chrome API.

// Сообщением может быть любой 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))
    }
)

Voor volledige communicatie kunt u verbindingen tot stand brengen via runtime.connect. Als antwoord zullen wij ontvangen runtime.Port, waarnaar u, zolang het open is, een onbeperkt aantal berichten kunt sturen. Aan de klantzijde bijvoorbeeld contentscript, het ziet er zo uit:

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

Server of achtergrond:

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

Er is ook een evenement onDisconnect en methode disconnect.

Toepassingsdiagram

Laten we een browserextensie maken die privésleutels opslaat, toegang biedt tot openbare informatie (adres, openbare sleutel communiceert met de pagina en zorgt ervoor dat applicaties van derden een handtekening voor transacties kunnen aanvragen).

Applicatie ontwikkeling

Onze applicatie moet zowel communiceren met de gebruiker als de pagina voorzien van een API om methoden aan te roepen (bijvoorbeeld om transacties te ondertekenen). Doe het met slechts één contentscript zal niet werken, omdat het alleen toegang heeft tot de DOM, maar niet tot de JS van de pagina. Verbind via runtime.connect dat kunnen we niet, omdat de API nodig is voor alle domeinen en alleen specifieke domeinen kunnen worden gespecificeerd in het manifest. Als gevolg hiervan ziet het diagram er als volgt uit:

Een veilige browserextensie schrijven

Er zal nog een script zijn - inpage, die we in de pagina zullen injecteren. Het zal in zijn context draaien en een API bieden om met de extensie te werken.

begin

Alle browserextensiecodes zijn beschikbaar op GitHub. Tijdens de beschrijving zullen er links naar commits zijn.

Laten we beginnen met het manifest:

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

Maak lege background.js, popup.js, inpage.js en contentscript.js. We voegen popup.html toe - en onze applicatie kan al in Google Chrome worden geladen en ervoor zorgen dat deze werkt.

Om dit te verifiëren, kunt u de code gebruiken vandaar. Naast wat we deden, configureerde de link de montage van het project met behulp van webpack. Om een ​​applicatie aan de browser toe te voegen, moet je in chrome://extensions load unpacked selecteren en de map met de bijbehorende extensie - in ons geval dist.

Een veilige browserextensie schrijven

Nu is onze extensie geïnstalleerd en werkt. U kunt de ontwikkelaarstools als volgt voor verschillende contexten uitvoeren:

pop-up ->

Een veilige browserextensie schrijven

Toegang tot de inhoudscriptconsole wordt uitgevoerd via de console van de pagina zelf waarop deze wordt gestart.Een veilige browserextensie schrijven

Berichtuitwisseling

We moeten dus twee communicatiekanalen opzetten: inpage <-> achtergrond en popup <-> achtergrond. Je kunt natuurlijk gewoon berichten naar de poort sturen en je eigen protocol bedenken, maar ik geef de voorkeur aan de aanpak die ik zag in het open source-project metamask.

Dit is een browserextensie voor het werken met het Ethereum-netwerk. Daarin communiceren verschillende delen van de applicatie via RPC met behulp van de dnode-bibliotheek. Hiermee kunt u vrij snel en gemakkelijk een uitwisseling organiseren als u deze voorziet van een nodejs-stream als transportmiddel (dat wil zeggen een object dat dezelfde interface implementeert):

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

Nu gaan we een applicatieklasse maken. Het zal API-objecten maken voor de pop-up en webpagina, en er een dnode voor maken:

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

Hier en hieronder gebruiken we in plaats van het algemene Chrome-object extensionApi, dat toegang heeft tot Chrome in de browser van Google en browsers in andere. Dit wordt gedaan voor compatibiliteit tussen browsers, maar voor de doeleinden van dit artikel zou men eenvoudigweg 'chrome.runtime.connect' kunnen gebruiken.

Laten we een toepassingsinstantie maken in het achtergrondscript:

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

Omdat dnode met streams werkt en we een poort ontvangen, is een adapterklasse nodig. Het is gemaakt met behulp van de readable-stream-bibliotheek, die nodejs-streams in de browser implementeert:

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

Laten we nu een verbinding maken in de gebruikersinterface:

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

Vervolgens maken we de verbinding in het inhoudsscript:

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

Omdat we de API niet in het inhoudsscript nodig hebben, maar rechtstreeks op de pagina, doen we twee dingen:

  1. We creëren twee stromen. Eén - richting de pagina, bovenaan het bericht. Hiervoor gebruiken wij dit dit pakket van de makers van metamask. De tweede stream is naar de achtergrond via de ontvangen poort runtime.connect. Laten we ze kopen. Nu heeft de pagina een stream naar de achtergrond.
  2. Injecteer het script in de DOM. Download het script (toegang daartoe was toegestaan ​​in het manifest) en maak een tag script met de inhoud erin:

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

Nu maken we een api-object in inpage en stellen we dit in op 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;
}

Wij zijn klaar Remote Procedure Call (RPC) met aparte API voor pagina en gebruikersinterface. Wanneer we een nieuwe pagina met de achtergrond verbinden, kunnen we dit zien:

Een veilige browserextensie schrijven

Lege API en oorsprong. Aan de paginazijde kunnen we de hallo-functie als volgt aanroepen:

Een veilige browserextensie schrijven

Werken met callback-functies in moderne JS is een slechte manier, dus laten we een kleine helper schrijven om een ​​dnode te maken waarmee je een API-object aan utils kunt doorgeven.

De API-objecten zien er nu als volgt uit:

export class SignerApp {

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

...

}

Een object op afstand ophalen, zoals dit:

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

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

En het aanroepen van functies retourneert een belofte:

Een veilige browserextensie schrijven

Versie met asynchrone functies beschikbaar hier.

Over het geheel genomen lijkt de RPC- en stream-aanpak behoorlijk flexibel: we kunnen stoommultiplexing gebruiken en verschillende API's voor verschillende taken maken. In principe kan dnode overal worden gebruikt, het belangrijkste is om het transport in de vorm van een nodejs-stream te verpakken.

Een alternatief is het JSON-formaat, dat het JSON RPC 2-protocol implementeert, maar werkt met specifieke transporten (TCP en HTTP(S)), wat in ons geval niet van toepassing is.

Interne staat en lokale opslag

We moeten de interne status van de applicatie opslaan, in ieder geval de ondertekeningssleutels. We kunnen vrij eenvoudig een status aan de applicatie toevoegen en methoden om deze te wijzigen in de pop-up API:

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

export class SignerApp {

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

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

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

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

    ...

} 

Op de achtergrond verpakken we alles in een functie en schrijven we het applicatieobject naar het venster, zodat we ermee kunnen werken vanaf de 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)
        }
    }
}

Laten we een paar sleutels van de UI-console toevoegen en kijken wat er met de status gebeurt:

Een veilige browserextensie schrijven

De status moet persistent worden gemaakt, zodat de sleutels niet verloren gaan bij het opnieuw opstarten.

We slaan het op in localStorage en overschrijven het bij elke wijziging. Vervolgens zal toegang daartoe ook nodig zijn voor de gebruikersinterface, en ik zou me ook graag willen abonneren op wijzigingen. Op basis hiervan zal het handig zijn om een ​​waarneembare opslag te creëren en de wijzigingen ervan te abonneren.

We zullen de mobx-bibliotheek gebruiken (https://github.com/mobxjs/mobx). De keuze viel erop omdat ik er niet mee hoefde te werken, maar ik het wel heel graag wilde bestuderen.

Laten we initialisatie van de beginstatus toevoegen en de winkel waarneembaar maken:

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

    ...

}

“Onder de motorkap” heeft mobx alle winkelvelden vervangen door proxy en onderschept alle oproepen ernaartoe. Het is mogelijk om u op deze berichten te abonneren.

Hieronder zal ik vaak de term “bij het wisselen” gebruiken, hoewel dit niet helemaal correct is. Mobx houdt de toegang tot velden bij. Getters en setters van proxy-objecten die door de bibliotheek worden gemaakt, worden gebruikt.

Actie-decorateurs dienen twee doelen:

  1. In de strikte modus met de vlag EnforceActions verbiedt mobx het rechtstreeks wijzigen van de status. Het wordt als een goede praktijk beschouwd om onder strikte omstandigheden te werken.
  2. Zelfs als een functie de status meerdere keren verandert (we veranderen bijvoorbeeld verschillende velden in verschillende coderegels), worden de waarnemers pas op de hoogte gesteld wanneer de functie is voltooid. Dit is vooral belangrijk voor de frontend, waar onnodige statusupdates leiden tot onnodige weergave van elementen. In ons geval zijn noch de eerste, noch de tweede bijzonder relevant, maar we zullen de best practices volgen. Het is gebruikelijk om decorateurs toe te voegen aan alle functies die de toestand van de waargenomen velden veranderen.

Op de achtergrond zullen we initialisatie toevoegen en de status opslaan in 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)
        }
    }
}

De reactiefunctie is hier interessant. Er zijn twee argumenten:

  1. Gegevenskiezer.
  2. Een handler die bij elke wijziging met deze gegevens wordt aangeroepen.

In tegenstelling tot redux, waarbij we de status expliciet als argument ontvangen, onthoudt mobx tot welke waarneembare gegevens we toegang hebben binnen de selector, en roept de handler alleen op als deze veranderen.

Het is belangrijk om precies te begrijpen hoe mobx beslist op welke observaties we ons abonneren. Als ik een selector in code als deze zou schrijven() => app.store, dan zal er nooit een reactie worden opgeroepen, omdat de opslag zelf niet waarneembaar is, maar alleen de velden ervan.

Als ik het zo zou schrijven () => app.store.keys, dan zou er opnieuw niets gebeuren, omdat bij het toevoegen/verwijderen van array-elementen de verwijzing ernaar niet zal veranderen.

Mobx fungeert voor het eerst als selector en houdt alleen de waarneembare gegevens bij waartoe we toegang hebben gehad. Dit gebeurt via proxy-getters. Daarom wordt hier de ingebouwde functie gebruikt toJS. Het retourneert een nieuw object waarbij alle proxy's zijn vervangen door de originele velden. Tijdens de uitvoering leest het alle velden van het object - vandaar dat de getters worden geactiveerd.

In de pop-upconsole voegen we opnieuw verschillende sleutels toe. Deze keer kwamen ze ook in localStorage terecht:

Een veilige browserextensie schrijven

Wanneer de achtergrondpagina opnieuw wordt geladen, blijft de informatie op zijn plaats.

Alle applicatiecode tot nu toe kan worden bekeken hier.

Veilige opslag van privésleutels

Het opslaan van privésleutels in leesbare tekst is onveilig: er is altijd een kans dat u wordt gehackt, toegang krijgt tot uw computer, enzovoort. Daarom slaan we in localStorage de sleutels op in een met een wachtwoord gecodeerde vorm.

Voor meer veiligheid voegen we een vergrendelde status toe aan de applicatie, waarbij er helemaal geen toegang tot de sleutels is. We zetten de extensie automatisch over naar de vergrendelde status vanwege een time-out.

Met Mobx kunt u slechts een minimale set gegevens opslaan, en de rest wordt automatisch op basis daarvan berekend. Dit zijn de zogenaamde berekende eigenschappen. Ze kunnen worden vergeleken met weergaven in databases:

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

Nu slaan we alleen de gecodeerde sleutels en het wachtwoord op. Al het andere wordt berekend. We voeren de overdracht naar een vergrendelde staat uit door het wachtwoord uit de staat te verwijderen. De publieke API heeft nu een methode voor het initialiseren van de opslag.

Geschreven voor encryptie hulpprogramma's die crypto-js gebruiken:

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

De browser heeft een inactieve API waarmee u zich kunt abonneren op een gebeurtenis - statuswijzigingen. Staat kan dienovereenkomstig zijn idle, active и locked. Voor inactiviteit kunt u een time-out instellen, en vergrendeld wordt ingesteld wanneer het besturingssysteem zelf wordt geblokkeerd. We zullen ook de selector voor opslaan in localStorage wijzigen:

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

De code vóór deze stap is hier.

Transactie

We komen dus bij het allerbelangrijkste: het creëren en ondertekenen van transacties op de blockchain. We zullen de WAVES-blockchain en -bibliotheek gebruiken golven-transacties.

Laten we eerst aan de status een reeks berichten toevoegen die moeten worden ondertekend, en vervolgens methoden toevoegen voor het toevoegen van een nieuw bericht, het bevestigen van de handtekening en het weigeren:

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

    ...
}

Wanneer we een nieuw bericht ontvangen, voegen we er metadata aan toe observable en toevoegen aan store.messages.

Als je dat niet doet observable handmatig, dan zal mobx het zelf doen bij het toevoegen van berichten aan de array. Er wordt echter een nieuw object gecreëerd waarnaar we geen referentie zullen hebben, maar we zullen het nodig hebben voor de volgende stap.

Vervolgens retourneren we een belofte die wordt opgelost wanneer de berichtstatus verandert. De status wordt bewaakt door een reactie, die zichzelf zal ‘doden’ wanneer de status verandert.

Methodecode approve и reject heel eenvoudig: wij wijzigen eenvoudig de status van het bericht, eventueel na ondertekening.

We plaatsen Goedkeuren en afwijzen in de UI API, newMessage in de pagina-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)
        }
    }

    ...
}

Laten we nu proberen de transactie te ondertekenen met de extensie:

Een veilige browserextensie schrijven

Over het algemeen is alles klaar, het enige dat overblijft is voeg een eenvoudige gebruikersinterface toe.

UI

De interface heeft toegang nodig tot de applicatiestatus. Aan de UI-kant zullen we het doen observable state en voeg een functie toe aan de API die deze status zal veranderen. Laten we toevoegen observable naar het API-object ontvangen van de achtergrond:

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

Aan het einde beginnen we met het renderen van de applicatie-interface. Dit is een reactietoepassing. Het achtergrondobject wordt eenvoudigweg doorgegeven met behulp van rekwisieten. Het zou natuurlijk correct zijn om een ​​aparte service voor methoden en een winkel voor de staat te maken, maar voor de doeleinden van dit artikel is dit voldoende:

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

Met mobx is het heel eenvoudig om te beginnen met renderen wanneer gegevens veranderen. We hangen de waarnemer-decorateur eenvoudigweg aan de verpakking mobx-reageer op de component, en render wordt automatisch aangeroepen wanneer waarneembare gegevens waarnaar door de component wordt verwezen, veranderen. Je hebt geen mapStateToProps nodig of verbinding zoals in redux. Alles werkt direct uit de doos:

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

De overige componenten kunnen in de code worden bekeken in de UI-map.

Nu moet u in de toepassingsklasse een statuskiezer voor de gebruikersinterface maken en de gebruikersinterface op de hoogte stellen wanneer deze verandert. Om dit te doen, voegen we een methode toe getState и reactionroeping 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())

        })
    }

    ...
}

Bij ontvangst van een object remote is gemaakt reaction om de status te wijzigen die de functie aanroept aan de UI-zijde.

De laatste hand is om de weergave van nieuwe berichten toe te voegen aan het extensiepictogram:

function setupApp() {
...

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

...
}

De applicatie is dus klaar. Webpagina's kunnen om een ​​handtekening vragen voor transacties:

Een veilige browserextensie schrijven

Een veilige browserextensie schrijven

De code is hier beschikbaar link.

Conclusie

Als u het artikel tot het einde heeft gelezen, maar nog vragen heeft, kunt u deze stellen op opslagplaatsen met extensie. Daar vindt u ook commits voor elke aangegeven stap.

En als u geïnteresseerd bent in de code voor de daadwerkelijke extensie, kunt u deze vinden hier.

Code, repository en functiebeschrijving van siemarell

Bron: www.habr.com

Voeg een reactie