Saugaus naršyklės plėtinio rašymas

Saugaus naršyklės plėtinio rašymas

Skirtingai nuo įprastos „kliento-serverio“ architektūros, decentralizuotos programos pasižymi:

  • Nereikia saugoti duomenų bazės su vartotojų prisijungimais ir slaptažodžiais. Prieigos informaciją saugo tik patys vartotojai, o jų autentiškumas patvirtinamas protokolo lygiu.
  • Nereikia naudoti serverio. Aplikacijų logika gali būti vykdoma blockchain tinkle, kuriame galima saugoti reikiamą duomenų kiekį.

Yra 2 gana saugios vartotojo raktų saugyklos – aparatinės įrangos piniginės ir naršyklės plėtiniai. Aparatinės piniginės dažniausiai yra ypač saugios, tačiau sunkiai naudojamos ir toli gražu nėra nemokamos, tačiau naršyklės plėtiniai yra puikus saugumo ir naudojimo paprastumo derinys, be to, galutiniams vartotojams gali būti visiškai nemokami.

Atsižvelgdami į visa tai, norėjome sukurti saugiausią plėtinį, kuris supaprastintų decentralizuotų programų kūrimą, suteikdamas paprastą API darbui su operacijomis ir parašais.
Toliau papasakosime apie šią patirtį.

Straipsnyje bus pateiktos nuoseklios instrukcijos, kaip parašyti naršyklės plėtinį, su kodų pavyzdžiais ir ekrano kopijomis. Visą kodą galite rasti saugyklos. Kiekvienas įsipareigojimas logiškai atitinka šio straipsnio skyrių.

Trumpa naršyklės plėtinių istorija

Naršyklės plėtiniai buvo naudojami ilgą laiką. Jie pasirodė „Internet Explorer“ 1999 m., „Firefox“ – 2004 m. Tačiau labai ilgą laiką nebuvo vieno pratęsimo standarto.

Galime pasakyti, kad jis pasirodė kartu su plėtiniais ketvirtojoje „Google Chrome“ versijoje. Žinoma, tada specifikacijų nebuvo, tačiau jos pagrindu tapo „Chrome“ API: užkariavusi didžiąją dalį naršyklių rinkos ir turėdama įmontuotą programų parduotuvę, „Chrome“ iš tikrųjų nustatė naršyklės plėtinių standartą.

„Mozilla“ turėjo savo standartą, tačiau, matydama „Chrome“ plėtinių populiarumą, bendrovė nusprendė sukurti suderinamą API. 2015 m. Mozilla iniciatyva World Wide Web Consortium (W3C) viduje buvo sukurta speciali grupė, kuri dirbs su kelių naršyklių plėtinių specifikacijomis.

Esami „Chrome“ skirtos API plėtiniai buvo naudojami kaip pagrindas. Darbas buvo atliktas remiant „Microsoft“ („Google“ atsisakė dalyvauti kuriant standartą), todėl pasirodė juodraštis specifikacijos.

Formaliai specifikaciją palaiko „Edge“, „Firefox“ ir „Opera“ (atkreipkite dėmesį, kad „Chrome“ nėra šiame sąraše). Tačiau iš tikrųjų standartas iš esmės suderinamas su „Chrome“, nes jis iš tikrųjų parašytas remiantis jo plėtiniais. Galite perskaityti daugiau apie WebExtensions API čia.

Pratęsimo struktūra

Vienintelis failas, kurio reikia plėtiniui, yra manifestas (manifest.json). Tai taip pat yra „įėjimo taškas“ į plėtrą.

Manifestas

Pagal specifikaciją manifesto failas yra galiojantis JSON failas. Visas aprašo raktų aprašymas su informacija apie tai, kurie raktai palaikomi kurioje naršyklėje, kurią galima peržiūrėti čia.

Raktai, kurių nėra specifikacijoje, „gali būti“ ignoruojami (ir „Chrome“, ir „Firefox“ praneša apie klaidas, tačiau plėtiniai ir toliau veikia).

