Scrivite una estensione di navigatore sicuru

Scrivite una estensione di navigatore sicuru

A cuntrariu di l'architettura "cliente-servitore" cumuna, l'applicazioni decentralizate sò carattarizati da:

  • Ùn ci hè bisognu di almacenà una basa di dati cù logins d'utilizatori è password. L'infurmazione d'accessu hè almacenata solu da l'utilizatori stessi, è a cunferma di a so autenticità si trova à u livellu di u protocolu.
  • Ùn ci hè bisognu di utilizà un servitore. A logica di l'applicazione pò esse eseguita nantu à una reta di blockchain, induve hè pussibule almacenà a quantità necessaria di dati.

Ci sò 2 magazzini relativamente sicuri per e chjave di l'utilizatori - portafogli hardware è estensioni di navigatore. I portafogli di hardware sò soprattuttu estremamente sicuri, ma difficiuli d'utilizà è luntanu da liberi, ma l'estensioni di u navigatore sò a cumminazione perfetta di sicurezza è facilità d'utilizazione, è ponu ancu esse completamente gratuiti per l'utilizatori finali.

Pigliendu tuttu questu in contu, vulemu fà l'estensione più sicura chì simplificà u sviluppu di l'applicazioni decentralizate furnisce una API simplice per travaglià cù transazzione è signature.
Vi cuntaremu di sta sperienza quì sottu.

L'articulu cuntene struzzioni passu per passu nantu à cumu scrive una estensione di navigatore, cù esempi di codice è screenshots. Pudete truvà tuttu u codice in repository. Ogni cummit logicamente currisponde à una sezione di stu articulu.

Una breve storia di l'estensioni di u navigatore

L'estensioni di u navigatore sò state intornu per un bellu pezzu. Apparsu in Internet Explorer in u 1999, in Firefox in u 2004. Tuttavia, per un tempu assai longu ùn ci era micca standard unicu per estensioni.

Pudemu dì chì hè apparsu cù estensioni in a quarta versione di Google Chrome. Di sicuru, ùn ci era micca specificazione allora, ma era l'API Chrome chì diventò a so basa: avè cunquistatu a maiò parte di u mercatu di u navigatore è avè una tenda d'applicazioni integrata, Chrome hà veramente stabilitu u standard per l'estensione di u navigatore.

Mozilla hà avutu u so propiu standard, ma videndu a popularità di l'estensione Chrome, a cumpagnia hà decisu di fà una API compatible. In u 2015, à l'iniziativa di Mozilla, un gruppu speciale hè statu creatu in u World Wide Web Consortium (W3C) per travaglià nantu à e specificazioni di estensione cross-browser.

L'estensioni API esistenti per Chrome sò state pigliate cum'è basa. U travagliu hè statu realizatu cù u supportu di Microsoft (Google hà ricusatu di participà à u sviluppu di u standard), è in u risultatu, un prugettu apparsu. quaternu.

Formalmente, a specificazione hè supportata da Edge, Firefox è Opera (nota chì Chrome ùn hè micca in questa lista). Ma in fattu, u standard hè largamente cumpatibile cù Chrome, postu chì hè veramente scrittu basatu annantu à e so estensioni. Pudete leghje più nantu à l'API WebExtensions ccà.

Struttura di estensione

L'unicu schedariu chì hè necessariu per l'estensione hè u manifestu (manifest.json). Hè ancu u "puntu d'entrata" à l'espansione.

Manifestu

Sicondu a specificazione, u schedariu manifestu hè un schedariu JSON validu. Una descrizzione completa di e chjave di manifestu cù infurmazioni nantu à quali chjavi sò supportati in quale navigatore pò esse vistu ccà.

