Pisanje sigurnog proširenja preglednika

Pisanje sigurnog proširenja preglednika

Za razliku od uobičajene arhitekture "klijent-poslužitelj", decentralizirane aplikacije karakteriziraju:

  • Nema potrebe za pohranjivanjem baze podataka s korisničkim prijavama i lozinkama. Pristupne informacije pohranjuju isključivo sami korisnici, a potvrda njihove autentičnosti događa se na razini protokola.
  • Nema potrebe za korištenjem poslužitelja. Logika aplikacije može se izvršiti na blockchain mreži, gdje je moguće pohraniti potrebnu količinu podataka.

Postoje 2 relativno sigurna skladišta za korisničke ključeve - hardverski novčanici i proširenja preglednika. Hardverski novčanici većinom su iznimno sigurni, no teški za korištenje i daleko od besplatnih, no ekstenzije preglednika savršena su kombinacija sigurnosti i jednostavnosti korištenja, a mogu biti i potpuno besplatne za krajnje korisnike.

Uzimajući sve ovo u obzir, htjeli smo napraviti najsigurnije proširenje koje pojednostavljuje razvoj decentraliziranih aplikacija pružajući jednostavan API za rad s transakcijama i potpisima.
Ispričat ćemo vam o ovom iskustvu u nastavku.

Članak će sadržavati upute korak po korak o tome kako napisati proširenje preglednika, s primjerima koda i snimkama zaslona. Sav kod možete pronaći u spremišta. Svaki commit logično odgovara dijelu ovog članka.

Kratka povijest proširenja preglednika

Proširenja preglednika postoje već dugo vremena. U Internet Exploreru su se pojavili još 1999. godine, u Firefoxu 2004. godine. Međutim, jako dugo nije postojao jedinstven standard za ekstenzije.

Možemo reći da se pojavio zajedno s proširenjima u četvrtoj verziji Google Chromea. Naravno, tada nije bilo specifikacije, ali je Chrome API postao njegova osnova: nakon što je osvojio većinu tržišta preglednika i imao ugrađenu trgovinu aplikacija, Chrome je zapravo postavio standard za proširenja preglednika.

Mozilla je imala vlastiti standard, ali vidjevši popularnost proširenja za Chrome, tvrtka je odlučila napraviti kompatibilan API. U 2015. godini, na inicijativu Mozille, unutar World Wide Web Consortiuma (W3C) stvorena je posebna grupa za rad na specifikacijama proširenja za više preglednika.

Postojeća API proširenja za Chrome uzeta su kao osnova. Rad je obavljen uz podršku Microsofta (Google je odbio sudjelovati u razvoju standarda), a kao rezultat pojavio se nacrt tehnički podaci.

Formalno, specifikaciju podržavaju Edge, Firefox i Opera (imajte na umu da Chrome nije na ovom popisu). No zapravo je standard u velikoj mjeri kompatibilan s Chromeom, budući da je zapravo napisan na temelju njegovih proširenja. Možete pročitati više o WebExtensions API-ju здесь.

Struktura proširenja

Jedina datoteka koja je potrebna za proširenje je manifest (manifest.json). To je također "ulazna točka" za proširenje.

manifest

Prema specifikaciji, datoteka manifesta valjana je JSON datoteka. Može se vidjeti potpuni opis ključeva manifesta s informacijama o tome koji su ključevi podržani u kojem pregledniku здесь.

Ključevi koji nisu u specifikaciji "mogu" se zanemariti (i Chrome i Firefox prijavljuju pogreške, ali proširenja nastavljaju raditi).

