Skrive en sikker nettleserutvidelse

Skrive en sikker nettleserutvidelse

I motsetning til den vanlige "klient-server"-arkitekturen, er desentraliserte applikasjoner preget av:

  • Det er ikke nødvendig å lagre en database med brukerpålogginger og passord. Tilgangsinformasjon lagres utelukkende av brukerne selv, og bekreftelse av deres autentisitet skjer på protokollnivå.
  • Du trenger ikke å bruke en server. Applikasjonslogikken kan utføres på et blokkjedenettverk, hvor det er mulig å lagre den nødvendige mengden data.

Det er 2 relativt sikre lagringer for brukernøkler - maskinvarelommebøker og nettleserutvidelser. Maskinvarelommebøker er stort sett ekstremt sikre, men vanskelige å bruke og langt fra gratis, men nettleserutvidelser er den perfekte kombinasjonen av sikkerhet og brukervennlighet, og kan også være helt gratis for sluttbrukere.

Med alt dette i betraktning, ønsket vi å lage den sikreste utvidelsen som forenkler utviklingen av desentraliserte applikasjoner ved å tilby et enkelt API for arbeid med transaksjoner og signaturer.
Vi vil fortelle deg om denne opplevelsen nedenfor.

Artikkelen vil inneholde trinnvise instruksjoner om hvordan du skriver en nettleserutvidelse, med kodeeksempler og skjermbilder. Du finner all koden i depoter. Hver forpliktelse tilsvarer logisk sett en del av denne artikkelen.

En kort historie om nettleserutvidelser

Nettleserutvidelser har eksistert i lang tid. De dukket opp i Internet Explorer tilbake i 1999, i Firefox i 2004. Men i svært lang tid var det ingen standard for utvidelser.

Vi kan si at den dukket opp sammen med utvidelser i den fjerde versjonen av Google Chrome. Selvfølgelig var det ingen spesifikasjon da, men det var Chrome API som ble grunnlaget: etter å ha erobret det meste av nettlesermarkedet og med en innebygd applikasjonsbutikk, satte Chrome faktisk standarden for nettleserutvidelser.

Mozilla hadde sin egen standard, men etter å ha sett populariteten til Chrome-utvidelser bestemte selskapet seg for å lage en kompatibel API. I 2015, på initiativ fra Mozilla, ble det opprettet en spesiell gruppe innenfor World Wide Web Consortium (W3C) for å jobbe med utvidelsesspesifikasjoner på tvers av nettlesere.

De eksisterende API-utvidelsene for Chrome ble tatt som grunnlag. Arbeidet ble utført med støtte fra Microsoft (Google nektet å delta i utviklingen av standarden), og som et resultat dukket det opp et utkast spesifikasjoner.

Formelt er spesifikasjonen støttet av Edge, Firefox og Opera (merk at Chrome ikke er på denne listen). Men faktisk er standarden stort sett kompatibel med Chrome, siden den faktisk er skrevet basert på utvidelsene. Du kan lese mer om WebExtensions API her.

Utvidelsesstruktur

Den eneste filen som kreves for utvidelsen er manifestet (manifest.json). Det er også "inngangspunktet" til utvidelsen.

manifest

I henhold til spesifikasjonen er manifestfilen en gyldig JSON-fil. En fullstendig beskrivelse av manifestnøkler med informasjon om hvilke nøkler som støttes i hvilken nettleser som kan vises her.

Nøkler som ikke er i spesifikasjonen "kan" bli ignorert (både Chrome og Firefox rapporterer feil, men utvidelsene fortsetter å fungere).