I chjavi chì ùn sò micca in a specificazione "puderanu" esse ignorati (sia Chrome è Firefox raportanu errori, ma l'estensioni cuntinueghjanu à travaglià).

È vogliu attirà l'attenzione à certi punti.

  1. fond - un oggettu chì include i seguenti campi:
    1. scrittura - un array di scripts chì saranu eseguiti in u cuntestu di fondo (ne parleremu un pocu dopu);
    2. pagina - invece di scripts chì saranu eseguiti in una pagina viota, pudete specificà html cù cuntenutu. In questu casu, u campu di script serà ignoratu, è i script anu da esse inseriti in a pagina di cuntenutu;
    3. s'intestanu - una bandiera binaria, s'ellu ùn hè micca specificatu, u navigatore "ucciderà" u prucessu di fondo quandu cunsidereghja chì ùn face nunda, è riavvia se ne necessariu. Altrimenti, a pagina serà scaricata solu quandu u navigatore hè chjusu. Ùn hè micca supportatu in Firefox.
  2. cuntenutu_scripts - un array d'uggetti chì vi permette di carricà diverse script in diverse pagine web. Ogni ughjettu cuntene i seguenti campi impurtanti:
    1. cirina - url di mudellu, chì determina se un script di cuntenutu particulari serà inclusu o micca.
    2. js - una lista di scripts chì saranu caricati in questa partita;
    3. exclude_matches - esclude da u campu match URL chì currispondenu à stu campu.
  3. page_action - hè in realtà un oggettu chì hè rispunsevule per l'icona chì hè visualizata accantu à a barra di indirizzu in u navigatore è l'interazzione cù questu. Permette ancu di vede una finestra popup, chì hè definita cù u vostru propiu HTML, CSS è JS.
    1. default_popup - percorso à u schedariu HTML cù l'interfaccia popup, pò cuntene CSS è JS.
  4. permessi - una matrice per a gestione di i diritti di estensione. Ci sò 3 tippi di diritti, chì sò descritti in detail ccà
  5. risorse_accessibili_web - risorse di estensione chì una pagina web pò dumandà, per esempiu, imagine, JS, CSS, schedari HTML.
  6. esternamente_connectable - quì pudete specificà esplicitamente l'ID di altre estensioni è domini di e pagine web da quale pudete cunnette. Un duminiu pò esse u sicondu livellu o più altu. Ùn funziona micca in Firefox.

Cuntestu di esecuzione

L'estensione hà trè cuntesti di esecuzione di codice, vale à dì, l'applicazione hè custituita da trè parti cù diversi livelli d'accessu à l'API di u navigatore.

Cuntestu di estensione

A maiò parte di l'API hè dispunibule quì. In questu cuntestu "vivanu":

  1. Pagina di fondo - "backend" parte di l'estensione. U schedariu hè specificatu in u manifestu cù a chjave "sfondu".
  2. Pagina popup - una pagina popup chì appare quandu cliccate nantu à l'icona di estensione. In u manifestu browser_action -> default_popup.
  3. Pagina persunalizata - pagina di estensione, "vivendu" in una tabulazione separata di a vista chrome-extension://<id_расширения>/customPage.html.

Stu cuntestu esiste indipindentamente da e finestre è e tabulazioni di u navigatore. Pagina di fondo esiste in una sola copia è sempre travaglia (l'eccezzioni hè a pagina di l'avvenimentu, quandu u script di fondo hè lanciatu da un avvenimentu è "morte" dopu a so esecuzione). Pagina popup esiste quandu a finestra popup hè aperta, è Pagina persunalizata - mentri a tabulazione cun ella hè aperta. Ùn ci hè micca accessu à altre tabulazioni è u so cuntenutu da stu cuntestu.

Cuntestu di script di cuntenutu

U schedariu di script di cuntenutu hè lanciatu cù ogni tabulazione di u navigatore. Hà accessu à una parte di l'API di l'estensione è à l'arburu DOM di a pagina web. Hè script di cuntenutu chì sò rispunsevuli di l'interazzione cù a pagina. L'estensioni chì manipulanu l'arbulu DOM facenu questu in scripts di cuntenutu - per esempiu, blocchi di publicità o traduttori. Inoltre, u script di cuntenutu pò cumunicà cù a pagina per via standard postMessage.

Cuntestu di a pagina web

Questa hè a vera pagina web stessa. Ùn hà nunda di fà cù l'estensione è ùn hà micca accessu quì, salvu in i casi induve u duminiu di sta pagina ùn hè micca esplicitamente indicatu in u manifestu (più nantu à questu quì sottu).

Scambiu di missaghji

Diverse parti di l'applicazione devenu scambià missaghji cù l'altri. Ci hè una API per questu runtime.sendMessage per mandà un missaghju background и tabs.sendMessage per mandà un missaghju à una pagina (script di cuntenutu, popup o pagina web se dispunibule externally_connectable). Quì sottu hè un esempiu quandu accede à l'API 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))
    }
)

