Att skriva ett säkert webbläsartillägg

Att skriva ett säkert webbläsartillägg

Till skillnad från den vanliga "klient-server"-arkitekturen kännetecknas decentraliserade applikationer av:

  • Det finns ingen anledning att lagra en databas med användarinloggningar och lösenord. Åtkomstinformation lagras uteslutande av användarna själva, och bekräftelse av deras äkthet sker på protokollnivå.
  • Du behöver inte använda en server. Applikationslogiken kan exekveras på ett blockchain-nätverk, där det är möjligt att lagra den erforderliga mängden data.

Det finns 2 relativt säkra lagringar för användarnycklar - hårdvaruplånböcker och webbläsartillägg. Hårdvaruplånböcker är för det mesta extremt säkra, men svåra att använda och långt ifrån gratis, men webbläsartillägg är den perfekta kombinationen av säkerhet och användarvänlighet och kan även vara helt gratis för slutanvändare.

Med hänsyn till allt detta ville vi göra den säkraste tillägget som förenklar utvecklingen av decentraliserade applikationer genom att tillhandahålla ett enkelt API för att arbeta med transaktioner och signaturer.
Vi kommer att berätta om denna upplevelse nedan.

Artikeln kommer att innehålla steg-för-steg-instruktioner om hur man skriver ett webbläsartillägg, med kodexempel och skärmdumpar. Du hittar all kod i förråd. Varje commit motsvarar logiskt sett ett avsnitt i den här artikeln.

En kort historik över webbläsartillägg

Webbläsartillägg har funnits länge. De dök upp i Internet Explorer redan 1999, i Firefox 2004. Men under mycket lång tid fanns det ingen enskild standard för förlängningar.

Vi kan säga att det dök upp tillsammans med tillägg i den fjärde versionen av Google Chrome. Naturligtvis fanns det ingen specifikation då, men det var Chrome API som blev dess grund: efter att ha erövrat större delen av webbläsarmarknaden och med en inbyggd applikationsbutik satte Chrome faktiskt standarden för webbläsartillägg.

Mozilla hade sin egen standard, men eftersom Chrome-tilläggen var populära, beslutade företaget att göra ett kompatibelt API. Under 2015, på initiativ av Mozilla, skapades en speciell grupp inom World Wide Web Consortium (W3C) för att arbeta med specifikationer för tillägg för flera webbläsare.

De befintliga API-tilläggen för Chrome togs som grund. Arbetet utfördes med stöd av Microsoft (Google vägrade att delta i utvecklingen av standarden), och som ett resultat dök ett utkast upp specifikationer.

Formellt stöds specifikationen av Edge, Firefox och Opera (observera att Chrome inte finns på den här listan). Men i själva verket är standarden till stor del kompatibel med Chrome, eftersom den faktiskt är skriven utifrån dess tillägg. Du kan läsa mer om WebExtensions API här.

Förlängningsstruktur

Den enda fil som krävs för tillägget är manifestet (manifest.json). Det är också "ingångspunkten" till expansionen.

manifest

Enligt specifikationen är manifestfilen en giltig JSON-fil. En fullständig beskrivning av manifestnycklar med information om vilka nycklar som stöds i vilken webbläsare som kan visas här.

Nycklar som inte finns i specifikationen "kan" ignoreras (både Chrome och Firefox rapporterar fel, men tilläggen fortsätter att fungera).

Och jag skulle vilja uppmärksamma några punkter.

  1. bakgrund — ett objekt som innehåller följande fält:
    1. skript — en rad skript som kommer att köras i bakgrundssammanhang (vi kommer att prata om detta lite senare);
    2. sida - istället för skript som kommer att köras på en tom sida, kan du ange html med innehåll. I det här fallet kommer skriptfältet att ignoreras och skripten måste infogas på innehållssidan;
    3. ihållande — en binär flagga, om den inte anges, kommer webbläsaren att "döda" bakgrundsprocessen när den anser att den inte gör någonting, och starta om den om det behövs. Annars kommer sidan bara att laddas ur när webbläsaren stängs. Stöds inte i Firefox.
  2. content_scripts — en uppsättning objekt som låter dig ladda olika skript till olika webbsidor. Varje objekt innehåller följande viktiga fält:
    1. tändstickor - mönster-url, som avgör om ett visst innehållsskript ska inkluderas eller inte.
    2. js — en lista över skript som kommer att laddas in i denna match;
    3. exclude_matches - utesluter från fältet match Webbadresser som matchar detta fält.
  3. page_action - är egentligen ett objekt som ansvarar för ikonen som visas bredvid adressfältet i webbläsaren och interaktion med den. Det låter dig också visa ett popup-fönster, som definieras med din egen HTML, CSS och JS.
    1. default_popup — sökväg till HTML-filen med popup-gränssnittet, kan innehålla CSS och JS.
  4. behörigheter — en array för hantering av tilläggsrättigheter. Det finns 3 typer av rättigheter som beskrivs i detalj här
  5. webb_tillgängliga_resurser — tilläggsresurser som en webbsida kan begära, till exempel bilder, JS, CSS, HTML-filer.
  6. externt_anslutbar — här kan du uttryckligen ange ID:n för andra tillägg och domäner på webbsidor från vilka du kan ansluta. En domän kan vara på andra nivå eller högre. Fungerar inte i Firefox.