Og jeg vil gjerne trekke oppmerksomhet til noen punkter.

  1. bakgrunn – et objekt som inkluderer følgende felt:
    1. skript — en rekke skript som vil bli utført i bakgrunnskonteksten (vi skal snakke om dette litt senere);
    2. side - i stedet for skript som skal kjøres på en tom side, kan du spesifisere html med innhold. I dette tilfellet vil skriptfeltet bli ignorert, og skriptene må settes inn på innholdssiden;
    3. vedvarende - et binært flagg, hvis ikke spesifisert, vil nettleseren "drepe" bakgrunnsprosessen når den vurderer at den ikke gjør noe, og starte den på nytt om nødvendig. Ellers vil siden kun lastes ned når nettleseren er lukket. Støttes ikke i Firefox.
  2. innholdsskript — en rekke objekter som lar deg laste forskjellige skript til forskjellige nettsider. Hvert objekt inneholder følgende viktige felt:
    1. fyrstikker - mønster url, som avgjør om et bestemt innholdsskript skal inkluderes eller ikke.
    2. js — en liste over skript som vil bli lastet inn i denne kampen;
    3. ekskluder_treff - ekskluderer fra feltet match URL-er som samsvarer med dette feltet.
  3. side_handling - er faktisk et objekt som er ansvarlig for ikonet som vises ved siden av adressefeltet i nettleseren og interaksjon med det. Den lar deg også vise et popup-vindu, som er definert ved hjelp av din egen HTML, CSS og JS.
    1. default_popup — bane til HTML-filen med popup-grensesnittet, kan inneholde CSS og JS.
  4. tillatelser — en matrise for å administrere utvidelsesrettigheter. Det er 3 typer rettigheter, som er beskrevet i detalj her
  5. web_accessible_resources — utvidelsesressurser som en nettside kan be om, for eksempel bilder, JS, CSS, HTML-filer.
  6. eksternt_tilkoblet — her kan du eksplisitt spesifisere ID-ene til andre utvidelser og domener på nettsider du kan koble til fra. Et domene kan være på andre nivå eller høyere. Fungerer ikke i Firefox.

Utførelseskontekst

Utvidelsen har tre kodeutførelseskontekster, det vil si at applikasjonen består av tre deler med forskjellige tilgangsnivåer til nettleserens API.

Utvidelseskontekst

Det meste av API er tilgjengelig her. I denne sammenhengen "lever" de:

  1. Bakgrunnsside — «backend»-delen av utvidelsen. Filen spesifiseres i manifestet ved å bruke "bakgrunn"-tasten.
  2. Popup-side — en popup-side som vises når du klikker på utvidelsesikonet. I manifestet browser_action -> default_popup.
  3. Tilpasset side - utvidelsesside, "bor" i en egen fane i visningen chrome-extension://<id_расширения>/customPage.html.

Denne konteksten eksisterer uavhengig av nettleservinduer og faner. Bakgrunnsside eksisterer i en enkelt kopi og fungerer alltid (unntaket er hendelsessiden, når bakgrunnsskriptet startes av en hendelse og "dør" etter at det er utført). Popup-side eksisterer når popup-vinduet er åpent, og Tilpasset side - mens fanen med den er åpen. Det er ingen tilgang til andre faner og deres innhold fra denne konteksten.

Innholdsskriptkontekst

Innholdsskriptfilen lanseres sammen med hver nettleserfane. Den har tilgang til deler av utvidelsens API og til DOM-treet på nettsiden. Det er innholdsskript som er ansvarlige for interaksjon med siden. Utvidelser som manipulerer DOM-treet gjør dette i innholdsskript – for eksempel annonseblokkere eller oversettere. Innholdsskriptet kan også kommunisere med siden via standard postMessage.

Nettsidekontekst

Dette er selve nettsiden. Det har ingenting med utvidelsen å gjøre og har ikke tilgang der, bortsett fra i tilfeller der domenet til denne siden ikke er eksplisitt angitt i manifestet (mer om dette nedenfor).

Meldingsutveksling

Ulike deler av applikasjonen må utveksle meldinger med hverandre. Det finnes en API for dette runtime.sendMessage å sende en melding background и tabs.sendMessage for å sende en melding til en side (innholdsskript, popup eller nettside hvis tilgjengelig externally_connectable). Nedenfor er et eksempel når du får tilgang 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 full kommunikasjon kan du opprette forbindelser gjennom runtime.connect. Som svar vil vi motta runtime.Port, som du kan sende et hvilket som helst antall meldinger til mens den er åpen. På klientsiden kan f.eks. contentscript, det ser slik ut:

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

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

Det er også et arrangement onDisconnect og metode disconnect.

Søknadsdiagram

La oss lage en nettleserutvidelse som lagrer private nøkler, gir tilgang til offentlig informasjon (adresse, offentlig nøkkel kommuniserer med siden og lar tredjepartsapplikasjoner be om en signatur for transaksjoner.

Applikasjonsutvikling

Vår applikasjon må både samhandle med brukeren og gi siden et API for å kalle metoder (for eksempel for å signere transaksjoner). Nøy deg med bare én contentscript vil ikke fungere, siden den bare har tilgang til DOM, men ikke til JS på siden. Koble til via runtime.connect vi kan ikke, fordi API er nødvendig på alle domener, og bare spesifikke kan spesifiseres i manifestet. Som et resultat vil diagrammet se slik ut:

Skrive en sikker nettleserutvidelse

Det kommer et annet manus - inpage, som vi vil sette inn på siden. Den vil kjøre i sin kontekst og gi en API for å jobbe med utvidelsen.

begynner

All nettleserutvidelseskode er tilgjengelig på GitHub. Under beskrivelsen vil det være lenker til forpliktelser.

La oss 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"]
}

Lag tomme background.js, popup.js, inpage.js og contentscript.js. Vi legger til popup.html - og applikasjonen vår kan allerede lastes inn i Google Chrome og sørge for at den fungerer.

For å bekrefte dette kan du ta koden derav. I tillegg til det vi gjorde, konfigurerte lenken sammenstillingen av prosjektet ved hjelp av webpack. For å legge til en applikasjon i nettleseren, i chrome://extensions må du velge load unpacked og mappen med den tilsvarende utvidelsen - i vårt tilfelle dist.

Skrive en sikker nettleserutvidelse

Nå er utvidelsen vår installert og fungerer. Du kan kjøre utviklerverktøyene for forskjellige kontekster som følger:

popup ->

Skrive en sikker nettleserutvidelse

Tilgang til innholdsskriptkonsollen utføres gjennom konsollen på selve siden den er lansert på.Skrive en sikker nettleserutvidelse

Meldingsutveksling

Så vi må etablere to kommunikasjonskanaler: inpage <-> bakgrunn og popup <-> bakgrunn. Du kan selvfølgelig bare sende meldinger til porten og finne opp din egen protokoll, men jeg foretrekker tilnærmingen som jeg så i metamask open source-prosjektet.

Dette er en nettleserutvidelse for å jobbe med Ethereum-nettverket. I den kommuniserer forskjellige deler av applikasjonen via RPC ved hjelp av dnode-biblioteket. Det lar deg organisere en utveksling ganske raskt og praktisk hvis du gir den en nodejs-strøm som en transport (som betyr et objekt som implementerer det samme grensesnittet):

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

Nå skal vi lage en applikasjonsklasse. Det vil lage API-objekter for popup-en og nettsiden, og lage 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, i stedet for det globale Chrome-objektet, bruker vi extensionApi, som får tilgang til Chrome i Googles nettleser og nettleser i andre. Dette gjøres for kompatibilitet på tvers av nettlesere, men for formålet med denne artikkelen kan man ganske enkelt bruke 'chrome.runtime.connect'.

La oss lage en applikasjonsforekomst i bakgrunnsskriptet:

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

Siden dnode fungerer med strømmer, og vi mottar en port, trengs det en adapterklasse. Den er laget ved hjelp av lesbar-strømbiblioteket, som implementerer nodejs-strømmer i nettleseren:

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

La oss nå opprette en tilkobling i brukergrensesnittet:

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

Deretter oppretter vi forbindelsen i innholdsskriptet:

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

Siden vi ikke trenger API-en i innholdsskriptet, men direkte på siden, gjør vi to ting:

  1. Vi lager to strømmer. En - mot siden, på toppen av postmeldingen. Til dette bruker vi denne denne pakken fra skaperne av metamask. Den andre strømmen er til bakgrunn over porten mottatt fra runtime.connect. La oss kjøpe dem. Nå vil siden ha en strøm til bakgrunnen.
  2. Injiser skriptet i DOM. Last ned skriptet (tilgang til det var tillatt i manifestet) og lag en tag script med innholdet inni:

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

Nå lager vi et api-objekt i inpage og setter 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 klare Remote Procedure Call (RPC) med separat API for side og UI. Når du kobler en ny side til bakgrunnen kan vi se dette:

Skrive en sikker nettleserutvidelse

Tom API og opprinnelse. På sidesiden kan vi kalle hello-funksjonen slik:

Skrive en sikker nettleserutvidelse

Å jobbe med tilbakeringingsfunksjoner i moderne JS er dårlig oppførsel, så la oss skrive en liten hjelper for å lage en dnode som lar deg sende et API-objekt til utils.

API-objektene vil nå se slik ut:

export class SignerApp {

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

...

}

Få et objekt fra fjernkontrollen slik:

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

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

Og å kalle funksjoner returnerer et løfte:

Skrive en sikker nettleserutvidelse

Versjon med asynkrone funksjoner tilgjengelig her.