Ir norėčiau atkreipti dėmesį į kai kuriuos dalykus.

  1. fonas — objektas, kurį sudaro šie laukai:
    1. scenarijai — scenarijų masyvas, kuris bus vykdomas foniniame kontekste (apie tai pakalbėsime šiek tiek vėliau);
    2. puslapis - vietoj scenarijų, kurie bus vykdomi tuščiame puslapyje, galite nurodyti html su turiniu. Tokiu atveju scenarijaus laukas bus ignoruojamas, o scenarijus reikės įterpti į turinio puslapį;
    3. išlikti — dvejetainė vėliavėlė, jei nenurodyta, naršyklė „nužudys“ foninį procesą, kai manys, kad nieko nedaro, ir prireikus paleis iš naujo. Priešingu atveju puslapis bus iškraunamas tik uždarius naršyklę. Nepalaikomas „Firefox“.
  2. turinio_skriptai - objektų masyvas, leidžiantis įkelti skirtingus scenarijus į skirtingus tinklalapius. Kiekviename objekte yra šie svarbūs laukai:
    1. degtukai - šablono URL, kuris nustato, ar tam tikras turinio scenarijus bus įtrauktas, ar ne.
    2. js — scenarijų, kurie bus įkelti į šį mačą, sąrašas;
    3. išskirti_atitikimus - pašalina iš lauko match URL, atitinkantys šį lauką.
  3. page_action - iš tikrųjų yra objektas, atsakingas už piktogramą, kuri rodoma šalia adreso juostos naršyklėje, ir sąveiką su ja. Tai taip pat leidžia rodyti iššokantįjį langą, kuris yra apibrėžtas naudojant jūsų HTML, CSS ir JS.
    1. default_popup - kelias į HTML failą su iššokančia sąsaja, gali būti CSS ir JS.
  4. Leidimai — išplėtimo teisių valdymo masyvas. Yra 3 teisių rūšys, kurios yra išsamiai aprašytos čia
  5. web_accessible_resources — plėtinių ištekliai, kurių gali prašyti tinklalapis, pavyzdžiui, vaizdai, JS, CSS, HTML failai.
  6. išoriškai_jungiamas – čia galite aiškiai nurodyti kitų tinklalapių plėtinių ir domenų, iš kurių galite prisijungti, ID. Domenas gali būti antrojo ar aukštesnio lygio. Neveikia Firefox.

Vykdymo kontekstas

Plėtinys turi tris kodo vykdymo kontekstus, tai yra, programą sudaro trys dalys su skirtingais prieigos prie naršyklės API lygiais.

Plėtinio kontekstas

Didžioji dalis API pasiekiama čia. Šiame kontekste jie „gyvena“:

  1. Fono puslapis — „galinė“ plėtinio dalis. Failas apraše nurodomas naudojant „fono“ klavišą.
  2. Iššokantis puslapis — iššokantis puslapis, kuris pasirodo spustelėjus plėtinio piktogramą. Manifeste browser_action -> default_popup.
  3. Pasirinktinis puslapis - plėtinio puslapis, „gyvenantis“ atskirame rodinio skirtuke chrome-extension://<id_расширения>/customPage.html.

Šis kontekstas egzistuoja nepriklausomai nuo naršyklės langų ir skirtukų. Fono puslapis egzistuoja vienoje kopijoje ir visada veikia (išimtis yra įvykio puslapis, kai fono scenarijų paleidžia įvykis ir „miršta“ jį įvykdžius). Iššokantis puslapis egzistuoja, kai atidarytas iššokantis langas, ir Pasirinktinis puslapis – kol atidarytas skirtukas su juo. Šiame kontekste nėra prieigos prie kitų skirtukų ir jų turinio.

Turinio scenarijaus kontekstas

Turinio scenarijaus failas paleidžiamas kartu su kiekvienu naršyklės skirtuku. Jis turi prieigą prie dalies plėtinio API ir tinklalapio DOM medžio. Tai turinio scenarijai, atsakingi už sąveiką su puslapiu. Plėtiniai, kurie valdo DOM medį, tai daro turinio scenarijuose, pvz., skelbimų blokavimo priemonėse arba vertėjai. Be to, turinio scenarijus gali susisiekti su puslapiu standartiniu būdu postMessage.

Tinklalapio kontekstas

Tai yra pats tinklalapis. Jis neturi nieko bendra su plėtiniu ir neturi prieigos prie jo, išskyrus atvejus, kai šio puslapio domenas nėra aiškiai nurodytas manifeste (daugiau apie tai toliau).

Žinutės

Skirtingos programos dalys turi keistis pranešimais viena su kita. Tam yra API runtime.sendMessage siųsti žinutę background и tabs.sendMessage siųsti žinutę į puslapį (turinio scenarijų, iššokantįjį langą arba tinklalapį, jei yra externally_connectable). Toliau pateikiamas pavyzdys, kaip pasiekti „Chrome“ API.

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

Norėdami visapusiškai bendrauti, galite užmegzti ryšius per runtime.connect. Atsakydami gausime runtime.Port, į kurį, kol jis atidarytas, galite siųsti bet kokį skaičių pranešimų. Iš kliento pusės, pvz. contentscript, atrodo taip:

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

Serveris arba fonas:

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

Taip pat yra renginys onDisconnect ir metodas disconnect.

Taikymo schema

Sukurkime naršyklės plėtinį, kuris saugo privačius raktus, suteikia prieigą prie viešosios informacijos (adresas, viešasis raktas bendrauja su puslapiu ir leidžia trečiųjų šalių programoms prašyti parašo sandoriams.

Programų kūrimas

Mūsų programa turi sąveikauti su vartotoju ir pateikti puslapiui API, kad iškviestų metodus (pvz., pasirašyti sandorius). Pasitenkinkite tik vienu contentscript neveiks, nes turi prieigą tik prie DOM, bet ne prie puslapio JS. Prisijunkite per runtime.connect negalime, nes API reikalinga visuose domenuose, o manifeste galima nurodyti tik konkrečius. Dėl to diagrama atrodys taip:

Saugaus naršyklės plėtinio rašymas

Bus kitas scenarijus - inpage, kurį įvesime į puslapį. Jis veiks savo kontekste ir suteiks API darbui su plėtiniu.

Pradėti

Visas naršyklės plėtinio kodas pasiekiamas adresu GitHub. Aprašymo metu bus nuorodos į įsipareigojimus.

Pradėkime nuo manifesto:

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

Sukurkite tuščią background.js, popup.js, inpage.js ir contentscript.js. Pridedame popup.html – mūsų programą jau galima įkelti į Google Chrome ir įsitikinti, kad ji veikia.

Norėdami tai patikrinti, galite paimti kodą taigi. Be to, ką padarėme, nuoroda sukonfigūravo projekto surinkimą naudojant žiniatinklio paketą. Norėdami pridėti programą prie naršyklės, chrome://extensions turite pasirinkti įkelti išpakuotą ir aplanką su atitinkamu plėtiniu - mūsų atveju dist.

Saugaus naršyklės plėtinio rašymas

Dabar mūsų plėtinys įdiegtas ir veikia. Kūrėjo įrankius įvairiems kontekstams galite paleisti taip:

iššokantis langas ->

Saugaus naršyklės plėtinio rašymas

Prieiga prie turinio scenarijaus konsolės pasiekiama per paties puslapio, kuriame jis paleistas, konsolę.Saugaus naršyklės plėtinio rašymas

Žinutės

Taigi, turime sukurti du ryšio kanalus: puslapio <-> foną ir iššokantįjį <-> foną. Žinoma, galite tiesiog siųsti pranešimus į prievadą ir sugalvoti savo protokolą, bet man labiau patinka požiūris, kurį mačiau atvirojo kodo projekte metamask.

Tai naršyklės plėtinys, skirtas darbui su Ethereum tinklu. Jame skirtingos programos dalys bendrauja per RPC, naudodamos dnode biblioteką. Tai leidžia gana greitai ir patogiai organizuoti mainus, jei jai pateikiate nodejs srautą kaip transportą (tai reiškia objektą, kuris įgyvendina tą pačią sąsają):

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

Dabar sukursime taikomųjų programų klasę. Jis sukurs API objektus iššokančiam langui ir tinklalapiui ir sukurs jiems skirtą mazgą:

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

Čia ir toliau vietoje visuotinio „Chrome“ objekto naudojame plėtinį „Api“, kuris pasiekia „Chrome“ „Google“ naršyklėje ir naršyklę kitose. Tai daroma dėl kelių naršyklių suderinamumo, tačiau šio straipsnio tikslais galima tiesiog naudoti „chrome.runtime.connect“.

Sukurkime programos egzempliorių foniniame scenarijuje:

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

Kadangi dnode veikia su srautais, o mes gauname prievadą, reikalinga adapterio klasė. Jis sukurtas naudojant skaitomo srauto biblioteką, kuri naršyklėje įdiegia nodejs srautus:

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

Dabar sukurkime ryšį vartotojo sąsajoje:

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

Tada turinio scenarijuje sukuriame ryšį:

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

Kadangi API mums reikia ne turinio scenarijuje, o tiesiai puslapyje, darome du dalykus:

  1. Sukuriame du srautus. Vienas – link puslapio, žinutės viršuje. Tam mes naudojame tai šis paketas iš metamaskų kūrėjų. Antrasis srautas nukreipiamas į foną per prievadą, gautą iš runtime.connect. Nusipirkime juos. Dabar puslapio fone bus srautas.
  2. Įveskite scenarijų į DOM. Atsisiųskite scenarijų (prieiga prie jo buvo leidžiama manifeste) ir sukurkite žymą script su turiniu viduje:

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

Dabar sukuriame API objektą puslapyje ir nustatome jį kaip visuotinį:

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

Mes pasiruošę Nuotolinis procedūrų skambutis (RPC) su atskiru API puslapiui ir vartotojo sąsajai. Prijungdami naują puslapį prie fono matome tai:

Saugaus naršyklės plėtinio rašymas

Tuščia API ir kilmė. Puslapio pusėje galime iškviesti „hello“ funkciją taip:

Saugaus naršyklės plėtinio rašymas

Darbas su atgalinio ryšio funkcijomis šiuolaikiniame JS yra blogas būdas, todėl parašykime nedidelį pagalbininką, kad sukurtume dnode, leidžiantį perduoti API objektą utils.

API objektai dabar atrodys taip:

export class SignerApp {

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

...

}

Objekto gavimas iš nuotolinio valdymo pulto taip:

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

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

O funkcijų iškvietimas suteikia pažadą:

Saugaus naršyklės plėtinio rašymas

Galima versija su asinchroninėmis funkcijomis čia.

Apskritai RPC ir srauto metodas atrodo gana lankstus: galime naudoti garų tankinimą ir sukurti keletą skirtingų API skirtingoms užduotims atlikti. Iš esmės dnode gali būti naudojamas bet kur, svarbiausia apvynioti transportą nodejs srauto forma.

Alternatyva yra JSON formatas, kuris įgyvendina JSON RPC 2 protokolą. Tačiau jis veikia su specifiniais perdavimais (TCP ir HTTP(S)), o tai mūsų atveju netaikoma.

Vidinė būsena ir vietinė saugykla

Turėsime išsaugoti vidinę programos būseną – bent jau pasirašymo raktus. Gana lengvai galime pridėti būseną prie programos ir jos keitimo metodus iššokančiajame API:

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

    ...

} 

Fone viską suvyniosime į funkciją ir įrašysime programos objektą į langą, kad galėtume su juo dirbti iš konsolės:

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

Pridėkime kelis UI konsolės klavišus ir pažiūrėkime, kas atsitiks su būsena:

Saugaus naršyklės plėtinio rašymas

Būseną reikia padaryti patvarią, kad paleidus iš naujo raktai nebūtų prarasti.

Mes išsaugosime jį vietinėje saugykloje, perrašydami jį su kiekvienu pakeitimu. Vėliau prieiga prie jos taip pat bus reikalinga vartotojo sąsajai, taip pat norėčiau užsiprenumeruoti pakeitimus. Remiantis tuo, bus patogu sukurti stebimą saugyklą ir užsiprenumeruoti jos pakeitimus.

Mes naudosime mobx biblioteką (https://github.com/mobxjs/mobx). Pasirinkimas krito ant jo, nes man nereikėjo su juo dirbti, bet labai norėjau tai studijuoti.

Pridėkime pradinės būsenos inicijavimą ir padarykime parduotuvę stebimą:

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

    ...

}

„Po gaubtu“ mobx pakeitė visus parduotuvės laukus tarpiniu serveriu ir perima visus skambučius į juos. Šias žinutes bus galima užsiprenumeruoti.

Žemiau dažnai vartosiu terminą „keičiant“, nors tai nėra visiškai teisinga. Mobx seka prieigą prie laukų. Naudojami tarpinio serverio objektų, kuriuos sukuria biblioteka, imtuvai ir nustatytojai.

Veiksmo dekoratoriai atlieka du tikslus:

  1. Griežtu režimu su enforceActions vėliava mobx draudžia tiesiogiai keisti būseną. Gera praktika laikoma dirbti griežtomis sąlygomis.
  2. Net jei funkcija kelis kartus keičia būseną – pavyzdžiui, pakeičiame kelis laukus keliose kodo eilutėse – stebėtojams pranešama tik tada, kai ji baigiama. Tai ypač svarbu sąsajai, kur dėl nereikalingų būsenų atnaujinimų nereikalingi atvaizduojami elementai. Mūsų atveju nei pirmasis, nei antrasis nėra itin aktualūs, tačiau vadovausimės geriausia praktika. Prie visų funkcijų, kurios keičia stebimų laukų būklę, įprasta pritvirtinti dekoratorius.

Fone pridėsime inicijavimą ir būsenos išsaugojimą vietinėje saugykloje:

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

Čia įdomi reakcijos funkcija. Jis turi du argumentus:

  1. Duomenų parinkiklis.
  2. Valdytojas, kuris bus iškviestas su šiais duomenimis kiekvieną kartą, kai jie pasikeičia.

Skirtingai nuo redux, kur kaip argumentą aiškiai gauname būseną, mobx prisimena, kuriuos stebimus objektus pasiekiame parinkiklio viduje, ir iškviečia tvarkyklę tik tada, kai jos pasikeičia.

Svarbu tiksliai suprasti, kaip mobx nusprendžia, kuriuos stebimus objektus prenumeruojame. Jei parašyčiau selektorių tokiu kodu() => app.store, tada reakcija niekada nebus vadinama, nes pati saugykla nėra stebima, tik jos laukai.

Jei taip parašiau () => app.store.keys, tada vėl nieko neatsitiktų, nes pridedant/pašalinant masyvo elementus nuoroda į jį nepasikeis.

„Mobx“ pirmą kartą veikia kaip parinkiklis ir seka tik tuos stebėjimus, kuriuos pasiekėme. Tai atliekama per proxy geterius. Todėl čia naudojama įmontuota funkcija toJS. Jis grąžina naują objektą su visais tarpiniais serveriais, pakeistais pradiniais laukais. Vykdymo metu jis nuskaito visus objekto laukus, todėl suveikia geteriai.

Iššokančiajame konsolėje vėl pridėsime kelis raktus. Šį kartą jie taip pat atsidūrė „localStorage“:

Saugaus naršyklės plėtinio rašymas

Kai foninis puslapis įkeliamas iš naujo, informacija lieka vietoje.

Galima peržiūrėti visą programos kodą iki šio taško čia.

Saugus privačių raktų saugojimas

Privačių raktų saugojimas aiškiame tekste yra nesaugus: visada yra tikimybė, kad būsite nulaužtas, gausite prieigą prie kompiuterio ir pan. Todėl „localStorage“ raktus saugosime slaptažodžiu užšifruota forma.

Siekiant didesnio saugumo, programai pridėsime užrakintą būseną, kurioje iš viso nebus prieigos prie raktų. Automatiškai perkelsime plėtinį į užrakinimo būseną dėl skirtojo laiko.

Mobx leidžia saugoti tik minimalų duomenų rinkinį, o likusieji automatiškai apskaičiuojami pagal jį. Tai yra vadinamosios skaičiuotinės savybės. Juos galima palyginti su duomenų bazių rodiniais:

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

Dabar saugome tik užšifruotus raktus ir slaptažodį. Visa kita apskaičiuojama. Mes atliekame perkėlimą į užrakinimo būseną, pašalindami slaptažodį iš būsenos. Viešoji API dabar turi saugyklos inicijavimo metodą.

Parašyta šifravimui komunalinės paslaugos naudojant crypto-js:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

Naršyklė turi neveikiančią API, per kurią galite užsiprenumeruoti įvykį – būsenos pasikeitimus. Valstybės, atitinkamai, gali būti idle, active и locked. Neveikiančiam režimui galite nustatyti skirtąjį laiką, o užrakinta nustatoma, kai blokuojama pati OS. Taip pat pakeisime įrašymo į localStorage parinkiklį:

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

Kodas prieš šį veiksmą yra čia.

Sandoriai

Taigi, mes prieiname prie svarbiausio dalyko: operacijų kūrimo ir pasirašymo blokų grandinėje. Mes naudosime WAVES blokų grandinę ir biblioteką bangos-sandoriai.

Pirma, pridėkime prie būsenos pranešimų, kuriuos reikia pasirašyti, masyvą, tada pridėkite metodus, kaip pridėti naują pranešimą, patvirtinti parašą ir atmesti:

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

    ...
}

Kai gauname naują pranešimą, pridedame prie jo metaduomenis, darykite observable ir pridėti prie store.messages.

Jei ne observable rankiniu būdu, tada mobx tai padarys pats, kai įtrauks pranešimus į masyvą. Tačiau jis sukurs naują objektą, į kurį neturėsime nuorodos, bet mums jo reikės kitam žingsniui.

Tada grąžiname pažadą, kuris galioja pasikeitus pranešimo būsenai. Būsena stebima reakcija, kuri pasikeitus būsenai „nužus“.

Metodo kodas approve и reject labai paprasta: tiesiog pakeičiame pranešimo būseną, jei reikia, pasirašę.

Į UI API įtraukėme Patvirtinti ir atmesti, o puslapio API – naują pranešimą:

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

    ...
}

Dabar pabandykime pasirašyti operaciją su plėtiniu:

Saugaus naršyklės plėtinio rašymas

Apskritai viskas paruošta, belieka tik pridėti paprastą vartotojo sąsają.

UI

Sąsajai reikia prieigos prie programos būsenos. UI pusėje mes tai padarysime observable būseną ir pridėkite prie API funkciją, kuri pakeis šią būseną. Pridurkime observable į API objektą, gautą iš fono:

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

Pabaigoje pradedame pateikti programos sąsają. Tai reagavimo programa. Fono objektas tiesiog perduodamas naudojant rekvizitus. Žinoma, būtų teisinga sukurti atskirą metodų paslaugą ir parduotuvę valstybei, tačiau šio straipsnio tikslams to pakanka:

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

Su mobx labai lengva pradėti atvaizdavimą pasikeitus duomenims. Stebėtojo dekoratorių tiesiog pakabiname ant pakuotės mobx-reaguoti komponente, o atvaizdavimas bus automatiškai iškviestas, kai pasikeis bet kokie komponento nurodyti stebėjimai. Jums nereikia jokių mapStateToProps arba prisijungti kaip redux. Viskas veikia iš karto iš dėžutės:

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

Likusius komponentus galima peržiūrėti kode UI aplanke.

Dabar taikomųjų programų klasėje turite nustatyti vartotojo sąsajos būsenos parinkiklį ir pranešti vartotojo sąsajai, kai ji pasikeičia. Norėdami tai padaryti, pridėkite metodą getState и reactionskambinant 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())

        })
    }

    ...
}

Kai gauna daiktą remote yra sukurta reaction pakeisti būseną, kuri iškviečia funkciją UI pusėje.

Paskutinis prisilietimas yra pridėti naujų pranešimų rodymą prie plėtinio piktogramos:

function setupApp() {
...

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

...
}

Taigi, programa yra paruošta. Tinklalapiai gali prašyti parašo sandoriams:

Saugaus naršyklės plėtinio rašymas

Saugaus naršyklės plėtinio rašymas

Kodą rasite čia nuoroda.

išvada

Jei perskaitėte straipsnį iki galo, bet vis tiek turite klausimų, galite juos užduoti adresu saugyklos su plėtiniu. Ten taip pat rasite įsipareigojimus kiekvienam nurodytam žingsniui.

Ir jei jus domina tikrojo plėtinio kodas, galite jį rasti čia.

Kodas, saugykla ir pareigų aprašymas iš siemarell

Šaltinis: www.habr.com

Добавить комментарий