Per una cumunicazione completa, pudete creà cunnessione attraversu runtime.connect. In risposta riceveremu runtime.Port, à quale, mentre hè apertu, pudete mandà ogni quantità di missaghji. Da u latu di u cliente, per esempiu, contentscript, pare cusì:

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

Servitore o fondo:

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

Ci hè ancu un avvenimentu onDisconnect è u metudu disconnect.

Schema di l'applicazione

Facemu una estensione di u navigatore chì guarda e chjave private, furnisce l'accessu à l'infurmazione publica (indirizzu, chjave publica cumunicà cù a pagina è permette à l'applicazioni di terzu per dumandà una firma per e transacciones.

Sviluppu di l'applicazioni

A nostra applicazione deve esse interagisce cù l'utilizatore è furnisce a pagina cù una API per chjamà metudi (per esempiu, per firmà transacciones). Fate cun un solu contentscript ùn funziona micca, postu chì hà solu accessu à u DOM, ma micca à u JS di a pagina. Cunnette via runtime.connect ùn pudemu micca, perchè l'API hè necessariu in tutti i duminii, è solu specifichi ponu esse specificati in u manifestu. In u risultatu, u diagramma sarà cusì:

Scrivite una estensione di navigatore sicuru

Ci sarà un altru script - inpage, chì avemu da inject in a pagina. Eseguirà in u so cuntestu è furnisce una API per travaglià cù l'estensione.

U principiu

Tuttu u codice di l'estensione di u navigatore hè dispunibule à GitHub. Durante a descrizzione, ci saranu ligami per i commits.

Cuminciamu cù u manifestu:

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

Crea background.js viotu, popup.js, inpage.js è contentscript.js. Aghjunghjemu popup.html - è a nostra applicazione pò esse digià caricata in Google Chrome è assicuratevi chì funziona.

Per verificà questu, pudete piglià u codice da quì. In più di ciò chì avemu fattu, u ligame hà cunfiguratu l'assemblea di u prughjettu usendu webpack. Per aghjunghje una applicazione à u navigatore, in chrome://extensions avete bisognu di selezziunà a carica unpacked è u cartulare cù l'estensione currispundenti - in u nostru casu dist.

Scrivite una estensione di navigatore sicuru

Avà a nostra estensione hè stallata è travaglia. Pudete eseguisce l'arnesi di sviluppatore per diversi cuntesti cusì:

popup ->

Scrivite una estensione di navigatore sicuru

L'accessu à a cunsola di script di cuntenutu hè realizatu attraversu a cunsola di a pagina stessa nantu à quale hè lanciata.Scrivite una estensione di navigatore sicuru

Scambiu di missaghji

Dunque, avemu bisognu di stabilisce dui canali di cumunicazione: inpage <-> background è popup <-> background. Pudete, sicuru, solu mandà messagi à u portu è inventà u vostru propiu protokollu, ma preferite l'approcciu chì aghju vistu in u prughjettu open source metamask.

Questa hè una estensione di navigatore per travaglià cù a reta Ethereu. In questu, diverse parti di l'applicazione cumunicanu via RPC utilizendu a biblioteca dnode. Permette di urganizà un scambiu abbastanza rapidamente è convenientemente si furnisce cù un flussu di nodejs cum'è un trasportu (chì significa un ughjettu chì implementa a stessa interfaccia):

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

Avà avemu da creà una classa di applicazione. Crearà oggetti API per u popup è a pagina web, è creà un dnode per elli:

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

Quì è quì sottu, invece di l'ughjettu Chrome globale, usemu extensionApi, chì accede à Chrome in u navigatore di Google è u navigatore in altri. Questu hè fattu per a cumpatibilità cross-browser, ma per i scopi di stu articulu unu puderia solu aduprà "chrome.runtime.connect".

Creemu una istanza di l'applicazione in u script di fondo:

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

Siccomu dnode travaglia cù flussi, è ricevemu un portu, una classa di adattatore hè necessariu. Hè fattu aduprendu a biblioteca di flussu leggibile, chì implementa i flussi di nodejs in u navigatore:

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

Avà criemu una cunnessione in l'UI:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Allora creamu a cunnessione in u script di cuntenutu:

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

Siccomu avemu bisognu di l'API micca in u script di cuntenutu, ma direttamente nantu à a pagina, facemu duie cose:

  1. Creemu dui flussi. Unu - versu a pagina, sopra à u postMessage. Per questu avemu aduprà questu stu pacchettu da i creatori di metamask. U sicondu flussu hè di fondu nantu à u portu ricevutu da runtime.connect. Cumpràli. Avà a pagina averà un flussu à u fondu.
  2. Inject u script in u DOM. Scaricate u script (l'accessu era permessu in u manifestu) è creanu una tag script cù u so cuntenutu in:

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

Avà criemu un oggettu api in inpage è u mettemu à u globale:

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

Semu pronti Call Procedure Remote (RPC) cù API separata per pagina è UI. Quandu cunnette una nova pagina à u fondu, pudemu vede questu:

Scrivite una estensione di navigatore sicuru

API viotu è origine. In u latu di a pagina, pudemu chjamà a funzione ciao cum'è questu:

Scrivite una estensione di navigatore sicuru

U travagliu cù e funzioni di callback in JS mudernu hè una cattiva mania, allora scrivemu un picculu aiutu per creà un dnode chì vi permette di passà un ughjettu API à utils.

L'oggetti API seranu avà cusì cusì:

export class SignerApp {

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

...

}

Ottene un oggettu da u remoto cum'è questu:

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

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

E funzioni di chjamà torna una prumessa:

Scrivite una estensione di navigatore sicuru

Versione cù funzioni asincrone dispunibili ccà.

In generale, l'approcciu RPC è u flussu pare abbastanza flexible: pudemu usà a multiplexing di vapore è creà parechje API diverse per e diverse attività. In principiu, dnode pò esse usatu in ogni locu, u principale hè di imbulighjà u trasportu in forma di un flussu di nodejs.

Una alternativa hè u formatu JSON, chì implementa u protocolu JSON RPC 2. In ogni casu, travaglia cù trasporti specifichi (TCP è HTTP (S)), chì ùn hè micca applicabile in u nostru casu.

U statu internu è u magazzinu lucali

Avemu bisognu di almacenà u statu internu di l'applicazione - almenu e chjave di firma. Pudemu abbastanza facilmente aghjunghje un statu à l'applicazione è metudi per cambià in l'API popup:

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

    ...

} 

In sfondate, mettimu tuttu in una funzione è scrivemu l'oggettu di l'applicazione in a finestra per pudè travaglià cun ellu da a cunsola:

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

Aghjunghjemu uni pochi di chjave da a cunsola UI è vede ciò chì succede cù u statu:

Scrivite una estensione di navigatore sicuru

U statu deve esse fattu persistente per chì e chjave ùn sò micca persu quandu si riavvia.

L'avemu guardatu in localStorage, soprascrivendu cù ogni cambiamentu. In seguitu, l'accessu à questu serà ancu necessariu per l'UI, è vogliu ancu abbunà à i cambiamenti. Basatu nantu à questu, serà cunvenutu per creà un almacenamentu observable è abbonate à i so cambiamenti.

Avemu aduprà a libreria mobx (https://github.com/mobxjs/mobx). A scelta hè cascata perchè ùn aghju micca bisognu di travaglià cun ellu, ma vulia veramente studià.

Aghjunghjemu l'inizializazione di u statu iniziale è facemu a tenda observable:

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

    ...

}

"Sutta u cappucciu", mobx hà rimpiazzatu tutti i campi di magazzini cù proxy è intercepte tutte e chjamate. Serà pussibule di abbunà à sti missaghji.

Sottu spessu aduprà u terminu "quandu cambià", ancu s'ellu ùn hè micca sanu sanu currettu. Mobx traccia l'accessu à i campi. Getters è setters di l'oggetti proxy chì a biblioteca crea sò usati.

I decoratori d'azzione servenu dui scopi:

  1. In modu strettu cù a bandiera enforceActions, mobx pruibisce di cambià u statu direttamente. Hè cunsideratu una bona pratica di travaglià in cundizioni strette.
  2. Ancu s'è una funzione cambia u statu parechje volte - per esempiu, cambiamu parechji campi in parechje linee di codice - l'osservatori sò avvisati solu quandu si compie. Questu hè soprattuttu impurtante per u frontend, induve l'aghjurnamenti di u statu innecessarii portanu à un rendering innecessariu di elementi. In u nostru casu, nè u primu nè u sicondu hè particularmente pertinenti, ma seguiteremu e pratiche megliu. Hè abitudine di aghjunghje decoratori à tutte e funzioni chì cambianu u statu di i campi osservati.

In u fondu aghjunghjemu l'inizializazione è salvemu u statu in 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)
        }
    }
}

A funzione di reazione hè interessante quì. Hà dui argumenti:

  1. Selettore di dati.
  2. Un gestore chì serà chjamatu cù questi dati ogni volta chì cambia.

A cuntrariu di redux, induve ricevemu esplicitamente u statu cum'è argumentu, mobx si ricorda à quale osservabili accedemu in u selettore, è chjama solu u gestore quandu cambianu.

Hè impurtante di capisce esattamente cumu mobx decide à quale osservabili avemu sottumessi. Sè aghju scrittu un selettore in codice cusì() => app.store, allura a reazione ùn serà mai chjamata, postu chì u almacenamiento stessu ùn hè micca observable, solu i so campi sò.

Se l'aghju scrittu cusì () => app.store.keys, Tandu torna nunda ùn succederia, postu chì quandu aghjunghje / sguassate elementi di array, a riferenza à questu ùn cambia micca.

Mobx agisce cum'è un selettore per a prima volta è mantene a traccia solu di l'osservabili chì avemu accessu. Questu hè fattu per mezu di getters proxy. Dunque, a funzione integrata hè aduprata quì toJS. Ritorna un ughjettu novu cù tutti i proxy rimpiazzati cù i campi originali. Durante l'esekzione, leghje tutti i campi di l'ughjettu - dunque i getters sò attivati.

In a cunsola popup, aghjunteremu di novu parechje chjave. Questa volta anu finitu ancu in u LocalStorage:

Scrivite una estensione di navigatore sicuru

Quandu a pagina di fondo hè ricaricata, l'infurmazioni ferma in u locu.

Tuttu u codice di l'applicazione finu à questu puntu pò esse vistu ccà.

U almacenamentu sicuru di chjavi privati

L'almacenamiento di chjavi privati ​​​​in un testu chjaru ùn hè micca sicuru: ci hè sempre una chance chì sarete pirate, accede à u vostru urdinatore, è cusì. Dunque, in localStorage guardemu e chjave in una forma criptata di password.

Per una sicurezza più grande, aghjunghjemu un statu chjusu à l'applicazione, in quale ùn ci sarà micca accessu à e chjave in tuttu. Trasferiremu automaticamente l'estensione à u statu chjusu per via di un timeout.

Mobx permette di almacenà solu un settore minimu di dati, è u restu hè automaticamente calculatu basatu annantu à questu. Quessi sò i cusì-chiamati proprietà computed. Puderanu esse paragunati à viste in basa di dati:

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

Avà guardamu solu i chjavi criptati è a password. Tuttu u restu hè calculatu. Facemu u trasferimentu à un statu chjusu cacciendu a password da u statu. L'API publica hà avà un metudu per inizializza l'almacenamiento.

Scrittu per a criptografia utilità chì utilizanu 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)
}

U navigatore hà una API inattiva per via di quale pudete subscribe à un avvenimentu - cambiamenti di statu. State, dunque, pò esse idle, active и locked. Per inattivu pudete stabilisce un timeout, è chjusu hè stabilitu quandu u SO stessu hè bluccatu. Cambiaremu ancu u selettore per salvà in 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)
        }
    }
}

U codice prima di stu passu hè ccà.

Transazzioni

Allora, ghjunghjemu à u più impurtante: creà è firmà transazzione nantu à u blockchain. Avemu aduprà a blockchain WAVES è a biblioteca onde-transazzioni.

Prima, aghjustemu à u statu un array di missaghji chì deve esse firmatu, dopu aghjunghje metudi per aghjunghje un novu messagiu, cunfirmendu a firma, è ricusà:

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

    ...
}

Quandu avemu ricevutu un novu missaghju, aghjunghje metadata à lu, fà observable è aghjunghje à store.messages.

Sì ùn avete micca observable manualmente, allora mobx farà ellu stessu quandu aghjunghje messagi à l'array. In ogni casu, hà da creà un novu ughjettu à quale ùn averemu micca un riferimentu, ma avemu bisognu per u prossimu passu.

Dopu, vultemu una prumessa chì risolve quandu u statutu di u messagiu cambia. U statutu hè monitoratu da a reazione, chì si "ucciderà" quandu u statu cambia.

Codice di u metudu approve и reject assai sèmplice: avemu solu cambià u statutu di u missaghju, dopu avè firmatu si ne necessariu.

Pudemu Appruvà è ricusate in l'API UI, newMessage in a pagina API:

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

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Avà pruvemu à firmà a transazzione cù l'estensione:

Scrivite una estensione di navigatore sicuru

In generale, tuttu hè prontu, tuttu ciò chì resta hè aghjunghje UI simplice.

UI

L'interfaccia hà bisognu di accessu à u statu di l'applicazione. Da u latu UI faremu observable state è aghjunghje una funzione à l'API chì cambierà stu statu. Aghjunghjemu observable à l'ughjettu API ricevutu da u fondu:

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

À a fine, cuminciamu à rende l'interfaccia di l'applicazione. Questa hè una applicazione di reazione. L'ughjettu di fondo hè simplicemente passatu cù l'uggetti. Saria correttu, sicuru, per fà un serviziu separatu per i metudi è una tenda per u statu, ma per i scopi di questu articulu hè abbastanza:

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

Cù mobx hè assai faciule per inizià a rendering quandu i dati cambianu. Semu simpricimenti appiccà u decoratore di l'osservatore da u pacchettu mobx-react nant'à u cumpunente, è rende serà automaticamente chjamatu quandu ogni observable riferitu da u cumpunente cambia. Ùn avete micca bisognu di mapStateToProps o cunnette cum'è in redux. Tuttu funziona ghjustu fora di a scatula:

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

I cumpunenti rimanenti ponu esse vistu in u codice in u cartulare UI.

Avà in a classa di l'applicazione avete bisognu di fà un selettore di statu per l'UI è avvisà l'UI quandu cambia. Per fà questu, aghjunghje un metudu getState и reactionchjamà 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())

        })
    }

    ...
}

Quandu riceve un oggettu remote hè creatu reaction per cambià u statu chì chjama a funzione in u latu UI.

U toccu finali hè di aghjunghje a visualizazione di novi messagi nantu à l'icona di estensione:

function setupApp() {
...

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

...
}

Dunque, l'applicazione hè pronta. E pagine web ponu dumandà una firma per e transazzione:

Scrivite una estensione di navigatore sicuru

Scrivite una estensione di navigatore sicuru

U codice hè dispunibule quì a lea.

cunchiusioni

Sè avete lettu l'articulu finu à a fine, ma avete sempre dumande, pudete dumandà à elli repository cù estensione. Ci truverete ancu commits per ogni passu designatu.

È se site interessatu à circà à u codice per l'estensione attuale, pudete truvà questu ccà.

Codice, repository è descrizzione di u travagliu da siemarell

Source: www.habr.com

Add a comment