Arakatzaile-luzapen seguru bat idazten

Arakatzaile-luzapen seguru bat idazten

"Bezero-zerbitzari" arkitektura arrunta ez bezala, aplikazio deszentralizatuak honako ezaugarri hauek ditu:

  • Ez dago datu-base bat gorde beharrik erabiltzaileen saio-hasiera eta pasahitzekin. Sarbide-informazioa erabiltzaileek soilik gordetzen dute, eta haien benetakotasunaren berrespena protokolo mailan gertatzen da.
  • Ez da zerbitzaririk erabili behar. Aplikazio-logika blockchain sare batean exekutatu daiteke, non beharrezkoa den datu kopurua gordetzeko.

Erabiltzaileen gakoentzako 2 biltegiratze nahiko seguru daude: hardware-zorroak eta arakatzailearen luzapenak. Hardware-zorroak gehienetan oso seguruak dira, baina erabiltzeko zailak eta doakoak ez direnez, baina arakatzailearen luzapenak segurtasunaren eta erabiltzeko erraztasunaren konbinazio ezin hobea dira, eta azken erabiltzaileentzat ere guztiz doakoak izan daitezke.

Hori guztia kontuan hartuta, aplikazio deszentralizatuen garapena errazten duen luzapen seguruena egin nahi izan dugu, transakzioekin eta sinadurarekin lan egiteko API sinple bat eskainiz.
Jarraian esperientzia honen berri emango dizugu.

Artikuluak arakatzailearen luzapena idazteko urratsez urratseko argibideak izango ditu, kode-adibideekin eta pantaila-argazkiekin. Kode guztia aurki dezakezu biltegiak. Konpromiso bakoitza logikoki artikulu honetako atal bati dagokio.

Arakatzaileen luzapenen historia laburra

Arakatzaileen luzapenak aspalditik daude. Internet Explorer-en 1999an agertu ziren, Firefoxen 2004an. Hala ere, denbora luzez ez zegoen luzapenetarako estandar bakarra.

Google Chrome-ren laugarren bertsioan luzapenekin batera agertu zela esan dezakegu. Jakina, orduan ez zegoen zehaztapenik, baina Chrome APIa izan zen bere oinarri bihurtu zena: nabigatzaileen merkatuaren zatirik handiena konkistatuta eta aplikazio-denda integratua edukita, Chrome-k benetan ezarri zuen arakatzailearen luzapenetarako estandarra.

Mozillak bere estandarra zuen, baina Chrome luzapenen ospea ikusita, konpainiak API bateragarri bat egitea erabaki zuen. 2015ean, Mozillaren ekimenez, World Wide Web Consortium (W3C) barruan talde berezi bat sortu zen arakatzaileen arteko luzapenaren zehaztapenak lantzeko.

Chromerako dauden API luzapenak oinarritzat hartu ziren. Lana Microsoft-en laguntzarekin egin zen (Google-k uko egin zion estandarraren garapenean parte hartzeari), eta ondorioz zirriborro bat agertu zen. zehaztapenak.

Formalki, zehaztapena Edge, Firefox eta Opera-k onartzen dute (kontuan izan Chrome ez dagoela zerrenda honetan). Baina, egia esan, estandarra Chrome-rekin bateragarria da neurri handi batean, bere luzapenetan oinarrituta idatzita baitago. WebExtensions APIari buruzko informazio gehiago irakur dezakezu Hemen.

Hedapen-egitura

Luzapenerako beharrezkoa den fitxategi bakarra manifestua da (manifest.json). Hedapenerako “sarrera” ere bada.

manifestu

Zehaztapenaren arabera, manifestu fitxategia baliozko JSON fitxategia da. Manifestu-gakoen deskribapen osoa, zein arakatzailetan ikus daitekeen onartzen diren gakoei buruzko informazioarekin Hemen.

Zehaztapenean ez dauden gakoak ez ikusi egin daitezke (Chromek zein Firefoxek erroreen berri ematen dute, baina luzapenek funtzionatzen jarraitzen dute).

Eta puntu batzuei arreta jarri nahi nieke.

  1. atzeko planoa — Eremu hauek barne hartzen dituen objektu bat:
    1. scripts — atzeko planoan exekutatuko diren script sorta bat (hori buruz pixka bat geroago hitz egingo dugu);
    2. orri - Orrialde huts batean exekutatuko diren scripten ordez, edukiarekin html zehaztu dezakezu. Kasu honetan, script-eremuari ez ikusi egingo zaio, eta scriptak eduki-orrian txertatu beharko dira;
    3. diraute — bandera bitar bat, zehazten ez bada, arakatzaileak atzeko planoko prozesua "hilko" du ezer egiten ez duela uste duenean, eta behar izanez gero berrabiaraziko du. Bestela, orria arakatzailea itxita dagoenean bakarrik deskargatuko da. Ez da onartzen Firefox-en.
  2. eduki_gidoiak — script desberdinak web orri ezberdinetan kargatzeko aukera ematen duen objektu sorta bat. Objektu bakoitzak eremu garrantzitsu hauek ditu:
    1. Partiduak - ereduaren URLa, edukien gidoi jakin bat sartuko den edo ez zehazten duena.
    2. js — partida honetan kargatuko diren scripten zerrenda;
    3. baztertu_partidak - eremutik kanpo uzten du match Eremu honekin bat datozen URLak.
  3. orrialdea_ekintza - Nabigatzailean helbide-barraren ondoan bistaratzen den ikonoaz eta harekin interakzioaz arduratzen den objektu bat da. Gainera, pop-up leiho bat bistaratzeko aukera ematen du, zure HTML, CSS eta JS erabiliz definitzen dena.
    1. default_popup — Popup interfazearekin HTML fitxategirako bidea, CSS eta JS izan ditzake.
  4. baimenak — Luzapen eskubideak kudeatzeko array bat. 3 eskubide mota daude, zehatz-mehatz azaltzen direnak Hemen
  5. web_erabilgarriak_baliabideak — Web-orri batek eska ditzakeen luzapen-baliabideak, adibidez, irudiak, JS, CSS, HTML fitxategiak.
  6. kanpotik_konektagarria — hemen esplizituki zehaztu ditzakezu konekta zaitezkeen web orrien beste hedapen eta domeinu batzuen IDak. Domeinu bat bigarren maila edo goragokoa izan daiteke. Ez dabil Firefox-en.

Exekuzio testuingurua

Luzapenak hiru kodea exekutatzeko testuinguru ditu, hau da, aplikazioak hiru zati ditu arakatzailearen APIrako sarbide maila ezberdinekin.

Hedapen testuingurua

API gehiena hemen eskuragarri dago. Testuinguru honetan “bizi” dira:

  1. Atzeko planoko orria — Luzapenaren “backend” zatia. Fitxategia manifestuan zehazten da "atzeko planoa" tekla erabiliz.
  2. Popup orria — luzapenaren ikonoan klik egiten duzunean agertzen den laster-orri bat. Manifestuan browser_action -> default_popup.
  3. Orri pertsonalizatua — luzapen orria, "bizi" ikuspegiko fitxa bereizi batean chrome-extension://<id_расширения>/customPage.html.

Testuinguru hau arakatzailearen leihoetatik eta fitxetatik kanpo dago. Atzeko planoko orria kopia bakarrean existitzen da eta beti funtzionatzen du (salbuespena gertaera orria da, atzeko planoko script-a gertaera batek abiarazten duenean eta exekutatu ondoren "hiltzen" denean). Popup orria laster-leihoa irekita dagoenean existitzen da, eta Orri pertsonalizatua — duen fitxa irekita dagoen bitartean. Testuinguru honetatik ez dago beste fitxetara eta haien edukietara sarbiderik.

Edukien gidoiaren testuingurua

Edukiaren script fitxategia arakatzailearen fitxa bakoitzarekin batera abiarazten da. Luzapenaren APIaren zati bat eta web-orriko DOM zuhaitzerako sarbidea du. Eduki-scriptak dira orriaren interakzioaz arduratzen direnak. DOM zuhaitza manipulatzen duten luzapenek hori egiten dute eduki-scriptetan, adibidez, iragarki-blokeatzaileak edo itzultzaileak. Gainera, edukiaren gidoia orriarekin estandar bidez komunikatu daiteke postMessage.

Web orriaren testuingurua

Hau da benetako web orria bera. Ez dauka luzapenarekin zerikusirik eta ez du bertan sarbiderik, orri honen domeinua manifestuan esplizituki adierazten ez den kasuetan izan ezik (hori gehiago behean).

Mezu trukea

Aplikazioaren zati ezberdinek mezuak trukatu behar dituzte elkarren artean. Horretarako API bat dago runtime.sendMessage mezu bat bidaltzeko background и tabs.sendMessage orri batera mezu bat bidaltzeko (eduki-gidoia, popup-a edo web orria eskuragarri badago externally_connectable). Jarraian Chrome APIra sartzean adibide bat dago.

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

Komunikazio osoa lortzeko, konexioak sor ditzakezu runtime.connect. Erantzun gisa jasoko dugu runtime.Port, irekita dagoen bitartean, edozein mezu bidal ditzakezu. Bezeroaren aldetik, adibidez, contentscript, hau itxura du:

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

Zerbitzaria edo atzeko planoa:

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

Ekitaldi bat ere badago onDisconnect eta metodoa disconnect.

Aplikazio-diagrama

Egin dezagun arakatzailearen luzapena gako pribatuak gordetzen dituena, informazio publikorako sarbidea ematen duena (helbidea, gako publikoa orriarekin komunikatzen da eta hirugarrenen aplikazioei transakzioetarako sinadura eskatzeko aukera ematen die).

Aplikazioen garapena

Gure aplikazioak erabiltzailearekin elkarreragin behar du eta orrialdeari API bat eman behar dio metodoak deitzeko (adibidez, transakzioak sinatzeko). Konformatu bakarrarekin contentscript ez du funtzionatuko, DOMerako sarbidea bakarrik baitu, baina ez orriaren JSrako. Konektatu bidez runtime.connect ezin dugu, APIa beharrezkoa delako domeinu guztietan, eta berariazkoak soilik zehaztu daitezkeelako manifestuan. Ondorioz, diagrama itxura hau izango da:

Arakatzaile-luzapen seguru bat idazten

Beste gidoi bat egongo da - inpage, orrialdean sartuko duguna. Bere testuinguruan exekutatuko da eta luzapenarekin lan egiteko API bat emango du.

Начало

Arakatzailearen luzapen-kode guztiak eskuragarri daude hemen GitHub. Deskribapenean zehar konpromisoetarako estekak egongo dira.

Has gaitezen manifestutik:

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

Sortu background.js, popup.js, inpage.js eta contentscript.js hutsik. Popup.html gehitzen dugu, eta gure aplikazioa dagoeneko Google Chrome-n kargatu daiteke eta ziurtatu funtzionatzen duela.

Hori egiaztatzeko, kodea har dezakezu beraz,. Egin genuenaz gain, estekak webpack erabiliz proiektuaren muntaia konfiguratu zuen. Aplikazio bat nabigatzailean gehitzeko, chrome://extensions-en hautatu behar duzu load despacked eta dagokion luzapena duen karpeta - gure kasuan dist.

Arakatzaile-luzapen seguru bat idazten

Orain gure luzapena instalatuta eta funtzionatzen ari da. Garatzaile-tresnak testuinguru desberdinetarako exekutatu ditzakezu honela:

popup ->

Arakatzaile-luzapen seguru bat idazten

Eduki-script-kontsolarako sarbidea abiarazten den orriaren beraren kontsolaren bidez egiten da.Arakatzaile-luzapen seguru bat idazten

Mezu trukea

Beraz, bi komunikazio kanal ezarri behar ditugu: inpage <-> background eta popup <-> background. Noski, mezuak portura bidali eta zure protokoloa asmatu dezakezu, baina nahiago dut metamask kode irekiko proiektuan ikusi dudan planteamendua.

Ethereum sarearekin lan egiteko arakatzailearen luzapena da. Bertan, aplikazioaren zati desberdinak RPC bidez komunikatzen dira dnode liburutegia erabiliz. Truke bat nahiko azkar eta eroso antolatzeko aukera ematen du garraio gisa nodejs korronte bat ematen badiozu (interfaze bera inplementatzen duen objektu bat esan nahi du):

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

Orain aplikazio klase bat sortuko dugu. Popup eta web-orrirako API objektuak sortuko ditu eta haientzako dnodo bat sortuko du:

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

Hemen eta behean, Chrome objektu globalaren ordez, extensionApi erabiltzen dugu, Chrome Google-ren arakatzailean eta arakatzailean besteetan sartzen dena. Hori arakatzaileen arteko bateragarritasunerako egiten da, baina artikulu honen helburuetarako, 'chrome.runtime.connect' erabil dezakezu.

Sortu dezagun aplikazio-instantzia bat atzeko planoko script-ean:

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

dnode-k korronteekin funtzionatzen duenez, eta ataka bat jasotzen dugunez, egokitzaile klase bat behar da. Irakurgai-korrontearen liburutegia erabiliz egiten da, arakatzailean nodejs korronteak ezartzen dituena:

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

Orain sortu dezagun konexio bat UI-n:

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

Ondoren, konexioa sortzen dugu edukiaren gidoian:

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

APIa edukiaren gidoian ez, orrialdean zuzenean behar dugunez, bi gauza egiten ditugu:

  1. Bi korronte sortzen ditugu. Bat - orrialderantz, postMezuaren gainean. Horretarako hau erabiltzen dugu pakete hau metamaskaren sortzaileetatik. Bigarren korrontea atzeko planoan dago jasotako atakaren gainean runtime.connect. Eros ditzagun. Orain orrialdeak korronte bat izango du atzeko planoan.
  2. Injektatu script-a DOM-en. Deskargatu script-a (manifestean baimendu zen sarbidea) eta sortu etiketa script bere edukia barruan:

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

Orain api objektu bat sortzen dugu inpage-n eta global gisa ezartzen dugu:

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

Prest gaude Urruneko prozedura-deia (RPC) orrialderako eta UIrako API bereiziarekin. Orrialde berri bat atzeko planora konektatzean hau ikus dezakegu:

Arakatzaile-luzapen seguru bat idazten

APIa eta jatorria hutsik. Orriaren aldean, kaixo funtzioari honela dei diezaiokegu:

Arakatzaile-luzapen seguru bat idazten

JS modernoan dei-itzuliaren funtzioekin lan egitea ohitura txarra da, beraz, idatz dezagun laguntzaile txiki bat utils-i API objektu bat pasatzeko dnode bat sortzeko.

API objektuak honela izango dira orain:

export class SignerApp {

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

...

}

Objektu bat urrunetik eskuratzea honela:

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

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

Eta funtzioei deitzeak promesa bat ematen du:

Arakatzaile-luzapen seguru bat idazten

Funtzio asinkronoak dituen bertsioa eskuragarri Hemen.

Orokorrean, RPC eta korrontearen ikuspegia nahiko malgua dirudi: lurrun-multiplexazioa erabil dezakegu eta hainbat API sor ditzakegu zeregin desberdinetarako. Printzipioz, dnode edonon erabil daiteke, gauza nagusia garraioa nodejs korronte baten moduan biltzea da.

Alternatiba bat JSON formatua da, JSON RPC 2 protokoloa inplementatzen duena. Hala ere, garraio espezifikoekin funtzionatzen du (TCP eta HTTP(S)), eta hori ez da aplikagarria gure kasuan.

Barne egoera eta tokiko biltegiratzea

Aplikazioaren barne-egoera gorde beharko dugu, gutxienez sinatzeko gakoak. Nahiko erraz gehi diezaiokegu egoera bat aplikazioari eta aldatzeko metodoak popup APIan:

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

    ...

} 

Atzeko planoan, dena funtzio batean bilduko dugu eta aplikazioaren objektua leihoan idatziko dugu, kontsolatik lan egin ahal izateko:

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

Gehitu ditzagun gako batzuk UI kontsolatik eta ikus dezagun zer gertatzen den egoerarekin:

Arakatzaile-luzapen seguru bat idazten

Egoera iraunkorra izan behar da, giltzak gal ez daitezen berrabiaraztean.

LocalStorage-n gordeko dugu, aldaketa bakoitzean gainidatziz. Gerora, sarbidea ere beharrezkoa izango da UI-rako, eta aldaketetarako harpidetza ere egin nahiko nuke. Horretan oinarrituta, komenigarria izango da biltegiratze behagarri bat sortzea eta haren aldaketetara harpidetzea.

Mobx liburutegia erabiliko dugu (https://github.com/mobxjs/mobx). Aukeratu egin zen, ez nuelako lan egin behar, baina benetan ikasi nahi nuen.

Gehitu dezagun hasierako egoeraren hasierako hasiera eta egin dezagun biltegia behagarria:

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

    ...

}

"Kanpaiaren azpian", mobx-ek dendako eremu guztiak proxyz ordezkatu ditu eta haiei egindako dei guztiak atzematen ditu. Mezu hauetara harpidetzeko aukera izango da.

Jarraian "aldatzean" terminoa erabiliko dut askotan, hau guztiz zuzena ez den arren. Mobx-ek eremuetarako sarbidearen jarraipena egiten du. Liburutegiak sortzen dituen proxy-objektuen lortzaileak eta ezartzaileak erabiltzen dira.

Ekintza dekoratzaileek bi helburu dituzte:

  1. EnforceActions banderarekin modu zorrotzean, mobx-ek debekatu egiten du egoera zuzenean aldatzea. Praktika ontzat jotzen da baldintza zorrotzetan lan egitea.
  2. Funtzio batek egoera hainbat aldiz aldatzen badu ere (adibidez, hainbat eremu aldatzen ditugu kode-lerroetan), behatzaileei jakinarazten zaie amaitzen denean soilik. Hau bereziki garrantzitsua da frontend-erako, non beharrezkoak ez diren egoera eguneratzeak elementuak alferrikako errendatzea eragiten baitu. Gure kasuan, ez lehenengoa ez bigarrena ez da bereziki garrantzitsua, baina praktika onak jarraituko ditugu. Ohikoa da behatutako eremuen egoera aldatzen duten funtzio guztietan dekoratzaileak eranstea.

Atzeko planoan hasieratzea gehituko dugu eta egoera gordeko dugu localStorage-n:

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

Erreakzio funtzioa interesgarria da hemen. Bi argudio ditu:

  1. Datu-hautatzailea.
  2. Datu hauekin aldatzen den bakoitzean deituko den kudeatzailea.

Redux-ek ez bezala, non egoera esplizituki jasotzen dugun argumentu gisa, mobx-ek hautatzailearen barruan zein behagarri sartzen garen gogoratzen du eta kudeatzaileari soilik deitzen dio aldatzen direnean.

Garrantzitsua da ulertzea mobx-ek zein behagarritara harpidetzen garen erabakitzen duen. Honelako kodean hautatzaile bat idatzi badut() => app.store, orduan erreakzioa ez da inoiz deituko, biltegiratzea bera ez baita behagarria, bere eremuak bakarrik bai.

Horrela idatzi badut () => app.store.keys, berriro ere ez litzateke ezer gertatuko, izan ere, array elementuak gehitzean/kentzean, haren erreferentzia ez da aldatuko.

Mobx-ek hautatzaile gisa jarduten du lehen aldiz eta soilik sartu ditugun behagarrien jarraipena egiten du. Hau proxy-jasotzaileen bidez egiten da. Beraz, hemen integratutako funtzioa erabiltzen da toJS. Objektu berri bat itzultzen du proxy guztiak jatorrizko eremuekin ordezkatuta. Exekuzioan zehar, objektuaren eremu guztiak irakurtzen ditu - beraz, getters abiarazten dira.

Popup kontsolan berriro gehituko ditugu hainbat gako. Oraingoan lokaleko biltegian ere amaitu dute:

Arakatzaile-luzapen seguru bat idazten

Atzeko planoa berriro kargatzen denean, informazioa bere lekuan geratzen da.

Orain arteko aplikazio-kode guztiak ikus daitezke Hemen.

Gako pribatuen biltegiratze segurua

Gako pribatuak testu garbian gordetzea ez da segurua: beti dago hackeatzea, ordenagailura sarbidea izateko eta abar izateko aukera. Hori dela eta, localStorage-n gakoak pasahitz enkriptatutako forma batean gordeko ditugu.

Segurtasun handiagoa lortzeko, blokeatutako egoera bat gehituko diogu aplikazioari, eta bertan ez da gakoetarako sarbiderik izango. Luzapena automatikoki blokeatuta dagoen egoerara transferituko dugu denbora-muga bat dela eta.

Mobx-ek gutxieneko datu multzo bat bakarrik gordetzeko aukera ematen du, eta gainerakoa automatikoki kalkulatzen da horren arabera. Hauek propietate konputatuak deiturikoak dira. Datu-baseetako bistekin aldera daitezke:

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

Orain enkriptatutako gakoak eta pasahitza soilik gordetzen ditugu. Gainontzeko guztia kalkulatuta dago. Blokeatutako egoera batera transferitzea pasahitza egoeratik kenduz egiten dugu. API publikoak biltegiratzea hasieratzeko metodo bat du orain.

Zifratzeko idatzia crypto-js erabiliz utilitateak:

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

Arakatzaileak inaktibo API bat du eta horren bidez gertaera batera harpidetu zaitezke - egoera aldaketak. Estatua, horren arabera, izan daiteke idle, active и locked. Inaktiborako denbora-muga ezar dezakezu eta blokeatuta dago OS bera blokeatuta dagoenean. LocalStorage-n gordetzeko hautatzailea ere aldatuko dugu:

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

Urrats honen aurreko kodea da Hemen.

Transakzioak

Beraz, garrantzitsuenera gatoz: blockchain-en transakzioak sortzea eta sinatzea. WAVES blockchain eta liburutegia erabiliko ditugu uhin-transakzioak.

Lehenik eta behin, gehitu diezaiogun egoerari sinatu beharreko mezu sorta bat, gero mezu berri bat gehitzeko, sinadura berresteko eta uko egiteko metodoak:

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

    ...
}

Mezu berri bat jasotzen dugunean metadatuak gehitzen dizkiogu, egin observable eta gehitu store.messages.

Ez baduzu observable eskuz, orduan mobx-ek berak egingo du mezuak array-ra gehitzean. Hala ere, objektu berri bat sortuko du, zeinaren erreferentziarik izango ez dugun, baina hurrengo urratserako beharko dugu.

Ondoren, mezuaren egoera aldatzen denean konpontzen den promesa bat itzultzen dugu. Egoera erreakzioaren bidez kontrolatzen da, eta horrek "bere burua hilko du" egoera aldatzen denean.

Metodoaren kodea approve и reject oso sinplea: mezuaren egoera aldatu besterik ez dugu egiten, behar izanez gero sinatu ondoren.

Onartu eta baztertu UI APIan, mezu berria orrialdeko APIan jarri dugu:

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

    ...
}

Orain saia gaitezen transakzioa luzapenarekin sinatzen:

Arakatzaile-luzapen seguru bat idazten

Oro har, dena prest dago, geratzen dena da gehitu UI sinplea.

UI

Interfazeak aplikazioaren egoerarako sarbidea behar du. UI aldean egingo dugu observable egoera eta gehitu egoera hori aldatuko duen APIari funtzio bat. Gehitu dezagun observable atzeko planotik jasotako API objektura:

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

Amaieran aplikazioaren interfazea errendatzen hasiko gara. Hau erreakzio aplikazio bat da. Atzeko planoko objektua atrezzo erabiliz pasatzen da. Zuzena litzateke, noski, metodo eta denda zerbitzu bereizi bat egitea estatuarentzat, baina artikulu honen helburuetarako nahikoa da:

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

Mobx-ekin oso erraza da errendatzen hastea datuak aldatzen direnean. Behatzaile dekoratzailea paketetik zintzilikatzen dugu mobx-erreakziona osagaian, eta errendatzea automatikoki deituko da osagaiak erreferentziatutako edozein behagarri aldatzen denean. Ez duzu mapStateToPropsrik behar edo konektatu redux-en bezala. Dena kaxatik aterata funtzionatzen du:

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

Gainerako osagaiak kodean ikus daitezke UI karpetan.

Orain aplikazio-klasean UI-rako egoera-hautatzailea egin behar duzu eta UI-ari jakinarazi behar diozu aldatzen denean. Horretarako, gehitu dezagun metodo bat getState и reactiondeituz 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())

        })
    }

    ...
}

Objektu bat jasotzean remote sortzen da reaction UI aldean funtzioa deitzen duen egoera aldatzeko.

Azken ukitua luzapenaren ikonoan mezu berrien bistaratzea gehitzea da:

function setupApp() {
...

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

...
}

Beraz, aplikazioa prest dago. Web-orriek sinadura eska dezakete transakzioetarako:

Arakatzaile-luzapen seguru bat idazten

Arakatzaile-luzapen seguru bat idazten

Kodea hemen dago eskuragarri link.

Ondorioa

Artikulua amaiera arte irakurri baduzu, baina oraindik galderarik baduzu, helbide honetan egin ditzakezu luzapena duten biltegiak. Bertan, izendatutako urrats bakoitzeko konpromisoak ere aurkituko dituzu.

Eta benetako luzapenaren kodea ikustea interesatzen bazaizu, hau aurki dezakezu Hemen.

Kodea, biltegia eta lanpostuaren deskribapena siemarell

Iturria: www.habr.com

Gehitu iruzkin berria