Skryf 'n veilige blaaieruitbreiding

Skryf 'n veilige blaaieruitbreiding

Anders as die algemene "kliënt-bediener" argitektuur, word gedesentraliseerde toepassings gekenmerk deur:

  • Dit is nie nodig om 'n databasis met gebruikersname en wagwoorde te stoor nie. Toegangsinligting word uitsluitlik deur die gebruikers self gestoor, en hul geldigheid word op protokolvlak bevestig.
  • Nie nodig om 'n bediener te gebruik nie. Die toepassingslogika kan in die blokkettingnetwerk uitgevoer word, waar dit moontlik is om die vereiste hoeveelheid data te stoor.

Daar is 2 relatief veilige bergings vir gebruikerssleutels - hardeware-beursies en blaaieruitbreidings. Terwyl hardeware-beursies meestal veilig is, maar moeilik om te gebruik en ver van gratis is, is blaaieruitbreidings die perfekte kombinasie van sekuriteit en gebruiksgemak, en kan dit heeltemal gratis wees vir eindgebruikers.

Gegewe dit alles, wou ons die veiligste uitbreiding maak wat die ontwikkeling van gedesentraliseerde toepassings vergemaklik deur 'n eenvoudige API te verskaf om met transaksies en handtekeninge te werk.
Ons sal jou hieronder vertel van hierdie ervaring.

Die artikel sal stap-vir-stap instruksies verskaf oor hoe om 'n blaaieruitbreiding te skryf, met kodevoorbeelde en skermkiekies. Jy kan al die kode vind in bewaarplekke. Elke commit stem logies ooreen met 'n afdeling van hierdie artikel.

'n Kort geskiedenis van blaaieruitbreidings

Blaaieruitbreidings bestaan ​​al lank. In Internet Explorer het hulle in 1999 verskyn, in Firefox - in 2004. Daar was egter vir 'n baie lang tyd geen enkele standaard vir uitbreidings nie.

Ons kan sê dat dit saam met uitbreidings in die vierde weergawe van Google Chrome verskyn het. Natuurlik was daar toe geen spesifikasie nie, maar dit was die Chrome API wat die basis daarvan geword het: nadat hy 'n groot deel van die blaaiermark gewen het en 'n ingeboude toepassingswinkel gehad het, het Chrome eintlik die standaard vir blaaieruitbreidings gestel.

Mozilla het sy eie standaard gehad, maar aangesien die gewildheid van Chrome-uitbreidings gesien word, het die maatskappy besluit om 'n versoenbare API te maak. In 2015, op inisiatief van Mozilla, is 'n spesiale groep binne die World Wide Web Consortium (W3C) geskep om aan spesifikasies vir kruisblaaier-uitbreidings te werk.

Die bestaande Chrome-uitbreidings-API is as basis geneem. Die werk is uitgevoer met die ondersteuning van Microsoft (Google het geweier om deel te neem aan die ontwikkeling van die standaard), en gevolglik het 'n konsep verskyn. spesifikasies.

Formeel word die spesifikasie ondersteun deur Edge, Firefox en Opera (let daarop dat Chrome nie by hierdie lys ingesluit is nie). Maar in werklikheid is die standaard grootliks versoenbaar met Chrome, aangesien dit eintlik geskryf is op grond van sy uitbreidings. Jy kan meer lees oor die WebExtensions API hier.

Uitbreidingstruktuur

Die enigste lêer wat vir die uitbreiding benodig word, is die manifes (manifest.json). Dit is ook die "toegangspunt" tot die uitbreiding.

manifes

Volgens spesifikasie is die manifeslêer 'n geldige JSON-lêer. 'n Volledige beskrywing van die manifessleutels met inligting oor watter sleutels ondersteun word in watter blaaier bekyk kan word hier.

Sleutels wat nie in die spesifikasie is nie "kan" geïgnoreer word (beide Chrome en Firefox rapporteer foute, maar uitbreidings werk steeds).

