Að skrifa örugga vafraviðbót

Að skrifa örugga vafraviðbót

Ólíkt venjulegum „viðskiptavinaþjónn“ arkitektúr, einkennast dreifð forrit af:

  • Það er engin þörf á að geyma gagnagrunn með notendaskráningum og lykilorðum. Aðgangsupplýsingar eru eingöngu geymdar af notendum sjálfum og staðfesting á áreiðanleika þeirra á sér stað á samskiptareglum.
  • Engin þörf á að nota netþjón. Hægt er að framkvæma forritunarrökfræðina á blockchain neti, þar sem hægt er að geyma nauðsynlegt magn af gögnum.

Það eru 2 tiltölulega öruggar geymslur fyrir notendalykla - vélbúnaðarveski og vafraviðbætur. Vélbúnaðarveski eru að mestu einstaklega örugg, en erfið í notkun og langt frá því að vera ókeypis, en vafraviðbætur eru hin fullkomna blanda af öryggi og þægilegri notkun og geta líka verið algjörlega ókeypis fyrir notendur.

Að teknu tilliti til alls þessa vildum við búa til öruggustu viðbótina sem einfaldar þróun dreifðra forrita með því að bjóða upp á einfalt API til að vinna með viðskipti og undirskriftir.
Við munum segja þér frá þessari reynslu hér að neðan.

Greinin mun innihalda skref-fyrir-skref leiðbeiningar um hvernig á að skrifa vafraviðbót, með kóðadæmum og skjámyndum. Þú getur fundið allan kóðann í geymslum. Hver skuldbinding samsvarar rökrétt hluta þessarar greinar.

Stutt saga um vafraviðbætur

Vafraviðbætur hafa verið til í langan tíma. Þeir birtust í Internet Explorer aftur árið 1999, í Firefox árið 2004. Hins vegar var í mjög langan tíma enginn einn staðall fyrir framlengingar.

Við getum sagt að það birtist ásamt viðbótum í fjórðu útgáfunni af Google Chrome. Auðvitað var engin forskrift þá, en það var Chrome API sem varð grundvöllur þess: Eftir að hafa sigrað stærstan hluta vaframarkaðarins og með innbyggða forritaverslun setti Chrome í raun staðalinn fyrir vafraviðbætur.

Mozilla var með sinn eigin staðal, en eftir að hafa séð vinsældir Chrome viðbótanna ákvað fyrirtækið að búa til samhæft API. Árið 2015, að frumkvæði Mozilla, var stofnaður sérstakur hópur innan World Wide Web Consortium (W3C) til að vinna að forskriftum um viðbót fyrir vafra.

Fyrirliggjandi API viðbætur fyrir Chrome voru teknar til grundvallar. Verkið var unnið með stuðningi Microsoft (Google neitaði að taka þátt í þróun staðalsins) og í kjölfarið birtust drög forskrift.

Formlega er forskriftin studd af Edge, Firefox og Opera (athugið að Chrome er ekki á þessum lista). En í raun er staðallinn að mestu samhæfður Chrome, þar sem hann er í raun skrifaður út frá viðbótum hans. Þú getur lesið meira um WebExtensions API hér.

Viðbyggingarbygging

Eina skráin sem þarf fyrir viðbótina er upplýsingaskráin (manifest.json). Það er líka „innkomustaðurinn“ að stækkuninni.

Manifesto

Samkvæmt forskriftinni er upplýsingaskráin gild JSON skrá. Full lýsing á upplýsingalyklum með upplýsingum um hvaða lyklar eru studdir í hvaða vafra er hægt að skoða hér.

Lykla sem eru ekki í forskriftinni „má“ vera hunsuð (bæði Chrome og Firefox tilkynna um villur, en viðbæturnar halda áfram að virka).

Og ég vil vekja athygli á nokkrum atriðum.

  1. bakgrunnur — hlutur sem inniheldur eftirfarandi reiti:
    1. forskriftir — fjölda handrita sem verða keyrð í bakgrunnssamhengi (við munum tala um þetta aðeins síðar);
    2. síðu - í stað skrifta sem verða keyrð á tómri síðu geturðu tilgreint html með innihaldi. Í þessu tilviki verður forskriftareiturinn hunsaður og forskriftirnar verða að vera settar inn á innihaldssíðuna;
    3. hverfa — tvöfaldur fáni, ef ekki er tilgreint, mun vafrinn „drepa“ bakgrunnsferlið þegar hann telur að það sé ekki að gera neitt og endurræsa það ef þörf krefur. Að öðrum kosti verður síðan aðeins afhlaðað þegar vafrinn er lokaður. Ekki stutt í Firefox.
  2. content_scripts — úrval af hlutum sem gerir þér kleift að hlaða mismunandi skriftum á mismunandi vefsíður. Hver hlutur inniheldur eftirfarandi mikilvæga reiti:
    1. eldspýtur - mynstur slóð, sem ákvarðar hvort tiltekið efnishandrit verði innifalið eða ekki.
    2. js — listi yfir forskriftir sem verða hlaðnar inn í þennan leik;
    3. útiloka_samsvörun - útilokar frá sviði match Vefslóðir sem passa við þennan reit.
  3. page_action - er í raun hlutur sem ber ábyrgð á tákninu sem birtist við hliðina á veffangastikunni í vafranum og samskiptum við það. Það gerir þér einnig kleift að birta sprettiglugga, sem er skilgreindur með eigin HTML, CSS og JS.
    1. default_popup — slóð að HTML skránni með sprettigluggaviðmótinu, gæti innihaldið CSS og JS.
  4. heimildir — fylki til að stjórna framlengingarrétti. Það eru 3 tegundir réttinda, sem lýst er ítarlega hér
  5. vef_aðgengilegar_auðlindir — viðbætur sem vefsíða getur beðið um, til dæmis myndir, JS, CSS, HTML skrár.
  6. ytra_tengjanlegt — hér getur þú sérstaklega tilgreint auðkenni annarra viðbóta og léna á vefsíðum sem þú getur tengst frá. Lén getur verið annað stig eða hærra. Virkar ekki í Firefox.

Framkvæmdarsamhengi

Viðbótin hefur þrjú samhengi til að keyra kóða, það er að forritið samanstendur af þremur hlutum með mismunandi aðgangsstigum að vafra API.

Framlengingarsamhengi

Flest af API er fáanlegt hér. Í þessu samhengi „lifa“ þeir:

  1. Bakgrunnssíða — „backend“ hluti af framlengingunni. Skráin er tilgreind í upplýsingaskránni með „bakgrunns“ lyklinum.
  2. Sprettiglugga síða - sprettiglugga sem birtist þegar þú smellir á viðbótartáknið. Í stefnuskránni browser_action -> default_popup.
  3. Sérsniðin síða - viðbyggingarsíða, „lifandi“ í sérstökum flipa á útsýninu chrome-extension://<id_расширения>/customPage.html.

Þetta samhengi er til óháð vafragluggum og flipa. Bakgrunnssíða er til í einu eintaki og virkar alltaf (undantekningin er viðburðarsíðan, þegar bakgrunnshandritið er ræst af atburði og „deyr“ eftir framkvæmd þess). Sprettiglugga síða er til þegar sprettiglugginn er opinn, og Sérsniðin síða — á meðan flipinn með honum er opinn. Það er enginn aðgangur að öðrum flipa og innihaldi þeirra úr þessu samhengi.

Samhengi efnishandrits

Innihaldsskriftarskráin er opnuð ásamt hverjum vafraflipa. Það hefur aðgang að hluta af API viðbótinni og að DOM tré vefsíðunnar. Það eru innihaldsskriftir sem bera ábyrgð á samskiptum við síðuna. Viðbætur sem vinna með DOM-tréð gera þetta í innihaldsskriftum - til dæmis auglýsingablokkarar eða þýðendur. Einnig getur innihaldshandritið átt samskipti við síðuna í gegnum staðlaða postMessage.

Samhengi vefsíðunnar

Þetta er eiginlega vefsíðan sjálf. Það hefur ekkert með viðbótina að gera og hefur ekki aðgang þar, nema í þeim tilvikum þar sem lén þessarar síðu er ekki sérstaklega tilgreint í upplýsingaskránni (nánar um þetta hér að neðan).

Skilaboðaskipti

Mismunandi hlutar forritsins verða að skiptast á skilaboðum sín á milli. Það er API fyrir þetta runtime.sendMessage að senda skilaboð background и tabs.sendMessage til að senda skilaboð á síðu (efnishandrit, sprettiglugga eða vefsíðu ef það er til staðar externally_connectable). Hér að neðan er dæmi um aðgang að 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))
    }
)

Fyrir full samskipti geturðu búið til tengingar í gegnum runtime.connect. Sem svar munum við fá runtime.Port, sem þú getur sent hvaða fjölda skeyta sem er á meðan það er opið. Á viðskiptavinamegin, td. contentscript, það lítur svona út:

// Опять же 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 eða bakgrunnur:

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

Það er líka viðburður onDisconnect og aðferð disconnect.

Umsóknarmynd

Við skulum búa til vafraviðbót sem geymir einkalykla, veitir aðgang að opinberum upplýsingum (heimilisfang, opinber lykill hefur samskipti við síðuna og gerir forritum þriðja aðila kleift að biðja um undirskrift fyrir viðskipti.

Umsókn þróun

Forritið okkar verður bæði að hafa samskipti við notandann og veita síðunni API til að hringja í aðferðir (til dæmis til að undirrita viðskipti). Láttu bara einn contentscript mun ekki virka, þar sem það hefur aðeins aðgang að DOM, en ekki að JS síðunnar. Tengstu í gegnum runtime.connect við getum það ekki, vegna þess að API er nauðsynlegt á öllum lénum og aðeins er hægt að tilgreina ákveðin í upplýsingaskránni. Fyrir vikið mun skýringarmyndin líta svona út:

Að skrifa örugga vafraviðbót

Það verður annað handrit - inpage, sem við sprautum inn á síðuna. Það mun keyra í samhengi sínu og veita API til að vinna með viðbótinni.

Byrja

Allur vafraviðbótakóði er fáanlegur á GitHub. Í lýsingunni verða tenglar á skuldbindingar.

Byrjum á stefnuskránni:

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

Búðu til tóman background.js, popup.js, inpage.js og contentscript.js. Við bætum við popup.html - og forritið okkar er nú þegar hægt að hlaða inn í Google Chrome og ganga úr skugga um að það virki.

Til að staðfesta þetta geturðu tekið kóðann þess vegna. Til viðbótar við það sem við gerðum, stillti hlekkurinn samsetningu verkefnisins með því að nota vefpakka. Til að bæta forriti við vafrann, í chrome://extensions þarftu að velja load unpacked og möppuna með samsvarandi viðbót - í okkar tilviki dist.

Að skrifa örugga vafraviðbót

Nú er viðbótin okkar sett upp og virkar. Þú getur keyrt þróunarverkfærin fyrir mismunandi samhengi eins og hér segir:

sprettiglugga ->

Að skrifa örugga vafraviðbót

Aðgangur að innihaldshandritsborðinu fer fram í gegnum stjórnborðið á síðunni sjálfri þar sem það er opnað.Að skrifa örugga vafraviðbót

Skilaboðaskipti

Þannig að við þurfum að koma á tveimur samskiptaleiðum: innsíðu <-> bakgrunnur og sprettiglugga <-> bakgrunnur. Þú getur auðvitað bara sent skilaboð til hafnarinnar og fundið upp þína eigin siðareglur, en ég vil frekar nálgunina sem ég sá í metamask open source verkefninu.

Þetta er vafraviðbót til að vinna með Ethereum netinu. Í því hafa mismunandi hlutar forritsins samskipti í gegnum RPC með því að nota dnode bókasafnið. Það gerir þér kleift að skipuleggja skipti nokkuð fljótt og þægilega ef þú gefur því nodejs straum sem flutning (sem þýðir hlut sem útfærir sama viðmót):

import Dnode from "dnode/browser";

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

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

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

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

Nú munum við búa til umsóknarflokk. Það mun búa til API hluti fyrir sprettigluggann og vefsíðuna og búa til dnode fyrir þá:

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 og að neðan, í stað hins alþjóðlega Chrome hlut, notum við extensionApi, sem opnar Chrome í vafra Google og vafra í öðrum. Þetta er gert fyrir samhæfni milli vafra, en fyrir þessa grein gæti maður einfaldlega notað 'chrome.runtime.connect'.

Við skulum búa til forritstilvik í bakgrunnshandritinu:

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

Þar sem dnode vinnur með straumum og við fáum port, þarf millistykki. Það er búið til með því að nota læsilega straumsafnið, sem útfærir nodejs strauma í vafranum:

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

Nú skulum við búa til tengingu í notendaviðmótinu:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Síðan búum við til tenginguna í innihaldsskriftinni:

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

Þar sem við þurfum API ekki í innihaldshandritinu, heldur beint á síðunni, gerum við tvennt:

  1. Við búum til tvo strauma. Einn - í átt að síðunni, ofan á færslunni Skilaboð. Til þess notum við þetta þessum pakka frá höfundum metamask. Annar straumurinn er í bakgrunni yfir gáttina sem barst frá runtime.connect. Við skulum kaupa þá. Nú mun síðan streyma í bakgrunninn.
  2. Sprautaðu handritinu inn í DOM. Sæktu handritið (aðgangur að því var leyfður í upplýsingaskránni) og búðu til merki script með innihaldi þess inni:

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

setupConnection();
injectScript();

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

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

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

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

Nú búum við til api hlut í inpage og stillum hann á global:

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ð erum tilbúin Remote Procedure Call (RPC) með aðskildum API fyrir síðu og notendaviðmót. Þegar nýja síðu er tengd við bakgrunn getum við séð þetta:

Að skrifa örugga vafraviðbót

Tómt API og uppruna. Á síðuhliðinni getum við kallað halló aðgerðina svona:

Að skrifa örugga vafraviðbót

Að vinna með svarhringingaraðgerðir í nútíma JS er slæmur siður, svo við skulum skrifa lítinn hjálpara til að búa til dnode sem gerir þér kleift að senda API hlut til utils.

API hlutirnir munu nú líta svona út:

export class SignerApp {

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

...

}

Að fá hlut frá fjarstýringu eins og þetta:

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

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

Og að kalla aðgerðir skilar loforði:

Að skrifa örugga vafraviðbót

Útgáfa með ósamstilltum aðgerðum í boði hér.

Á heildina litið virðist RPC og straumaðferðin nokkuð sveigjanleg: við getum notað gufumultiplexing og búið til nokkur mismunandi API fyrir mismunandi verkefni. Í grundvallaratriðum er hægt að nota dnode hvar sem er, aðalatriðið er að vefja flutninginn í formi nodejs straums.

Annar valkostur er JSON sniðið, sem útfærir JSON RPC 2 samskiptareglur. Hins vegar virkar það með tilteknum flutningum (TCP og HTTP(S)), sem á ekki við í okkar tilviki.

Innra ríki og staðbundið Geymsla

Við þurfum að geyma innra ástand forritsins - að minnsta kosti undirritunarlyklana. Við getum auðveldlega bætt ástandi við forritið og aðferðum til að breyta því í sprettiglugga API:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

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

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

    ...

} 

Í bakgrunni munum við vefja öllu inn í aðgerð og skrifa forritshlutinn í gluggann svo að við getum unnið með hann frá stjórnborðinu:

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

Við skulum bæta við nokkrum lyklum frá notendaborðinu og sjá hvað gerist með ástandið:

Að skrifa örugga vafraviðbót

Gera þarf ástandið viðvarandi svo lyklarnir glatist ekki við endurræsingu.

Við munum geyma það í localStorage og skrifa yfir það við hverja breytingu. Í kjölfarið verður aðgangur að honum einnig nauðsynlegur fyrir HÍ og ég vil líka gerast áskrifandi að breytingum. Út frá þessu verður þægilegt að búa til sýnilega geymslu og gerast áskrifandi að breytingum hennar.

Við munum nota mobx bókasafnið (https://github.com/mobxjs/mobx). Valið féll á það vegna þess að ég þurfti ekki að vinna með það, en mig langaði virkilega að læra það.

Við skulum bæta við frumstillingu á upphafsástandinu og gera verslunina sjáanlega:

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

    ...

}

„Undir hettunni,“ hefur mobx skipt út öllum verslunareitum fyrir umboð og hlerar öll símtöl til þeirra. Hægt verður að gerast áskrifandi að þessum skilaboðum.

Hér að neðan mun ég oft nota hugtakið „þegar breytist“, þó það sé ekki alveg rétt. Mobx rekur aðgang að reitum. Notaðir eru tökutæki og stillingar umboðshluta sem safnið býr til.

Aðgerðarskreytingar þjóna tveimur tilgangi:

  1. Í ströngum ham með enforceActions fána, bannar mobx að breyta ríkinu beint. Það þykir góð vinnubrögð að vinna við ströng skilyrði.
  2. Jafnvel þótt aðgerð breyti ástandinu nokkrum sinnum - til dæmis breytum við nokkrum reitum í nokkrum kóðalínum - fá áheyrnarfulltrúarnir aðeins tilkynningu þegar henni er lokið. Þetta er sérstaklega mikilvægt fyrir framenda, þar sem óþarfa ástandsuppfærslur leiða til óþarfa flutnings á þáttum. Í okkar tilviki er hvorki sú fyrri né sú seinni sérstaklega viðeigandi, en við munum fylgja bestu starfsvenjum. Venjulegt er að festa skreytingaraðila við allar aðgerðir sem breyta ástandi sviðanna.

Í bakgrunni munum við bæta við frumstillingu og vista ástandið í 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)
        }
    }
}

Viðbragðsfallið er áhugavert hér. Það hefur tvö rök:

  1. Gagnaval.
  2. Meðhöndlari sem verður kallaður til með þessum gögnum í hvert skipti sem þau breytast.

Ólíkt redux, þar sem við tökum beinlínis á móti ástandinu sem rök, man mobx hvaða sýnishorn við fáum aðgang að inni í veljaranum og hringir aðeins í stjórnandann þegar þeir breytast.

Það er mikilvægt að skilja nákvæmlega hvernig mobx ákveður hvaða sýnishorn við gerumst áskrifendur að. Ef ég skrifaði selector í kóða eins og þennan() => app.store, þá verður viðbragð aldrei kallað, þar sem geymslan sjálf er ekki sjáanleg, aðeins svið hennar eru það.

Ef ég skrifaði þetta svona () => app.store.keys, aftur myndi ekkert gerast, þar sem þegar þú bætir við/fjarlægir fylkiseiningar mun tilvísunin í það ekki breytast.

Mobx virkar sem veljari í fyrsta skipti og heldur aðeins utan um sýnilegt atriði sem við höfum fengið aðgang að. Þetta er gert í gegnum proxy getters. Þess vegna er innbyggða aðgerðin notuð hér toJS. Það skilar nýjum hlut þar sem öllum umboðum er skipt út fyrir upprunalegu reiti. Meðan á framkvæmd stendur, les það alla reiti hlutarins - þess vegna eru getterarnir ræstir.

Í sprettigluggaborðinu munum við aftur bæta við nokkrum lyklum. Að þessu sinni enduðu þeir einnig í localStorage:

Að skrifa örugga vafraviðbót

Þegar bakgrunnssíðan er endurhlaðin eru upplýsingarnar áfram á sínum stað.

Hægt er að skoða allan umsóknarkóða fram að þessu hér.

Örugg geymsla einkalykla

Það er óöruggt að geyma einkalykla með skýrum texta: það er alltaf möguleiki á að þú verðir fyrir tölvusnápur, færð aðgang að tölvunni þinni og svo framvegis. Þess vegna munum við í localStorage geyma lyklana á dulkóðuðu lykilorði.

Til að auka öryggi, munum við bæta læstu ástandi við forritið, þar sem enginn aðgangur verður að lyklunum yfirleitt. Við munum sjálfkrafa flytja viðbótina í læst ástand vegna tímaleysis.

Mobx gerir þér kleift að geyma aðeins lágmarks gagnasett og afgangurinn er sjálfkrafa reiknaður út frá því. Þetta eru svokallaðir tölvueiginleikar. Þeim má líkja við skoðanir í gagnagrunnum:

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

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

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

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

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

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

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

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

Nú geymum við aðeins dulkóðuðu lyklana og lykilorðið. Allt annað er reiknað út. Við gerum flutninginn í læst ástand með því að fjarlægja lykilorðið úr ríkinu. Opinbera API hefur nú aðferð til að frumstilla geymsluna.

Skrifað fyrir dulkóðun tól sem nota 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)
}

Vafrinn er með aðgerðalaus API þar sem þú getur gerst áskrifandi að atburði - ástandsbreytingar. Ríki, í samræmi við það, getur verið idle, active и locked. Fyrir aðgerðaleysi geturðu stillt tímamörk og læst er stillt þegar stýrikerfið sjálft er læst. Við munum einnig breyta valkostinum fyrir vistun í 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)
        }
    }
}

Kóðinn fyrir þetta skref er hér.

Viðskipti

Svo komum við að því mikilvægasta: að búa til og undirrita viðskipti á blockchain. Við munum nota WAVES blockchain og bókasafnið öldur-viðskipti.

Í fyrsta lagi skulum við bæta við ástandið fjölda skilaboða sem þarf að undirrita, bæta síðan við aðferðum til að bæta við nýjum skilaboðum, staðfesta undirskriftina og hafna:

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

    ...
}

Þegar við fáum ný skilaboð bætum við lýsigögnum við þau, gerum það observable og bæta við store.messages.

Ef þú gerir það ekki observable handvirkt, þá mun mobx gera það sjálfur þegar skilaboðum er bætt við fylkið. Hins vegar mun það búa til nýjan hlut sem við munum ekki hafa tilvísun í, en við munum þurfa hann fyrir næsta skref.

Næst skilum við loforð sem leysist þegar skilaboðastaðan breytist. Staðan er fylgst með viðbrögðum, sem munu „drepa sig“ þegar staðan breytist.

Aðferðakóði approve и reject mjög einfalt: við breytum einfaldlega stöðu skilaboðanna, eftir að hafa undirritað það ef þörf krefur.

Við setjum Samþykkja og hafna í UI API, newMessage í síðu 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)
        }
    }

    ...
}

Nú skulum við reyna að skrifa undir viðskiptin með viðbótinni:

Að skrifa örugga vafraviðbót

Almennt séð er allt tilbúið, allt sem eftir er bæta við einföldu notendaviðmóti.

UI

Viðmótið þarf aðgang að umsóknarstöðu. Á HÍ hliðinni munum við gera observable ástand og bæta aðgerð við API sem mun breyta þessu ástandi. Við skulum bæta við observable við API hlutinn sem fékkst úr bakgrunni:

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

Í lokin byrjum við að gera forritsviðmótið. Þetta er viðbragðsforrit. Bakgrunnshluturinn er einfaldlega liðinn með því að nota leikmuni. Það væri auðvitað rétt að gera sérstaka þjónustu fyrir aðferðir og verslun fyrir ríkið, en fyrir þessa grein nægir þetta:

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

Með mobx er mjög auðvelt að byrja að birta þegar gögn breytast. Við hengjum einfaldlega áhorfandann úr pakkanum mobx-viðbrögð á íhlutinn, og rendering verður sjálfkrafa kölluð þegar allir sjáanlegir hlutir sem íhluturinn vísar til breytast. Þú þarft ekki neitt mapStateToProps eða tengist eins og í redux. Allt virkar beint úr kassanum:

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

Hægt er að skoða íhlutina sem eftir eru í kóðanum í UI möppunni.

Nú í forritaflokknum þarftu að búa til stöðuval fyrir HÍ og láta HÍ vita þegar það breytist. Til að gera þetta skulum við bæta við aðferð getState и reactionað hringja 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())

        })
    }

    ...
}

Þegar tekið er á móti hlut remote er verið að skapa reaction til að breyta ástandinu sem kallar á aðgerðina á HÍ hliðinni.

Síðasta snertingin er að bæta við birtingu nýrra skilaboða á viðbótartákninu:

function setupApp() {
...

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

...
}

Svo umsóknin er tilbúin. Vefsíður geta beðið um undirskrift fyrir viðskipti:

Að skrifa örugga vafraviðbót

Að skrifa örugga vafraviðbót

Kóðinn er fáanlegur hér tengill.

Ályktun

Ef þú hefur lesið greinina til enda, en hefur samt spurningar, geturðu spurt þær á geymslur með framlengingu. Þar finnur þú einnig skuldbindingar fyrir hvert tiltekið skref.

Og ef þú hefur áhuga á að skoða kóðann fyrir raunverulega viðbótina geturðu fundið þetta hér.

Kóði, geymsla og starfslýsing frá siemarell

Heimild: www.habr.com

Bæta við athugasemd