Vi skriver en sikker browserudvidelse

Vi skriver en sikker browserudvidelse

I modsætning til den almindelige «klient-server» arkitektur, er det karakteristisk for decentrale applikationer:

  • Der er ikke behov for at gemme en database med brugernes loginoplysninger og passwords. Oplysninger til adgang gemmes udelukkende hos brugerne selv, og deres autentificering sker på protokolniveau.
  • Der er ikke behov for at bruge en server. Applikationens logik kan udføres i blockchain-netværket, hvor det også er muligt at gemme det nødvendige datavolumen.

Der findes 2 forholdsvis sikre opbevaringsmetoder til brugerens nøgler - hardware-wallets og browser-extensions. Hardware-wallets er generelt meget sikre, men de er svære at bruge og langt fra gratis, mens browser-extensions er en ideel kombination af sikkerhed og brugervenlighed og kan endda være helt gratis for slutbrugeren.

I lyset af dette ønskede vi at lave en så sikker extension som muligt, der forenkler udviklingen af decentrale applikationer ved at tilbyde et simpelt API til arbejdet med transaktioner og signaturer.
Om denne oplevelse vil vi fortælle dig nedenfor.

Artiklen vil indeholde en trin-for-trin guide til, hvordan man skriver en browser-extension med kodeeksempler og screenshots. Du kan finde al koden i repository. Hver commit svarer logisk til sektionen i denne artikel.

En kort historie om browser-extensions

Browser-extensions har eksisteret i lang tid. De optrådte i Internet Explorer allerede i 1999, og i Firefox i 2004. Dog har der længe ikke været nogen enhedsstandard for extensions.

Man kan sige, at den opstod sammen med extensions i den fjerde version af Google Chrome. Selvfølgelig eksisterede der ikke nogen specifikation på det tidspunkt, men netop Chrome API blev dens grundlag: idet Chrome erobrede størstedelen af browser-markedet og havde en indbygget app-butik, satte Chrome reelt standarden for browser-extensions.

Mozilla havde sin egen standard, men erkendte, at Chrome-extensions var populære, og besluttede at udvikle et kompatibelt API. I 2015 blev der på initiativ af Mozilla dannet en særlig gruppe indenfor World Wide Web Consortium (W3C) for at arbejde med specifikationerne til tværbrowser-extensions.

Basis for the extension was an existing API for Chrome. The work was supported by Microsoft (Google refused to participate in the development of the standard), resulting in a draft. specifikationerne.

Formally, the specification is supported by Edge, Firefox, and Opera (note that Chrome is absent from this list). However, in reality, the standard is largely compatible with Chrome, as it was actually written based on its extensions. You can read more about the WebExtensions API. her.

Structure of the extension

The only file that is required for the extension is the manifest (manifest.json). It also serves as the 'entry point' into the extension.

Manifest

According to the specification, the manifest file is a valid JSON file. A complete description of the manifest keys, including information on which keys are supported in which browser, can be found. her.

Keys not present in the specification 'may' be ignored (both Chrome and Firefox report errors, but extensions continue to work).

I would like to highlight some points.

  1. background — an object that includes the following fields:
    1. scripts — an array of scripts that will be executed in the background context (we will talk about this a bit later);
    2. page — instead of scripts that will run on a blank page, you can specify HTML with content. In this case, the script field will be ignored, and scripts need to be inserted into the content page;
    3. persistent — a binary flag, if not specified, the browser will 'kill' the background process when it deems it to be idle and restart it when necessary. Otherwise, the page will only be unloaded when the browser is closed. Not supported in Firefox.
  2. content_scripts — an array of objects that allows loading different scripts for different web pages. Each object contains the following important fields:
    1. matches — URL pattern, which determines whether a specific content script will be included or not.
    2. js — a list of scripts that will be loaded for this match;
    3. exclude_matches — excludes from the field match URLs that match this field.
  3. page_action — essentially an object that handles the icon displayed next to the address bar in the browser and interaction with it. It also allows showing a popup window defined by its HTML, CSS, and JS.
    1. default_popup — path to the HTML file with the popup interface, which may contain CSS and JS.
  4. permissions — et array til styring af udvidelsesrettigheder. Der findes 3 typer rettigheder, som er beskrevet i detaljer her
  5. web_accessible_resources — udvidelsesressourcer, som en webside kan anmode om, for eksempel billeder, JS-filer, CSS, HTML.
  6. externally_connectable — her kan du eksplicit angive ID'er på andre udvidelser og domæner af websider, som der kan forbindes til. Domænet kan være af andet niveau og derover. Virker ikke i Firefox.