I želio bih skrenuti pozornost na neke točke.

  1. pozadina — objekt koji uključuje sljedeća polja:
    1. skripta — niz skripti koje će se izvršavati u pozadinskom kontekstu (o tome ćemo malo kasnije);
    2. stranica - umjesto skripti koje će se izvršavati na praznoj stranici, možete navesti html sa sadržajem. U ovom slučaju, polje skripte će biti zanemareno, a skripte će se morati umetnuti na stranicu sadržaja;
    3. uporan — binarna zastavica, ako nije navedena, preglednik će "ubiti" pozadinski proces kada smatra da ne radi ništa i ponovno ga pokrenuti ako je potrebno. U suprotnom, stranica će se isprazniti samo kada se preglednik zatvori. Nije podržano u Firefoxu.
  2. skripte_sadržaja — niz objekata koji vam omogućuje učitavanje različitih skripti na različite web stranice. Svaki objekt sadrži sljedeća važna polja:
    1. šibice - uzorak url, koji određuje hoće li određena skripta sadržaja biti uključena ili ne.
    2. js — popis skripti koje će biti učitane u ovu utakmicu;
    3. isključi_podudaranja - isključuje s terena match URL-ovi koji odgovaraju ovom polju.
  3. stranica_akcija - zapravo je objekt koji je odgovoran za ikonu koja se prikazuje pored adresne trake u pregledniku i interakciju s njom. Također vam omogućuje prikaz skočnog prozora koji je definiran pomoću vašeg vlastitog HTML-a, CSS-a i JS-a.
    1. zadani_skočni prozor — put do HTML datoteke sa skočnim sučeljem, može sadržavati CSS i JS.
  4. dozvole — polje za upravljanje pravima proširenja. Postoje 3 vrste prava, koja su detaljno opisana ovdje
  5. web_dostupni_resursi — resursi proširenja koje web stranica može zahtijevati, na primjer, slike, JS, CSS, HTML datoteke.
  6. vanjski_spojiv — ovdje možete eksplicitno navesti ID-ove drugih ekstenzija i domena web stranica s kojih se možete spojiti. Domena može biti druge razine ili više. Ne radi u Firefoxu.

Kontekst izvršenja

Ekstenzija ima tri konteksta izvršavanja koda, odnosno aplikacija se sastoji od tri dijela s različitim razinama pristupa API-ju preglednika.

Kontekst proširenja

Većina API-ja dostupna je ovdje. U tom kontekstu oni “žive”:

  1. Pozadinska stranica — “backend” dio proširenja. Datoteka je navedena u manifestu pomoću tipke "background".
  2. Skočna stranica — skočna stranica koja se pojavljuje kada kliknete na ikonu proširenja. U manifestu browser_action -> default_popup.
  3. Prilagođena stranica — stranica proširenja, "živi" u zasebnoj kartici prikaza chrome-extension://<id_расширения>/customPage.html.

Ovaj kontekst postoji neovisno o prozorima preglednika i karticama. Pozadinska stranica postoji u jednoj kopiji i uvijek radi (iznimka je stranica događaja, kada se pozadinska skripta pokreće događajem i "umire" nakon njegovog izvršenja). Skočna stranica postoji kada je skočni prozor otvoren, i Prilagođena stranica — dok je kartica s njim otvorena. Ne postoji pristup drugim karticama i njihovim sadržajima iz ovog konteksta.

Kontekst skripte sadržaja

Datoteka skripte sadržaja pokreće se zajedno sa svakom karticom preglednika. Ima pristup dijelu API-ja proširenja i DOM stablu web stranice. Za interakciju sa stranicom odgovorne su skripte sadržaja. Proširenja koja manipuliraju DOM stablom to čine u skriptama sadržaja - na primjer, programi za blokiranje oglasa ili prevoditelji. Također, sadržajna skripta može komunicirati sa stranicom putem standarda postMessage.

Kontekst web stranice

Ovo je sama stvarna web stranica. Nema nikakve veze s ekstenzijom i nema pristup tamo, osim u slučajevima kada domena ove stranice nije izričito navedena u manifestu (više o tome u nastavku).

Razmjena poruka

Različiti dijelovi aplikacije moraju međusobno razmjenjivati ​​poruke. Za to postoji API runtime.sendMessage poslati poruku background и tabs.sendMessage za slanje poruke na stranicu (skriptu sadržaja, skočni prozor ili web stranicu ako je dostupna externally_connectable). Ispod je primjer pristupa Chrome API-ju.

// Сообщением может быть любой 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 potpunu komunikaciju možete uspostaviti veze putem runtime.connect. Kao odgovor ćemo dobiti runtime.Port, na koji, dok je otvoren, možete slati neograničeni broj poruka. Na strani klijenta npr. contentscript, izgleda ovako:

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

Poslužitelj ili pozadina:

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

Postoji i događaj onDisconnect i metoda disconnect.

Dijagram primjene

Napravimo ekstenziju preglednika koja pohranjuje privatne ključeve, omogućuje pristup javnim informacijama (adresa, javni ključ komunicira sa stranicom i omogućuje aplikacijama trećih strana da traže potpis za transakcije.

Razvoj aplikacija

Naša aplikacija mora komunicirati s korisnikom i pružiti stranici API za pozivanje metoda (na primjer, za potpisivanje transakcija). Zadovoljite se samo jednim contentscript neće raditi jer ima pristup samo DOM-u, ali ne i JS-u stranice. Povežite se putem runtime.connect ne možemo, jer je API potreban na svim domenama, a samo određene mogu biti navedene u manifestu. Kao rezultat, dijagram će izgledati ovako:

Pisanje sigurnog proširenja preglednika

Bit će još jedan scenarij - inpage, koje ćemo ubaciti u stranicu. Pokretat će se u svom kontekstu i pružiti API za rad s proširenjem.

početak

Sav kod proširenja preglednika dostupan je na GitHub. Tijekom opisa nalazit će se poveznice na obveze.

Počnimo s 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"]
}

Napravite prazan background.js, popup.js, inpage.js i contentscript.js. Dodamo popup.html - i našu aplikaciju već možemo učitati u Google Chrome i uvjeriti se da radi.

Da biste to potvrdili, možete uzeti kod stoga. Osim onoga što smo učinili, veza je konfigurirala montažu projekta pomoću webpacka. Za dodavanje aplikacije u preglednik potrebno je u chrome://extensions odabrati load unpacked i mapu s pripadajućom ekstenzijom - u našem slučaju dist.

Pisanje sigurnog proširenja preglednika

Sada je naše proširenje instalirano i radi. Možete pokrenuti razvojne alate za različite kontekste kako slijedi:

skočni prozor ->

Pisanje sigurnog proširenja preglednika

Pristup konzoli skripte sadržaja ostvaruje se preko konzole same stranice na kojoj se ona pokreće.Pisanje sigurnog proširenja preglednika

Razmjena poruka

Dakle, moramo uspostaviti dva komunikacijska kanala: inpage <-> background i popup <-> background. Možete, naravno, samo slati poruke portu i izmisliti vlastiti protokol, ali više volim pristup koji sam vidio u metamask projektu otvorenog koda.

Ovo je proširenje preglednika za rad s Ethereum mrežom. U njemu različiti dijelovi aplikacije komuniciraju putem RPC-a koristeći knjižnicu dnode. Omogućuje vam da organizirate razmjenu prilično brzo i praktično ako joj pružite nodejs stream kao prijenos (što znači objekt koji implementira isto sučelje):

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

Sada ćemo kreirati klasu aplikacije. Stvorit će API objekte za skočni prozor i web-stranicu te stvoriti čvor za njih:

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

Ovdje i ispod, umjesto globalnog Chrome objekta, koristimo extensionApi, koji pristupa Chromeu u Googleovom pregledniku i pregledniku u ostalima. To je učinjeno radi kompatibilnosti s više preglednika, ali za potrebe ovog članka mogli biste jednostavno upotrijebiti 'chrome.runtime.connect'.

Kreirajmo instancu aplikacije u pozadinskoj skripti:

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

Budući da dnode radi s tokovima, a mi primamo port, potrebna je klasa adaptera. Izrađen je pomoću biblioteke readable-stream, koja implementira nodejs streamove u pregledniku:

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

Kreirajmo sada vezu u korisničkom sučelju:

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

Zatim stvaramo vezu u skripti sadržaja:

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

Budući da API ne trebamo u skripti sadržaja, već izravno na stranici, radimo dvije stvari:

  1. Stvaramo dva toka. Jedan - prema stranici, na vrhu postPoruke. Za ovo koristimo ovo ovaj paket od tvoraca metamaske. Drugi tok je u pozadini preko primljenog priključka runtime.connect. Kupimo ih. Sada će stranica imati stream u pozadini.
  2. Umetnite skriptu u DOM. Preuzmite skriptu (pristup joj je dopušten u manifestu) i izradite oznaku script sa svojim sadržajem unutra:

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

Sada stvaramo api objekt na inpage i postavljamo ga 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;
}

Mi smo spremni Remote Procedure Call (RPC) s zasebnim API-jem za stranicu i korisničko sučelje. Kada povezujemo novu stranicu s pozadinom, možemo vidjeti ovo:

Pisanje sigurnog proširenja preglednika

Prazan API i porijeklo. Na strani stranice, funkciju hello možemo pozvati ovako:

Pisanje sigurnog proširenja preglednika

Rad s funkcijama povratnog poziva u modernom JS-u nije uljudan, pa napišimo mali pomoćnik za stvaranje dnodea koji vam omogućuje prosljeđivanje API objekta uslužnim programima.

API objekti sada će izgledati ovako:

export class SignerApp {

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

...

}

Dohvaćanje objekta s daljine ovako:

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

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

A pozivanje funkcija vraća obećanje:

Pisanje sigurnog proširenja preglednika

Dostupna verzija s asinkronim funkcijama здесь.

Sve u svemu, pristup RPC-a i toka čini se prilično fleksibilnim: možemo koristiti parno multipleksiranje i stvoriti nekoliko različitih API-ja za različite zadatke. U principu, dnode se može koristiti bilo gdje, glavna stvar je omotati transport u obliku nodejs streama.

Alternativa je JSON format koji implementira protokol JSON RPC 2. Međutim, radi sa specifičnim transportima (TCP i HTTP(S)), što u našem slučaju nije primjenjivo.

Interno stanje i lokalna pohrana

Morat ćemo pohraniti unutarnje stanje aplikacije - barem ključeve za potpisivanje. Vrlo jednostavno možemo dodati stanje aplikaciji i metode za njegovu promjenu u skočnom 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)
        }
    }

    ...

} 

U pozadini ćemo sve zamotati u funkciju i napisati objekt aplikacije u prozor tako da možemo raditi s njim iz 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 nekoliko tipki s UI konzole i vidimo što će se dogoditi sa stanjem:

Pisanje sigurnog proširenja preglednika

Stanje treba učiniti postojanim kako se ključevi ne bi izgubili prilikom ponovnog pokretanja.

Pohranit ćemo ga u localStorage, prebrisati ga sa svakom promjenom. Naknadno će mu pristup biti potreban i za korisničko sučelje, a također bih se želio pretplatiti na promjene. Na temelju toga bit će zgodno stvoriti vidljivu pohranu i pretplatiti se na njezine promjene.

Koristit ćemo mobx biblioteku (https://github.com/mobxjs/mobx). Izbor je pao na njega jer nisam morao raditi s njim, ali sam ga stvarno želio proučavati.

Dodajmo inicijalizaciju početnog stanja i učinimo trgovinu vidljivom:

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

    ...

}

"Ispod haube", mobx je zamijenio sva polja trgovine proxyjem i presreće sve pozive prema njima. Bit će moguće pretplatiti se na ove poruke.

U nastavku ću često koristiti izraz "prilikom promjene", iako to nije sasvim točno. Mobx prati pristup poljima. Koriste se getteri i postavljači proxy objekata koje biblioteka stvara.

Akcijski dekorateri imaju dvije svrhe:

  1. U strogom načinu rada s oznakom enforceActions, mobx zabranjuje izravnu promjenu stanja. Smatra se dobrom praksom raditi pod strogim uvjetima.
  2. Čak i ako funkcija promijeni stanje nekoliko puta - na primjer, promijenimo nekoliko polja u nekoliko redaka koda - promatrači su obaviješteni tek kada završi. Ovo je posebno važno za sučelje, gdje nepotrebna ažuriranja stanja dovode do nepotrebnog prikazivanja elemenata. U našem slučaju ni prvo ni drugo nije osobito relevantno, ali ćemo slijediti najbolju praksu. Uobičajeno je da se svim funkcijama koje mijenjaju stanje promatranih polja dodaju dekoratori.

U pozadini ćemo dodati inicijalizaciju i spremanje stanja u 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)
        }
    }
}

Ovdje je zanimljiva funkcija reakcije. Ima dva argumenta:

  1. Birač podataka.
  2. Rukovatelj koji će biti pozvan s ovim podacima svaki put kad se promijene.

Za razliku od reduxa, gdje izričito primamo stanje kao argument, mobx pamti kojim observablema pristupamo unutar selektora i poziva rukovatelja samo kada se promijene.

Važno je točno razumjeti kako mobx odlučuje na koje ćemo se observable pretplatiti. Kad bih napisao selektor u ovakvom kodu() => app.store, tada reakcija nikada neće biti pozvana, budući da sama pohrana nije vidljiva, već samo njena polja.

Kad bih to ovako napisao () => app.store.keys, tada se opet ništa ne bi dogodilo, budući da se prilikom dodavanja/uklanjanja elemenata niza referenca na njega neće promijeniti.

Mobx po prvi put djeluje kao selektor i prati samo observable kojima smo pristupili. To se radi putem proxy gettera. Stoga se ovdje koristi ugrađena funkcija toJS. Vraća novi objekt sa svim proxyjima zamijenjenim izvornim poljima. Tijekom izvođenja čita sva polja objekta - stoga se getteri pokreću.

U skočnu konzolu ponovno ćemo dodati nekoliko tipki. Ovaj put su također završili u localStorageu:

Pisanje sigurnog proširenja preglednika

Kada se pozadinska stranica ponovno učita, informacije ostaju na mjestu.

Svi programski kodovi do ove točke mogu se vidjeti здесь.

Sigurno skladištenje privatnih ključeva

Pohranjivanje privatnih ključeva u čistom tekstu nije sigurno: uvijek postoji mogućnost da ćete biti hakirani, dobiti pristup vašem računalu i tako dalje. Stoga ćemo u localStorage pohraniti ključeve u obliku šifriranom lozinkom.

Za veću sigurnost aplikaciji ćemo dodati zaključano stanje u kojem uopće neće biti pristupa ključevima. Automatski ćemo prebaciti proširenje u zaključano stanje zbog vremenskog ograničenja.

Mobx omogućuje pohranu samo minimalnog skupa podataka, a ostatak se automatski izračunava na temelju njega. To su takozvana izračunata svojstva. Mogu se usporediti s pogledima u bazama podataka:

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

Sada pohranjujemo samo šifrirane ključeve i lozinku. Sve ostalo je izračunato. Prijenos u zaključano stanje vršimo uklanjanjem lozinke iz stanja. Javni API sada ima metodu za pokretanje pohrane.

Napisano za šifriranje uslužni programi koji koriste 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)
}

Preglednik ima neaktivan API putem kojeg se možete pretplatiti na događaj - promjene stanja. Država, prema tome, može biti idle, active и locked. Za mirovanje možete postaviti vrijeme čekanja, a zaključano je postavljeno kada je sam OS blokiran. Također ćemo promijeniti selektor za spremanje u 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)
        }
    }
}

Kod prije ovog koraka je здесь.

Transakcije

Dakle, došli smo do najvažnije stvari: kreiranje i potpisivanje transakcija na blockchainu. Koristit ćemo WAVES blockchain i knjižnicu valovi-transakcije.

Prvo, dodajmo stanju niz poruka koje je potrebno potpisati, a zatim dodamo metode za dodavanje nove poruke, potvrđivanje potpisa i odbijanje:

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

    ...
}

Kada primimo novu poruku, dodajemo joj metapodatke, tj observable i dodati na store.messages.

Ako to ne učinite observable ručno, tada će mobx to učiniti sam prilikom dodavanja poruka u polje. Međutim, stvorit će novi objekt na koji nećemo imati referencu, ali će nam trebati za sljedeći korak.

Zatim vraćamo obećanje koje se rješava kada se status poruke promijeni. Status se prati reakcijom koja će se “ubiti” kada se status promijeni.

Šifra metode approve и reject vrlo jednostavno: jednostavno promijenimo status poruke, nakon što je potpišemo ako je potrebno.

Stavljamo Odobrenje i odbijanje u UI API, novu poruku u API stranice:

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

    ...
}

Sada pokušajmo potpisati transakciju s ekstenzijom:

Pisanje sigurnog proširenja preglednika

Općenito, sve je spremno, ostalo je samo dodajte jednostavno korisničko sučelje.

UI

Sučelje treba pristup stanju aplikacije. Na strani korisničkog sučelja ćemo učiniti observable stanje i dodajte funkciju API-ju koja će promijeniti ovo stanje. Dodajmo observable na API objekt primljen iz pozadine:

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 kraju počinjemo renderirati sučelje aplikacije. Ovo je React aplikacija. Pozadinski objekt se jednostavno prosljeđuje pomoću rekvizita. Bilo bi, naravno, ispravno napraviti zasebnu uslugu za metode i trgovinu za državu, ali za potrebe ovog članka ovo je dovoljno:

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

S mobxom je vrlo lako pokrenuti renderiranje kada se podaci promijene. Promatrač dekorater jednostavno objesimo na paket mobx-reagirati na komponenti, a renderiranje će se automatski pozvati kada se bilo koji observable na koji se referira komponenta promijeni. Ne trebate nikakav mapStateToProps niti se povezujete kao u reduxu. Sve radi odmah iz kutije:

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 mogu se vidjeti u kodu u mapi UI.

Sada u klasi aplikacije trebate napraviti selektor stanja za UI i obavijestiti UI kada se promijeni. Da bismo to učinili, dodajmo metodu getState и reactionpozivajući 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())

        })
    }

    ...
}

Prilikom prijema predmeta remote je stvoren reaction za promjenu stanja koje poziva funkciju na strani korisničkog sučelja.

Posljednji dodir je dodavanje prikaza novih poruka na ikonu proširenja:

function setupApp() {
...

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

...
}

Dakle, aplikacija je spremna. Web stranice mogu zahtijevati potpis za transakcije:

Pisanje sigurnog proširenja preglednika

Pisanje sigurnog proširenja preglednika

Kod je dostupan ovdje link.

Zaključak

Ako ste pročitali članak do kraja, ali još uvijek imate pitanja, možete ih postaviti na spremišta s ekstenzijom. Tamo ćete također pronaći obveze za svaki određeni korak.

A ako ste zainteresirani pogledati kôd za stvarno proširenje, možete pronaći ovo здесь.

Šifra, spremište i opis posla iz siemarell

Izvor: www.habr.com

Dodajte komentar