Totalt sett virker RPC- og strømtilnærmingen ganske fleksibel: vi kan bruke steam-multipleksing og lage flere forskjellige API-er for forskjellige oppgaver. I prinsippet kan dnode brukes hvor som helst, det viktigste er å pakke transporten inn i form av en nodejs-strøm.

Et alternativ er JSON-formatet, som implementerer JSON RPC 2-protokollen. Det fungerer imidlertid med spesifikke transporter (TCP og HTTP(S)), som ikke er aktuelt i vårt tilfelle.

Intern statlig og lokal lagring

Vi må lagre den interne tilstanden til applikasjonen - i det minste signeringsnøklene. Vi kan ganske enkelt legge til en tilstand til applikasjonen og metoder for å endre den 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 bakgrunnen pakker vi alt inn i en funksjon og skriver applikasjonsobjektet til vindu slik at vi kan jobbe 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)
        }
    }
}

La oss legge til noen få nøkler fra UI-konsollen og se hva som skjer med tilstanden:

Skrive en sikker nettleserutvidelse

Tilstanden må gjøres vedvarende slik at nøklene ikke går tapt ved omstart.

Vi vil lagre det i localStorage, og overskrive det med hver endring. Deretter vil tilgang til den også være nødvendig for brukergrensesnittet, og jeg vil også gjerne abonnere på endringer. Basert på dette vil det være praktisk å opprette en observerbar lagring og abonnere på endringene.

Vi vil bruke mobx-biblioteket (https://github.com/mobxjs/mobx). Valget falt på det fordi jeg ikke trengte å jobbe med det, men jeg ville virkelig studere det.

La oss legge til initialisering av starttilstanden og gjø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 panseret," mobx har erstattet alle butikkfelt med proxy og avlytter alle anrop til dem. Det vil være mulig å abonnere på disse meldingene.

Nedenfor vil jeg ofte bruke begrepet "ved endring", selv om dette ikke er helt riktig. Mobx sporer tilgang til felt. Gettere og settere av proxy-objekter som biblioteket oppretter brukes.

Actiondekoratører tjener to formål:

  1. I streng modus med enforceActions-flagget, forbyr mobx å endre staten direkte. Det anses som god praksis å jobbe under strenge forhold.
  2. Selv om en funksjon endrer tilstanden flere ganger - for eksempel endrer vi flere felt i flere kodelinjer - får observatørene beskjed først når den er fullført. Dette er spesielt viktig for frontend, der unødvendige tilstandsoppdateringer fører til unødvendig gjengivelse av elementer. I vårt tilfelle er verken den første eller den andre spesielt relevant, men vi vil følge beste praksis. Det er vanlig å feste dekoratører til alle funksjoner som endrer tilstanden til de observerte feltene.

I bakgrunnen vil vi legge til initialisering og lagre 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)
        }
    }
}

Reaksjonsfunksjonen er interessant her. Den har to argumenter:

  1. Datavelger.
  2. En behandler som vil bli kalt opp med disse dataene hver gang de endres.

I motsetning til redux, hvor vi eksplisitt mottar tilstanden som et argument, husker mobx hvilke observerbare vi får tilgang til inne i velgeren, og ringer bare behandleren når de endrer seg.

Det er viktig å forstå nøyaktig hvordan mobx bestemmer hvilke observerbare vi abonnerer på. Hvis jeg skrev en velger i kode som denne() => app.store, da vil reaksjon aldri bli kalt, siden selve lagringen ikke er observerbar, bare dens felt er det.

Hvis jeg skrev det slik () => app.store.keys, så igjen ville ingenting skje, siden når du legger til/fjerner array-elementer, vil referansen til den ikke endres.

Mobx fungerer som en velger for første gang og holder kun oversikt over observerbare som vi har tilgang til. Dette gjøres gjennom proxy-gettere. Derfor brukes den innebygde funksjonen her toJS. Den returnerer et nytt objekt med alle proxyer erstattet med de opprinnelige feltene. Under utførelse leser den alle feltene til objektet - derfor utløses getterne.

I popup-konsollen vil vi igjen legge til flere taster. Denne gangen havnet de også i localStorage:

Skrive en sikker nettleserutvidelse

Når bakgrunnssiden er lastet inn på nytt, forblir informasjonen på plass.

All applikasjonskode frem til dette punktet kan sees her.

Sikker lagring av private nøkler

Å lagre private nøkler i klartekst er utrygt: det er alltid en sjanse for at du blir hacket, får tilgang til datamaskinen din og så videre. Derfor vil vi i localStorage lagre nøklene i en passordkryptert form.

For større sikkerhet vil vi legge til en låst tilstand til applikasjonen, der det ikke vil være tilgang til nøklene i det hele tatt. Vi vil automatisk overføre utvidelsen til låst tilstand på grunn av et tidsavbrudd.

Mobx lar deg lagre bare et minimumssett med data, og resten beregnes automatisk basert på det. Dette er de såkalte beregnede egenskapene. 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')
        }
    }
}

Nå lagrer vi kun de krypterte nøklene og passordet. Alt annet er beregnet. Vi gjør overføringen til en låst tilstand ved å fjerne passordet fra staten. Den offentlige APIen har nå en metode for å initialisere lagringen.

Skrevet for kryptering verktøy som bruker 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)
}

Nettleseren har en inaktiv API som du kan abonnere på en hendelse - tilstandsendringer. Staten kan følgelig være idle, active и locked. For inaktiv kan du sette en timeout, og låst settes når selve operativsystemet er blokkert. Vi vil også endre velgeren for lagring 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 trinnet er her.

Transaksjonen

Så vi kommer til det viktigste: opprette og signere transaksjoner på blokkjeden. Vi vil bruke WAVES blokkjeden og biblioteket bølger-transaksjoner.

Først, la oss legge til staten en rekke meldinger som må signeres, og deretter legge til metoder for å legge til en ny melding, bekrefte signaturen og nekte:

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 mottar en ny melding, legger vi til metadata til den, gjør det observable og legg til store.messages.

Hvis du ikke gjør det observable manuelt, så vil mobx gjøre det selv når du legger til meldinger i arrayet. Det vil imidlertid lage et nytt objekt som vi ikke vil ha en referanse til, men vi trenger det til neste trinn.

Deretter returnerer vi et løfte som løser seg når meldingsstatusen endres. Status overvåkes av reaksjon, som vil "drepe seg selv" når status endres.

Metodekode approve и reject veldig enkelt: vi endrer ganske enkelt statusen til meldingen, etter å ha signert den om nødvendig.

Vi legger Godkjenn og avvis i UI API, newMessage i siden 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)
        }
    }

    ...
}

La oss nå prøve å signere transaksjonen med utvidelsen:

Skrive en sikker nettleserutvidelse

Generelt er alt klart, alt som gjenstår er legg til enkelt brukergrensesnitt.

UI

Grensesnittet trenger tilgang til applikasjonstilstanden. På UI-siden vil vi gjøre det observable tilstand og legg til en funksjon til API-en som vil endre denne tilstanden. La oss legge til observable til API-objektet mottatt fra bakgrunnen:

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

På slutten begynner vi å gjengi applikasjonsgrensesnittet. Dette er en reaksjonsapplikasjon. Bakgrunnsobjektet passeres ganske enkelt ved hjelp av rekvisitter. Det ville selvfølgelig være riktig å lage en egen tjeneste for metoder og en butikk for staten, men for formålet med denne artikkelen 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 veldig enkelt å begynne å gjengi når data endres. Vi henger rett og slett observatørdekoratøren fra pakken mobx-reager på komponenten, og gjengivelsen kalles automatisk når noen observerbare som refereres til av komponenten endres. Du trenger ikke noen mapStateToProps eller koble til som i redux. Alt fungerer rett ut av esken:

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 komponentene kan sees i koden i UI-mappen.

Nå i applikasjonsklassen må du lage en tilstandsvelger for brukergrensesnittet og varsle brukergrensesnittet når det endres. For å gjøre dette, la oss legge til 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 mottar en gjenstand remote er opprettet reaction for å endre tilstanden som kaller opp funksjonen på UI-siden.

Den siste berøringen er å legge til visning av nye meldinger på utvidelsesikonet:

function setupApp() {
...

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

...
}

Så søknaden er klar. Nettsider kan be om en signatur for transaksjoner:

Skrive en sikker nettleserutvidelse

Skrive en sikker nettleserutvidelse

Koden er tilgjengelig her link.

Konklusjon

Hvis du har lest artikkelen til slutten, men fortsatt har spørsmål, kan du stille dem på depoter med utvidelse. Der finner du også forpliktelser for hvert utpekt trinn.

Og hvis du er interessert i å se på koden for selve utvidelsen, kan du finne denne her.

Kode, depot og stillingsbeskrivelse fra siemarell

Kilde: www.habr.com

Legg til en kommentar