Udførelseskontekst

En udvidelse har tre udførelseskontekster, hvilket betyder, at applikationen består af tre dele med forskellige niveauer af adgang til API'en i browseren.

Udvidelseskontekst

Her er det meste af API'en tilgængelig. I denne kontekst "lever":

  1. Baggrundsside — 'backend'-delen af udvidelsen. Filen angives i manifestet under nøgleordet "background".
  2. Pop-up-side — en pop-up-side, der vises, når du klikker på udvidelsesikonet. I manifestet browser_action -> default_popup.
  3. Egen side — en udvidelsesside, der 'lever' i en separat fane i formatet chrome-extension:///customPage.html.

Denne kontekst eksisterer uafhængigt af browservinduer og faner. Baggrundsside eksisterer i en enkelt instans og kører altid (undtagelse — event side, når baggrundsskriptet startes efter en begivenhed og 'dør' efter udførelsen). Pop-up-side eksisterer, når pop-up-vinduet er åbent, og Egen side — så længe fanen med det er åben. Der er ingen adgang til andre faner og deres indhold fra denne kontekst.

Indholdsskript kontekst

Indholdsscript-filen køres sammen med hver browserfane. Den har adgang til en del af API'en for udvidelsen samt DOM-træet på websiden. Det er netop indholdsscripts, der ansvarer for interaktion med siden. Udvidelser, der manipulerer DOM-træet, gør det i indholdsscripts - for eksempel annonceblokkere eller oversættere. Desuden kan indholdsscriptet kommunikere med siden via standard postMessage.

Webside kontekst

Dette er selve websiden. Den har ikke noget at gøre med udvidelsen og har ikke adgang til den, medmindre domænet for denne side eksplicit er angivet i manifestet (mere om dette nedenfor).

Meddelelser

Forskellige dele af applikationen skal kommunikere med hinanden. For dette formål findes API'et runtime.sendMessage til at sende en meddelelse background og tabs.sendMessage til at sende en meddelelse til siden (indholdsscriptet, popuppen eller web-siden, når der er externally_connectable). Nedenfor er et eksempel på, når du tilgår Chrome API'et.

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

For full communication, connections can be created through runtime.connect. In response, we will receive runtime.Port, into which, as long as it is open, any number of messages can be sent. On the client side, for example, contentscript, it looks like this:

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

There is also an event onDisconnect og metoden disconnect.

Application scheme