Utförandekontext

Tillägget har tre kodexekveringskontexter, det vill säga applikationen består av tre delar med olika åtkomstnivåer till webbläsarens API.

Förlängningssammanhang

Det mesta av API:t är tillgängligt här. I detta sammanhang "lever" de:

  1. Bakgrundssida — "backend"-del av tillägget. Filen specificeras i manifestet med hjälp av tangenten "bakgrund".
  2. Popup-sida — en popup-sida som visas när du klickar på tilläggsikonen. I manifestet browser_action -> default_popup.
  3. Anpassad sida — tilläggssida, "bor" i en separat flik i vyn chrome-extension://<id_расширения>/customPage.html.

Detta sammanhang existerar oberoende av webbläsarfönster och flikar. Bakgrundssida finns i en enda kopia och fungerar alltid (undantaget är händelsesidan, när bakgrundsskriptet startas av en händelse och "dör" efter dess körning). Popup-sida finns när popup-fönstret är öppet, och Anpassad sida — medan fliken med den är öppen. Det finns ingen tillgång till andra flikar och deras innehåll från detta sammanhang.

Innehållsskriptkontext

Innehållsskriptfilen startas tillsammans med varje webbläsarflik. Den har tillgång till en del av tilläggets API och till DOM-trädet på webbsidan. Det är innehållsskript som ansvarar för interaktion med sidan. Tillägg som manipulerar DOM-trädet gör detta i innehållsskript – till exempel annonsblockerare eller översättare. Dessutom kan innehållsskriptet kommunicera med sidan via standard postMessage.

Webbsidans sammanhang

Detta är själva webbsidan. Det har inget med tillägget att göra och har inte åtkomst dit, förutom i de fall där domänen för denna sida inte uttryckligen anges i manifestet (mer om detta nedan).

Meddelandeutbyte

Olika delar av applikationen måste utbyta meddelanden med varandra. Det finns ett API för detta runtime.sendMessage att skicka ett meddelande background и tabs.sendMessage för att skicka ett meddelande till en sida (innehållsskript, popup eller webbsida om tillgängligt externally_connectable). Nedan är ett exempel när du använder 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))
    }
)

För fullständig kommunikation kan du skapa förbindelser genom runtime.connect. Som svar kommer vi att få runtime.Port, som du kan skicka valfritt antal meddelanden till när den är öppen. På kundsidan kan t.ex. contentscript, det ser ut så här:

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

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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 finns också ett evenemang onDisconnect och metod disconnect.

Tillämpningsdiagram

Låt oss skapa ett webbläsartillägg som lagrar privata nycklar, ger tillgång till offentlig information (adress, offentlig nyckel kommunicerar med sidan och tillåter tredjepartsapplikationer att begära en signatur för transaktioner.

Applikationsutveckling

Vår applikation måste både interagera med användaren och förse sidan med ett API för att anropa metoder (till exempel för att signera transaktioner). Nöj dig med bara en contentscript kommer inte att fungera, eftersom den bara har tillgång till DOM, men inte till sidans JS. Anslut via runtime.connect vi kan inte, eftersom API behövs på alla domäner, och endast specifika kan anges i manifestet. Som ett resultat kommer diagrammet att se ut så här:

Att skriva ett säkert webbläsartillägg

Det kommer ett annat manus - inpage, som vi kommer att injicera på sidan. Det kommer att köras i sitt sammanhang och tillhandahålla ett API för att arbeta med tillägget.

börjar

Alla webbläsartilläggskoder är tillgängliga på GitHub. Under beskrivningen kommer det att finnas länkar till commits.

Låt oss börja 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"]
}

