Suojatun selainlaajennuksen kirjoittaminen

Suojatun selainlaajennuksen kirjoittaminen

Toisin kuin yleinen "asiakas-palvelin" -arkkitehtuuri, hajautetuille sovelluksille on tunnusomaista:

  • Tietokantaa ei tarvitse tallentaa käyttäjätunnuksilla ja salasanoilla. Pääsytiedot tallennetaan yksinomaan käyttäjien itsensä toimesta, ja niiden aitouden varmistus tapahtuu protokollatasolla.
  • Palvelinta ei tarvitse käyttää. Sovelluslogiikka voidaan suorittaa lohkoketjuverkossa, johon on mahdollista tallentaa tarvittava määrä dataa.

Käyttäjäavaimille on kaksi suhteellisen turvallista säilytyspaikkaa - laitteistolompakot ja selainlaajennukset. Laitteistolompakot ovat useimmiten erittäin turvallisia, mutta vaikeita käyttää ja kaukana ilmaisista, mutta selainlaajennukset ovat täydellinen yhdistelmä turvallisuutta ja helppokäyttöisyyttä, ja ne voivat olla myös täysin ilmaisia ​​loppukäyttäjille.

Kaiken tämän huomioon ottaen halusimme tehdä turvallisimman laajennuksen, joka yksinkertaistaa hajautettujen sovellusten kehittämistä tarjoamalla yksinkertaisen sovellusliittymän tapahtumien ja allekirjoitusten käsittelyyn.
Kerromme sinulle tästä kokemuksesta alla.

Artikkeli sisältää vaiheittaiset ohjeet selainlaajennuksen kirjoittamiseen, koodiesimerkkejä ja kuvakaappauksia. Löydät kaikki koodit arkistot. Jokainen sitoumus vastaa loogisesti tämän artikkelin osaa.

Selainlaajennusten lyhyt historia

Selainlaajennukset ovat olleet olemassa jo pitkään. Ne ilmestyivät Internet Explorerissa vuonna 1999 ja Firefoxissa vuonna 2004. Pitkään aikaan ei kuitenkaan ollut yhtä standardia laajennuksille.

Voimme sanoa, että se ilmestyi laajennusten kanssa Google Chromen neljännessä versiossa. Tuolloin ei tietenkään ollut eritelmiä, mutta Chrome-sovellusliittymästä tuli sen perusta: valloitettuaan suurimman osan selainmarkkinoista ja sillä oli sisäänrakennettu sovelluskauppa, Chrome itse asiassa asetti standardin selainlaajennuksille.

Mozillalla oli oma standardinsa, mutta nähtyään Chrome-laajennusten suosion yritys päätti tehdä yhteensopivan API:n. Vuonna 2015 Mozillan aloitteesta perustettiin World Wide Web Consortiumissa (W3C) erityinen ryhmä, joka työskentelee selainlaajennusten eritelmien parissa.

Nykyiset Chromen API-laajennukset otettiin perustaksi. Työ tehtiin Microsoftin tuella (Google kieltäytyi osallistumasta standardin kehittämiseen), ja tuloksena ilmestyi luonnos tekniset tiedot.

Muodollisesti spesifikaatiota tukevat Edge, Firefox ja Opera (huomaa, että Chrome ei ole tässä luettelossa). Mutta itse asiassa standardi on suurelta osin yhteensopiva Chromen kanssa, koska se on itse asiassa kirjoitettu sen laajennusten perusteella. Voit lukea lisää WebExtensions API:sta täällä.

Laajennusrakenne

Ainoa laajennukseen vaadittava tiedosto on manifesti (manifest.json). Se on myös "sisääntulokohta" laajentumiseen.

manifesti

Määrityksen mukaan luettelotiedosto on kelvollinen JSON-tiedosto. Täydellinen kuvaus luetteloavaimista ja tietoja siitä, mitä avaimia tuetaan missäkin selaimessa täällä.

Avaimet, jotka eivät ole määrittelyssä "voidaan" jättää huomiotta (sekä Chrome että Firefox raportoivat virheistä, mutta laajennukset toimivat edelleen).

Ja haluaisin kiinnittää huomion joihinkin seikkoihin.

  1. tausta — objekti, joka sisältää seuraavat kentät:
    1. skriptejä — joukko komentosarjoja, jotka suoritetaan taustakontekstissa (puhumme tästä hieman myöhemmin);
    2. sivulla — Tyhjälle sivulle suoritettavien komentosarjojen sijaan voit määrittää sisällön sisältävän html:n. Tässä tapauksessa komentosarjakenttä jätetään huomioimatta ja komentosarjat on lisättävä sisältösivulle.
    3. itsepintainen — binäärilippu, jos sitä ei ole määritetty, selain "tappaa" taustaprosessin, kun se katsoo, ettei se tee mitään, ja käynnistää sen tarvittaessa uudelleen. Muuten sivu puretaan vain, kun selain suljetaan. Ei tueta Firefoxissa.
  2. content_scripts — joukko objekteja, joiden avulla voit ladata erilaisia ​​komentosarjoja eri web-sivuille. Jokainen objekti sisältää seuraavat tärkeät kentät:
    1. tulitikut - mallin URL-osoite, joka määrittää, sisällytetäänkö tietty sisältöskripti vai ei.
    2. js — luettelo skripteistä, jotka ladataan tähän otteluun;
    3. poissulkevat_osumat - sulkee pois kentältä match URL-osoitteet, jotka vastaavat tätä kenttää.
  3. page_action - on itse asiassa objekti, joka vastaa selaimen osoitepalkin vieressä näkyvästä kuvakkeesta ja vuorovaikutuksesta sen kanssa. Sen avulla voit myös näyttää ponnahdusikkunan, joka on määritetty käyttämällä omaa HTML-, CSS- ja JS-koodiasi.
    1. oletusponnahdusikkuna — polku HTML-tiedostoon, jossa on ponnahdusikkuna, voi sisältää CSS:n ja JS:n.
  4. Oikeudet — taulukko laajennusoikeuksien hallintaa varten. Oikeuksia on 3 tyyppiä, jotka kuvataan yksityiskohtaisesti täällä
  5. web_accessible_resources — laajennusresurssit, joita verkkosivu voi pyytää, esimerkiksi kuvat, JS-, CSS-, HTML-tiedostot.
  6. ulkoisesti_liitettävissä - Tässä voit määrittää nimenomaisesti muiden laajennusten tunnukset ja verkkosivujen verkkotunnukset, joihin voit muodostaa yhteyden. Verkkotunnus voi olla toisen tason tai korkeampi. Ei toimi Firefoxissa.

Toteutuskonteksti

Laajennuksella on kolme koodin suorituskontekstia, eli sovellus koostuu kolmesta osasta, joilla on eri käyttöoikeustasot selaimen sovellusliittymään.

Laajennuksen konteksti

Suurin osa API:sta on saatavilla täältä. Tässä yhteydessä he "elävät":

  1. Taustasivu — laajennuksen taustaosa. Tiedosto määritellään luettelossa "tausta"-näppäimellä.
  2. Ponnahdusikkuna — ponnahdusikkuna, joka tulee näkyviin, kun napsautat laajennuskuvaketta. Manifestissa browser_action -> default_popup.
  3. Mukautettu sivu — laajennussivu, "elävä" näkymän erillisessä välilehdessä chrome-extension://<id_расширения>/customPage.html.

Tämä konteksti on olemassa selainikkunoista ja välilehdistä riippumatta. Taustasivu on olemassa yhtenä kopiona ja toimii aina (poikkeuksena on tapahtumasivu, kun tapahtuma käynnistää taustaskriptin ja "kuolee" sen suorittamisen jälkeen). Ponnahdusikkuna on olemassa, kun ponnahdusikkuna on auki, ja Mukautettu sivu - kun välilehti on auki. Tästä kontekstista ei ole pääsyä muihin välilehtiin ja niiden sisältöön.

Sisällön käsikirjoituksen konteksti

Sisältökomentosarjatiedosto käynnistetään jokaisen selaimen välilehden mukana. Sillä on pääsy osaan laajennuksen sovellusliittymästä ja verkkosivun DOM-puuhun. Sisältökomentosarjat ovat vastuussa vuorovaikutuksesta sivun kanssa. DOM-puuta käsittelevät laajennukset tekevät tämän sisältöskripteissä – esimerkiksi mainosten esto-ohjelmissa tai kääntäjissä. Myös sisältöskripti voi kommunikoida sivun kanssa standardin kautta postMessage.

Web-sivun konteksti

Tämä on itse web-sivu. Sillä ei ole mitään tekemistä laajennuksen kanssa, eikä sillä ole pääsyä sinne, paitsi tapauksissa, joissa tämän sivun verkkotunnusta ei ole erikseen ilmoitettu luettelossa (lisätietoja alla).

Viestien vaihto

Sovelluksen eri osien on vaihdettava viestejä keskenään. Tätä varten on API runtime.sendMessage lähettää viesti background и tabs.sendMessage lähettääksesi viestin sivulle (sisältökomentosarja, ponnahdusikkuna tai verkkosivu, jos sellainen on saatavilla externally_connectable). Alla on esimerkki Chrome-sovellusliittymän käyttämisestä.

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

Täydellistä viestintää varten voit luoda yhteyksiä kautta runtime.connect. Vastauksena saamme runtime.Port, johon voit sen ollessa auki lähettää minkä tahansa määrän viestejä. Asiakaspuolella esim. contentscript, se näyttää tältä:

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

Palvelin tai tausta:

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

Siellä on myös tapahtuma onDisconnect ja menetelmä disconnect.

Sovelluskaavio

Tehdään selainlaajennus, joka tallentaa yksityisiä avaimia, tarjoaa pääsyn julkisiin tietoihin (osoite, julkinen avain kommunikoi sivun kanssa ja sallii kolmannen osapuolen sovellusten pyytää allekirjoitusta tapahtumille.

Sovellus kehitys

Sovelluksemme tulee olla vuorovaikutuksessa käyttäjän kanssa ja tarjota sivulle API menetelmien kutsumista varten (esimerkiksi tapahtumien allekirjoittamiseen). Pärjää vain yhdellä contentscript ei toimi, koska sillä on pääsy vain DOM:iin, mutta ei sivun JS:ään. Yhdistä kautta runtime.connect emme voi, koska API tarvitaan kaikissa verkkotunnuksissa ja vain tietyt voidaan määrittää luettelossa. Tämän seurauksena kaavio näyttää tältä:

Suojatun selainlaajennuksen kirjoittaminen

Tulee toinen käsikirjoitus - inpage, jonka lisäämme sivulle. Se toimii kontekstissaan ja tarjoaa sovellusliittymän laajennuksen kanssa työskentelemiseen.

alku

Kaikki selaimen laajennuskoodit ovat saatavilla osoitteessa GitHub. Kuvauksen aikana tulee linkkejä sitoumuksiin.

Aloitetaan manifestista:

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

Luo tyhjä background.js, popup.js, inpage.js ja contentscript.js. Lisäämme popup.html - ja sovelluksemme voidaan jo ladata Google Chromeen ja varmistaa, että se toimii.

Voit varmistaa tämän ottamalla koodin siten. Sen lisäksi, mitä teimme, linkki konfiguroi projektin kokoonpanon webpackilla. Jos haluat lisätä sovelluksen selaimeen, chrome://extensionsissa sinun on valittava lataus unpacked ja kansio vastaavalla laajennuksella - meidän tapauksessamme dist.

Suojatun selainlaajennuksen kirjoittaminen

Nyt laajennus on asennettu ja toimii. Voit käyttää kehittäjätyökaluja eri yhteyksissä seuraavasti:

ponnahdusikkuna ->

Suojatun selainlaajennuksen kirjoittaminen

Pääsy sisällön komentosarjakonsoliin tapahtuu sen sivun konsolin kautta, jolla se käynnistetään.Suojatun selainlaajennuksen kirjoittaminen

Viestien vaihto

Joten meidän on perustettava kaksi viestintäkanavaa: inpage <-> background ja popup <-> background. Voit tietysti vain lähettää viestejä porttiin ja keksiä oman protokollan, mutta pidän parempana lähestymistapaa, jonka näin avoimen lähdekoodin metamaskiprojektissa.

Tämä on selainlaajennus Ethereum-verkon kanssa työskentelemiseen. Siinä sovelluksen eri osat kommunikoivat RPC:n kautta käyttämällä dnode-kirjastoa. Sen avulla voit järjestää vaihdon melko nopeasti ja kätevästi, jos annat sille nodejs-virran kuljetuksena (eli objektia, joka toteuttaa saman rajapinnan):

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

Nyt luomme sovellusluokan. Se luo API-objekteja ponnahdusikkunalle ja verkkosivulle ja luo niille dnoden:

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

Tässä ja alla globaalin Chrome-objektin sijaan käytämme laajennusApi, joka käyttää Chromea Googlen selaimessa ja selainta muissa. Tämä tehdään selainten välisen yhteensopivuuden vuoksi, mutta tämän artikkelin tarkoituksiin voit käyttää vain tiedostoa "chrome.runtime.connect".

Luodaan sovellusesiintymä taustaskriptiin:

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

Koska dnode toimii virtojen kanssa ja saamme portin, tarvitaan sovitinluokka. Se on tehty käyttämällä readable-stream -kirjastoa, joka toteuttaa nodejs-virrat selaimessa:

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

Luodaan nyt yhteys käyttöliittymään:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Sitten luomme yhteyden sisältöskriptiin:

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

Koska tarvitsemme API:n ei sisältöskriptissä, vaan suoraan sivulla, teemme kaksi asiaa:

  1. Luomme kaksi virtaa. Yksi - sivua kohti, viestin päällä. Tätä varten käytämme tätä tämä paketti metamaskin tekijöiltä. Toinen virta on taustalla vastaanotetun portin yli runtime.connect. Ostetaan ne. Nyt sivulla on stream taustalla.
  2. Lisää komentosarja DOM:iin. Lataa komentosarja (pääsy siihen sallittiin luettelossa) ja luo tunniste script sen sisältö sisällä:

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

Nyt luomme api-objektin inpagessa ja asetamme sen globaaliksi:

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

Olemme valmiita Remote Procedure Call (RPC) erillisellä API:lla sivulle ja käyttöliittymälle. Kun yhdistämme uuden sivun taustaan, voimme nähdä tämän:

Suojatun selainlaajennuksen kirjoittaminen

Tyhjä API ja alkuperä. Sivun puolella voimme kutsua hello-funktiota seuraavasti:

Suojatun selainlaajennuksen kirjoittaminen

Takaisinsoittotoimintojen käyttäminen nykyaikaisessa JS:ssä on huonoa tapaa, joten kirjoitetaan pieni apulainen luomaan dnode, jonka avulla voit välittää API-objektin utilsille.

API-objektit näyttävät nyt tältä:

export class SignerApp {

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

...

}

Objektin hakeminen kaukosäätimestä näin:

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

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

Ja funktioiden kutsuminen palauttaa lupauksen:

Suojatun selainlaajennuksen kirjoittaminen

Saatavilla asynkronisilla toiminnoilla varustettu versio täällä.

Kaiken kaikkiaan RPC- ja stream-lähestymistapa vaikuttaa melko joustavalta: voimme käyttää höyrymultipleksointia ja luoda useita erilaisia ​​API:ita eri tehtäviin. Periaatteessa dnodea voidaan käyttää missä tahansa, tärkeintä on kääriä kuljetus nodejs-virran muotoon.

Vaihtoehtona on JSON-muoto, joka toteuttaa JSON RPC 2 -protokollan, mutta se toimii tiettyjen kuljetusten (TCP ja HTTP(S)) kanssa, mikä ei sovellu meidän tapauksessamme.

Sisäinen tila ja paikallinen tallennustila

Meidän on tallennettava sovelluksen sisäinen tila - ainakin allekirjoitusavaimet. Voimme melko helposti lisätä sovellukseen tilan ja menetelmät sen muuttamiseen ponnahdusikkunassa:

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

    ...

} 

Taustalla käärimme kaiken funktioon ja kirjoitamme sovellusobjektin ikkunaan, jotta voimme työskennellä sen kanssa konsolista:

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

Lisätään muutama näppäin käyttöliittymäkonsolista ja katsotaan mitä tilalle tapahtuu:

Suojatun selainlaajennuksen kirjoittaminen

Tilasta on tehtävä pysyvä, jotta avaimet eivät katoa uudelleenkäynnistyksen yhteydessä.

Tallennamme sen localStorageen ja korvaamme sen jokaisen muutoksen yhteydessä. Myöhemmin pääsy siihen on välttämätöntä myös käyttöliittymälle, ja haluan myös tilata muutokset. Tämän perusteella on kätevää luoda havaittava tallennustila ja tilata sen muutokset.

Käytämme mobx-kirjastoa (https://github.com/mobxjs/mobx). Valinta osui siihen, koska minun ei tarvinnut työskennellä sen kanssa, mutta halusin todella opiskella sitä.

Lisätään alkutilan alustus ja tehdään kauppa havaittavaksi:

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

    ...

}

"Under the hood", mobx on korvannut kaikki kaupan kentät välityspalvelimella ja sieppaa kaikki puhelut niille. Näitä viestejä on mahdollista tilata.

Alla käytän usein termiä "muuttuessaan", vaikka se ei ole täysin oikein. Mobx seuraa pääsyä pelloille. Käytetään kirjaston luomien välityspalvelinobjektien saajia ja asettajia.

Toimintasisustajien palvelee kahta tarkoitusta:

  1. Tiukassa tilassa enforceActions-lipun kanssa mobx kieltää tilan muuttamisen suoraan. Hyväksi muodoksi katsotaan työskennellä tiukoissa olosuhteissa.
  2. Vaikka funktio muuttaa tilaa useita kertoja - esimerkiksi vaihdamme useita kenttiä useilla koodiriveillä - tarkkailijat saavat ilmoituksen vasta, kun se on valmis. Tämä on erityisen tärkeää käyttöliittymälle, jossa tarpeettomat tilapäivitykset johtavat elementtien tarpeettomaan renderöintiin. Meidän tapauksessamme ensimmäinen tai toinen ei ole erityisen merkityksellinen, mutta noudatamme parhaita käytäntöjä. Kaikkiin toimintoihin, jotka muuttavat havaittujen kenttien tilaa, on tapana kiinnittää koristeita.

Taustalla lisäämme alustuksen ja tilan tallennuksen localStorageen:

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

Reaktiofunktio on mielenkiintoinen tässä. Sillä on kaksi argumenttia:

  1. Tietojen valitsin.
  2. Käsittelijä, jota kutsutaan näiden tietojen kanssa aina, kun ne muuttuvat.

Toisin kuin redux, jossa saamme eksplisiittisesti tilan argumenttina, mobx muistaa, mihin havainnoitaviin tietoihin pääsemme valitsimen sisällä, ja kutsuu käsittelijää vain, kun ne muuttuvat.

On tärkeää ymmärtää tarkasti, kuinka mobx päättää, mitkä havainnot tilaamme. Jos kirjoittaisin valitsimen koodiin näin() => app.store, silloin reaktiota ei koskaan kutsuta, koska itse varasto ei ole havaittavissa, vain sen kentät ovat.

Jos kirjoitin näin () => app.store.keys, silloin taaskaan ei tapahtuisi mitään, koska taulukkoelementtejä lisättäessä/poistettaessa viittaus siihen ei muutu.

Mobx toimii valitsimena ensimmäistä kertaa ja pitää kirjaa vain havainnoista, joita olemme käyttäneet. Tämä tehdään välityspalvelinten kautta. Siksi tässä käytetään sisäänrakennettua toimintoa toJS. Se palauttaa uuden objektin, jonka kaikki välityspalvelimet on korvattu alkuperäisillä kentillä. Suorituksen aikana se lukee kaikki objektin kentät - joten getterit laukeavat.

Ponnahduskonsoliin lisäämme jälleen useita avaimia. Tällä kertaa ne päätyivät myös localStorageen:

Suojatun selainlaajennuksen kirjoittaminen

Kun taustasivu ladataan uudelleen, tiedot pysyvät paikoillaan.

Kaikki sovelluskoodit tähän asti ovat nähtävissä täällä.

Yksityisten avainten turvallinen tallennus

Yksityisten avainten tallentaminen selkeänä tekstinä ei ole turvallista: on aina mahdollisuus, että sinut hakkeroidaan, pääset käyttämään tietokonettasi ja niin edelleen. Siksi localStorageen tallennamme avaimet salasanalla salatussa muodossa.

Turvallisuuden lisäämiseksi lisäämme sovellukseen lukitun tilan, jossa avaimiin ei pääse ollenkaan. Siirrämme laajennuksen automaattisesti lukittuun tilaan aikakatkaisun vuoksi.

Mobx mahdollistaa vain vähimmäistietojen tallentamisen, ja loput lasketaan automaattisesti sen perusteella. Nämä ovat niin sanottuja laskettuja ominaisuuksia. Niitä voidaan verrata tietokantojen näkymiin:

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

Nyt tallennamme vain salatut avaimet ja salasanat. Kaikki muu lasketaan. Teemme siirron lukittuun tilaan poistamalla salasanan tilasta. Julkisella API:lla on nyt menetelmä tallennustilan alustamiseksi.

Kirjoitettu salausta varten apuohjelmat, jotka käyttävät krypto-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)
}

Selaimessa on idle API, jonka kautta voit tilata tapahtuman - tilamuutoksia. valtio, vastaavasti, voi olla idle, active и locked. Joutokäynnille voit asettaa aikakatkaisun, ja lukittu asetetaan, kun itse käyttöjärjestelmä on estetty. Muutamme myös paikalliseen tallennustilaan tallennuksen valitsimen:

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

Tätä vaihetta edeltävä koodi on täällä.

Liiketoimi

Joten pääsemme tärkeimpään asiaan: tapahtumien luomiseen ja allekirjoittamiseen lohkoketjussa. Käytämme WAVES-lohkoketjua ja kirjastoa aallot-transaktiot.

Lisätään ensin tilaan joukko viestejä, jotka täytyy allekirjoittaa, ja sitten menetelmät uuden viestin lisäämiseen, allekirjoituksen vahvistamiseen ja hylkäämiseen:

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

    ...
}

Kun saamme uuden viestin, lisäämme siihen metatiedot, tee observable ja lisää siihen store.messages.

Jos et observable manuaalisesti, mobx tekee sen itse, kun lisää viestejä taulukkoon. Se kuitenkin luo uuden objektin, johon meillä ei ole viittausta, mutta tarvitsemme sitä seuraavaa vaihetta varten.

Seuraavaksi palautamme lupauksen, joka ratkeaa, kun viestin tila muuttuu. Tilaa valvotaan reaktiolla, joka "tappaa itsensä", kun tila muuttuu.

Menetelmäkoodi approve и reject hyvin yksinkertainen: muutamme vain viestin tilaa allekirjoittamisen jälkeen tarvittaessa.

Laitamme Hyväksy ja hylkää UI API:ssa, newMessage sivun API:ssa:

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

    ...
}

Yritetään nyt allekirjoittaa tapahtuma laajennuksella:

Suojatun selainlaajennuksen kirjoittaminen

Yleensä kaikki on valmista, jäljellä on vain lisää yksinkertainen käyttöliittymä.

UI

Käyttöliittymä tarvitsee pääsyn sovelluksen tilaan. Käyttöliittymän puolella teemme observable tila ja lisää sovellusliittymään toiminto, joka muuttaa tämän tilan. Lisätään observable taustalta saatuun API-objektiin:

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

Lopussa aloitamme sovelluksen käyttöliittymän renderöinnin. Tämä on reaktiosovellus. Taustaobjekti yksinkertaisesti välitetään käyttämällä rekvisiitta. Olisi tietysti oikein tehdä erillinen palvelu menetelmille ja myymälä valtiolle, mutta tämän artikkelin tarkoituksiin tämä riittää:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

Mobx:lla on erittäin helppo aloittaa renderöinti, kun tiedot muuttuvat. Riputamme vain tarkkailijasisustajan pakkauksesta mobx-reagoi komponentissa, ja renderöinti kutsutaan automaattisesti, kun komponentin viittaamat havainnot muuttuvat. Et tarvitse mapStateToPropsia tai yhteyttä kuten reduxissa. Kaikki toimii suoraan laatikosta:

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

Loput komponentit näkyvät koodissa UI-kansiossa.

Nyt sovellusluokassa sinun on tehtävä käyttöliittymän tilanvalitsin ja ilmoitettava käyttöliittymälle, kun se muuttuu. Tehdään tämä lisäämällä menetelmä getState и reactionkutsumus 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())

        })
    }

    ...
}

Kun vastaanotetaan esine remote luodaan reaction muuttaaksesi tilaa, joka kutsuu funktiota käyttöliittymän puolella.

Viimeinen kosketus on lisätä uusien viestien näyttö laajennuskuvakkeeseen:

function setupApp() {
...

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

...
}

Joten sovellus on valmis. Verkkosivut voivat pyytää allekirjoitusta tapahtumille:

Suojatun selainlaajennuksen kirjoittaminen

Suojatun selainlaajennuksen kirjoittaminen

Koodi löytyy täältä linkki.

Johtopäätös

Jos olet lukenut artikkelin loppuun, mutta sinulla on edelleen kysyttävää, voit kysyä niitä osoitteessa arkistot laajennuksella. Sieltä löydät myös sitoumukset jokaiselle määrätylle vaiheelle.

Ja jos olet kiinnostunut tarkastelemaan varsinaisen laajennuksen koodia, löydät tämän täällä.

Koodi, tietovarasto ja työnkuvaus alkaen siemarell

Lähde: will.com

Lisää kommentti