Let’s create a browser extension that stores private keys, provides access to public information (address, public key communicates with the page and allows third-party applications to request transaction signatures.

Udvikling af applikation

Our application must interact with the user as well as provide the page API for calling methods (for example, for signing transactions). Just using contentscript won't work, as it only has access to the DOM, but not to the JS of the page. We cannot connect via runtime.connect because the API needs to be available on all domains, but the manifest can only specify specific ones. As a result, the scheme will look like this:

Vi skriver en sikker browserudvidelse

There will be another script — inpage, which we will inject into the page. It will execute in its context and provide an API for interacting with the extension.

Start

The entire code of the browser extension is available at GitHub. In the process description, there will be links to commits.

Let's start with the manifest:

{
  // Name and description, version. All this will be visible in the browser at chrome://extensions/?id=<extension id>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts that will be executed in the background, there can be several
  "background": {
    "scripts": ["background.js"]
  },

  // Which HTML to use for popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Content scripts.
  // We have one object: for all URLs starting with http or https we launch
  // content script context with contentscript.js script. To run it immediately upon receiving the document for all frames
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Access to localStorage and idle API is allowed
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Here, resources that the web page will have access to are specified. That is, they can be requested via fetch or simply xhr
  "web_accessible_resources": ["inpage.js"]
}

Vi opretter tomme background.js, popup.js, inpage.js og contentscript.js. Vi tilføjer popup.html — og vores applikation kan allerede indlæses i Google Chrome for at sikre, at den fungerer.

For at sikre dette kan vi tage koden from here. Udover hvad vi har gjort, er der ved linket indstillet til at bygge projektet med webpack. For at tilføje applikationen til browseren skal du vælge load unpacked i chrome://extensions og vælge mappen med den relevante udvidelse — i vores tilfælde dist.

Vi skriver en sikker browserudvidelse

Nu er vores udvidelse installeret og fungerer. Du kan starte udviklerværktøjerne for forskellige kontekster på følgende måde:

popup ->

Vi skriver en sikker browserudvidelse

Adgang til konsollen for indholdsskriptet opnås gennem konsollen på selve siden, hvor det kører.Vi skriver en sikker browserudvidelse

Meddelelser

Så vi skal etablere to kommunikationskanaler: inpage background og popup background. Man kan selvfølgelig blot sende beskeder til porten og opfinde sit eget protokol, men jeg foretrækker den tilgang, jeg så i et open source-projekt som metamask.

Dette er en browserudvidelse til at arbejde med Ethereum-netværket. Her kommunikerer forskellige dele af applikationen via RPC ved hjælp af biblioteket dnode. Det gør det muligt hurtigt og nemt at organisere udvekslingen, hvis man giver nodejs stream som transport (dvs. et objekt, der implementerer det samme interface):

import Dnode from "dnode/browser";

// I dette eksempel indgår vi en betingelse om, at klienten fjernt kalder funktioner på serveren, selvom intet forhindrer os i at gøre det tovejs

// Server
// API, som vi ønsker at tilbyde
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transporten, som dnode vil arbejde over. Enhver nodejs stream. I browseren er der biblioteket 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Klient
const dnodeClient = Dnode() // Kald uden argument betyder, at vi ikke leverer API på den anden side

// Vil udskrive 'world' i konsollen
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Nu vil vi oprette en applikationsklasse. Den vil oprette API-objekter til popup og webside samt skabe dnode til dem:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Return the API object for the UI
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Return the API object for the page
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Connect the popup UI
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Connect the page
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

Her og fremover bruger vi ikke det globale Chrome-objekt, men extentionApi, som henvender sig til Chrome i Google-browseren og til browser i andre. Dette gøres for at sikre tværbrowserkompatibilitet, men i denne artikel kunne vi blot have brugt 'chrome.runtime.connect'.

Lad os oprette en instans af appen i background-scriptet:

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

const app = new SignerApp();

// onConnect udløses ved tilslutning af 'processer' (contentscript, popup, eller side af udvidelsen)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Når forbindelsen er etableret, kan vi angive et navn, baseret på dette navn kan vi identificere, hvem der har tilsluttet sig, contentscript eller UI
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Da dnode arbejder med streams, og vi modtager en port, er en adapterklasse nødvendig. Den er lavet med biblioteket readable-stream, som implementerer nodejs-streams i browseren:

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

Lad os nu oprette forbindelsen i UI:

import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import Dnode from 'dnode\/browser';

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

setupUi().catch(console.error);

async function setupUi(){
    \/\/ Ligesom i applikationsklassen opretter vi en port, indkapsler den i en stream, laver 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)
        })
    });

    \/\/ Gør API-objektet tilgængeligt fra konsollen
    if (DEV_MODE){
        global.background = background;
    }
}