En ek wil graag die aandag op 'n paar punte vestig.

  1. agtergrond — 'n voorwerp wat die volgende velde insluit:
    1. skrifte - 'n verskeidenheid skrifte wat in die agtergrondkonteks uitgevoer sal word (ons sal 'n bietjie later hieroor praat);
    2. bladsy - in plaas van skrifte wat in 'n leë bladsy uitgevoer sal word, kan jy html met inhoud instel. In hierdie geval sal die skrifveld geïgnoreer word, en die skrifte sal in die inhoudbladsy ingevoeg moet word;
    3. aanhoudende - Binêre vlag, indien nie gespesifiseer nie, sal die blaaier die agtergrondproses "doodmaak" as dit van mening is dat dit niks doen nie, en herbegin indien nodig. Andersins sal die bladsy eers afgelaai word wanneer die blaaier gesluit is. Word nie in Firefox ondersteun nie.
  2. inhoud_skrifte - 'n verskeidenheid van voorwerpe wat jou toelaat om verskillende skrifte na verskillende webblaaie te laai. Elke voorwerp bevat die volgende belangrike velde:
    1. wedstryde - url patroon, wat bepaal of 'n spesifieke inhoudskrip ingesluit sal word of nie.
    2. js — 'n lys skrifte wat in hierdie wedstryd gelaai sal word;
    3. sluit_passings uit - sluit van die veld uit match URL'e wat by hierdie veld pas.
  3. bladsy_aksie - is eintlik 'n voorwerp wat verantwoordelik is vir die ikoon wat langs die adresbalk in die blaaier vertoon word, en interaksie daarmee. Laat jou ook toe om 'n opspringvenster te wys wat met jou HTML, CSS en JS gestel is.
    1. verstek_opspringer - pad na HTML-lêer met opspring-koppelvlak, kan CSS en JS bevat.
  4. regte - 'n skikking vir die bestuur van uitbreidingsregte. Daar is 3 tipes regte, wat in detail beskryf word hier
  5. web_toeganklike_hulpbronne - uitbreidingsbronne wat 'n webblad kan aanvra, byvoorbeeld beelde, JS, CSS, HTML-lêers.
  6. ekstern_koppelbaar - hier kan u die ID's van ander uitbreidings en webbladdomeine uitdruklik spesifiseer waaruit u kan koppel. Die domein kan van die tweede vlak en hoër wees. Werk nie in Firefox nie.

Uitvoering konteks

Die uitbreiding het drie kode uitvoering kontekste, dit wil sê, die toepassing bestaan ​​uit drie dele met verskillende vlakke van toegang tot die blaaier API.

uitbreiding konteks

Die meeste van die API is hier beskikbaar. In hierdie konteks, "lewendig":

  1. agtergrond bladsy - "backend" deel van die uitbreiding. Die lêer word in die manifes gespesifiseer deur die "agtergrond" sleutel.
  2. Opspring bladsy - opspringbladsy wat verskyn wanneer u op die uitbreidingsikoon klik. In die manifes browser_action -> default_popup.
  3. Pasgemaakte bladsy - uitbreiding bladsy, "leef" in 'n aparte oortjie van die aansig chrome-extension://<id_расширения>/customPage.html.

Hierdie konteks bestaan ​​onafhanklik van blaaiervensters en oortjies. agtergrond bladsy bestaan ​​in 'n enkele geval en werk altyd (die uitsondering is die gebeurtenisbladsy, wanneer die agtergrondskrif deur 'n gebeurtenis geaktiveer word en "sterf" nadat dit uitgevoer is). Opspring bladsy bestaan ​​wanneer die opspringvenster oop is, en Pasgemaakte bladsy - terwyl die oortjie daarmee oop is. Daar is geen toegang tot ander oortjies en hul inhoud vanuit hierdie konteks nie.

Inhoud skrif konteks

Die inhoudskriplêer word saam met elke blaaieroortjie geloods. Dit het toegang tot 'n deel van die uitbreiding se API en tot die DOM-boom van die webblad. Dit is die inhoudskrifte wat verantwoordelik is vir interaksie met die bladsy. Uitbreidings wat die DOM-boom manipuleer, doen dit in inhoudskrifte, soos advertensieblokkeerders of vertalers. Die inhoudskrip kan ook deur die standaard met die bladsy kommunikeer postMessage.

Webbladkonteks

Dit is die werklike webblad self. Dit het niks met die uitbreiding te doen nie en het geen toegang daar nie, behalwe wanneer die domein van hierdie bladsy nie uitdruklik in die manifes gespesifiseer word nie (meer daaroor hieronder).

Boodskap uitruil

Verskillende dele van die toepassing moet boodskappe met mekaar uitruil. Daar is 'n API hiervoor. runtime.sendMessage om 'n boodskap te stuur background и tabs.sendMessage om 'n boodskap na 'n bladsy te stuur (inhoudskrif, opspring of webblad indien beskikbaar) externally_connectable). Hieronder is 'n voorbeeld wanneer toegang tot die Chrome API verkry word.

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

Vir volledige kommunikasie kan jy verbindings skep deur runtime.connect. In reaksie sal ons ontvang runtime.Port, waarheen jy, terwyl dit oop is, enige aantal boodskappe kan stuur. Aan die kliëntekant, bv. contentscript, dit lyk so:

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

Bediener of agtergrond:

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

Daar is ook 'n geleentheid onDisconnect en metode disconnect.

Toepassingskema

Kom ons maak 'n blaaieruitbreiding wat privaat sleutels stoor, toegang tot publieke inligting bied (adres, publieke sleutel kommunikeer met die bladsy en laat derdeparty-toepassings toe om 'n transaksiehandtekening aan te vra.

Toepassingsontwikkeling

Ons toepassing moet beide interaksie met die gebruiker hê en 'n API-bladsy verskaf vir oproepmetodes (byvoorbeeld vir die ondertekening van transaksies). Kom oor die weg met net een contentscript sal misluk, aangesien dit net toegang tot die DOM het, nie tot die JS van die bladsy nie. Koppel via runtime.connect ons kan nie, want die API is nodig op alle domeine, en slegs spesifiekes kan in die manifes gespesifiseer word. As gevolg hiervan sal die skema soos volg lyk:

Skryf 'n veilige blaaieruitbreiding

Daar sal nog 'n draaiboek wees - inpage, wat ons in die bladsy sal inspuit. Dit sal in sy konteks loop en 'n API bied om met die uitbreiding te werk.

Begin

Alle blaaieruitbreidingskode is beskikbaar by GitHub. In die beskrywingsproses sal daar skakels na commits wees.

Kom ons begin met die manifes:

{
  // Имя и описание, версия. Все это будет видно в браузере в 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"]
}

Ons skep leë agtergrond.js, popup.js, inpage.js en contentscript.js. Ons voeg popup.html by - en ons toepassing kan reeds in Google Chrome gelaai word en maak seker dat dit werk.

Om dit te verifieer, kan jy die kode neem vandaar. Benewens wat ons gedoen het, is die skakel gekonfigureer om die projek met behulp van webpack te bou. Om die toepassing by die blaaier te voeg, in chrome://extensions, moet u laai uitgepak kies en 'n gids met die toepaslike uitbreiding - in ons geval, dist.

Skryf 'n veilige blaaieruitbreiding

Nou is ons uitbreiding geïnstalleer en werk. U kan ontwikkelaarnutsgoed vir verskillende kontekste soos volg begin:

pop-up ->

Skryf 'n veilige blaaieruitbreiding

Toegang tot die konsole van die inhoudskrip word uitgevoer deur die konsole van die bladsy self, waarop dit bekendgestel word.Skryf 'n veilige blaaieruitbreiding

Boodskap uitruil

Dus, ons moet twee kommunikasiekanale opstel: inpage <-> agtergrond en pop-up <-> agtergrond. Jy kan natuurlik net boodskappe na die hawe stuur en jou eie protokol uitvind, maar ek verkies die benadering wat ek op die metamask oopbronprojek gespioeneer het.

Dit is 'n blaaieruitbreiding om met die Ethereum-netwerk te werk. Daarin kommunikeer verskillende dele van die toepassing via RPC met behulp van die dnode-biblioteek. Dit laat jou toe om die uitruil vinnig en gerieflik te organiseer as jy dit voorsien van 'n nodejs-stroom as 'n vervoer (wat beteken 'n voorwerp wat dieselfde koppelvlak implementeer):

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

Nou sal ons 'n toepassingsklas skep. Dit sal die API-voorwerpe vir die pop-up en die webblad skep, en 'n dnode vir hulle skep:

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

Hierna, in plaas van die globale Chrome-objek, gebruik ons ​​die extensionApi, wat verwys na Chrome in die blaaier van Google en na die blaaier in ander. Dit word gedoen vir kruisblaaierversoenbaarheid, maar binne die raamwerk van hierdie artikel kan 'n mens eenvoudig 'chrome.runtime.connect' gebruik.

Kom ons skep 'n toepassingsinstansie in die agtergrondskrif:

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

Aangesien dnode met strome werk, en ons 'n poort kry, is 'n adapterklas nodig. Dit word gemaak met behulp van die leesbare stroom-biblioteek, wat nodejs-strome in die blaaier implementeer:

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

Nou skep ons 'n verbinding in die UI:

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

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

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в 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;
    }
}

Ons skep dan die verbinding in die inhoudskrip:

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

Aangesien ons die API nie in die inhoudskrip nodig het nie, maar direk op die bladsy, doen ons twee dinge:

  1. Ons skep twee strome. Een is na die bladsy, bo-op postMessage. Hiervoor gebruik ons ​​hier hierdie pakket van die skeppers van metamask. Die tweede stroom is na agtergrond oor die poort wat van ontvang is runtime.connect. Ons pyp hulle. Die bladsy sal nou na die agtergrond stroom.
  2. Spuit die skrif in die DOM in. Laai die skrif af (toegang daartoe is in die manifes toegelaat) en skep 'n merker script met sy inhoud binne:

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

Nou skep ons 'n api-objek in inpage en begin dit globaal:

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

Ons is gereed Remote Procedure Call (RPC) met aparte API vir bladsy en UI. Wanneer 'n nuwe bladsy aan die agtergrond gekoppel word, kan ons dit sien:

Skryf 'n veilige blaaieruitbreiding

Leë API en oorsprong. Aan die bladsykant kan ons die hallo-funksie soos volg noem:

Skryf 'n veilige blaaieruitbreiding

Om met terugbelfunksies in moderne JS te werk is slegte maniere, so kom ons skryf 'n klein helper om 'n dnode te skep wat jou toelaat om dit na die API-voorwerp in utils te stuur.

Die API-voorwerpe sal nou soos volg lyk:

export class SignerApp {

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

...

}

Kry 'n voorwerp van afstandbeheer soos hierdie:

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

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

En die funksie oproep gee 'n belofte terug:

Skryf 'n veilige blaaieruitbreiding

Weergawe met asynchrone funksies beskikbaar hier.

Oor die algemeen blyk die benadering met RPC en streaming redelik buigsaam te wees: ons kan stoommultipleksing gebruik en verskeie verskillende API's vir verskillende take skep. In beginsel kan dnode oral gebruik word, die belangrikste ding is om die vervoer in die vorm van 'n nodejs-stroom te draai.

'n Alternatief is die JSON-formaat, wat die JSON RPC 2-protokol implementeer. Dit werk egter met spesifieke vervoer (TCP en HTTP(S)), wat nie in ons geval van toepassing is nie.

Interne staat en plaaslike berging

Ons sal die interne toestand van die toepassing moet stoor - ten minste die sleutels vir ondertekening. Ons kan maklik 'n toestand by die toepassing voeg en metodes om dit in die opspring-API te verander:

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

    ...

} 

Op die agtergrond draai ons alles in 'n funksie en skryf die toepassingsvoorwerp na venster sodat ons daarmee vanaf die konsole kan werk:

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

Kom ons voeg 'n paar sleutels van die UI-konsole by en kyk wat gebeur met die toestand:

Skryf 'n veilige blaaieruitbreiding

Die toestand moet aanhoudend gemaak word sodat die sleutels nie verlore gaan met herbegin nie.

Ons sal in localStorage stoor en met elke verandering oorskryf. Daarna sal toegang daartoe ook nodig wees vir die UI, en ek wil ook inteken op veranderinge. Op grond hiervan sal dit gerieflik wees om waarneembare berging te maak en in te teken op die veranderinge daarvan.

Ons sal die mobx-biblioteek (https://github.com/mobxjs/mobx). Die keuse het op haar geval, want ek hoef nie saam met haar te werk nie, maar ek wou haar baie graag studeer.

Kom ons voeg die inisialisering van die aanvanklike toestand by en maak die winkel waarneembaar:

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

    ...

}

"Onder die kap" mobx het alle winkelvelde met proxy vervang en alle oproepe na hulle onderskep. Jy kan op hierdie boodskappe inteken.

In die volgende sal ek dikwels die term “op verandering” gebruik, hoewel dit nie heeltemal korrek is nie. Mobx spoor toegang tot velde na. Die getters en stelers van die proxy-objekte wat die biblioteek skep, word gebruik.

Die aksieversierders dien twee doeleindes:

  1. In streng modus met die enforceActions-vlag, verbied mobx om die staat direk te verander. Dit word as 'n goeie vorm beskou om in streng modus te werk.
  2. Selfs as 'n funksie verskeie kere van toestand verander - byvoorbeeld, ons verander verskeie velde in veelvuldige reëls kode - word waarnemers eers in kennis gestel wanneer dit voltooi is. Dit is veral belangrik vir die frontend, waar ekstra toestandopdaterings lei tot onnodige weergawe van elemente. In ons geval is nie die eerste nóg die tweede besonder relevant nie, maar ons sal die beste praktyke volg. Dit is gebruiklik om versierders op alle funksies te hang wat die toestand van waarneembare velde verander.

Voeg in die agtergrond inisialisering by en stoor die staat in 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)
        }
    }
}

Die reaksiefunksie is hier interessant. Dit het twee argumente:

  1. Data keurder.
  2. 'n Hanteerder wat met hierdie data geroep sal word elke keer as dit verander.

Anders as redux, waar ons die staat uitdruklik as 'n argument ontvang, onthou mobx presies watter waarneembare items ons binne die kieser verkry, en slegs wanneer hulle verander, roep dit die hanteerder.

Dit is belangrik om te verstaan ​​hoe mobx besluit op watter waarneembare items ons inteken. As in kode het ek 'n selector soos hierdie geskryf() => app.store, dan sal die reaksie nooit genoem word nie, aangesien die stoor self nie waarneembaar is nie, net sy velde is.

As ek so geskryf het () => app.store.keys, dan sal niks weer gebeur nie, aangesien die verwysing daarna nie sal verander wanneer skikkingselemente bygevoeg of verwyder word nie.

Mobx voer die kieserfunksie vir die eerste keer uit en hou net tred met die waarneembare items wat ons verkry het. Dit word gedoen deur proxy-getters. So hier is die ingeboude funksie toJS. Dit gee 'n nuwe voorwerp terug met alle gevolmagtigdes vervang met die oorspronklike velde. Tydens uitvoering lees dit al die velde van die voorwerp - daarom werk die getters.

Kom ons voeg 'n paar sleutels weer in die opspringkonsole by. Hierdie keer het hulle ook in localStorage beland:

Skryf 'n veilige blaaieruitbreiding

Wanneer die agtergrondbladsy herlaai word, bly die inligting in plek.

Alle toepassingskodes tot op hierdie punt kan bekyk word hier.

Veilige berging van privaat sleutels

Om privaat sleutels oop te hou is nie veilig nie: daar is altyd die moontlikheid dat jy gehack sal word, toegang tot jou rekenaar sal kry, ensovoorts. Daarom, in localStorage sal ons die sleutels in 'n wagwoord-geïnkripteer vorm stoor.

Vir groter sekuriteit sal ons 'n geslote toestand by die toepassing voeg, waarin daar glad nie toegang tot die sleutels sal wees nie. Ons sal die uitbreiding outomaties na die geslote toestand oordra teen uitteltyd.

Mobx laat jou toe om slegs 'n minimale stel data te stoor, en die res word outomaties op grond daarvan bereken. Dit is die sogenaamde berekende eienskappe. Hulle kan vergelyk word met aansigte in databasisse:

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

Nou stoor ons slegs geënkripteerde sleutels en wagwoord. Alles anders word bereken. Ons doen die oordrag na die geslote toestand deur die wagwoord uit die staat te verwyder. Die publieke API het 'n metode om die winkel te inisialiseer.

Geskryf vir enkripsie nutsprogramme wat crypto-js gebruik:

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

Die blaaier het 'n ledige API waardeur jy kan inteken op 'n gebeurtenis - toestandveranderinge. Staat, onderskeidelik, kan wees idle, active и locked. Vir ledig, kan jy 'n uitteltyd stel, en gesluit word gestel wanneer die bedryfstelsel self gesluit is. Ons sal ook die kieser vir berging in localStorage verander:

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

Die kode tot by hierdie stap is hier.

Transaksies

So, ons kom by die belangrikste ding: die skepping en ondertekening van transaksies in die blokketting. Ons sal die WAVES blockchain en die biblioteek gebruik golwe-transaksies.

Laat ons eers 'n reeks boodskappe by die staat voeg wat onderteken moet word, dan - metodes om 'n nuwe boodskap by te voeg, die handtekening te bevestig en te weier:

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

    ...
}

Wanneer 'n nuwe boodskap ontvang word, voeg ons metadata daarby, doen observable en voeg by store.messages.

Indien nie gedoen nie observable handmatig, dan sal mobx dit self doen wanneer dit by die boodskapskikking gevoeg word. Dit sal egter 'n nuwe voorwerp skep waarna ons nie 'n verwysing sal hê nie, maar ons sal dit nodig hê vir die volgende stap.

Vervolgens gee ons 'n belofte terug wat opgelos word wanneer die boodskapstatus verander. Die status word gemonitor deur 'n reaksie wat homself sal "doodmaak" wanneer die status verander.

Metode kode approve и reject is baie eenvoudig: ons verander net die status van die boodskap, nadat ons dit voorheen onderteken het, indien nodig.

Keur goed en verwerp ons in die UI API, newMessage - in die bladsy 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)
        }
    }

    ...
}

Kom ons probeer nou om die transaksie met die uitbreiding te onderteken:

Skryf 'n veilige blaaieruitbreiding

Oor die algemeen is alles gereed, dit bly voeg 'n eenvoudige UI by.

UI

Die koppelvlak benodig toegang tot die toepassingstatus. Aan die UI-kant sal ons doen observable staat en voeg 'n funksie by die API wat hierdie toestand sal verander. Kom ons voeg by observable na die API-voorwerp wat vanaf agtergrond ontvang is:

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

Aan die einde begin ons die toepassingskoppelvlak weergee. Dit is 'n reaksie-toepassing. Die agtergrondvoorwerp word eenvoudig geslaag met behulp van rekwisiete. Dit is natuurlik korrek om 'n aparte diens vir metodes en 'n winkel vir die staat te maak, maar dit is genoeg vir hierdie artikel:

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

Met mobx is dit baie maklik om 'n weergawe te aktiveer wanneer die data verander. Ons hang net die waarnemer versierder uit die pakkie mobx-reageer op die komponent, en weergee sal outomaties opgeroep word wanneer enige waarneembares wat deur die komponent verwys word verander. Geen behoefte aan enige mapStateToProps of verbind soos in redux nie. Alles werk reg uit die boks:

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

Die res van die komponente kan in die kode gesien word in ui-lêergids.

Nou in die toepassingsklas moet jy 'n staatkieser vir die UI maak en die UI in kennis stel wanneer dit verander. Om dit te doen, voeg 'n metode by getState и reactionroeping 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())

        })
    }

    ...
}

Wanneer 'n voorwerp ontvang word remote is geskep reaction om die toestand te verander wat die funksie aan die UI-kant oproep.

Die laaste aanraking is om die vertoning van nuwe boodskappe op die uitbreidingsikoon by te voeg:

function setupApp() {
...

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

...
}

So, die aansoek is gereed. Webbladsye kan versoek om transaksies te onderteken:

Skryf 'n veilige blaaieruitbreiding

Skryf 'n veilige blaaieruitbreiding

Die kode is hier beskikbaar skakel.

Gevolgtrekking

As jy die artikel tot die einde gelees het, maar jy het nog vrae, kan jy dit invra bewaarplekke met uitbreiding. Op dieselfde plek sal jy commits vind onder elke aangewese stap.

En as jy belangstel om die kode vir die regte uitbreiding te sien, kan jy dit vind hier.

Kode, bewaarplek en posbeskrywing van siemarell

Bron: will.com

Voeg 'n opmerking