Turvalise brauseri laienduse kirjutamine

Turvalise brauseri laienduse kirjutamine

Erinevalt tavalisest "klient-server" arhitektuurist iseloomustavad detsentraliseeritud rakendusi:

  • Pole vaja salvestada andmebaasi kasutajate sisselogimiste ja paroolidega. Juurdepääsuteavet salvestavad eranditult kasutajad ise ja nende autentsuse kinnitamine toimub protokolli tasemel.
  • Serverit pole vaja kasutada. Rakendusloogikat saab teostada plokiahela võrgus, kuhu on võimalik salvestada vajalik kogus andmeid.

Kasutajavõtmete jaoks on 2 suhteliselt turvalist salvestusruumi – riistvaralised rahakotid ja brauserilaiendid. Riistvaralised rahakotid on enamasti üliturvalised, kuid raskesti kasutatavad ja kaugeltki mitte tasuta, kuid brauseri laiendused on ideaalne kombinatsioon turvalisusest ja kasutusmugavusest ning võivad olla ka lõppkasutajatele täiesti tasuta.

Seda kõike arvesse võttes soovisime teha kõige turvalisema laienduse, mis lihtsustab detsentraliseeritud rakenduste arendamist, pakkudes lihtsat API-d tehingute ja allkirjadega töötamiseks.
Sellest kogemusest räägime teile allpool.

Artikkel sisaldab samm-sammult juhiseid brauseri laienduse kirjutamiseks koos koodinäidete ja ekraanipiltidega. Kogu koodi leiate siit hoidlad. Iga kohustus vastab loogiliselt selle artikli jaotisele.

Brauserilaiendite lühiajalugu

Brauserilaiendid on olnud kasutusel juba pikka aega. Need ilmusid Internet Exploreris 1999. aastal ja Firefoxis 2004. aastal. Kuid väga pikka aega ei olnud laienduste jaoks ühtset standardit.

Võime öelda, et see ilmus koos laiendustega Google Chrome'i neljandas versioonis. Muidugi polnud siis spetsifikatsiooni, kuid selle aluseks sai Chrome'i API: olles vallutanud suurema osa brauseriturust ja omades sisseehitatud rakenduste poodi, seadis Chrome tegelikult brauserilaienduste standardi.

Mozillal oli oma standard, kuid Chrome'i laienduste populaarsust nähes otsustas ettevõte teha ühilduva API. 2015. aastal loodi Mozilla initsiatiivil World Wide Web Consortium (W3C) raames spetsiaalne grupp, mis tegeleb brauseriüleste laienduste spetsifikatsioonidega.

Aluseks võeti Chrome'i olemasolevad API laiendused. Töö viidi läbi Microsofti toel (Google keeldus standardi väljatöötamises osalemast) ja selle tulemusena ilmus mustand spetsifikatsioonid.

Formaalselt toetavad spetsifikatsiooni Edge, Firefox ja Opera (pange tähele, et Chrome pole selles loendis). Kuid tegelikult ühildub standard suures osas Chrome'iga, kuna see on tegelikult kirjutatud selle laienduste põhjal. Lisateavet WebExtensions API kohta saate lugeda siin.

Laienduse struktuur

Ainus fail, mida laienduse jaoks on vaja, on manifest (manifest.json). See on ka laienemise "sisenemispunkt".

Manifest

Vastavalt spetsifikatsioonile on manifesti fail kehtiv JSON-fail. Manifestivõtmete täielik kirjeldus koos teabega selle kohta, milliseid võtmeid millises brauseris saab vaadata siin.

Võtmeid, mis pole spetsifikatsioonis "võib" ignoreerida (nii Chrome kui ka Firefox teatavad vigadest, kuid laiendused töötavad edasi).

Ja ma tahaksin juhtida tähelepanu mõnele punktile.

  1. tagapõhi — objekt, mis sisaldab järgmisi välju:
    1. skripte — skriptide massiiv, mida käivitatakse taustakontekstis (sellest räägime veidi hiljem);
    2. lehekülg - tühjal lehel käivitatavate skriptide asemel saate määrata sisuga html-i. Sel juhul skriptivälja ignoreeritakse ja skriptid tuleb sisestada sisulehele;
    3. püsivad — binaarne lipp, kui seda pole määratud, "tappab" brauser taustaprotsessi, kui leiab, et see ei tee midagi, ja vajadusel taaskäivitab. Vastasel juhul laaditakse leht maha ainult siis, kui brauser on suletud. Firefoxis ei toetata.
  2. sisu_skriptid — objektide massiiv, mis võimaldab laadida erinevatele veebilehtedele erinevaid skripte. Iga objekt sisaldab järgmisi olulisi välju:
    1. tikud - mustri URL, mis määrab, kas konkreetne sisuskript kaasatakse või mitte.
    2. js — sellesse vastesse laaditavate skriptide loend;
    3. välista_vastused - välistab põllult match URL-id, mis vastavad sellele väljale.
  3. page_action - on tegelikult objekt, mis vastutab brauseris aadressiriba kõrval kuvatava ikooni ja sellega suhtlemise eest. Samuti võimaldab see kuvada hüpikakna, mis on määratletud teie enda HTML-i, CSS-i ja JS-i abil.
    1. vaikimisi_hüpik — hüpikliidesega HTML-faili tee, võib sisaldada CSS-i ja JS-i.
  4. Õigused — massiiv laiendusõiguste haldamiseks. Seal on 3 tüüpi õigusi, mida kirjeldatakse üksikasjalikult siin
  5. web_accessible_resources — laiendusressursid, mida veebileht saab taotleda, näiteks pildid, JS-, CSS-, HTML-failid.
  6. väliselt_ühendatav — siin saate selgesõnaliselt määrata muude laienduste ID-d ja veebilehtede domeenid, kust saate ühenduse luua. Domeen võib olla teisel või kõrgemal tasemel. Firefoxis ei tööta.

Täitmise kontekst

Laiendusel on kolm koodikäivituskonteksti, see tähendab, et rakendus koosneb kolmest osast, millel on erinevad juurdepääsutasemed brauseri API-le.

Laienduse kontekst

Suurem osa API-st on saadaval siin. Selles kontekstis nad "elavad":

  1. Taustaleht — laienduse tagaosa. Fail määratakse manifestis taustklahvi abil.
  2. Hüpik leht — hüpikaken, mis ilmub, kui klõpsate laienduse ikoonil. Manifestis browser_action -> default_popup.
  3. Kohandatud leht — laiendusleht, “elab” vaate eraldi vahekaardil chrome-extension://<id_расширения>/customPage.html.

See kontekst eksisteerib brauseriakendest ja vahekaartidest sõltumatult. Taustaleht eksisteerib ühes eksemplaris ja töötab alati (erandiks on sündmuse leht, kui taustaskript käivitatakse sündmuse poolt ja "sureb" pärast selle täitmist). Hüpik leht on olemas, kui hüpikaken on avatud ja Kohandatud leht — kui vaheleht on avatud. Sellest kontekstist pole juurdepääsu teistele vahekaartidele ja nende sisule.

Sisu skripti kontekst

Sisuskriptifail käivitatakse koos iga brauseri vahekaardiga. Sellel on juurdepääs osale laienduse API-st ja veebilehe DOM-puule. Lehega suhtlemise eest vastutavad sisuskriptid. DOM-puud manipuleerivad laiendused teevad seda sisuskriptides – näiteks reklaamiblokeerijates või tõlkijates. Samuti saab sisuskript lehega suhelda standardse kaudu postMessage.

Veebilehe kontekst

See on tegelik veebileht ise. Sellel pole laiendusega midagi pistmist ja sellel pole sinna juurdepääsu, välja arvatud juhtudel, kui selle lehe domeen ei ole manifestis selgesõnaliselt märgitud (selle kohta lähemalt allpool).

Sõnumid

Rakenduse erinevad osad peavad omavahel sõnumeid vahetama. Selle jaoks on olemas API runtime.sendMessage sõnumi saatmiseks background и tabs.sendMessage lehele sõnumi saatmiseks (sisuskript, hüpikaken või veebileht, kui see on saadaval externally_connectable). Allpool on näide Chrome'i API-le juurdepääsu kohta.

// Сообщением может быть любой 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äieliku suhtluse jaoks saate luua ühendusi läbi runtime.connect. Vastuseks saame runtime.Port, millele saate avatuna saata suvalise arvu sõnumeid. Kliendi poolel näiteks contentscript, näeb see välja selline:

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

Server või taust:

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

Toimub ka üritus onDisconnect ja meetod disconnect.

Rakendusskeem

Teeme brauseri laienduse, mis salvestab privaatvõtmed, annab juurdepääsu avalikule teabele (aadress, avalik võti suhtleb lehega ja võimaldab kolmandate osapoolte rakendustel nõuda tehingute jaoks allkirja.

Rakenduste arendamine

Meie rakendus peab nii kasutajaga suhtlema kui ka pakkuma lehele API-d meetodite kutsumiseks (näiteks tehingute allkirjastamiseks). Saa hakkama vaid ühega contentscript ei tööta, kuna sellel on juurdepääs ainult DOM-ile, kuid mitte lehe JS-ile. Ühendage kaudu runtime.connect me ei saa, sest API-d on vaja kõigis domeenides ja manifestis saab määrata ainult konkreetseid. Selle tulemusena näeb diagramm välja järgmine:

Turvalise brauseri laienduse kirjutamine

Tuleb veel üks stsenaarium - inpage, mille me lehele süstime. See töötab oma kontekstis ja pakub laiendusega töötamiseks API-d.

Algus

Kõik brauseri laienduse koodid on saadaval aadressil GitHub. Kirjelduse ajal on lingid kohustustele.

Alustame manifestiga:

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

Looge tühjad background.js, popup.js, inpage.js ja contentscript.js. Lisame popup.html - ja meie rakenduse saab juba Google Chrome'i laadida ja veenduda, et see töötab.

Selle kontrollimiseks võite võtta koodi siit. Lisaks sellele, mida me tegime, konfigureeris link veebipaketi abil projekti kokkupaneku. Rakenduse lisamiseks brauserisse tuleb chrome://extensions-is valida load lahti ja vastava laiendiga kaust – meie puhul dist.

Turvalise brauseri laienduse kirjutamine

Nüüd on meie laiendus installitud ja töötab. Arendaja tööriistu saate erinevate kontekstide jaoks käivitada järgmiselt.

hüpikaken ->

Turvalise brauseri laienduse kirjutamine

Juurdepääs sisu skriptikonsoolile toimub selle lehe konsooli kaudu, millel see käivitatakse.Turvalise brauseri laienduse kirjutamine

Sõnumid

Seega peame looma kaks suhtluskanalit: lehe <-> taust ja hüpikakna <-> taust. Muidugi võite lihtsalt pordile sõnumeid saata ja oma protokolli välja mõelda, kuid ma eelistan lähenemist, mida nägin metamaski avatud lähtekoodiga projektis.

See on brauseri laiendus Ethereumi võrguga töötamiseks. Selles suhtlevad rakenduse erinevad osad RPC kaudu, kasutades dnode teeki. See võimaldab teil vahetust üsna kiiresti ja mugavalt korraldada, kui pakute sellele transpordina nodejs-voogu (see tähendab sama liidest rakendavat objekti):

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

Nüüd loome rakendusklassi. See loob hüpikakna ja veebilehe jaoks API-objektid ning loob neile dnode:

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

Siin ja allpool kasutame globaalse Chrome'i objekti asemel laiendusApi, mis pääseb juurde Google'i brauseris Chrome'ile ja teistes brauseris. Seda tehakse brauseritevahelise ühilduvuse tagamiseks, kuid selle artikli jaoks võib kasutada lihtsalt faili „chrome.runtime.connect”.

Loome taustaskriptis rakenduse eksemplari:

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

Kuna dnode töötab voogudega ja me saame pordi, on vaja adapterklassi. See on tehtud loetava voo teegi abil, mis rakendab brauseris nodejs-vooge:

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

Nüüd loome kasutajaliideses ühenduse:

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

Seejärel loome sisuskriptis ühenduse:

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

Kuna vajame API-d mitte sisuskriptis, vaid otse lehel, teeme kahte asja:

  1. Loome kaks voogu. Üks - lehe poole, postituse ülaosas. Selleks kasutame seda see pakett metamaski loojatelt. Teine voog on taustaks üle saadud pordi runtime.connect. Ostame need. Nüüd on lehel taustal voog.
  2. Sisestage skript DOM-i. Laadige skript alla (juurdepääs sellele oli manifestis lubatud) ja looge silt script mille sisu sees:

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

Nüüd loome siselehel API-objekti ja määrame selle globaalseks:

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

Oleme valmis Remote Procedure Call (RPC) eraldi API-ga lehe ja kasutajaliidese jaoks. Uue lehe ühendamisel taustaga näeme järgmist:

Turvalise brauseri laienduse kirjutamine

Tühi API ja päritolu. Leheküljel saame terefunktsiooni kutsuda järgmiselt:

Turvalise brauseri laienduse kirjutamine

Kaasaegses JS-is tagasihelistamisfunktsioonidega töötamine on halb, nii et kirjutame väikese abimehe dnode loomiseks, mis võimaldab teil API-objekti utilisidele edastada.

API objektid näevad nüüd välja järgmised:

export class SignerApp {

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

...

}

Objekti hankimine kaugjuhtimispuldist järgmiselt:

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

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

Ja funktsioonide kutsumine annab lubaduse:

Turvalise brauseri laienduse kirjutamine

Saadaval asünkroonsete funktsioonidega versioon siin.

Üldiselt tundub RPC ja voo lähenemine üsna paindlik: saame kasutada aurumultipleksimist ja luua erinevate ülesannete jaoks mitu erinevat API-d. Põhimõtteliselt saab dnode'i kasutada kõikjal, peaasi, et transport mähitaks nodejs voo kujul.

Alternatiiviks on JSON-vorming, mis rakendab protokolli JSON RPC 2. Kuid see töötab konkreetsete transportidega (TCP ja HTTP(S)), mis meie puhul ei kehti.

Sisemine olek ja kohalik salvestusruum

Peame salvestama rakenduse sisemise oleku – vähemalt allkirjastamisvõtmed. Hüpikakna API-s saame üsna lihtsalt lisada rakendusele oleku ja selle muutmise meetodid:

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

    ...

} 

Taustal mähime kõik funktsiooni ja kirjutame rakenduse objekti aknasse, et saaksime sellega konsoolist töötada:

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

Lisame kasutajaliidese konsoolilt mõned klahvid ja vaatame, mis olekuga juhtub:

Turvalise brauseri laienduse kirjutamine

Olek tuleb muuta püsivaks, et taaskäivitamisel võtmed kaduma ei läheks.

Salvestame selle localStorage'i, kirjutades selle iga muudatusega üle. Edaspidi on sellele juurdepääs vajalik ka kasutajaliidese jaoks ning soovin ka muudatustega liituda. Selle põhjal on mugav luua jälgitav salvestusruum ja tellida selle muudatused.

Me kasutame mobx teeki (https://github.com/mobxjs/mobx). Valik langes selle peale, sest ma ei pidanud sellega töötama, aga ma tõesti tahtsin seda õppida.

Lisame algoleku lähtestamise ja teeme poe jälgitavaks:

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

    ...

}

"Kaoti all" on mobx asendanud kõik poeväljad puhverserveriga ja peatab kõik kõned neile. Neid sõnumeid on võimalik tellida.

Allpool kasutan sageli terminit "muutmisel", kuigi see pole täiesti õige. Mobx jälgib juurdepääsu põldudele. Kasutatakse teegi loodud puhverserveri objektide hankijaid ja määrajaid.

Tegevuskaunistajatel on kaks eesmärki:

  1. Ranges režiimis koos lipuga enforceActions keelab mobx oleku otse muutmise. Heaks tavaks peetakse rangetel tingimustel töötamist.
  2. Isegi kui funktsioon muudab olekut mitu korda – näiteks muudame mitmel koodireal mitut välja –, teavitatakse vaatlejaid alles siis, kui see on lõppenud. See on eriti oluline kasutajaliidese jaoks, kus mittevajalikud olekuvärskendused põhjustavad elementide tarbetut renderdamist. Meie puhul ei ole esimene ega teine ​​eriti asjakohane, kuid järgime parimaid tavasid. Kõigile funktsioonidele, mis muudavad vaadeldavate väljade olekut, on tavaks kinnitada dekoraatorid.

Taustal lisame lähtestamise ja oleku salvestamise localStorage'i:

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

Reaktsioonifunktsioon on siin huvitav. Sellel on kaks argumenti:

  1. Andmete valija.
  2. Töötleja, millele helistatakse nende andmetega iga kord, kui need muutuvad.

Erinevalt reduxist, kus me saame argumendina selgesõnaliselt oleku, jätab mobx meelde, millistele vaadeldavatele andmetele selektoris ligi pääseme, ja kutsub töötlejat ainult siis, kui need muutuvad.

Oluline on täpselt mõista, kuidas mobx otsustab, milliseid jälgitavaid andmeid me tellime. Kui ma kirjutaksin valija sellisesse koodi() => app.store, siis reaktsiooni ei kutsuta kunagi välja, kuna salvestusruum ise pole vaadeldav, on ainult selle väljad.

Kui ma selle niimoodi kirjutaksin () => app.store.keys, siis jällegi ei juhtuks midagi, kuna massiivi elementide lisamisel/eemaldamisel viide sellele ei muutu.

Mobx toimib selektorina esimest korda ja jälgib ainult vaadeldavaid andmeid, millele oleme juurde pääsenud. Seda tehakse puhverserveri hankijate kaudu. Seetõttu kasutatakse siin sisseehitatud funktsiooni toJS. See tagastab uue objekti, mille kõik puhverserverid on asendatud algsete väljadega. Täitmise ajal loeb see kõik objekti väljad - seega käivitatakse getterid.

Hüpikkonsoolis lisame taas mitu klahvi. Seekord sattusid need ka localStorage'i:

Turvalise brauseri laienduse kirjutamine

Taustalehe uuesti laadimisel jääb teave paigale.

Kogu rakenduse koodi kuni selle punktini saab vaadata siin.

Privaatvõtmete turvaline hoidmine

Privaatvõtmete salvestamine selgetekstis on ebaturvaline: alati on võimalus, et teid häkitakse, saate juurdepääsu arvutile jne. Seetõttu salvestame localStorage'is võtmed parooliga krüptitud kujul.

Suurema turvalisuse huvides lisame rakendusele lukustatud oleku, milles võtmetele ei pääse üldse ligi. Viime laienduse ajalõpu tõttu automaatselt üle lukustatud olekusse.

Mobx võimaldab salvestada vaid minimaalse andmehulga ning ülejäänu arvutatakse selle põhjal automaatselt. Need on nn arvutatud omadused. Neid saab võrrelda vaadetega andmebaasides:

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

Nüüd salvestame ainult krüptitud võtmed ja parooli. Kõik muu on arvutatud. Teostame ülekande lukustatud olekusse, eemaldades olekust parooli. Avalikul API-l on nüüd meetod salvestusruumi lähtestamiseks.

Kirjutatud krüpteerimiseks krüpto-js-i kasutavad utiliidid:

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

Brauseril on jõudeoleku API, mille kaudu saate tellida sündmuse - olekumuutused. Riik vastavalt võib olla idle, active и locked. Tühikäigu jaoks saate määrata ajalõpu ja lukustatud määratakse siis, kui OS ise on blokeeritud. Muudame ka localStorage'i salvestamise valijat:

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

Kood enne seda sammu on siin.

Tehingud

Niisiis, jõuame kõige olulisema asjani: plokiahelas tehingute loomine ja allkirjastamine. Kasutame WAVES-i plokiahelat ja raamatukogu lained-tehingud.

Esmalt lisame olekusse rida sõnumeid, mis vajavad allkirjastamist, seejärel lisame meetodid uue kirja lisamiseks, allkirja kinnitamiseks ja keeldumiseks:

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

    ...
}

Kui saame uue sõnumi, lisame sellele metaandmed, tee observable ja lisada juurde store.messages.

Kui sa seda ei tee observable käsitsi, siis mobx teeb seda massiivi sõnumite lisamisel ise. See loob aga uue objekti, millele meil pole viidet, kuid vajame seda järgmiseks sammuks.

Järgmisena tagastame lubaduse, mis kaob, kui sõnumi olek muutub. Olekut jälgitakse reaktsiooni abil, mis oleku muutumisel "tappab ennast".

Meetodi kood approve и reject väga lihtne: muudame lihtsalt sõnumi olekut, vajadusel pärast allkirjastamist.

Panime kasutajaliidese API-sse kinnituse ja tagasilükkamise ning lehe API-sse newMessage:

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

    ...
}

Proovime nüüd tehingut allkirjastada laiendiga:

Turvalise brauseri laienduse kirjutamine

Üldiselt on kõik valmis, jääb vaid üle lisage lihtne kasutajaliides.

UI

Liides vajab juurdepääsu rakenduse olekule. Kasutajaliidese poolel me seda teeme observable olek ja lisage API-le funktsioon, mis seda olekut muudab. Lisame observable taustalt saadud API-objektile:

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

Lõpuks alustame rakenduse liidese renderdamist. See on reageerimisrakendus. Taustaobjekt edastatakse lihtsalt rekvisiite kasutades. Muidugi oleks õige teha meetodite jaoks eraldi teenus ja riigi pood, kuid selle artikli jaoks piisab sellest:

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

Mobxiga on andmete muutumisel renderdamist väga lihtne alustada. Vaatleja dekoraatori riputame lihtsalt pakendi külge mobx-reageerida komponendil ja renderdus kutsutakse automaatselt välja, kui komponendi poolt viidatud vaadeldavad andmed muutuvad. Teil pole vaja mapStateToPropsi ega ühendada nagu reduxis. Kõik töötab karbist välja võttes:

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

Ülejäänud komponente saab koodist vaadata kasutajaliidese kaustas.

Nüüd peate rakenduste klassis tegema kasutajaliidese olekuvalija ja teavitama kasutajaliidest, kui see muutub. Selleks lisame meetodi getState и reactionhelistades 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())

        })
    }

    ...
}

Objekti vastuvõtmisel remote on loodud reaction funktsiooni kasutajaliidese poolel oleva oleku muutmiseks.

Viimane puudutus on lisada laienduse ikoonile uute sõnumite kuva.

function setupApp() {
...

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

...
}

Niisiis, rakendus on valmis. Veebilehed võivad nõuda tehingute jaoks allkirja:

Turvalise brauseri laienduse kirjutamine

Turvalise brauseri laienduse kirjutamine

Kood on saadaval siin link.

Järeldus

Kui olete artikli lõpuni lugenud, kuid teil on endiselt küsimusi, võite neid esitada aadressil laiendiga hoidlad. Sealt leiate ka kohustused iga määratud sammu kohta.

Ja kui olete huvitatud tegeliku laienduse koodi vaatamisest, leiate selle siin.

Kood, hoidla ja ametijuhend alates siemarell

Allikas: www.habr.com

Lisa kommentaar