Derefter opretter vi en forbindelse i content-scriptet:

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 {
        \/\/ injicer script i siden
        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);
    }
}

Da vi har brug for API'et ikke i content-scriptet, men direkte på siden, gør vi to ting:

  1. Opretter to streams. Den ene - mod siden, oven på postMessage. Til dette bruger vi denne pakke fra skaberne af metamask. Den anden stream - til baggrunden oven på porten, modtaget fra runtime.connect. Vi pipeliner dem. Nu vil siden have en stream til baggrunden.
  2. Vi injicerer scriptet i DOM. Vi downloader scriptet (adgang til det var tilladt i manifestet) og opretter et tag script med dens indhold indeni:

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

setupConnection();
injectScript();

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

    \/\/ Stream til siden
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        \/\/ injicer script i siden
        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 opretter vi et api-objekt i inpage og indfører det globalt:

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

setupInpageApi().catch(console.error);

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

    const dnode = Dnode();

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

    // Får API objektet
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

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

Vi er klar Remote Procedure Call (RPC) med et separat API til siden og UI. Når en ny side er tilsluttet baggrunden, kan vi se det:

Vi skriver en sikker browserudvidelse

Tom API og origin. På siden kan vi kalde funktionen hello sådan her:

Vi skriver en sikker browserudvidelse

At arbejde med callback-funktioner i moderne JS er forældet, så lad os skrive en lille helper til at oprette dnode, der gør det muligt at sende objekter til API i utils.

API objekter vil nu se således ud:

export class SignerApp {

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

...

}

Modtagelse af objekt fra remote på følgende måde:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Ved hjælp af utils ændrer vi alle callback til promises
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

Og kaldet til funktioner returnerer et promise:

Vi skriver en sikker browserudvidelse

Versionen med asynkrone funktioner er tilgængelig her.

Generelt virker tilgangen med RPC og streams ganske fleksibel: vi kan bruge stream multiplexing og oprette flere forskellige API'er til forskellige opgaver. I princippet kan dnode bruges hvor som helst, det vigtigste er at pakke transporten som en nodejs stream.

Et alternativ er JSON formatet, der implementerer JSON RPC 2 protokollen. Men det arbejder kun med specifikke transportsystemer (TCP og HTTP(S)), hvilket ikke er anvendeligt i vores tilfælde.

Intern state og localStorage

Vi har brug for at gemme den interne state af applikationen – mindst nøglerne til signatur. Vi kan relativt nemt tilføje state til applikationen og metoder til at ændre det i 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)
        }
    }

    ...

} 

I baggrunden pakker vi alt ind i en funktion og skriver applikationsobjektet til window, så det kan bruges fra konsollen:

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

Lad os tilføje et par nøgler fra UI-konsollen og se, hvordan tilstanden blev:

Vi skriver en sikker browserudvidelse

Tilstanden skal gøres vedvarende, så nøglerne ikke går tabt ved genstart.

Vi opbevarer dem i localStorage og overskriver dem ved hver ændring. Senere vil adgangen til det også være nødvendig for UI, og vi ønsker også at abonnere på ændringer. Derfor vil det være praktisk at lave et observerbart lager (observable storage) og abonnere på dets ændringer.

Vi vil bruge mobx-biblioteket (https://github.com/mobxjs/mobx). Valget faldt på det, fordi jeg ikke havde arbejdet med det før, men virkelig ville lære det.

Lad os tilføje initialisering af den indledende tilstand og gøre lageret observerbart:

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

export class SignerApp {

    constructor(initState = {}) {
        // Eksternt forbliver lageret det samme objekt, men nu er alle dets felter blevet proxy, der overvåger adgangen til dem
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Metoder, der ændrer observerbare, bør pakkes ind i en dekorator
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

"Under motorhjelmen" har mobx erstattet alle felter i lageret med proxy og aflytter alle henvendelser til dem. Disse henvendelser kan man abonnere på.

Senere vil jeg ofte bruge udtrykket "ved ændring", selvom det ikke helt er korrekt. Mobx overvåger faktisk adgangen til felterne. Der bruger man gettere og settere i proxy-objekter, som biblioteket opretter.

Action-dekoratorer tjener to formål:

  1. I streng tilstand med flaget enforceActions forbyder mobx at ændre tilstanden direkte. Det betragtes som god skik at arbejde netop i streng tilstand.
  2. Selv hvis funktionen ændrer tilstanden flere gange – for eksempel ændrer vi flere felter på flere linjer kode – bliver observatørerne kun underrettet, når den er færdig. Dette er især vigtigt for frontend, hvor unødvendige opdateringer af tilstanden fører til unødvendig rendering af elementer. I vores tilfælde er hverken det første eller det andet særligt relevant, men vi vil følge bedste praksis. Dekoratorer er almindeligt at sætte på alle funktioner, der ændrer tilstanden for observerede felter.

I baggrunden tilføjer vi initialisering og gemmer tilstanden i localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ". /utils/extensionApi";
import {PortStream} from ". /utils/PortStream";
import {SignerApp} from ". /SignerApp";
// Hjælpefunktioner. Skriver / læser objektet til / fra localStorage i JSON-streng format under nøgle '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;
    }

    // Opsætning af tilstandens vedholdenhed

    // Resultatet af reaction tildeles en variabel, så abonnementsforholdet kan annulleres. Vi har ikke brug for det, men det er efterladt som eksempel
    const localStorageReaction = reaction(
        () => toJS(app.store), // Data-selektorfunktion
        saveState // Funktion, der vil blive kaldt, når data, som returneres af selektoren, ændres
    );

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

Den interessante funktion her er reaction. Den har to argumenter:

  1. Dataselektor.
  2. En behandler, der vil blive kaldt med disse data hver gang, de ændres.

I modsætning til redux, hvor vi klart får tilstanden som argument, husker mobx, hvilke specifikke observable vi henvender os til inde i selektoren, og kun når de ændres, kaldes behandleren.

Det er vigtigt at forstå, hvordan mobx beslutter, hvilke observable vi abonnerer på. Hvis jeg skrev selektoren således() => app.store, så ville reaction aldrig blive kaldt, da selve opbevaringen ikke er observerbar; kun dens felter er det.

Hvis jeg havde skrevet således () => app.store.keys, ville der igen ikke ske noget, da referencen til arrayet ikke ændres ved tilføjelse / sletning af elementer.

Mobx udfører for første gang funktionen som en selector og holder kun øje med de observable, vi har haft adgang til. Dette gøres gennem proxy getters. Derfor er den indbyggede funktion blevet anvendt her toJS. Den returnerer et nyt objekt, hvor alle proxier er erstattet med de originale felter. Under udførelsen læser den alle felter i objektet – dermed aktiveres getters.

I popup-konsollen tilføjer vi igen flere nøgler. Denne gang er de også blevet gemt i localStorage:

Vi skriver en sikker browserudvidelse

Ved genindlæsning af background-siden forbliver informationen på plads.

Hele applikationskoden op til dette punkt kan ses her.

Sikker opbevaring af private nøgler

At opbevare private nøgler i klar tekst er usikkert: der er altid en risiko for, at du bliver hacket, får adgang til din computer osv. Derfor vil vi opbevare nøglerne i localStorage i krypteret form med en adgangskode.

For at øge sikkerheden tilføjer vi en locked state til applikationen, hvor der ikke vil være adgang til nøglerne. Vi vil automatisk skifte udvidelsen til locked state efter en timeout.

Mobx tillader at gemme kun et minimum af data, mens resten automatisk beregnes på baggrund af dem. Dette kaldes computede properties. De kan sammenlignes med views i databaser:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Værktøjer til sikker kryptering af strenge. Bruges crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Opbevarer adgangskode og krypterede nøgler. Hvis adgangskoden er null - er applikationen låst
            password: null,
            vault: initState.vault,

            // Gettere til beregnede felter. Kan sammenlignes med visning i databasen.
            get locked() {
                return this.password == null;
            },
            get keys() {
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password);
            },
            get initialized() {
                return this.vault !== undefined;
            }
        });
    }
    // Initialisering af et tomt lager med en ny adgangskode
    @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
        );
    }

    ... // kode til forbindelse og api

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

    _checkLocked() {
        if (this.store.locked) {
            throw new Error('App er låst');
        }
    }

    // Metoder til kryptering/dekryptering af lageret
    static _encryptVault(obj, pass) {
        const jsonString = JSON.stringify(obj);
        return encrypt(jsonString, pass);
    }

    static _decryptVault(str, pass) {
        if (str === undefined) {
            throw new Error('Lager ikke initialiseret');
        }
        try {
            const jsonString = decrypt(str, pass);
            return JSON.parse(jsonString);
        } catch (e) {
            throw new Error('Forkert adgangskode');
        }
    }
}

Nu opbevarer vi kun krypterede nøgler og adgangskoden. Alt andet beregnes. Overgangen til tilstanden låst sker ved at fjerne adgangskoden fra tilstanden. I det offentlige API er der tilføjet en metode til at initialisere lageret.

Til kryptering er der skrevet værktøjer ved hjælp af crypto-js:

import CryptoJS from 'crypto-js'

// Anvendes for at komplicere adgangspunktet ved brute force angreb. For hver adgangskodevariant skal angriberen lave 5000 hashes
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);
}

Browseren har et idle API, som man kan abonnere på begivenheden - ændringer i tilstanden. Tilstanden kan naturligvis være idle, active og låst. For idle kan man indstille en timeout, og locked aktiveres, når operationen selv blokeres. Derudover ændrer vi selector til at gemme i 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 kalder vi tydeligt det felt, som der vil være adgang til, reaction vil fungere korrekt
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Timeout for inaktivitet, når begivenheden aktiveres
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Hvis brugeren har låst skærmen eller har været inaktiv i den angivne periode, låser vi applikationen
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Forbind til andre kontekster
    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)
        }
    }
}

Koden op til dette trin findes her.

Transaktioner

Så vi er kommet til det vigtigste: oprettelse og signering af transaktioner på blockchain. Vi vil bruge WAVES blockchain og biblioteket waves-transactions.

Til at begynde med tilføjer vi i tilstanden et array af beskeder, der skal signeres, derefter - metoder til at tilføje en ny besked, bekræfte signaturen og afvise:

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) {
        // For each message, we create metadata with id, status, creation time, etc.
        const message = observable.object({
            id: uuid(), // Identifier using uuid
            origin, // The origin will be displayed later in the interface
            data, // 
            status: 'new', // There will be four statuses: new, signed, rejected, and failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // We return a promise within which mobx monitors the changes to the message. As soon as the status changes, we will resolve it
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // We will observe the message status
                (status, reaction) => { // The second argument is a reference to the reaction itself, so it can be disposed inside the call
                    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'
    }

    ...
}

Når vi modtager en ny besked, tilføjer vi metadata til den, observable og tilføjer den til store.messages.

Hvis ikke vi gør dette observable manuelt, vil mobx selv gøre det, når den tilføjes til arrayet messages. Men det vil skabe et nyt objekt, som vi ikke har et link til, og det vil være nødvendigt til næste skridt.

Derudover returnerer vi et løfte, som bliver opfyldt, når beskedens status ændres. Status overvåges af en reaktion, der i sig selv 'dør', når status ændres.

Koden for metoderne approve og reject er meget simpel: vi ændrer blot status for beskeden, efter at den er blevet godkendt, hvis det er nødvendigt.

Vi flytter godkendelse og afvisning til API UI, newMessage – til API-siderne:

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

    ...
}

Nu vil vi prøve at underskrive en transaktion med udvidelsen:

Vi skriver en sikker browserudvidelse

Generelt er alt klar, der mangler bare at tilføje en simpel UI.

UI

Interfacet skal have adgang til appens tilstand. På UI-siden vil vi lave observable en tilstand og tilføje en API-funktion, der vil ændre denne tilstand. Vi tilføjer observable til API-objektet, der er modtaget fra baggrund:

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() {
    // Forbinder os til porten, skaber et stream
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Opretter et tomt observable for tilstanden i baggrunden
    let backgroundState = observable.object({});
    const api = {
        // Giver baggrunden en funktion, der vil opdatere observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

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

    // Tilføjer observable med tilstanden til baggrunden
    background.state = backgroundState;

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

    // Starter interfacet
    await initApp(background)
}

Til sidst starter vi rendering af applikationens interface. Dette er en react-applikation. Baggrundsobjektet overføres simpelthen ved hjælp af props. Det ville være korrekt at lave en separat service til metoderne og et store til tilstanden, men inden for rammerne af denne artikel er det tilstrækkeligt:

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

// Initialiserer applikationen med baggrundsobjektet som props
export async function initApp(background){
    render(
        ,
        document.getElementById('app-content')
    );
}

Med mobx er det meget let at starte rendering ved ændringer i data. Vi tilføjer ganske enkelt dekoratoren observer fra pakken mobx-react komponenten, og renderingen vil automatisk blive kaldt ved ændringer af enhver observable, som komponenten refererer til. Der er ikke behov for mapStateToProps eller connect, som i redux. Alt fungerer straks '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 \/\/ Denne komponent får automatisk kaldt render-metoden, hvis der sker ændringer i observable, som den refererer til
export default class App extends Component {

    \/\/ Det er selvfølgelig bedre at udskille logikken for side-rendering i routing og ikke bruge indlejrede ternære operatorer,
    \/\/ og binde observable og metoder direkte til de komponenter, der bruger dem
    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;Lås app</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Slet alle nøgler og init</button>}
            </div>
        </Fragment>
    }
}

De øvrige komponenter kan ses i koden i mappen UI.

Nu skal der i applikationsklassen laves en state-selektor for UI, og ved ændringer heri skal UI informeres. Til dette tilføjer vi metoden getState og reaction, der kalder 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 {

    ...

    // offentlig
    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) => {
            // Opretter reaktion på ændringer i state, der kalder den eksterne procedure og opdaterer state i UI-processen
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Tredje argument kan bruges til at overføre parametre. fireImmediatly betyder, at reaktionen udføres første gang straks.
                // Det er nødvendigt for at få den indledende tilstand. Delay tillader at indstille debounce
                {fireImmediately: true, delay: 500}
            );
            // Fjern abonnementslisten ved klientens afslutning
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Ved modtagelse af objektet fjern oprettes reaction ved ændringen af staten, der kalder en funktion på UI-siden.

Sidste finjustering - lad os tilføje visning af nye beskeder på ikonet for udvidelsen:

function setupApp() {
...

    // Reaktion på at sætte teksten i badge.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Så applikationen er klar. Websider kan anmode om signaturer på transaktioner:

Vi skriver en sikker browserudvidelse

Vi skriver en sikker browserudvidelse

Koden er tilgængelig på denne linket.

Konklusion

Hvis du har læst artiklen færdig, men stadig har spørgsmål, kan du stille dem i repoet med udvidelsen. Her vil du også finde commits for hvert angivet trin.

Og hvis du er interesseret i at se koden til en ægte udvidelse, kan du finde den her.

Koden, repoet og beskrivelsen af arbejdet fra siemarell

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster