Skrivning af en sikker browserudvidelse

Skrivning af en sikker browserudvidelse

I modsætning til den almindelige "klient-server"-arkitektur er decentrale applikationer karakteriseret ved:

  • Der er ingen grund til at gemme en database med brugerlogin og adgangskoder. Adgangsoplysninger gemmes udelukkende af brugerne selv, og bekræftelse af deres ægthed sker på protokolniveau.
  • Ingen grund til at bruge en server. Applikationslogikken kan udføres på et blockchain-netværk, hvor det er muligt at lagre den nødvendige mængde data.

Der er 2 relativt sikre opbevaringssteder til brugernøgler - hardware wallets og browser-udvidelser. Hardware wallets er for det meste ekstremt sikre, men svære at bruge og langt fra gratis, men browserudvidelser er den perfekte kombination af sikkerhed og brugervenlighed, og kan også være helt gratis for slutbrugere.

Med alt dette i betragtning, ønskede vi at lave den mest sikre udvidelse, der forenkler udviklingen af ​​decentrale applikationer ved at levere en simpel API til at arbejde med transaktioner og signaturer.
Vi vil fortælle dig om denne oplevelse nedenfor.

Artiklen vil indeholde trin-for-trin instruktioner om, hvordan man skriver en browserudvidelse, med kodeeksempler og skærmbilleder. Du kan finde al koden i depoter. Hver commit svarer logisk til et afsnit af denne artikel.

En kort historie om browserudvidelser

Browserudvidelser har eksisteret i lang tid. De dukkede op i Internet Explorer tilbage i 1999, i Firefox i 2004. Men i meget lang tid var der ingen enkelt standard for udvidelser.

Vi kan sige, at det dukkede op sammen med udvidelser i den fjerde version af Google Chrome. Selvfølgelig var der ingen specifikation dengang, men det var Chrome API, der blev dens grundlag: Efter at have erobret det meste af browsermarkedet og med en indbygget applikationsbutik satte Chrome faktisk standarden for browserudvidelser.

Mozilla havde sin egen standard, men i betragtning af populariteten af ​​Chrome-udvidelser besluttede virksomheden at lave en kompatibel API. I 2015 blev der på initiativ af Mozilla oprettet en særlig gruppe inden for World Wide Web Consortium (W3C) til at arbejde med specifikationer for udvidelser på tværs af browsere.

De eksisterende API-udvidelser til Chrome blev taget som grundlag. Arbejdet blev udført med støtte fra Microsoft (Google nægtede at deltage i udviklingen af ​​standarden), og som et resultat dukkede et udkast op specifikationer.

Formelt er specifikationen understøttet af Edge, Firefox og Opera (bemærk, at Chrome ikke er på denne liste). Men faktisk er standarden stort set kompatibel med Chrome, da den faktisk er skrevet ud fra dens udvidelser. Du kan læse mere om WebExtensions API her.

Udbygningsstruktur

Den eneste fil, der kræves til udvidelsen, er manifestet (manifest.json). Det er også "indgangspunktet" til udvidelsen.

manifest

Ifølge specifikationen er manifestfilen en gyldig JSON-fil. En komplet beskrivelse af manifestnøgler med information om, hvilke nøgler der understøttes i hvilken browser der kan ses her.

Nøgler, der ikke er i specifikationen "kan" ignoreres (både Chrome og Firefox rapporterer fejl, men udvidelserne fortsætter med at fungere).

Og jeg vil gerne henlede opmærksomheden på nogle punkter.

  1. baggrund — et objekt, der omfatter følgende felter:
    1. scripts — en række scripts, der vil blive udført i baggrundskonteksten (vi vil tale om dette lidt senere);
    2. side - i stedet for scripts, der vil blive udført på en tom side, kan du angive html med indhold. I dette tilfælde vil scriptfeltet blive ignoreret, og scripts skal indsættes på indholdssiden;
    3. vedholdende — et binært flag, hvis det ikke er angivet, vil browseren "dræbe" baggrundsprocessen, når den mener, at den ikke gør noget, og genstarte den, hvis det er nødvendigt. Ellers bliver siden først aflæst, når browseren er lukket. Ikke understøttet i Firefox.
  2. content_scripts — en række objekter, der giver dig mulighed for at indlæse forskellige scripts til forskellige websider. Hvert objekt indeholder følgende vigtige felter:
    1. tændstikkermønster url, som bestemmer, om et bestemt indholdsscript vil blive inkluderet eller ej.
    2. js — en liste over scripts, der vil blive indlæst i denne kamp;
    3. ekskluder_matches - udelukker fra feltet match URL'er, der matcher dette felt.
  3. side_handling - er faktisk et objekt, der er ansvarlig for det ikon, der vises ved siden af ​​adresselinjen i browseren, og interaktion med den. Det giver dig også mulighed for at vise et popup-vindue, som er defineret ved hjælp af din egen HTML, CSS og JS.
    1. default_popup — sti til HTML-filen med popup-grænsefladen, kan indeholde CSS og JS.
  4. Tilladelser — et array til styring af udvidelsesrettigheder. Der er 3 typer rettigheder, som er beskrevet detaljeret her
  5. web_tilgængelige_ressourcer — udvidelsesressourcer, som en webside kan anmode om, for eksempel billeder, JS, CSS, HTML-filer.
  6. eksternt_tilslutbar — her kan du udtrykkeligt angive ID'erne for andre udvidelser og domæner på websider, hvorfra du kan oprette forbindelse. Et domæne kan være på andet niveau eller højere. Virker ikke i Firefox.

Udførelseskontekst

Udvidelsen har tre kodeeksekveringskontekster, det vil sige, at applikationen består af tre dele med forskellige niveauer af adgang til browser-API'en.

Udvidelseskontekst

Det meste af API'en er tilgængelig her. I denne sammenhæng "lever" de:

  1. Baggrundsside — "backend" del af udvidelsen. Filen er specificeret i manifestet ved hjælp af "baggrund"-tasten.
  2. Popup-side — en pop op-side, der vises, når du klikker på udvidelsesikonet. I manifestet browser_action -> default_popup.
  3. Brugerdefineret side — udvidelsesside, "levende" i en separat fane i visningen chrome-extension://<id_расширения>/customPage.html.

Denne kontekst eksisterer uafhængigt af browservinduer og faner. Baggrundsside eksisterer i en enkelt kopi og fungerer altid (undtagelsen er begivenhedssiden, når baggrundsscriptet startes af en begivenhed og "dør" efter dets udførelse). Popup-side eksisterer, når pop op-vinduet er åbent, og Brugerdefineret side - mens fanen med den er åben. Der er ingen adgang til andre faner og deres indhold fra denne sammenhæng.

Indholdsscriptkontekst

Indholdsscriptfilen lanceres sammen med hver browserfane. Den har adgang til en del af udvidelsens API og til DOM-træet på websiden. Det er indholdsscripts, der er ansvarlige for interaktion med siden. Udvidelser, der manipulerer DOM-træet, gør dette i indholdsscripts - for eksempel annonceblokkere eller oversættere. Indholdsscriptet kan også kommunikere med siden via standard postMessage.

Websidekontekst

Dette er selve websiden. Det har intet at gøre med udvidelsen og har ikke adgang der, undtagen i tilfælde hvor domænet på denne side ikke er eksplicit angivet i manifestet (mere om dette nedenfor).

messaging

Forskellige dele af applikationen skal udveksle beskeder med hinanden. Der er en API til dette runtime.sendMessage at sende en besked background и tabs.sendMessage for at sende en besked til en side (indholdsscript, popup eller webside, hvis det er tilgængeligt externally_connectable). Nedenfor er et eksempel på adgang til 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))
    }
)

For fuld kommunikation kan du skabe forbindelser gennem runtime.connect. Som svar vil vi modtage runtime.Port, hvortil du, mens den er åben, kan sende et vilkårligt antal beskeder. På klientsiden er der f.eks. contentscript, det ser sådan ud:

// Опять же 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 eller baggrund:

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

Der er også et arrangement onDisconnect og metode disconnect.

Anvendelsesdiagram

Lad os lave en browserudvidelse, der gemmer private nøgler, giver adgang til offentlig information (adresse, offentlig nøgle kommunikerer med siden og tillader tredjepartsapplikationer at anmode om en signatur for transaktioner.

Applikationsudvikling

Vores applikation skal både interagere med brugeren og forsyne siden med en API til at kalde metoder (for eksempel til at signere transaktioner). nøjes med kun én contentscript vil ikke virke, da den kun har adgang til DOM, men ikke til sidens JS. Tilslut via runtime.connect det kan vi ikke, fordi API'en er nødvendig på alle domæner, og kun specifikke kan specificeres i manifestet. Som et resultat vil diagrammet se således ud:

Skrivning af en sikker browserudvidelse

Der kommer et andet script - inpage, som vi vil injicere på siden. Den kører i sin kontekst og giver en API til at arbejde med udvidelsen.

begynder

Alle browserudvidelseskoder er tilgængelige på GitHub. Under beskrivelsen vil der være links til commits.

Lad os starte med manifestet:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Opret 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 og sørge for, at den virker.

For at bekræfte dette kan du tage koden dermed. Ud over hvad vi gjorde, konfigurerede linket samlingen af ​​projektet ved hjælp af webpack. For at tilføje en applikation til browseren skal du i chrome://extensions vælge load unpacked og mappen med den tilsvarende udvidelse - i vores tilfælde dist.

Skrivning af en sikker browserudvidelse

Nu er vores udvidelse installeret og virker. Du kan køre udviklerværktøjerne til forskellige sammenhænge som følger:

pop op ->

Skrivning af en sikker browserudvidelse

Adgang til indholdsscriptkonsollen udføres gennem konsollen på selve siden, hvor den er lanceret.Skrivning af en sikker browserudvidelse

messaging

Så vi er nødt til at etablere to kommunikationskanaler: inpage <-> baggrund og popup <-> baggrund. Du kan selvfølgelig bare sende beskeder til porten og opfinde din egen protokol, men jeg foretrækker den tilgang, som jeg så i metamask open source-projektet.

Dette er en browserudvidelse til at arbejde med Ethereum-netværket. I den kommunikerer forskellige dele af applikationen via RPC ved hjælp af dnode-biblioteket. Det giver dig mulighed for at organisere en udveksling ret hurtigt og bekvemt, hvis du forsyner den med en nodejs-strøm som en transport (hvilket betyder et objekt, der implementerer den samme grænseflade):

import Dnode from "dnode/browser";

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

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

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

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

Nu vil vi oprette en applikationsklasse. Det vil oprette API-objekter til popup- og websiden og oprette en dnode for dem:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

Her og nedenfor bruger vi i stedet for det globale Chrome-objekt extensionApi, som tilgår Chrome i Googles browser og browser i andre. Dette gøres for kompatibilitet på tværs af browsere, men til formålet med denne artikel kunne man blot bruge 'chrome.runtime.connect'.

Lad os oprette en applikationsforekomst i baggrundsscriptet:

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

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Da dnode arbejder med streams, og vi modtager en port, er der behov for en adapterklasse. Det er lavet ved hjælp af readable-stream-biblioteket, 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 en forbindelse i brugergrænsefladen:

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

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

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

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

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Så skaber vi forbindelsen i indholdsscriptet:

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

setupConnection();
injectScript();

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

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

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

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Da vi ikke har brug for API'en i indholdsscriptet, men direkte på siden, gør vi to ting:

  1. Vi laver to strømme. En - mod siden, oven på postBeskeden. Til dette bruger vi dette denne pakke fra skaberne af metamask. Den anden stream er til baggrund over porten modtaget fra runtime.connect. Lad os købe dem. Nu vil siden have en stream til baggrunden.
  2. Injicer scriptet i DOM. Download scriptet (adgang til det var tilladt i manifestet), og opret et tag script med indholdet indeni:

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

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Nu opretter vi et api-objekt i inpage og indstiller det til globalt:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

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

Skrivning af en sikker browserudvidelse

Tom API og oprindelse. På sidesiden kan vi kalde hej-funktionen sådan:

Skrivning af en sikker browserudvidelse

At arbejde med tilbagekaldsfunktioner i moderne JS er dårlig manerer, så lad os skrive en lille hjælper til at skabe en dnode, der giver dig mulighed for at videregive et API-objekt til utils.

API-objekterne vil nu se sådan ud:

export class SignerApp {

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

...

}

Sådan hentes et objekt fra fjernbetjeningen:

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

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

Og at kalde funktioner returnerer et løfte:

Skrivning af en sikker browserudvidelse

Version med asynkrone funktioner tilgængelig her.

Samlet set virker RPC- og stream-tilgangen ret fleksibel: vi kan bruge steam-multipleksing og oprette flere forskellige API'er til forskellige opgaver. I princippet kan dnode bruges overalt, det vigtigste er at pakke transporten ind i form af en nodejs-strøm.

Et alternativ er JSON-formatet, som implementerer JSON RPC 2-protokollen. Det fungerer dog med specifikke transporter (TCP og HTTP(S)), hvilket ikke er relevant i vores tilfælde.

Intern statslig og lokal opbevaring

Vi bliver nødt til at gemme applikationens interne tilstand - i det mindste signeringsnøglerne. Vi kan ganske nemt tilføje en tilstand til applikationen og metoder til at ændre den i popup-API'en:

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 vinduet, så vi kan arbejde med det 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 taster fra UI-konsollen og se, hvad der sker med tilstanden:

Skrivning af en sikker browserudvidelse

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

Vi gemmer det i localStorage og overskriver det ved hver ændring. Efterfølgende vil adgang til den også være nødvendig for UI'en, og jeg vil også gerne abonnere på ændringer. Baseret på dette vil det være praktisk at oprette et observerbart lager og abonnere på dets ændringer.

Vi vil bruge mobx-biblioteket (https://github.com/mobxjs/mobx). Valget faldt på det, fordi jeg ikke skulle arbejde med det, men jeg ville virkelig studere det.

Lad os tilføje initialisering af den oprindelige tilstand og gøre butikken observerbar:

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

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

"Under motorhjelmen" har mobx erstattet alle butiksfelter med proxy og aflytter alle opkald til dem. Det vil være muligt at abonnere på disse beskeder.

Nedenfor vil jeg ofte bruge udtrykket "når man skifter", selvom det ikke er helt korrekt. Mobx sporer adgang til felter. Gettere og sættere af proxy-objekter, som biblioteket opretter, bruges.

Action dekoratører tjener to formål:

  1. I streng tilstand med enforceActions-flaget forbyder mobx at ændre staten direkte. Det anses for god praksis at arbejde under strenge forhold.
  2. Selvom en funktion ændrer tilstanden flere gange - for eksempel ændrer vi flere felter i flere linjer kode - får observatørerne først besked, når den er færdig. Dette er især vigtigt for frontend, hvor unødvendige tilstandsopdateringer fører til unødvendig gengivelse af elementer. I vores tilfælde er hverken den første eller den anden særlig relevant, men vi vil følge den bedste praksis. Det er sædvanligt at knytte dekoratører til alle funktioner, der ændrer tilstanden af ​​de observerede felter.

I baggrunden vil vi tilføje initialisering og gemme tilstanden i localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Reaktionsfunktionen er interessant her. Det har to argumenter:

  1. Datavælger.
  2. En behandler, der vil blive kaldt med disse data, hver gang den ændres.

I modsætning til redux, hvor vi eksplicit modtager tilstanden som et argument, husker mobx hvilke observabler vi får adgang til inde i vælgeren, og kalder kun handleren, når de ændrer sig.

Det er vigtigt at forstå præcis, hvordan mobx beslutter, hvilke observables vi abonnerer på. Hvis jeg skrev en vælger i kode som denne() => app.store, så vil reaktion aldrig blive kaldt, da selve lagringen ikke er observerbar, kun dens felter er.

Hvis jeg skrev det sådan her () => app.store.keys, så igen ville der ikke ske noget, da når du tilføjer/fjerner array-elementer, vil referencen til den ikke ændre sig.

Mobx fungerer som en vælger for første gang og overvåger kun de observerbare elementer, som vi fik adgang til. Dette gøres gennem proxy-getters. Derfor bruges den indbyggede funktion her toJS. Det returnerer et nyt objekt med alle proxyer erstattet med de originale felter. Under udførelsen læser den alle objektets felter - derfor udløses getterne.

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

Skrivning af en sikker browserudvidelse

Når baggrundssiden genindlæses, forbliver informationen på plads.

Al applikationskode indtil dette tidspunkt kan ses her.

Sikker opbevaring af private nøgler

Det er usikkert at gemme private nøgler i klartekst: Der er altid en chance for, at du bliver hacket, får adgang til din computer og så videre. Derfor vil vi i localStorage gemme nøglerne i en adgangskodekrypteret form.

For større sikkerhed tilføjer vi en låst tilstand til applikationen, hvor der slet ikke vil være adgang til nøglerne. Vi vil automatisk overføre udvidelsen til låst tilstand på grund af en timeout.

Mobx giver dig mulighed for kun at gemme et minimum af data, og resten beregnes automatisk baseret på det. Det er de såkaldte beregnede egenskaber. De kan sammenlignes med visninger i databaser:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

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

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

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

Nu gemmer vi kun de krypterede nøgler og adgangskode. Alt andet er beregnet. Vi foretager overførslen til en låst tilstand ved at fjerne adgangskoden fra staten. Den offentlige API har nu en metode til at initialisere lageret.

Skrevet til kryptering hjælpeprogrammer, der bruger crypto-js:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

Browseren har en inaktiv API, hvorigennem du kan abonnere på en begivenhed - tilstandsændringer. Staten kan følgelig være idle, active и locked. For inaktiv kan du indstille en timeout, og låst er indstillet, når selve OS er blokeret. Vi vil også ændre vælgeren til at gemme til localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Koden før dette trin er her.

Transaktion

Så vi kommer til det vigtigste: oprettelse og underskrift af transaktioner på blockchain. Vi vil bruge WAVES blockchain og bibliotek bølger-transaktioner.

Lad os først tilføje en række meddelelser til staten, der skal signeres, og derefter tilføje metoder til at tilføje en ny meddelelse, 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) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

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

Hvis du ikke gør det observable manuelt, så vil mobx gøre det selv, når du tilføjer beskeder til arrayet. Det vil dog oprette et nyt objekt, som vi ikke vil have en reference til, men vi skal bruge det til næste trin.

Dernæst returnerer vi et løfte, der løser sig, når meddelelsesstatus ændres. Status overvåges af reaktion, som vil "slå sig selv ihjel", når status ændres.

Metode kode approve и reject meget simpelt: vi ændrer simpelthen status for meddelelsen, efter at have underskrevet den, hvis det er nødvendigt.

Vi sætter Godkend og afvis i UI API, newMessage i side API:

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

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Lad os nu prøve at underskrive transaktionen med udvidelsen:

Skrivning af en sikker browserudvidelse

Generelt er alt klar, det eneste, der er tilbage er tilføje simpel brugergrænseflade.

UI

Grænsefladen skal have adgang til applikationstilstanden. På UI-siden vil vi gøre observable tilstand og tilføje en funktion til API'et, der vil ændre denne tilstand. Lad os tilføje observable til API-objektet modtaget fra baggrunden:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

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

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

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

    // Запуск интерфейса
    await initApp(background)
}

Til sidst begynder vi at gengive applikationsgrænsefladen. Dette er en reaktionsapplikation. Baggrundsobjektet passeres simpelthen ved hjælp af rekvisitter. Det ville selvfølgelig være korrekt at lave en separat service til metoder og en butik til staten, men til formålet med denne artikel er dette nok:

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

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

Med mobx er det meget nemt at begynde at rendere, når data ændres. Vi hænger blot observatørdekoratøren fra pakken mobx-reager på komponenten, og render vil automatisk blive kaldt, når eventuelle observerbare elementer, der refereres til af komponenten, ændres. Du behøver ikke nogen mapStateToProps eller oprette forbindelse som i redux. Alt fungerer lige ud af kassen:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

De resterende komponenter kan ses i koden i UI-mappen.

Nu i applikationsklassen skal du lave en tilstandsvælger for brugergrænsefladen og underrette brugergrænsefladen, når den ændres. For at gøre dette, lad os tilføje en metode getState и reactionringer remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Når du modtager en genstand remote er oprettet reaction for at ændre den tilstand, der kalder funktionen på UI-siden.

Den sidste berøring er at tilføje visningen af ​​nye beskeder på udvidelsesikonet:

function setupApp() {
...

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

...
}

Så ansøgningen er klar. Websider kan anmode om en signatur for transaktioner:

Skrivning af en sikker browserudvidelse

Skrivning af en sikker browserudvidelse

Koden er tilgængelig her link.

Konklusion

Hvis du har læst artiklen til slutningen, men stadig har spørgsmål, kan du stille dem på repositories med udvidelse. Der vil du også finde commits for hvert udpeget trin.

Og hvis du er interesseret i at se på koden til selve udvidelsen, kan du finde denne her.

Kode, repository og jobbeskrivelse fra siemarell

Kilde: www.habr.com

Tilføj en kommentar