Skribante sekuran retumilon

Skribante sekuran retumilon

Male al la komuna "kliento-servilo" arkitekturo, malcentralizitaj aplikoj estas karakterizitaj per:

  • Ne necesas konservi datumbazon kun uzantsalutinoj kaj pasvortoj. Alirinformoj estas stokitaj ekskluzive fare de la uzantoj mem, kaj konfirmo de ilia aŭtenteco okazas sur la protokola nivelo.
  • Ne necesas uzi servilon. La aplika logiko povas esti ekzekutita sur blokĉena reto, kie eblas stoki la bezonatan kvanton da datumoj.

Estas 2 relative sekuraj stokejoj por uzantŝlosiloj - aparataj monujoj kaj retumiloj. Aparataj monujoj estas plejparte ege sekuraj, sed malfacile uzeblaj kaj malproksimaj de senpagaj, sed retumiloj estas la perfekta kombinaĵo de sekureco kaj facileco de uzado, kaj ankaŭ povas esti tute senpagaj por finaj uzantoj.

Konsiderante ĉion ĉi, ni volis fari la plej sekuran etendon, kiu simpligas la disvolviĝon de malcentralizitaj aplikoj provizante simplan API por labori kun transakcioj kaj subskriboj.
Pri ĉi tiu sperto ni rakontos al vi sube.

La artikolo enhavos paŝon post paŝo instrukciojn pri kiel skribi retumila etendo, kun kodaj ekzemploj kaj ekrankopioj. Vi povas trovi la tutan kodon en deponejoj. Ĉiu transdono logike respondas al sekcio de ĉi tiu artikolo.

Mallonga Historio de Retumilo-Etendaĵoj

Retumilo-etendaĵoj ekzistas delonge. Ili aperis en Internet Explorer jam en 1999, en Fajrovulpo en 2004. Tamen, dum tre longa tempo ekzistis neniu ununura normo por etendaĵoj.

Ni povas diri, ke ĝi aperis kune kun etendoj en la kvara versio de Google Chrome. Kompreneble, tiam ne estis specifo, sed ĝi estis la Chrome API kiu fariĝis ĝia bazo: konkerinte la plej grandan parton de la retumila merkato kaj havante enkonstruitan aplikaĵbutikon, Chrome efektive starigis la normon por retumilo-etendaĵoj.

Mozilla havis sian propran normon, sed vidante la popularecon de Chrome-etendaĵoj, la kompanio decidis fari kongruan API. En 2015, laŭ iniciato de Mozilla, speciala grupo estis kreita ene de la World Wide Web Consortium (W3C) por labori pri trans-retumila etendaĵospecifoj.

La ekzistantaj API-etendaĵoj por Chrome estis prenitaj kiel bazo. La laboro estis farita kun la subteno de Microsoft (Google rifuzis partopreni en la evoluo de la normo), kaj kiel rezulto aperis skizo. specifoj.

Formale, la specifo estas subtenata de Edge, Firefox kaj Opera (notu, ke Chrome ne estas en ĉi tiu listo). Sed fakte, la normo estas plejparte kongrua kun Chrome, ĉar ĝi estas fakte skribita surbaze de siaj etendaĵoj. Vi povas legi pli pri la WebExtensions API tie.

Etenda strukturo

La nura dosiero necesa por la etendaĵo estas la manifesto (manifest.json). Ĝi ankaŭ estas la "enirpunkto" al la ekspansio.

Manifesto

Laŭ la specifo, la manifestdosiero estas valida JSON-dosiero. Plena priskribo de manifestŝlosiloj kun informoj pri kiuj ŝlosiloj estas subtenataj en kiu retumilo povas esti vidita tie.

Ŝlosiloj kiuj ne estas en la specifo "povas" esti ignoritaj (kaj Chrome kaj Firefox raportas erarojn, sed la etendaĵoj daŭre funkcias).

Kaj mi ŝatus atentigi pri iuj punktoj.

  1. fono — objekto kiu inkluzivas la jenajn kampojn:
    1. skriptoj — aro da skriptoj, kiuj estos ekzekutitaj en la fona kunteksto (pri tio ni parolos iom poste);
    2. paĝo - anstataŭ skriptoj, kiuj estos ekzekutitaj en malplena paĝo, vi povas specifi html kun enhavo. En ĉi tiu kazo, la skriptokampo estos ignorita, kaj la skriptoj devos esti enmetitaj en la enhavpaĝon;
    3. persisti — binara flago, se ne specifita, la retumilo "mortigos" la fonprocezon kiam ĝi konsideras ke ĝi nenion faras, kaj rekomencos ĝin se necese. Alie, la paĝo estos malŝarĝita nur kiam la retumilo estas fermita. Ne subtenata en Firefox.
  2. enhavo_skriptoj — aro da objektoj, kiuj ebligas al vi ŝargi malsamajn skriptojn al malsamaj retpaĝoj. Ĉiu objekto enhavas la sekvajn gravajn kampojn:
    1. alumetoj - ŝablono url, kiu determinas ĉu aparta enhavskripto estos inkluzivita aŭ ne.
    2. js — listo de skriptoj, kiuj estos ŝarĝitaj en ĉi tiun matĉon;
    3. ekskludi_matĉoj - ekskludas de la kampo match URL-oj kiuj kongruas kun ĉi tiu kampo.
  3. paĝo_ago - estas fakte objekto, kiu respondecas pri la ikono, kiu estas montrata apud la adresbreto en la retumilo kaj interago kun ĝi. Ĝi ankaŭ permesas al vi montri ŝprucfenestron, kiu estas difinita uzante vian propran HTML, CSS kaj JS.
    1. default_popup — vojo al la HTML-dosiero kun la ŝprucfenestra interfaco, povas enhavi CSS kaj JS.
  4. permesojn — tabelo por administri etendrajtojn. Estas 3 specoj de rajtoj, kiuj estas detale priskribitaj tie
  5. retejo_atingeblaj_rimedoj — etendaj rimedoj, kiujn retpaĝo povas peti, ekzemple bildojn, JS, CSS, HTML-dosierojn.
  6. ekstere_konektebla — ĉi tie vi povas eksplicite specifi la ID-ojn de aliaj etendaĵoj kaj domajnoj de retpaĝoj, de kiuj vi povas konektiĝi. Regado povas esti duanivela aŭ pli alta. Ne funkcias en Firefox.

Kunteksto de ekzekuto

La etendaĵo havas tri kodajn ekzekutkuntekstojn, tio estas, la aplikaĵo konsistas el tri partoj kun malsamaj niveloj de aliro al la retumila API.

Etenda kunteksto

Plejparto de la API haveblas ĉi tie. En ĉi tiu kunteksto ili "vivas":

  1. Fona paĝo — "backend" parto de la etendaĵo. La dosiero estas specifita en la manifesto per la "fono" ŝlosilo.
  2. Ŝprucpaĝo — ŝprucfenestra paĝo, kiu aperas kiam vi alklakas la etendaĵikonon. En la manifesto browser_action -> default_popup.
  3. Propra paĝo — etenda paĝo, "loĝante" en aparta langeto de la vido chrome-extension://<id_расширения>/customPage.html.

Ĉi tiu kunteksto ekzistas sendepende de foliumilaj fenestroj kaj langetoj. Fona paĝo ekzistas en ununura kopio kaj ĉiam funkcias (la escepto estas la okazaĵpaĝo, kiam la fona skripto estas lanĉita de evento kaj "mortas" post ĝia ekzekuto). Ŝprucpaĝo ekzistas kiam la ŝprucfenestro estas malfermita, kaj Propra paĝo — dum la langeto kun ĝi estas malfermita. Ne estas aliro al aliaj langetoj kaj ilia enhavo de ĉi tiu kunteksto.

Enhava skripto-kunteksto

La enhava skripto-dosiero estas lanĉita kune kun ĉiu retumila langeto. Ĝi havas aliron al parto de la API de la etendaĵo kaj al la DOM-arbo de la retpaĝo. Estas enhavaj skriptoj kiuj respondecas pri interago kun la paĝo. Etendaĵoj, kiuj manipulas la DOM-arbon, faras tion en enhavaj skriptoj - ekzemple reklamblokiloj aŭ tradukistoj. Ankaŭ, la enhava skripto povas komuniki kun la paĝo per normo postMessage.

Retpaĝa kunteksto

Ĉi tio estas la reala retpaĝo mem. Ĝi havas nenion komunan kun la etendaĵo kaj ne havas aliron tie, krom en kazoj kie la domajno de ĉi tiu paĝo ne estas eksplicite indikita en la manifesto (pli pri tio ĉi sube).

Mesaĝo-interŝanĝo

Malsamaj partoj de la aplikaĵo devas interŝanĝi mesaĝojn unu kun la alia. Estas API por ĉi tio runtime.sendMessage por sendi mesaĝon background и tabs.sendMessage sendi mesaĝon al paĝo (enhava skripto, ŝprucfenestro aŭ retpaĝo se disponebla externally_connectable). Malsupre estas ekzemplo kiam vi aliras la 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))
    }
)

Por plena komunikado, vi povas krei ligojn tra runtime.connect. Responde ni ricevos runtime.Port, al kiu, dum ĝi estas malfermita, vi povas sendi ajnan nombron da mesaĝoj. Sur la klienta flanko, ekzemple, contentscript, ĝi aspektas jene:

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

Servilo aŭ fono:

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

Estas ankaŭ evento onDisconnect kaj metodo disconnect.

Aplika diagramo

Ni faru retumilon, kiu stokas privatajn ŝlosilojn, donas aliron al publikaj informoj (adreso, publika ŝlosilo komunikas kun la paĝo kaj permesas al triapartaj aplikoj peti subskribon por transakcioj.

Disvolviĝo de aplikaĵo

Nia aplikaĵo devas ambaŭ interagi kun la uzanto kaj provizi la paĝon per API por voki metodojn (ekzemple por subskribi transakciojn). Fariĝu per nur unu contentscript ne funkcios, ĉar ĝi nur havas aliron al la DOM, sed ne al la JS de la paĝo. Konekti per runtime.connect ni ne povas, ĉar la API estas bezonata en ĉiuj domajnoj, kaj nur specifaj povas esti specifitaj en la manifesto. Kiel rezulto, la diagramo aspektos jene:

Skribante sekuran retumilon

Estos alia skripto - inpage, kiun ni injektos en la paĝon. Ĝi funkcios en sia kunteksto kaj provizos API por labori kun la etendaĵo.

Начало

Ĉiu retumila etendokodo estas havebla ĉe GitHub. Dum la priskribo estos ligiloj al kommits.

Ni komencu per la manifesto:

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

Kreu malplenajn background.js, popup.js, inpage.js kaj contentscript.js. Ni aldonas popup.html - kaj nia aplikaĵo jam povas esti ŝargita en Google Chrome kaj certigi ke ĝi funkcias.

Por kontroli ĉi tion, vi povas preni la kodon de ĉi tie. Krom tio, kion ni faris, la ligilo agordis la muntadon de la projekto uzante retpakon. Por aldoni aplikaĵon al la retumilo, en chrome://extensions vi devas elekti ŝargi malpakitan kaj la dosierujon kun la responda etendaĵo - en nia kazo dist.

Skribante sekuran retumilon

Nun nia etendo estas instalita kaj funkcias. Vi povas ruli la programilojn por malsamaj kuntekstoj jene:

ŝprucfenestro ->

Skribante sekuran retumilon

Aliro al la konzolo pri enhavo-skripto okazas per la konzolo de la paĝo mem, sur kiu ĝi estas lanĉita.Skribante sekuran retumilon

Mesaĝo-interŝanĝo

Do, ni devas establi du komunikajn kanalojn: enpaĝa <-> fono kaj ŝprucfenestro <-> fono. Vi povas, kompreneble, simple sendi mesaĝojn al la haveno kaj inventi vian propran protokolon, sed mi preferas la aliron, kiun mi vidis en la metamasko malfermfonteca projekto.

Ĉi tio estas retumila etendo por labori kun la reto Ethereum. En ĝi, malsamaj partoj de la aplikaĵo komunikas per RPC uzante la dnode-bibliotekon. Ĝi ebligas al vi organizi interŝanĝon sufiĉe rapide kaj oportune, se vi provizas ĝin per nodejs-fluo kiel transporto (signifas objekton kiu efektivigas la saman interfacon):

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

Nun ni kreos aplikan klason. Ĝi kreos API-objektojn por la ŝprucfenestro kaj retpaĝo, kaj kreos dnodon por ili:

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

Ĉi tie kaj malsupre, anstataŭ la tutmonda Chrome objekto, ni uzas extensionApi, kiu aliras Chrome en la retumilo de Guglo kaj retumilo en aliaj. Ĉi tio estas farita por interretumila kongruo, sed por la celoj de ĉi tiu artikolo, vi povus simple uzi 'chrome.runtime.connect'.

Ni kreu aplikaĵon en la fona skripto:

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 dnode funkcias kun fluoj, kaj ni ricevas havenon, necesas adaptila klaso. Ĝi estas farita per la legebla-flua biblioteko, kiu efektivigas nodejs-fluojn en la retumilo:

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

Nun ni kreu konekton en la UI:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Poste ni kreas la konekton en la enhava skripto:

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 ni bezonas la API ne en la enhava skripto, sed rekte sur la paĝo, ni faras du aferojn:

  1. Ni kreas du fluojn. Unu - al la paĝo, supre de la postMesaĝo. Por tio ni uzas ĉi tion ĉi tiu pako de la kreintoj de metamasko. La dua rivereto estas al fono super la haveno ricevita de runtime.connect. Ni aĉetu ilin. Nun la paĝo havos fluon al la fono.
  2. Injektu la skripton en la DOM. Elŝutu la skripton (aliro al ĝi estis permesita en la manifesto) kaj kreu etikedon script kun ĝia enhavo interne:

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

Nun ni kreas api-objekton en enpaĝo kaj agordas ĝin al tutmonda:

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

Ni estas pretaj Remote Procedure Call (RPC) kun aparta API por paĝo kaj UI. Konektante novan paĝon al fono, ni povas vidi ĉi tion:

Skribante sekuran retumilon

Malplena API kaj origino. Sur la paĝoflanko, ni povas voki la salutan funkcion jene:

Skribante sekuran retumilon

Labori kun revokfunkcioj en moderna JS estas malbona maniero, do ni skribu malgrandan helpanton por krei dnodon, kiu ebligas vin transdoni API-objekton al utils.

La API-objektoj nun aspektos jene:

export class SignerApp {

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

...

}

Akiri objekton de fora kiel ĉi tio:

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

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

Kaj voki funkciojn resendas promeson:

Skribante sekuran retumilon

Disponebla versio kun nesinkronaj funkcioj tie.

Ĝenerale, la RPC kaj stream-aliro ŝajnas sufiĉe fleksebla: ni povas uzi vaporan multipleksadon kaj krei plurajn malsamajn API-ojn por malsamaj taskoj. Principe, dnode povas esti uzata ie ajn, la ĉefa afero estas envolvi la transporton en la formo de nodejs-rivereto.

Alternativo estas la formato JSON, kiu efektivigas la protokolon JSON RPC 2. Tamen ĝi funkcias kun specifaj transportoj (TCP kaj HTTP(S)), kio ne aplikeblas en nia kazo.

Interna ŝtato kaj loka Stokado

Ni devos konservi la internan staton de la aplikaĵo - almenaŭ la subskribajn ŝlosilojn. Ni povas sufiĉe facile aldoni staton al la aplikaĵo kaj metodojn por ŝanĝi ĝin en la ŝprucfenestra 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)
        }
    }

    ...

} 

Fone, ni envolvos ĉion en funkcion kaj skribos la aplikaĵobjekton al fenestro por ke ni povu labori kun ĝi de la konzolo:

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

Ni aldonu kelkajn klavojn de la UI-konzolo kaj vidu, kio okazas kun la stato:

Skribante sekuran retumilon

La stato devas esti persista por ke la ŝlosiloj ne perdiĝu dum rekomenco.

Ni stokos ĝin en loka Stokado, anstataŭigante ĝin kun ĉiu ŝanĝo. Poste, aliro al ĝi ankaŭ estos necesa por la UI, kaj mi ankaŭ ŝatus aboni ŝanĝojn. Surbaze de ĉi tio, estos oportune krei observeblan stokadon kaj aboni ĝiajn ŝanĝojn.

Ni uzos la mobx-bibliotekon (https://github.com/mobxjs/mobx). La elekto falis sur ĝin ĉar mi ne devis labori kun ĝi, sed mi tre volis studi ĝin.

Ni aldonu inicialigon de la komenca stato kaj faru la vendejon observebla:

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

    ...

}

"Sub la kapuĉo," mobx anstataŭigis ĉiujn vendejajn kampojn per prokurilo kaj kaptas ĉiujn vokojn al ili. Eblos aboni ĉi tiujn mesaĝojn.

Malsupre mi ofte uzos la terminon "ŝanĝante", kvankam ĉi tio ne estas tute ĝusta. Mobx spuras aliron al kampoj. Riceviloj kaj agordiloj de prokuraj objektoj, kiujn la biblioteko kreas, estas uzataj.

Agaj dekoraciistoj servas du celojn:

  1. En strikta reĝimo kun la enforceActions flago, mobx malpermesas ŝanĝi la ŝtaton rekte. Estas konsiderata bona praktiko labori sub striktaj kondiĉoj.
  2. Eĉ se funkcio ŝanĝas la staton plurfoje - ekzemple, ni ŝanĝas plurajn kampojn en pluraj linioj de kodo - la observantoj estas sciigitaj nur kiam ĝi finiĝas. Ĉi tio estas precipe grava por la fasado, kie nenecesaj ŝtataj ĝisdatigoj kondukas al nenecesa bildigo de elementoj. En nia kazo, nek la unua nek la dua estas aparte grava, sed ni sekvos la plej bonajn praktikojn. Estas kutime alligi dekoraciistojn al ĉiuj funkcioj, kiuj ŝanĝas la staton de la observitaj kampoj.

Fone ni aldonos inicialigon kaj konservadon de la stato en loka Stokado:

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

La reakcia funkcio estas interesa ĉi tie. Ĝi havas du argumentojn:

  1. Elektilo de datumoj.
  2. Pritraktilo, kiu estos vokita kun ĉi tiuj datumoj ĉiufoje kiam ĝi ŝanĝiĝas.

Male al redux, kie ni eksplicite ricevas la staton kiel argumenton, mobx memoras kiujn observaĵojn ni aliras ene de la elektilo, kaj nur vokas la prizorganton kiam ili ŝanĝiĝas.

Gravas kompreni precize kiel mobx decidas al kiuj observaĵoj ni abonas. Se mi skribis elektilon en kodo tia() => app.store, tiam reago neniam estos nomita, ĉar la stokado mem ne estas observebla, nur ĝiaj kampoj estas.

Se mi skribis ĝin tiel () => app.store.keys, tiam denove nenio okazus, ĉar aldonante/forigante tabelelementojn, la referenco al ĝi ne ŝanĝiĝos.

Mobx funkcias kiel elektilo por la unua fojo kaj nur konservas observaĵojn, kiujn ni aliris. Ĉi tio estas farita per prokuriloj. Tial, la enkonstruita funkcio estas uzata ĉi tie toJS. Ĝi resendas novan objekton kun ĉiuj prokuriloj anstataŭigitaj per la originaj kampoj. Dum ekzekuto, ĝi legas ĉiujn kampojn de la objekto - tial la riceviloj estas ekigitaj.

En la ŝprucfenestra konzolo ni denove aldonos plurajn klavojn. Ĉi-foje ili ankaŭ eniris lokan Stokadon:

Skribante sekuran retumilon

Kiam la fona paĝo estas reŝargita, la informoj restas en la loko.

Ĉiu aplika kodo ĝis ĉi tiu punkto estas videbla tie.

Sekura stokado de privataj ŝlosiloj

Stoki privatajn ŝlosilojn en klara teksto estas nesekura: ĉiam estas ŝanco, ke vi estos hakita, akiri aliron al via komputilo, ktp. Tial, en localStorage ni stokos la ŝlosilojn en pasvort-ĉifrita formo.

Por pli granda sekureco, ni aldonos ŝlositan staton al la aplikaĵo, en kiu tute ne estos aliro al la ŝlosiloj. Ni aŭtomate translokigos la etendon al la ŝlosita stato pro tempodaŭro.

Mobx permesas vin stoki nur minimuman aron da datumoj, kaj la resto aŭtomate estas kalkulita surbaze de ĝi. Ĉi tiuj estas la tiel nomataj komputitaj ecoj. Ili povas esti komparitaj kun vidoj en datumbazoj:

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

Nun ni konservas nur la ĉifritajn ŝlosilojn kaj pasvorton. Ĉio alia estas kalkulita. Ni faras la translokigon al ŝlosita ŝtato forigante la pasvorton de la ŝtato. La publika API nun havas metodon por pravalorigi la stokadon.

Skribita por ĉifrado iloj uzante 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)
}

La retumilo havas neaktivan API per kiu vi povas aboni eventon - ŝtatŝanĝoj. Ŝtato, sekve, povas esti idle, active и locked. Por neaktiva vi povas agordi tempon, kaj ŝlosita estas agordita kiam la OS mem estas blokita. Ni ankaŭ ŝanĝos la elektilon por konservi en loka Stokado:

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

La kodo antaŭ ĉi tiu paŝo estas tie.

Transakcioj

Do, ni venas al la plej grava afero: krei kaj subskribi transakciojn sur la blokĉeno. Ni uzos la blokĉenon kaj bibliotekon de WAVES ondoj-transakcioj.

Unue, ni aldonu al la ŝtato aron da mesaĝoj, kiuj devas esti subskribitaj, poste aldonu metodojn por aldoni novan mesaĝon, konfirmi la subskribon kaj rifuzi:

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

    ...
}

Kiam ni ricevas novan mesaĝon, ni aldonas metadatumojn al ĝi, faru observable kaj aldoni al store.messages.

Se vi ne faras observable permane, tiam mobx faros ĝin mem aldonante mesaĝojn al la tabelo. Tamen ĝi kreos novan objekton, al kiu ni ne havos referencon, sed ni bezonos ĝin por la sekva paŝo.

Poste, ni resendas promeson, kiu solvas kiam la mesaĝo-stato ŝanĝiĝas. La statuso estas monitorita per reago, kiu "mortigos sin" kiam la statuso ŝanĝiĝos.

Metoda kodo approve и reject tre simple: ni simple ŝanĝas la staton de la mesaĝo, post subskribi ĝin se necese.

Ni metas Aprobu kaj malakcepti en la UI-API, novanMesaĝon en la paĝo-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)
        }
    }

    ...
}

Nun ni provu subskribi la transakcion per la etendaĵo:

Skribante sekuran retumilon

Ĝenerale, ĉio estas preta, ĉio, kio restas aldonu simplan UI.

UI

La interfaco bezonas aliron al la aplika stato. Sur la UI-flanko ni faros observable stato kaj aldonu funkcion al la API, kiu ŝanĝos ĉi tiun staton. Ni aldonu observable al la API-objekto ricevita de fono:

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

Je la fino ni komencas bildigi la aplikan interfacon. Ĉi tio estas reagi aplikaĵo. La fonobjekto estas simple preterpasita uzante apogilojn. Estus ĝuste, kompreneble, fari apartan servon por metodoj kaj vendejo por la ŝtato, sed por la celoj de ĉi tiu artikolo ĉi tio sufiĉas:

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

Kun mobx estas tre facile komenci bildigon kiam datumoj ŝanĝiĝas. Ni simple pendigas la observan dekoraciiston de la pakaĵo mobx-reagi sur la komponento, kaj bildigo estos aŭtomate vokita kiam iuj observeblaj referencoj de la komponento ŝanĝiĝos. Vi ne bezonas mapStateToProps aŭ konekti kiel en redux. Ĉio funkcias tuj el la skatolo:

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

La ceteraj komponantoj estas videblaj en la kodo en la UI-dosierujo.

Nun en la aplika klaso vi devas fari ŝtatselektilon por la UI kaj sciigi la UI kiam ĝi ŝanĝiĝas. Por fari tion, ni aldonu metodon getState и reactionvokado 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())

        })
    }

    ...
}

Kiam oni ricevas objekton remote estas kreita reaction ŝanĝi la staton kiu vokas la funkcion sur la UI-flanko.

La fina tuŝo estas aldoni montradon de novaj mesaĝoj sur la etenda ikono:

function setupApp() {
...

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

...
}

Do, la aplikaĵo estas preta. Retpaĝoj povas peti subskribon por transakcioj:

Skribante sekuran retumilon

Skribante sekuran retumilon

La kodo haveblas ĉi tie ligilo.

konkludo

Se vi legis la artikolon ĝis la fino, sed ankoraŭ havas demandojn, vi povas demandi ilin ĉe deponejoj kun etendo. Tie vi ankaŭ trovos komitaĵojn por ĉiu elektita paŝo.

Kaj se vi interesiĝas rigardi la kodon por la reala etendo, vi povas trovi ĉi tion tie.

Kodo, deponejo kaj laborpriskribo de siemarell

fonto: www.habr.com

Aldoni komenton