We schrijven een veilig browserextensie

We schrijven een veilig browserextensie

In tegenstelling tot de veelvoorkomende «client-server» architectuur, kenmerken gedecentraliseerde applicaties zich door:

  • Er is geen behoefte om een database met gebruikersinloggegevens en wachtwoorden op te slaan. Informatie voor toegang wordt uitsluitend bij de gebruikers zelf bewaard, en de bevestiging van hun betrouwbaarheid gebeurt op het protocolniveau.
  • Er is geen behoefte om een server te gebruiken. De logica van de applicatie kan worden uitgevoerd in het blockchain-netwerk, waar ook de benodigde gegevens kunnen worden opgeslagen.

Er zijn 2 relatief veilige opslagplaatsen voor gebruikerssleutels — hardware wallets en browserextensies. Hardware wallets zijn over het algemeen bijzonder veilig, maar ook moeilijk te gebruiken en meestal niet gratis, terwijl browserextensies de ideale combinatie van veiligheid en gebruiksgemak bieden, en bovendien vaak volledig gratis zijn voor eindgebruikers.

Gegeven al deze factoren, wilden we een zo veilig mogelijke extensie maken, die de ontwikkeling van gedecentraliseerde applicaties vereenvoudigt door een eenvoudige API voor transacties en handtekeningen te bieden.
Over deze ervaring vertellen we je hieronder.

In het artikel zal een stapsgewijze instructie worden gegeven over hoe je een browserextensie schrijft, met voorbeeldcode en screenshots. Je kunt alle code vinden in de repository. Elke commit komt logisch overeen met een sectie van dit artikel.

Korte geschiedenis van browserextensies

Browserextensies bestaan al geruime tijd. Ze werden al geïntroduceerd in Internet Explorer in 1999 en in Firefox in 2004. Toch was er lange tijd geen uniforme standaard voor extensies.

Je zou kunnen zeggen dat deze standaard ontstond met de extensies van de vierde versie van Google Chrome. Natuurlijk was er toen geen specificatie, maar juist de Chrome API vormde de basis daaromheen: door een groot deel van de browsermarkt te veroveren en een ingebouwde app-store te hebben, stelde Chrome in wezen de standaard voor browserextensies vast.

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

De bestaande API van extensies voor Chrome is als basis gebruikt. Het werk werd ondersteund door Microsoft (Google weigerde deel te nemen aan de ontwikkeling van de standaard), en uiteindelijk ontstond er een concept. de specificatie.

Formeel wordt de specificatie ondersteund door Edge, Firefox en Opera (merk op dat Chrome niet in deze lijst staat). Maar in werkelijkheid is de standaard in veel opzichten compatibel met Chrome, omdat deze feitelijk is geschreven op basis van zijn extensies. Meer informatie over de WebExtensions API is beschikbaar. hier.

De structuur van de extensie

Het enige bestand dat absoluut nodig is voor de extensie, is het manifest (manifest.json). Dit bestand is ook het 'toegangspunt' voor de extensie.

Manifest

Volgens de specificatie is het manifestbestand een geldig JSON-bestand. Een volledige beschrijving van de sleutels van het manifest, inclusief welke sleutels in welke browser worden ondersteund, is beschikbaar. hier.

Sleutels die niet in de specificatie zijn opgenomen, 'kunnen' genegeerd worden (zowel Chrome als Firefox geven fouten aan, maar de extensies blijven werken).

Ik wil graag enkele punten benadrukken.

  1. background — een object dat de volgende velden bevat:
    1. scripts — een array van scripts die zullen worden uitgevoerd in de background-context (daarover later meer);
    2. pagina — in plaats van scripts die op een lege pagina worden uitgevoerd, kan HTML met inhoud worden opgegeven. In dat geval zal het scriptveld worden genegeerd, en moeten scripts in de pagina met inhoud worden ingevoegd;
    3. persistent — een binaire vlag; als deze niet is opgegeven, zal de browser het background-proces 'sluiten' wanneer deze denkt dat het niets doet, en opnieuw starten indien nodig. Anders zal de pagina alleen worden geladen bij het sluiten van de browser. Niet ondersteund in Firefox.
  2. content_scripts — een array van objecten waarmee verschillende scripts naar verschillende webpagina's kunnen worden geladen. Elk object bevat de volgende belangrijke velden:
    1. matches — een URL-patroon,dat bepaalt of een specifieke content-script zal worden geladen.
    2. js — een lijst van scripts die bij deze match zullen worden geladen;
    3. exclude_matches — sluit uit van het veld match URL's die aan dit veld voldoen.
  3. page_action — is in feite een object dat verantwoordelijk is voor het pictogram dat naast de adresbalk in de browser wordt weergegeven, en interactie ermee. Hiermee kan ook een popupvenster worden weergegeven dat wordt gedefinieerd met behulp van zijn eigen HTML, CSS en JS.
    1. default_popup — pad naar het HTML-bestand met de popup-interface, kan CSS en JS bevatten.
  4. permissions — array voor het beheren van de extensierechten. Er zijn 3 soorten rechten die in detail worden beschreven. here
  5. web_accessible_resources — bronnen van de extensie die door de webpagina kunnen worden aangevraagd, zoals afbeeldingen, JS-bestanden, CSS, HTML.
  6. externally_connectable — hier kunnen expliciet ID's van andere extensies en domeinen van webpagina's worden opgegeven waarvan verbinding kan worden gemaakt. Het domein kan van tweede niveau of hoger zijn. Werkt niet in Firefox.

Uitvoeringscontext

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

Extensiegcontext

Hier is het grootste deel van de API beschikbaar. In deze context 'wonen':

  1. Achtergrondpagina — de 'back-end' van de extensie. Het bestand wordt in het manifest opgegeven met de sleutel 'background'.
  2. Popup-pagina — de popup-pagina die verschijnt wanneer op het icoon van de extensie wordt geklikt. In het manifest browser_action -> default_popup.
  3. Aangepaste pagina — de pagina van de extensie die 'woont' in een apart tabblad van de vorm chrome-extension:///customPage.html.

Deze context bestaat onafhankelijk van vensters en tabbladen van de browser. Achtergrondpagina bestaat in enkele exemplaar en werkt altijd (uitgezonderd de event-pagina, wanneer het achtergrondscript wordt gestart op een gebeurtenis en 'sterft' na de uitvoering). Popup-pagina bestaat wanneer het popup-venster is geopend, en Aangepaste pagina — zolang er een tabblad met deze is geopend. Geen toegang tot andere tabbladen en hun inhoud vanuit deze context.

Inhoudsscriptcontext

Het bestand van het inhoudscript wordt samen met elke tab van de browser uitgevoerd. Het heeft toegang tot een deel van de API van de extensie en de DOM-boom van de webpagina. Het zijn de inhoudscripts die verantwoordelijk zijn voor de interactie met de pagina. Extensies die de DOM-boom manipuleren, doen dit in de inhoudscripts - bijvoorbeeld advertentieblockers of vertalers. Ook kan het inhoudscript communiceren met de pagina via de standaard postMessage.

Webpagina-context

Dit is eigenlijk de webpagina zelf. Deze heeft niets te maken met de extensie en heeft daar geen toegang toe, behalve in gevallen waarin in het manifest expliciet het domein van deze pagina is opgegeven (hierover meer hieronder).

Berichtenuitwisseling

Verschillende delen van de applicatie moeten berichten met elkaar uitwisselen. Hiervoor is er de API runtime.sendMessage om een bericht te verzenden background en tabs.sendMessage om een bericht naar de pagina te sturen (inhoudsscript, popup of webpagina indien aanwezig) externally_connectable). Hieronder is een voorbeeld bij het aanroepen van 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 kunnen verbindingen worden gemaakt via runtime.connect. Als antwoord krijgen we runtime.Port, waarin, zolang deze open is, een onbeperkt aantal berichten kan worden verzonden. Aan de klantzijde, bijvoorbeeld, contentscript, ziet dit er als volgt 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 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) {
    ...
});

Er is ook een gebeurtenis onDisconnect en de methode ontkoppelen.

Applicatieschema

Laten we een browserextensie maken die privé-sleutels opslaat, toegang biedt tot openbare informatie (adres, public key communiceert met de pagina) en derden in staat stelt om handtekeningen voor transacties op te vragen.

Applicatieontwikkeling

Onze applicatie moet zowel met de gebruiker interageren als de pagina een API bieden om methoden aan te roepen (bijvoorbeeld voor het ondertekenen van transacties). Alleen met contentscript gaat niet lukken, want het heeft alleen toegang tot de DOM, maar niet tot de JS van de pagina. Verbinden via runtime.connect is niet mogelijk, omdat de API op alle domeinen vereist is, terwijl in het manifest alleen specifieke domeinen kunnen worden opgegeven. Het schema zal er als volgt uitzien:

We schrijven een veilig browserextensie

Er zal nog een script zijn — inpage, dat we in de pagina zullen injecteren. Dit zal worden uitgevoerd in de context ervan en een API bieden voor interactie met de extensie.

Inleiding

De gehele code van de browserextensie is beschikbaar op GitHub. Tijdens de beschrijving zullen er links naar commits zijn.

Laten we beginnen met het manifest:

{
  // Naam en beschrijving, versie. Dit alles zal zichtbaar zijn in de browser op chrome://extensions/?id=<extensie id>
  "name": "Signer",
  "description": "Extensie demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts die op de achtergrond moeten worden uitgevoerd, er kunnen er meerdere zijn
  "background": {
    "scripts": ["background.js"]
  },

  // Welke html te gebruiken voor de popup
  "browser_action": {
    "default_title": "Mijn Extensie",
    "default_popup": "popup.html"
  },

  // Inhoudscripts.
  // We hebben één object: voor alle url's die beginnen met http of https starten we
  // de contenscript context met het script contentscript.js. Starten onmiddellijk bij het ontvangen van het document voor alle frames
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Toegang tot localStorage en idle api is toegestaan
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Hier worden de bronnen aangegeven waartoe de webpagina toegang heeft. Deze kunnen worden opgevraagd met fetche' m of gewoon xhr
  "web_accessible_resources": ["inpage.js"]
}

We creëren lege background.js, popup.js, inpage.js en contentscript.js. We voegen popup.html toe — en onze applicatie kan nu al in Google Chrome worden geladen om te controleren of deze werkt.

Om dit te verifiëren, kunnen we de code nemen hier. Bovendien is er een projectbuild ingesteld via webpack op de link. Om de applicatie in de browser toe te voegen, moet je in chrome://extensions 'load unpacked' selecteren en de map met de bijbehorende extensie — in ons geval dist.

We schrijven een veilig browserextensie

Nu is onze extensie geïnstalleerd en werkt deze. De ontwikkelaarstools voor verschillende contexten kunnen als volgt worden gestart:

popup ->

We schrijven een veilig browserextensie

Toegang tot de console van het content script gebeurt via de console van de pagina waarop deze is uitgevoerd.We schrijven een veilig browserextensie

Berichtenuitwisseling

Dus, we moeten twee communicaties kanalen instellen: inpage <-> background en popup <-> background. Natuurlijk kun je gewoon berichten naar de poort sturen en je eigen protocol uitvinden, maar ik geef de voorkeur aan de benadering die ik heb gezien in het open source project metamask.

Dit is een browserextensie voor het werken met het Ethereum-netwerk. In het heeft verschillende delen van de applicatie communicatie via RPC met behulp van de dnode-bibliotheek. Deze maakt het relatief snel en handig om de uitwisseling te organiseren, als je nodejs stream als transport biedt (met andere woorden, een object dat dezelfde interface implementeert):

import Dnode from "dnode/browser";

// In dit voorbeeld gaan we ervan uit dat de client op afstand functies op de server aanroept, hoewel niets ons tegenhoudt om het bidirectioneel te maken

// Server
// API die we willen aanbieden
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transport, waarop dnode zal werken. Elke nodejs stream. In de browser is er de 'readable-stream' bibliotheek
connectionStream.pipe(dnode).pipe(connectionStream)

// Client
const dnodeClient = Dnode() // Aanroep zonder argument betekent dat we geen API aan de andere kant aanbieden

// Dit zal 'world' naar de console loggen
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Nu gaan we een applicatieklasse maken. Deze zal API-objecten voor popup en webpagina's maken en ook dnode voor hen creëren:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Retourneert het API-object voor de ui
    popupApi(){
        return {
            hello: cb => cb(null, 'wereld')
        }
    }

    // Retourneert het API-object voor de pagina
    pageApi(){
        return {
            hello: cb => cb(null, 'wereld')
        }
    }

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

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

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

    // Verbindt de pagina
    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 verder gebruiken we in plaats van het globale Chrome-object extentionApi, dat verbinding maakt met Chrome in de Google-browser en met browser in andere. Dit wordt gedaan voor cross-browser compatibiliteit, maar in het kader van dit artikel zou men ook eenvoudig 'chrome.runtime.connect' kunnen gebruiken.

Laten we een instantie van de applicatie maken in het achtergrondscript:

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

const app = new SignerApp();

// onConnect wordt geactiveerd bij het verbinden van 'processen' (contentscript, popup of pagina van de extensie)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Bij het tot stand brengen van de verbinding kan een naam worden opgegeven, met deze naam bepalen we wie er verbinding maakt, de content script of ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Aangezien dnode met streams werkt en we een poort ontvangen, is een adapterklasse nodig. Deze is gemaakt met behulp van de bibliotheek readable-stream, 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 - verbroken verbinding'))
        }
        cb()
    }
}

Laten we nu de verbinding in de UI tot stand brengen:

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

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

Vervolgens maken we een verbinding in het content script:

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 {
        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 content-script nodig hebben, maar direct op de pagina, doen we twee dingen:

  1. We creëren twee streams. Eén – naar de pagina, bovenop postMessage. Hiervoor gebruiken we deze pakket van de makers van metamask. De tweede stream – naar de achtergrond via de gekregen poort van runtime.connect. We pipen ze. Nu heeft de pagina een stream naar de achtergrond.
  2. We injecteren het script in de DOM. We halen het script op (toegang ertoe was toegestaan in het manifest) en creëren een tag script met de inhoud ervan 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 {
        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);
    }
}

We creëren nu het api-object in inpage en maken het global:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream naar de inhoudscript
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Verkrijg het API-object
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Toegang via window
    global.SignerApp = pageApi;
}

Wij zijn er klaar voor Remote Procedure Call (RPC) met een aparte API voor de pagina en de UI. Bij het aansluiten van een nieuwe pagina op de achtergrond kunnen we dit zien:

We schrijven een veilig browserextensie

Lege API en origin. Aan de kant van de pagina kunnen we de functie hello als volgt aanroepen:

We schrijven een veilig browserextensie

Werken met callback-functies in modern JS is verouderd, dus we zullen een kleine helper schrijven voor het maken van dnode, waarmee we de API-objecten in utils kunnen doorgeven.

API-objecten zullen er nu als volgt uitzien:

export class SignerApp {

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

...

}

Het verkrijgen van een object van remote als volgt:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Met behulp van utilities veranderen we alle callbacks in promises
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

En het aanroepen van functies retourneert een belofte:

We schrijven een veilig browserextensie

De versie met asynchrone functies is beschikbaar hier.

Over het algemeen lijkt de aanpak met RPC en streams vrij flexibel: we kunnen stream multiplexing gebruiken en meerdere verschillende API's voor verschillende taken maken. In principe kan dnode overal worden gebruikt, zolang we het transport in de vorm van een nodejs-stream omhullen.

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

Interne status en localStorage

We zullen de interne status van de applicatie moeten opslaan — tenminste, de sleutels voor ondertekening. We kunnen de status vrij gemakkelijk aan de applicatie toevoegen en methoden voor het wijzigen ervan in de popup API:

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

export class SignerApp {

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

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

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

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

    ...

} 

In de background wikkelen we alles in een functie en schrijven we het applicatieobject naar window, zodat we er vanuit de console mee kunnen werken:

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 vanuit de UI-console enkele sleutels toevoegen en kijken wat er met de status is gebeurd:

We schrijven een veilig browserextensie

De status moet persistent worden gemaakt, zodat de sleutels niet verloren gaan bij herstart.

We slaan het op in localStorage, waarbij we het bij elke wijziging overschrijven. Toegang daartoe zal ook nodig zijn voor de UI, en we willen ons ook abonneren op wijzigingen. Daarom is het handig om een observable opslag te maken en ons aan de wijzigingen te abonneren.

We zullen de mobx-bibliotheek gebruiken (https://github.com/mobxjs/mobx). We hebben voor haar gekozen, omdat we er nog niet mee hadden gewerkt, maar het graag wilden leren.

Laten we de initialisatie van de begintoestand toevoegen en de store observable maken:

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

export class SignerApp {

    constructor(initState = {}) {
        \/\/ Uiterlijk blijft de store hetzelfde object, alleen zijn nu al zijn velden proxy's die de toegang tot hen volgen
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    \/\/ Methoden die observable wijzigen, worden vaak verpakt met een decorator
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

‘Onder de motorkap’ heeft mobx alle velden van de store vervangen door proxy's en onderschept alle toegang tot hen. Voor deze toegangen kan je je abonneren.

Verder zal ik de term “bij wijziging” vaak gebruiken, hoewel dit niet helemaal correct is. Mobx volgt namelijk de toegang tot de velden. Getter- en setter-methoden van de proxy-objecten worden gebruikt, die door de bibliotheek worden aangemaakt.

De action decorators dienen twee doelen:

  1. In strict mode met de enforceActions-vlag verbiedt mobx het rechtstreeks wijzigen van de status. Het wordt beschouwd als goed om echt in de strict mode te werken.
  2. Zelfs als de functie de staat meerdere keren verandert – bijvoorbeeld als we meerdere velden in verschillende regels code wijzigen – worden de observers alleen op de hoogte gesteld na de voltooiing ervan. Dit is vooral belangrijk voor de frontend, waar onnodige staatupdates leiden tot ongewenste renderingen van elementen. In ons geval zijn beide niet echt relevant, maar we zullen de beste praktijken volgen. Decorators worden vaak toegepast op alle functies die de staat van de geobserveerde velden wijzigen.

In de achtergrond voegen we initialisatie en opslag van de staat in localStorage toe:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Hulpmethoden. Neemt op/schrijft een object naar/van localStorage in de vorm van een JSON-string met de sleutel '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;
    }

    // Stel de staatpersistentie in

    // Het resultaat van reaction wordt aan een variabele toegewezen zodat de abonnement kan worden opgezegd. We hebben dit niet nodig, het is ter illustratie overgelaten
    const localStorageReaction = reaction(
        () => toJS(app.store), // Gegevensselector functie
        saveState // Functie die wordt aangeroepen wanneer de gegevens die door de selector worden geretourneerd veranderen
    );

    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 functie reaction is hier interessant. Het heeft twee argumenten:

  1. Gegevensselector.
  2. Handler die met deze gegevens zal worden aangeroepen elke keer als ze veranderen.

In tegenstelling tot redux, waar we de staat expliciet als argument krijgen, onthoudt mobx naar welke observable we binnen de selector verwijzen, en roept het de handler alleen aan bij wijzigingen.

Het is belangrijk te begrijpen hoe mobx bepaalt op welke observables we ons abonneren. Als ik de selector in de code op deze manier zou schrijven() => app.store, dan zou de reaction nooit worden aangeroepen, omdat de opslag op zich niet observabel is, alleen de velden daarvan zijn dat.

Als ik het op deze manier zou schrijven () => app.store.keys, dan zou er opnieuw niets gebeuren, omdat bij het toevoegen/verwijderen van elementen van de array de referentie niet zou veranderen.

Mobx voert voor de eerste keer de selectorfunctie uit en houdt alleen toezicht op die observable waar we toegang toe hebben gekregen. Dit gebeurt via proxy-getters. Daarom is hier een ingebouwde functie gebruikt. toJS. Deze retourneert een nieuw object waarin alle proxies zijn vervangen door de originele velden. Tijdens de uitvoering leest het alle velden van het object, waardoor de getters worden geactiveerd.

In de pop-up console voegen we opnieuw een paar sleutels toe. Deze keer zijn ze ook in localStorage terechtgekomen:

We schrijven een veilig browserextensie

Bij het herladen van de achtergrondpagina blijft de informatie op zijn plaats.

De volledige code van de applicatie tot nu toe is hier te bekijken. hier.

Veilige opslag van privésleutels

Het opslaan van privésleutels in open tekst is onveilig: er is altijd een kans dat je wordt gehackt, dat iemand toegang krijgt tot je computer, enzovoort. Daarom zullen we de sleutels in localStorage versleuteld met een wachtwoord opslaan.

Voor meer veiligheid voegen we een locked state aan de applicatie toe, waarbij er helemaal geen toegang tot de sleutels is. We zullen de extensie automatisch naar de locked state omzetten na een time-out.

Mobx maakt het mogelijk om alleen de minimale set gegevens op te slaan, terwijl de rest automatisch op basis daarvan wordt berekend. Dit zijn zogenaamde computed properties. Ze kunnen worden vergeleken met views in databases:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Hulpmiddelen voor veilige versleuteling van strings. Gebruikt crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Bewaar wachtwoord en versleutelde sleutels. Als wachtwoord null is - applicatie is vergrendeld
            password: null,
            vault: initState.vault,

            // Getters voor berekenbare velden. Vergelijkbaar met view in db.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Initialiseer een lege kluis met een nieuw wachtwoord
    @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 voor verbinding en api

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

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

    // Methoden voor het versleutelen/ontsleutelen van de kluis
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Kluis niet geïnitialiseerd')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Onjuist wachtwoord')
        }
    }
}

Nu slaan we alleen versleutelde sleutels en een wachtwoord op. Alles anders wordt berekend. We veranderen de status naar locked door het wachtwoord uit de status te verwijderen. In de openbare API is er een methode toegevoegd voor het initialiseren van de kluis.

Voor versleuteling zijn geschreven hulpmiddelen met gebruik van crypto-js:

import CryptoJS from 'crypto-js'

// Wordt gebruikt voor het compliceren van het raden van wachtwoorden door brute-force. Voor elke mogelijke wachtwoord moet de aanvaller 5000 hashes maken
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 idle API waarmee je kunt abonneren op het evenement — statuswijzigingen. De status kan dus zijn idle, actief en vergrendeld. Voor idle kan een tijdslimiet worden ingesteld, terwijl locked wordt ingesteld wanneer het besturingssysteem zelf vergrendeld is. We zullen ook de selector wijzigen voor opslag in 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;
    }

    // Nu roepen we expliciet het veld aan dat toegankelijk zal zijn, de reaction zal correct werken
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Tijdslimiet voor inactiviteit, wanneer het evenement wordt geactiveerd
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Als de gebruiker het scherm vergrendelt of gedurende de opgegeven periode inactief is, vergrendelen we de applicatie
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Verbind met andere contexten
    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 tot dit punt bevindt zich hier.

Transacties

Dus, we zijn aangekomen bij het belangrijkste: het creëren en ondertekenen van transacties op de blockchain. We zullen de WAVES blockchain en de bibliotheek waves-transactions.

Eerst voegen we een array van berichten toe aan de status die ondertekend moeten worden, gevolgd door methoden voor het toevoegen van een nieuw bericht, bevestigen van de handtekening en afwijzing:

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) {
        // Voor elk bericht maken we metadata met id, status, tijdstip van creatie, enz.
        const message = observable.object({
            id: uuid(), // Identifier, gebruik makend van uuid
            origin, // Origin zullen we later in de interface tonen
            data, //
            status: 'new', // Er zijn vier statussen: new, signed, rejected en failed
            timestamp: Date.now()
        });
        console.log(`nieuw bericht: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // We retourneren een belofte waarin mobx de wijzigingen van het bericht monitort. Zodra de status verandert, zullen we deze oplossen.
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // We zullen de status van het bericht observeren
                (status, reaction) => { // de tweede parameter is een verwijzing naar de reaction zelf, zodat deze van binnenuit kan worden vernietigd
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Gebruiker heeft het bericht afgewezen'));
                            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(`Geen bericht met 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(`Geen bericht met id:${id}`);
        message.status = 'rejected';
    }

    ...
}

Bij het ontvangen van een nieuw bericht voegen we metadata toe. observable en voegen toe aan store.messages.

Als je dit niet handmatig doet, zal mobx dit zelf doen bij het toevoegen aan de array messages. Echter, het creëert een nieuw object, waar we geen verwijzing naar hebben, en dat is nodig voor de volgende stap. observable Daarna retourneren we een belofte die oplost bij de wijziging van de status van het bericht. De status wordt bewaakt door een reaction, die zichzelf ‘dood’ maakt bij een statuswijziging.

De code van de methoden

approve reject en is heel simpel: we veranderen gewoon de status van het bericht, voorafgaand aan het ondertekenen indien nodig. heel eenvoudig: we wijzigen gewoon de status van het bericht, voorafgaand aan ondertekening, indien nodig.

Goedkeuren en afwijzen brengen we naar de API UI, newMessage — naar de API pagina's:

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 een transactie te ondertekenen via de extensie:

We schrijven een veilig browserextensie

Over het algemeen is alles gereed, het resteert nog een simpele UI toe te voegen.

UI

De interface heeft toegang nodig tot de staat van de applicatie. Aan de UI-kant maken we observable een staat aan en voegen we een functie aan de API toe die deze staat kan wijzigen. We voegen observable toe aan het API-object, verkregen 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() {
    // Verbindt met de poort, maakt een stream aan
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Maakt een lege observable voor de status van de achtergrond
    let backgroundState = observable.object({});
    const api = {
        // Geven de achtergrond een functie die de observable zal bijwerken
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Maak een RPC-object
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Voeg de observable met status aan de achtergrond toe
    background.state = backgroundState;

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

    // Start de interface
    await initApp(background)
}

Aan het einde starten we de render van de applicatie-interface. Dit is een React-applicatie. Het achtergrondobject wordt gewoon doorgegeven via props. Het is natuurlijk beter om een aparte service te maken voor methoden en een store voor de staat, maar binnen het kader van dit artikel is dit voldoende:

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

// Initialiseer de applicatie met het achtergrondobject als props
export async function initApp(background){
    render(
        ,
        document.getElementById('app-content')
    );
}

Met behulp van MobX is het heel eenvoudig om de render te starten bij dataveranderingen. We hangen gewoon de observer-decorator van het pakket eraan vast mobx-react A component, and the render will be called automatically upon changing any observable that the component refers to. No need for any mapStateToProps or connect, as in redux. Everything works right "out of the box":

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 // De component met deze decorator zal automatisch de render-methode aanroepen als de observable waarnaar hij verwijst verandert
export default class App extends Component {

    // Het is natuurlijk beter om de logica voor het renderen van pagina's in de router te plaatsen en geen geneste ternary operators te gebruiken,
    // en de observables en methoden voor achtergrondprocessen direct aan de componenten te koppelen die ze gebruiken
    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;Vergrendel App</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Verwijder alle sleutels en init</button>}
            </div>
        </Fragment>
    }
}

The other components can be viewed in the code in the UI folder.

Now in the application class, it is necessary to create a state selector for the UI and notify the UI when it changes. For this, we will add the method getState en reaction, calling 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) => {
            // Create a reaction to state changes that will call the remote procedure and update the state in the ui process
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // The third argument can pass parameters. fireImmediatly means that the reaction will execute the first time immediately.
                // This is necessary to get the initial state. Delay allows setting debounce
                {fireImmediately: true, delay: 500}
            );
            // Remove subscription when the client disconnects
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

When receiving an object remote is created reaction to monitor state changes, which calls a function on the UI side.

The final touch — let's add the display of new messages on the extension icon:

function setupApp() {
...

    // Reaction on setting the badge text.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

So, the application is ready. Web pages can request transaction signatures:

We schrijven een veilig browserextensie

We schrijven een veilig browserextensie

The code is available at this de link.

Conclusie

If you have read the article to the end but still have questions, you can ask them in the repository with the extension. There you will find commits for each designated step.

And if you are interested in viewing the code of a real extension, you can find it hier.

Code, repository, and description from siemarell

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster