Pisanje varne razširitve brskalnika

Pisanje varne razširitve brskalnika

Za razliko od običajne arhitekture »odjemalec-strežnik« je za decentralizirane aplikacije značilno:

  • Ni potrebe po shranjevanju baze podatkov z uporabniškimi prijavami in gesli. Informacije o dostopu hranijo izključno uporabniki sami, potrditev njihove pristnosti pa poteka na ravni protokola.
  • Ni potrebe po uporabi strežnika. Logika aplikacije se lahko izvaja na omrežju blockchain, kjer je možno shraniti zahtevano količino podatkov.

Obstajata 2 razmeroma varni shrambi za uporabniške ključe – strojne denarnice in razširitve brskalnika. Strojne denarnice so večinoma izjemno varne, a težke za uporabo in še zdaleč niso brezplačne, vendar so razširitve brskalnika popolna kombinacija varnosti in enostavne uporabe ter so lahko za končne uporabnike tudi povsem brezplačne.

Ob upoštevanju vsega tega smo želeli izdelati najbolj varno razširitev, ki poenostavi razvoj decentraliziranih aplikacij z zagotavljanjem preprostega API-ja za delo s transakcijami in podpisi.
Spodaj vam bomo povedali o tej izkušnji.

Članek bo vseboval navodila po korakih o tem, kako napisati razširitev brskalnika, s primeri kode in posnetki zaslona. Vso kodo najdete v repozitorije. Vsaka potrditev logično ustreza razdelku tega članka.

Kratka zgodovina razširitev brskalnika

Razširitve brskalnikov obstajajo že dolgo časa. V Internet Explorerju so se pojavili že leta 1999, v Firefoxu pa leta 2004. Vendar zelo dolgo ni bilo enotnega standarda za razširitve.

Lahko rečemo, da se je pojavil skupaj z razširitvami v četrti različici Google Chroma. Seveda takrat ni bilo nobene specifikacije, vendar je Chrome API postal njegova osnova: Chrome je z osvojitvijo večine trga brskalnikov in z vgrajeno trgovino aplikacij dejansko postavil standard za razširitve brskalnika.

Mozilla je imela svoj standard, vendar se je podjetje zaradi priljubljenosti razširitev za Chrome odločilo narediti združljiv API. Leta 2015 je bila na pobudo Mozille v okviru World Wide Web Consortium (W3C) ustanovljena posebna skupina za delo na specifikacijah razširitev med brskalniki.

Za osnovo so bile vzete obstoječe razširitve API-ja za Chrome. Delo je potekalo s podporo Microsofta (Google je zavrnil sodelovanje pri razvoju standarda), zato se je pojavil osnutek specifikacije.

Formalno specifikacijo podpirajo Edge, Firefox in Opera (upoštevajte, da Chrome ni na tem seznamu). Toda v resnici je standard v veliki meri združljiv s Chromom, saj je dejansko napisan na podlagi njegovih razširitev. Več o API-ju WebExtensions lahko preberete tukaj.

Struktura razširitve

Edina datoteka, ki je potrebna za razširitev, je manifest (manifest.json). Je tudi "vstopna točka" za širitev.

Manifest

V skladu s specifikacijo je datoteka manifesta veljavna datoteka JSON. Ogledati si je mogoče popoln opis manifestnih ključev z informacijami o tem, kateri ključi so podprti v katerem brskalniku tukaj.

Ključi, ki niso v specifikaciji, se »lahko« prezrejo (tako Chrome kot Firefox poročata o napakah, vendar razširitvi še naprej delujeta).

In rad bi opozoril na nekaj točk.

  1. ozadje — predmet, ki vključuje naslednja polja:
    1. skripte — niz skriptov, ki se bodo izvajali v kontekstu ozadja (o tem bomo govorili malo kasneje);
    2. Stran - namesto skriptov, ki se bodo izvajale na prazni strani, lahko določite html z vsebino. V tem primeru bo polje s skriptom prezrto, skripte pa bo treba vstaviti na stran z vsebino;
    3. obstojne — binarna zastavica, če ni določena, bo brskalnik »ubil« proces v ozadju, ko meni, da ne dela ničesar, in ga po potrebi znova zagnal. V nasprotnem primeru bo stran razložena šele, ko je brskalnik zaprt. Ni podprto v Firefoxu.
  2. vsebinski_skripti — niz predmetov, ki vam omogoča nalaganje različnih skriptov na različne spletne strani. Vsak predmet vsebuje naslednja pomembna polja:
    1. tekme - vzorec url, ki določa, ali bo določen skript vsebine vključen ali ne.
    2. js — seznam skriptov, ki bodo naloženi v to tekmo;
    3. exclude_matches - izloči iz polja match URL-ji, ki ustrezajo temu polju.
  3. page_action - je pravzaprav predmet, ki je odgovoren za ikono, ki je prikazana poleg naslovne vrstice v brskalniku in interakcijo z njo. Omogoča tudi prikaz pojavnega okna, ki je definirano z uporabo lastnega HTML, CSS in JS.
    1. default_popup — pot do datoteke HTML s pojavnim vmesnikom, lahko vsebuje CSS in JS.
  4. Dovoljenja — polje za upravljanje razširitvenih pravic. Obstajajo 3 vrste pravic, ki so podrobno opisane tukaj
  5. spletni_dostopni_viri — razširitveni viri, ki jih lahko zahteva spletna stran, na primer slike, datoteke JS, CSS, HTML.
  6. zunanje_povezljiv — tukaj lahko izrecno določite ID-je drugih končnic in domen spletnih strani, s katerih se lahko povežete. Domena je lahko druge ravni ali višje. Ne deluje v Firefoxu.

Kontekst izvajanja

Razširitev ima tri kontekste izvajanja kode, to pomeni, da je aplikacija sestavljena iz treh delov z različnimi stopnjami dostopa do API-ja brskalnika.

Kontekst razširitve

Večina API-ja je na voljo tukaj. V tem kontekstu »živijo«:

  1. Stran v ozadju — »backend« del razširitve. Datoteka je določena v manifestu s tipko »ozadje«.
  2. Pojavna stran — pojavna stran, ki se prikaže, ko kliknete ikono razširitve. V manifestu browser_action -> default_popup.
  3. Stran po meri — razširitvena stran, ki »živi« v ločenem zavihku pogleda chrome-extension://<id_расширения>/customPage.html.

Ta kontekst obstaja neodvisno od oken in zavihkov brskalnika. Stran v ozadju obstaja v eni sami kopiji in vedno deluje (izjema je stran dogodka, ko se skript v ozadju zažene z dogodkom in po njegovi izvedbi "umre"). Pojavna stran obstaja, ko je odprto pojavno okno in Stran po meri — ko je zavihek z njim odprt. Iz tega konteksta ni dostopa do drugih zavihkov in njihove vsebine.

Kontekst skripta vsebine

Datoteka s skriptom vsebine se zažene skupaj z vsakim zavihkom brskalnika. Ima dostop do dela API-ja razširitve in do drevesa DOM spletne strani. Za interakcijo s stranjo so odgovorni skripti vsebine. Razširitve, ki manipulirajo z drevesom DOM, to počnejo v skriptih vsebine - na primer zaviralci oglasov ali prevajalniki. Prav tako lahko vsebinski skript komunicira s stranjo prek standarda postMessage.

Kontekst spletne strani

To je dejanska spletna stran. Nima nobene zveze s končnico in do nje nima dostopa, razen v primerih, ko domena te strani ni izrecno navedena v manifestu (več o tem spodaj).

Izmenjava sporočil

Različni deli aplikacije si morajo med seboj izmenjevati sporočila. Za to obstaja API runtime.sendMessage da pošljete sporočilo background и tabs.sendMessage za pošiljanje sporočila na stran (skript vsebine, pojavno okno ali spletno stran, če je na voljo externally_connectable). Spodaj je primer dostopa do API-ja Chrome.

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

Za popolno komunikacijo lahko ustvarite povezave prek runtime.connect. V odgovor bomo prejeli runtime.Port, na katero lahko, ko je odprta, pošljete poljubno število sporočil. Na strani odjemalca je npr. contentscript, izgleda takole:

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

Strežnik ali ozadje:

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

Obstaja tudi dogodek onDisconnect in metoda disconnect.

Diagram uporabe

Naredimo razširitev brskalnika, ki shranjuje zasebne ključe, omogoča dostop do javnih informacij (naslov, javni ključ komunicira s stranjo in omogoča aplikacijam tretjih oseb, da zahtevajo podpis za transakcije.

Razvoj aplikacij

Naša aplikacija mora komunicirati z uporabnikom in strani zagotoviti API za klicanje metod (na primer za podpisovanje transakcij). Zadovoljite se samo z enim contentscript ne bo delovalo, ker ima dostop samo do DOM-a, ne pa tudi do JS strani. Povežite se prek runtime.connect ne moremo, ker je API potreben na vseh domenah, v manifestu pa je mogoče navesti samo določene. Kot rezultat bo diagram videti takole:

Pisanje varne razširitve brskalnika

Tam bo še en scenarij - inpage, ki ga bomo vnesli na stran. Deloval bo v svojem kontekstu in zagotovil API za delo z razširitvijo.

začenja

Vsa koda razširitve brskalnika je na voljo na GitHub. Med opisom bodo povezave do zavez.

Začnimo z manifestom:

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

Ustvarite prazen background.js, popup.js, inpage.js in contentscript.js. Dodamo popup.html - in našo aplikacijo lahko že naložimo v Google Chrome in se prepričamo, da deluje.

Če želite to preveriti, lahko vzamete kodo zato. Poleg tega, kar smo storili, je povezava konfigurirala sestavo projekta z uporabo webpacka. Če želite dodati aplikacijo v brskalnik, morate v chrome://extensions izbrati load unpacked in mapo z ustrezno končnico - v našem primeru dist.

Pisanje varne razširitve brskalnika

Zdaj je naša razširitev nameščena in deluje. Orodja za razvijalce lahko zaženete za različne kontekste, kot sledi:

pojavno okno ->

Pisanje varne razširitve brskalnika

Dostop do konzole vsebinskega skripta poteka prek konzole same strani, na kateri se zažene.Pisanje varne razširitve brskalnika

Izmenjava sporočil

Vzpostaviti moramo torej dva komunikacijska kanala: inpage <-> ozadje in pojavno okno <-> ozadje. Seveda lahko samo pošljete sporočila v vrata in izumite svoj lasten protokol, vendar imam raje pristop, ki sem ga videl v odprtokodnem projektu metamask.

To je razširitev brskalnika za delo z omrežjem Ethereum. V njej različni deli aplikacije komunicirajo prek RPC z uporabo knjižnice dnode. Omogoča vam, da izmenjavo organizirate precej hitro in priročno, če ji zagotovite tok nodejs kot transport (kar pomeni objekt, ki implementira isti vmesnik):

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

Zdaj bomo ustvarili razred aplikacije. Ustvaril bo objekte API za pojavno okno in spletno stran ter zanju ustvaril vozlišče:

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

Tukaj in spodaj namesto globalnega Chromovega objekta uporabljamo extensionApi, ki dostopa do Chroma v Googlovem brskalniku in brskalnika v drugih. To je narejeno zaradi združljivosti med brskalniki, vendar bi lahko za namene tega članka preprosto uporabili »chrome.runtime.connect«.

Ustvarimo primerek aplikacije v skriptu v ozadju:

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

Ker dnode deluje s tokovi in ​​prejmemo vrata, je potreben razred adapterja. Izdelan je s knjižnico readable-stream, ki implementira tokove nodejs v brskalniku:

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

Zdaj pa ustvarimo povezavo v uporabniškem vmesniku:

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

Nato ustvarimo povezavo v skriptu vsebine:

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

Ker API-ja ne potrebujemo v skriptu vsebine, temveč neposredno na strani, naredimo dve stvari:

  1. Ustvarimo dva toka. Ena - proti strani, na vrhu objave Sporočilo. Za to uporabljamo to ta paket od ustvarjalcev metamaske. Drugi tok je v ozadju prek vrat, iz katerega je bil prejet runtime.connect. Kupimo jih. Zdaj bo stran imela tok v ozadju.
  2. Vnesite skript v DOM. Prenesite skript (dostop do njega je bil dovoljen v manifestu) in ustvarite oznako script z vsebino v notranjosti:

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

Zdaj ustvarimo objekt api v inpage in ga nastavimo na globalno:

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

Pripravljeni smo Remote Procedure Call (RPC) z ločenim API-jem za stran in uporabniški vmesnik. Pri povezovanju nove strani z ozadjem lahko vidimo tole:

Pisanje varne razširitve brskalnika

Prazen API in izvor. Na strani strani lahko funkcijo hello pokličemo takole:

Pisanje varne razširitve brskalnika

Delo s funkcijami povratnega klica v sodobnem JS je slabo, zato napišimo majhen pomočnik za ustvarjanje vozlišča dnode, ki vam omogoča, da predmet API posredujete pripomočkom.

Objekti API bodo zdaj videti takole:

export class SignerApp {

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

...

}

Pridobivanje objekta na daljavo, kot je ta:

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

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

In klicanje funkcij vrne obljubo:

Pisanje varne razširitve brskalnika

Na voljo je različica z asinhronimi funkcijami tukaj.

Na splošno se zdi, da sta RPC in tokovni pristop precej prilagodljiva: uporabimo lahko parno multipleksiranje in ustvarimo več različnih API-jev za različne naloge. Načeloma se lahko dnode uporablja kjer koli, glavno je, da transport zavijete v obliki toka nodejs.

Alternativa je format JSON, ki implementira protokol JSON RPC 2. Deluje pa s posebnimi transporti (TCP in HTTP(S)), kar v našem primeru ni uporabno.

Notranje stanje in lokalna shramba

Shraniti bomo morali notranje stanje aplikacije – vsaj ključe za podpisovanje. Aplikaciji lahko preprosto dodamo stanje in metode za njegovo spreminjanje v pojavnem API-ju:

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

    ...

} 

V ozadju bomo vse zavili v funkcijo in objekt aplikacije zapisali v okno, da bomo lahko z njim delali s konzole:

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

Dodajmo nekaj tipk iz konzole uporabniškega vmesnika in poglejmo, kaj se zgodi s stanjem:

Pisanje varne razširitve brskalnika

Stanje je treba narediti obstojno, da se ključi ob ponovnem zagonu ne izgubijo.

Shranili ga bomo v localStorage in ga prepisali ob vsaki spremembi. Kasneje bo dostop do nje potreben tudi za uporabniški vmesnik, na spremembe pa se tudi naročam. Na podlagi tega bo priročno ustvariti opazovano shrambo in se naročiti na njene spremembe.

Uporabili bomo knjižnico mobx (https://github.com/mobxjs/mobx). Izbira je padla nanjo, ker mi ni bilo treba delati z njo, vendar sem si jo zelo želela preučiti.

Dodajmo inicializacijo začetnega stanja in naredimo trgovino opazljivo:

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

    ...

}

"Pod pokrovom" je mobx vsa polja trgovine zamenjal s posrednikom in prestreže vse klice do njih. Na ta sporočila se bo mogoče naročiti.

V nadaljevanju bom pogosto uporabljal izraz »pri menjavi«, čeprav to ni povsem pravilno. Mobx sledi dostopu do polj. Uporabljajo se pridobivalniki in nastavljalci proxy objektov, ki jih ustvari knjižnica.

Akcijski dekoraterji imajo dva namena:

  1. V strogem načinu z zastavico enforceActions mobx prepoveduje neposredno spreminjanje stanja. Za dobro prakso velja delo pod strogimi pogoji.
  2. Tudi če funkcija večkrat spremeni stanje - na primer spremenimo več polj v več vrsticah kode - so opazovalci obveščeni šele, ko se zaključi. To je še posebej pomembno za sprednji del, kjer nepotrebne posodobitve stanja vodijo do nepotrebnega upodabljanja elementov. V našem primeru niti prvo niti drugo ni posebej relevantno, vendar bomo upoštevali najboljše prakse. Vsem funkcijam, ki spreminjajo stanje opazovanih polj, je običajno pripeti dekoratorje.

V ozadju bomo dodali inicializacijo in shranjevanje stanja v localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Tu je zanimiva reakcijska funkcija. Ima dva argumenta:

  1. Izbirnik podatkov.
  2. Obravnavalnik, ki bo poklican s temi podatki vsakič, ko se spremenijo.

Za razliko od reduxa, kjer izrecno prejmemo stanje kot argument, si mobx zapomni, do katerih opazovalcev dostopamo znotraj izbirnika, in pokliče upravljalnik samo, ko se spremenijo.

Pomembno je natančno razumeti, kako mobx odloča, na katere opazovane vrednosti smo naročeni. Če bi napisal izbirnik v kodi, kot je ta() => app.store, potem reakcija ne bo nikoli poklicana, saj samega pomnilnika ni mogoče opazovati, opazovati so le njegova polja.

Če bi takole napisal () => app.store.keys, potem se spet ne bi zgodilo nič, saj se pri dodajanju/odstranjevanju elementov polja sklic nanj ne bo spremenil.

Mobx prvič deluje kot izbirnik in spremlja samo opazovalke, do katerih smo dostopali. To se naredi prek posredniških pridobivalnikov. Zato je tukaj uporabljena vgrajena funkcija toJS. Vrne nov objekt z vsemi posredniki, zamenjanimi z izvirnimi polji. Med izvajanjem prebere vsa polja objekta - zato se sprožijo pridobivalniki.

V pojavno konzolo bomo spet dodali več ključev. Tudi tokrat so končali v localStorage:

Pisanje varne razširitve brskalnika

Ko se stran v ozadju znova naloži, informacije ostanejo na mestu.

Ogledate si lahko vso kodo aplikacije do te točke tukaj.

Varno shranjevanje zasebnih ključev

Shranjevanje zasebnih ključev v čistem besedilu ni varno: vedno obstaja možnost, da vas bodo vdrli, pridobili dostop do vašega računalnika itd. Zato bomo v localStorage shranili ključe v obliki, šifrirani z geslom.

Za večjo varnost bomo aplikaciji dodali zaklenjeno stanje, v katerem sploh ne bo dostopa do ključev. Zaradi časovne omejitve bomo razširitev samodejno prenesli v zaklenjeno stanje.

Mobx omogoča shranjevanje le minimalnega nabora podatkov, preostanek pa se samodejno izračuna na podlagi tega. To so tako imenovane računalniške lastnosti. Lahko jih primerjamo s pogledi v zbirkah podatkov:

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

Zdaj hranimo samo šifrirane ključe in geslo. Vse ostalo je izračunano. Prenos v zaklenjeno stanje naredimo tako, da odstranimo geslo iz stanja. Javni API ima zdaj metodo za inicializacijo pomnilnika.

Napisano za šifriranje pripomočki, ki uporabljajo 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)
}

Brskalnik ima nedejaven API, prek katerega se lahko naročite na dogodek - spremembe stanja. Država, v skladu s tem, lahko idle, active и locked. Za mirovanje lahko nastavite časovno omejitev, zaklenjeno pa je nastavljeno, ko je sam OS blokiran. Spremenili bomo tudi izbirnik za shranjevanje v localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Koda pred tem korakom je tukaj.

Posel

Tako smo prišli do najpomembnejše stvari: ustvarjanje in podpisovanje transakcij v verigi blokov. Uporabili bomo verigo blokov in knjižnico WAVES valovi-transakcije.

Najprej dodajmo stanju niz sporočil, ki jih je treba podpisati, nato pa dodajmo metode za dodajanje novega sporočila, potrditev podpisa in zavrnitev:

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

    ...
}

Ko prejmemo novo sporočilo, mu dodamo metapodatke observable in dodajte k store.messages.

Če ne observable ročno, potem bo mobx pri dodajanju sporočil v polje to naredil sam. Vendar pa bo ustvaril nov objekt, na katerega ne bomo imeli sklica, vendar ga bomo potrebovali za naslednji korak.

Nato vrnemo obljubo, ki se razreši, ko se status sporočila spremeni. Stanje spremlja reakcija, ki se ob spremembi statusa »ubije«.

Koda metode approve и reject zelo preprosto: preprosto spremenimo status sporočila, po potrebi ga podpišemo.

Odobri in zavrni v API UI, newMessage v API strani:

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

    ...
}

Zdaj pa poskusimo podpisati transakcijo s pripono:

Pisanje varne razširitve brskalnika

Na splošno je vse pripravljeno, ostalo je le dodajte preprost uporabniški vmesnik.

UI

Vmesnik potrebuje dostop do stanja aplikacije. Na strani uporabniškega vmesnika bomo naredili observable in v API dodajte funkcijo, ki bo spremenila to stanje. Dodajmo observable na objekt API, prejet iz ozadja:

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

Na koncu začnemo upodabljati vmesnik aplikacije. To je aplikacija za odziv. Objekt ozadja se preprosto posreduje z uporabo rekvizitov. Seveda bi bilo pravilno narediti ločeno storitev za metode in trgovino za državo, vendar je za namene tega članka to dovolj:

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

Z mobxom je zelo enostavno začeti upodabljanje, ko se podatki spremenijo. Dekorater opazovalca enostavno obesimo iz paketa mobx-react na komponenti in upodabljanje bo samodejno poklicano, ko se spremeni katera koli opazovalna vrednost, na katero se sklicuje komponenta. Ne potrebujete nobenih mapStateToProps ali povezave kot v reduxu. Vse deluje takoj iz škatle:

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

Preostale komponente si lahko ogledate v kodi v mapi UI.

Zdaj morate v razredu aplikacije narediti izbirnik stanja za uporabniški vmesnik in obvestiti uporabniški vmesnik, ko se spremeni. Če želite to narediti, dodajmo metodo getState и reactionklicanje 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())

        })
    }

    ...
}

Ob prejemu predmeta remote je ustvarjen reaction da spremenite stanje, ki kliče funkcijo na strani uporabniškega vmesnika.

Zadnji dotik je dodajanje prikaza novih sporočil na ikono razširitve:

function setupApp() {
...

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

...
}

Tako, aplikacija je pripravljena. Spletne strani lahko zahtevajo podpis za transakcije:

Pisanje varne razširitve brskalnika

Pisanje varne razširitve brskalnika

Koda je na voljo tukaj povezava.

Zaključek

Če ste članek prebrali do konca, vendar imate še vedno vprašanja, jih lahko postavite na repozitoriji z razširitvijo. Tam boste našli tudi potrditve za vsak določen korak.

In če vas zanima koda za dejansko razširitev, lahko to najdete tukaj.

Koda, repozitorij in opis delovnega mesta iz siemarell

Vir: www.habr.com

Dodaj komentar