Skapa tomma background.js, popup.js, inpage.js och contentscript.js. Vi lägger till popup.html - och vår applikation kan redan laddas in i Google Chrome och se till att den fungerar.

För att verifiera detta kan du ta koden hence. Utöver vad vi gjorde, konfigurerade länken sammansättningen av projektet med hjälp av webpack. För att lägga till en applikation i webbläsaren, i chrome://extensions måste du välja ladda upp uppackad och mappen med motsvarande tillägg - i vårt fall dist.

Att skriva ett säkert webbläsartillägg

Nu är vårt tillägg installerat och fungerar. Du kan köra utvecklarverktygen för olika sammanhang enligt följande:

popup ->

Att skriva ett säkert webbläsartillägg

Åtkomst till innehållsskriptkonsolen görs via konsolen på själva sidan där den startas.Att skriva ett säkert webbläsartillägg

Meddelandeutbyte

Så vi måste etablera två kommunikationskanaler: inpage <-> bakgrund och popup <-> bakgrund. Du kan naturligtvis bara skicka meddelanden till porten och uppfinna ditt eget protokoll, men jag föredrar det tillvägagångssätt som jag såg i metamask-projektet med öppen källkod.

Detta är en webbläsartillägg för att arbeta med Ethereum-nätverket. I den kommunicerar olika delar av applikationen via RPC med hjälp av dnode-biblioteket. Det låter dig organisera ett utbyte ganska snabbt och bekvämt om du förser det med en nodejs-ström som en transport (vilket betyder ett objekt som implementerar samma gränssnitt):

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 ska vi skapa en applikationsklass. Det kommer att skapa API-objekt för popup- och webbsidan, och skapa en dnod för 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)
        })
    }
}

Här och nedan, istället för det globala Chrome-objektet, använder vi extensionApi, som kommer åt Chrome i Googles webbläsare och webbläsare i andra. Detta görs för kompatibilitet över webbläsare, men för den här artikeln kan man helt enkelt använda "chrome.runtime.connect".

Låt oss skapa en applikationsinstans i bakgrundsskriptet:

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

Eftersom dnode fungerar med strömmar, och vi får en port, behövs en adapterklass. Den är gjord med hjälp av läsbar strömbibliotek, som implementerar nodejs-strömmar i webbläsaren:

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

Låt oss nu skapa en anslutning i användargränssnittet:

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

Sedan skapar vi kopplingen i innehållsskriptet:

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

Eftersom vi inte behöver API:t i innehållsskriptet, utan direkt på sidan, gör vi två saker:

  1. Vi skapar två strömmar. Ett - mot sidan, överst i postmeddelandet. För detta använder vi detta detta paket från skaparna av metamask. Den andra strömmen är till bakgrund över porten som tas emot från runtime.connect. Låt oss köpa dem. Nu kommer sidan att ha en ström i bakgrunden.
  2. Injicera skriptet i DOM. Ladda ner skriptet (åtkomst till det var tillåtet i manifestet) och skapa en tagg script med dess innehåll inuti:

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 skapar vi ett api-objekt i inpage och ställer in det på 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 är redo Remote Procedure Call (RPC) med separat API för sida och UI. När du ansluter en ny sida till bakgrunden kan vi se detta:

Att skriva ett säkert webbläsartillägg

Tomt API och ursprung. På sidan kan vi kalla hej-funktionen så här:

Att skriva ett säkert webbläsartillägg

Att arbeta med återuppringningsfunktioner i modern JS är dåligt uppförande, så låt oss skriva en liten hjälpare för att skapa en dnode som låter dig skicka ett API-objekt till utils.

API-objekten kommer nu att se ut så här:

export class SignerApp {

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

...

}

Få ett objekt från fjärrkontrollen så här:

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

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

Och att anropa funktioner returnerar ett löfte:

Att skriva ett säkert webbläsartillägg

Version med asynkrona funktioner tillgängliga här.

Sammantaget verkar RPC- och stream-metoden ganska flexibel: vi kan använda steam-multiplexering och skapa flera olika API:er för olika uppgifter. I princip kan dnode användas var som helst, huvudsaken är att linda in transporten i form av en nodejs-ström.

Ett alternativ är JSON-formatet, som implementerar protokollet JSON RPC 2. Det fungerar dock med specifika transporter (TCP och HTTP(S)), vilket inte är tillämpligt i vårt fall.

Intern statlig och lokal lagring

Vi kommer att behöva lagra applikationens interna tillstånd - åtminstone signeringsnycklarna. Vi kan ganska enkelt lägga till ett tillstånd till applikationen och metoder för att ändra det i popup-API:et:

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 bakgrunden kommer vi att slå in allt i en funktion och skriva applikationsobjektet till fönstret så att vi kan arbeta med det från konsolen:

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

Låt oss lägga till några nycklar från UI-konsolen och se vad som hände med tillståndet:

Att skriva ett säkert webbläsartillägg

Tillståndet måste göras beständigt så att nycklarna inte går förlorade vid omstart.

Vi kommer att lagra det i localStorage och skriva över det vid varje ändring. Därefter kommer åtkomst till det också att vara nödvändigt för UI, och jag skulle också vilja prenumerera på ändringar. Baserat på detta kommer det att vara bekvämt att skapa en observerbar lagring och prenumerera på dess ändringar.

Vi kommer att använda mobx-biblioteket (https://github.com/mobxjs/mobx). Valet föll på det eftersom jag inte behövde jobba med det, men jag ville verkligen studera det.

Låt oss lägga till initiering av initialtillståndet och göra lagret observerbart:

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 huven" har mobx ersatt alla butiksfält med proxy och avlyssnar alla samtal till dem. Det kommer att vara möjligt att prenumerera på dessa meddelanden.

Nedan kommer jag ofta att använda termen "när man byter", även om detta inte är helt korrekt. Mobx spårar åtkomst till fält. Getters och sättare av proxyobjekt som biblioteket skapar används.

Actiondekoratörer har två syften:

  1. I strikt läge med enforceActions-flaggan förbjuder mobx att ändra tillstånd direkt. Det anses vara god praxis att arbeta under strikta villkor.
  2. Även om en funktion ändrar tillstånd flera gånger - till exempel ändrar vi flera fält i flera kodrader - meddelas observatörerna först när den är klar. Detta är särskilt viktigt för frontend, där onödiga tillståndsuppdateringar leder till onödig rendering av element. I vårt fall är varken den första eller den andra särskilt relevant, men vi kommer att följa bästa praxis. Det är vanligt att fästa dekoratörer till alla funktioner som ändrar tillståndet för de observerade fälten.

I bakgrunden kommer vi att lägga till initialisering och spara tillståndet 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 är intressant här. Den har två argument:

  1. Dataväljare.
  2. En hanterare som kommer att anropas med denna data varje gång den ändras.

Till skillnad från redux, där vi uttryckligen tar emot tillståndet som ett argument, kommer mobx ihåg vilka observerbara objekt vi kommer åt inuti väljaren, och anropar bara hanteraren när de ändras.

Det är viktigt att förstå exakt hur mobx bestämmer vilka observerbara objekt vi prenumererar på. Om jag skrev en väljare i kod som denna() => app.store, då kommer reaktion aldrig att kallas, eftersom själva lagringen inte är observerbar, bara dess fält är det.

Om jag skrev det så här () => app.store.keys, då skulle ingenting hända, eftersom referensen till den inte kommer att ändras när man lägger till/tar bort arrayelement.

Mobx fungerar som en väljare för första gången och håller bara reda på observerbara objekt som vi har tillgång till. Detta görs genom proxy-getters. Därför används den inbyggda funktionen här toJS. Den returnerar ett nytt objekt med alla proxyservrar ersatta med de ursprungliga fälten. Under exekvering läser den alla fält i objektet - därför utlöses gettarna.

I popup-konsolen kommer vi återigen att lägga till flera nycklar. Den här gången hamnade de även i localStorage:

Att skriva ett säkert webbläsartillägg

När bakgrundssidan laddas om förblir informationen på plats.

All applikationskod fram till denna punkt kan ses här.

Säker förvaring av privata nycklar

Att lagra privata nycklar i klartext är osäkert: det finns alltid en chans att du blir hackad, får tillgång till din dator och så vidare. Därför kommer vi i localStorage att lagra nycklarna i en lösenordskrypterad form.

För ökad säkerhet kommer vi att lägga till ett låst läge i applikationen, där det inte kommer att finnas någon åtkomst till nycklarna alls. Vi kommer automatiskt att överföra anknytningen till låst läge på grund av en timeout.

Mobx låter dig lagra endast en minimal uppsättning data, och resten beräknas automatiskt baserat på det. Dessa är de så kallade beräknade egenskaperna. De kan jämföras med vyer 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 lagrar vi bara de krypterade nycklarna och lösenordet. Allt annat är beräknat. Vi gör överföringen till ett låst läge genom att ta bort lösenordet från tillståndet. Det offentliga API:et har nu en metod för att initiera lagringen.

Skrivet för kryptering verktyg som använder 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)
}

Webbläsaren har ett inaktivt API genom vilket du kan prenumerera på en händelse - tillståndsändringar. Staten kan följaktligen vara idle, active и locked. För inaktiv kan du ställa in en timeout, och låst ställs in när själva operativsystemet är blockerat. Vi kommer också att ändra väljaren för att spara till 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öre detta steg är här.

Transaktioner

Så vi kommer till det viktigaste: att skapa och signera transaktioner på blockchain. Vi kommer att använda WAVES blockchain och bibliotek vågtransaktioner.

Låt oss först lägga till en rad meddelanden som måste signeras till tillståndet och sedan lägga till metoder för att lägga till ett nytt meddelande, bekräfta signaturen och vägra:

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 får ett nytt meddelande lägger vi till metadata till det, gör det observable och lägg till store.messages.

Om du inte gör det observable manuellt, då kommer mobx att göra det själv när du lägger till meddelanden till arrayen. Det kommer dock att skapa ett nytt objekt som vi inte kommer att ha en referens till, men vi kommer att behöva det för nästa steg.

Därefter returnerar vi ett löfte som löser sig när meddelandestatusen ändras. Statusen övervakas av reaktion, som kommer att "döda sig själv" när status ändras.

Metodkod approve и reject mycket enkelt: vi ändrar helt enkelt meddelandets status, efter att ha undertecknat det vid behov.

Vi lägger in Godkänn och avvisa i UI API, newMessage i sidans 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)
        }
    }

    ...
}

Låt oss nu försöka signera transaktionen med tillägget:

Att skriva ett säkert webbläsartillägg

I allmänhet är allt klart, allt som återstår är lägg till enkelt användargränssnitt.

UI

Gränssnittet behöver åtkomst till applikationsläget. På UI-sidan kommer vi att göra observable tillstånd och lägg till en funktion i API:t som kommer att ändra detta tillstånd. Låt oss lägga till observable till API-objektet mottaget från bakgrunden:

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

I slutet börjar vi rendera applikationsgränssnittet. Detta är en reaktionsapplikation. Bakgrundsobjektet skickas helt enkelt med hjälp av rekvisita. Det skulle naturligtvis vara korrekt att göra en separat tjänst för metoder och en butik för staten, men för denna artikels syften räcker detta:

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 är det väldigt enkelt att börja rendera när data ändras. Vi hänger helt enkelt observatörsdekoratören från paketet mobx-reagera på komponenten, och renderingen kommer att anropas automatiskt när några observerbara objekt som komponenten refererar till ändras. Du behöver ingen mapStateToProps eller ansluta som i redux. Allt fungerar direkt ur lådan:

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 återstående komponenterna kan ses i koden i UI-mappen.

Nu i applikationsklassen måste du göra en tillståndsväljare för användargränssnittet och meddela användargränssnittet när det ändras. För att göra detta, låt oss lägga till en metod getState и reactionkallelse 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 tar emot ett föremål remote är skapad reaction för att ändra tillståndet som anropar funktionen på UI-sidan.

Den sista touchen är att lägga till visningen av nya meddelanden på tilläggsikonen:

function setupApp() {
...

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

...
}

Så, ansökan är klar. Webbsidor kan begära en signatur för transaktioner:

Att skriva ett säkert webbläsartillägg

Att skriva ett säkert webbläsartillägg

Koden finns här länk.

Slutsats

Om du har läst artikeln till slutet, men fortfarande har frågor kan du ställa dem på arkiv med förlängning. Där hittar du också commits för varje utsett steg.

Och om du är intresserad av att titta på koden för själva tillägget kan du hitta denna här.

Kod, förråd och arbetsbeskrivning från siemarell

Källa: will.com

Lägg en kommentar