Nulis ekstensi browser sing aman

Nulis ekstensi browser sing aman

Ora kaya arsitektur "klien-server" umum, aplikasi desentralisasi ditondoi dening:

  • Ora perlu nyimpen database karo login pangguna lan sandhi. Informasi akses disimpen sacara eksklusif dening pangguna dhewe, lan konfirmasi keasliane ana ing tingkat protokol.
  • Ora perlu nggunakake server. Logika aplikasi bisa dieksekusi ing jaringan pamblokiran, ing ngendi bisa nyimpen jumlah data sing dibutuhake.

Ana 2 panyimpenan sing relatif aman kanggo tombol pangguna - dompet hardware lan ekstensi browser. Dompet hardware biasane aman banget, nanging angel digunakake lan adoh saka gratis, nanging ekstensi browser minangka kombinasi keamanan sing sampurna lan gampang digunakake, lan uga bisa gratis kanggo pangguna pungkasan.

Nganggep kabeh iki, kita pengin nggawe ekstensi paling aman sing nyederhanakake pangembangan aplikasi desentralisasi kanthi nyedhiyakake API sing gampang kanggo nggarap transaksi lan teken.
Kita bakal ngandhani babagan pengalaman iki ing ngisor iki.

Artikel kasebut bakal ngemot instruksi langkah demi langkah babagan carane nulis ekstensi browser, kanthi conto kode lan gambar. Sampeyan bisa nemokake kabeh kode ing repositori. Saben komit kanthi logis cocog karo bagean artikel iki.

Sejarah Singkat Ekstensi Browser

Ekstensi browser wis suwe saya suwe. Dheweke muncul ing Internet Explorer ing taun 1999, ing Firefox ing taun 2004. Nanging, kanggo wektu sing suwe ora ana standar siji kanggo ekstensi.

Kita bisa ujar manawa katon bebarengan karo ekstensi ing versi kaping papat Google Chrome. Mesthine, ora ana spesifikasi, nanging API Chrome sing dadi basis: sawise nelukake sebagian besar pasar browser lan duwe toko aplikasi sing dibangun, Chrome bener nyetel standar kanggo ekstensi browser.

Mozilla duwe standar dhewe, nanging ndeleng popularitas ekstensi Chrome, perusahaan mutusake nggawe API sing kompatibel. Ing 2015, kanthi inisiatif Mozilla, klompok khusus digawe ing World Wide Web Consortium (W3C) kanggo nggarap spesifikasi ekstensi lintas-browser.

Ekstensi API sing ana kanggo Chrome dijupuk minangka basis. Karya kasebut ditindakake kanthi dhukungan saka Microsoft (Google ora gelem melu pangembangan standar kasebut), lan minangka asil rancangan muncul. spesifikasi.

Secara resmi, spesifikasi kasebut didhukung dening Edge, Firefox lan Opera (cathetan yen Chrome ora ana ing dhaptar iki). Nanging nyatane, standar kasebut umume kompatibel karo Chrome, amarga sejatine ditulis adhedhasar ekstensi. Sampeyan bisa maca liyane babagan API WebExtensions kene.

Struktur ekstensi

Siji-sijine file sing dibutuhake kanggo ekstensi yaiku manifest (manifest.json). Iku uga "titik entri" kanggo expansion.

Nggambarake

Miturut spesifikasi, file manifest minangka file JSON sing sah. Katrangan lengkap babagan tombol nyata kanthi informasi babagan tombol sing didhukung ing browser sing bisa dideleng kene.

Tombol sing ora ana ing spesifikasi "bisa" diabaikan (loro Chrome lan Firefox laporan kesalahan, nanging ekstensi terus bisa digunakake).

Lan aku pengin narik kawigaten sawetara poin.

  1. latar mburi - obyek sing kalebu kolom ing ngisor iki:
    1. skrip — macem-macem skrip sing bakal dieksekusi ing konteks latar mburi (kita bakal ngomong babagan iki mengko);
    2. Kaca - tinimbang skrip sing bakal dieksekusi ing kaca kosong, sampeyan bisa nemtokake html karo isi. Ing kasus iki, kolom skrip bakal diabaikan, lan skrip kudu dilebokake ing kaca isi;
    3. tahan - gendéra binar, yen ora ditemtokake, browser bakal "mateni" proses latar mburi nalika nganggep yen ora nindakake apa-apa, lan miwiti maneh yen perlu. Yen ora, kaca mung bakal dibongkar nalika browser ditutup. Ora didhukung ing Firefox.
  2. isi_skrip — macem-macem obyek sing ngidini sampeyan mbukak skrip sing beda menyang kaca web sing beda. Saben obyek ngemot kolom penting ing ngisor iki:
    1. cocog - pola url, sing nemtokake manawa skrip isi tartamtu bakal dilebokake utawa ora.
    2. js — dhaptar skrip sing bakal dimuat ing pertandhingan iki;
    3. exclude_matches - ora kalebu saka lapangan match URL sing cocog karo lapangan iki.
  3. page_action - iku bener obyek sing tanggung jawab kanggo lambang sing ditampilake ing jejere baris alamat ing browser lan interaksi karo. Sampeyan uga ngidini sampeyan nampilake jendhela popup, sing ditetepake nggunakake HTML, CSS lan JS sampeyan dhewe.
    1. default_popup — path menyang file HTML karo antarmuka nyembul, bisa ngemot CSS lan JS.
  4. ijin - array kanggo ngatur hak extension. Ana 3 jinis hak, sing diterangake kanthi rinci kene
  5. web_accessible_resources - sumber ekstensi sing bisa dijaluk kaca web, contone, gambar, JS, CSS, file HTML.
  6. externally_connectable — ing kene sampeyan bisa kanthi jelas nemtokake ID ekstensi lan domain liyane saka kaca web sing bisa disambungake. Domain bisa dadi tingkat kapindho utawa luwih dhuwur. Ora bisa digunakake ing Firefox.

Konteks eksekusi

Ekstensi kasebut nduweni telung konteks eksekusi kode, yaiku, aplikasi kasebut dumadi saka telung bagean kanthi tingkat akses sing beda menyang API browser.

Konteks ekstensi

Umume API kasedhiya ing kene. Ing konteks iki, dheweke "urip":

  1. Kaca latar mburi - bagean "backend" saka extension. File kasebut ditemtokake ing manifest nggunakake tombol "latar mburi".
  2. Kaca popup - kaca popup sing katon nalika sampeyan ngeklik lambang extension. Ing manifesto browser_action -> default_popup.
  3. kaca adat - kaca extension, "urip" ing tab kapisah saka tampilan chrome-extension://<id_расширения>/customPage.html.

Konteks iki ora ana ing jendela lan tab browser. Kaca latar mburi ana ing salinan siji lan tansah dianggo (pangecualian kaca acara, nalika script latar mburi dibukak dening acara lan "mati" sawise eksekusi). Kaca popup ana nalika jendhela nyembul mbukak, lan kaca adat - nalika tab karo mbukak. Ora ana akses menyang tab liyane lan isine saka konteks iki.

Konteks skrip konten

File script isi dibukak bebarengan karo saben tab browser. Nduwe akses menyang bagean saka API extension lan menyang wit DOM kaca web. Iku skrip isi sing tanggung jawab kanggo interaksi karo kaca. Ekstensi sing manipulasi wit DOM nindakake iki ing skrip isi - contone, pamblokir iklan utawa penerjemah. Uga, skrip isi bisa komunikasi karo kaca liwat standar postMessage.

Konteks kaca web

Iki minangka kaca web sing nyata. Ora ana hubungane karo ekstensi lan ora duwe akses ing kana, kajaba ing kasus ing ngendi domain kaca iki ora dituduhake kanthi jelas ing manifest (liyane ing ngisor iki).

Ganti pesen

Bagean aplikasi sing beda-beda kudu saling ijol-ijolan pesen. Ana API kanggo iki runtime.sendMessage kanggo ngirim pesen background и tabs.sendMessage kanggo ngirim pesen menyang kaca (skrip isi, popup utawa kaca web yen kasedhiya externally_connectable). Ing ngisor iki conto nalika ngakses 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))
    }
)

Kanggo komunikasi lengkap, sampeyan bisa nggawe sambungan liwat runtime.connect. Nanggepi kita bakal nampa runtime.Port, sing, nalika mbukak, sampeyan bisa ngirim nomer pesen. Ing sisih klien, contone, contentscript, katon kaya iki:

// Опять же 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 utawa latar mburi:

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

Ana uga acara onDisconnect lan metode disconnect.

Diagram aplikasi

Ayo nggawe ekstensi browser sing nyimpen kunci pribadi, menehi akses menyang informasi umum (alamat, kunci umum komunikasi karo kaca lan ngidini aplikasi pihak katelu njaluk teken kanggo transaksi.

Pangembangan aplikasi

Aplikasi kita kudu sesambungan karo pangguna lan menehi kaca API kanggo nelpon metode (contone, kanggo mlebu transaksi). Nggawe mung siji contentscript ora bakal bisa, amarga mung nduweni akses menyang DOM, nanging ora menyang JS kaca. Sambungake liwat runtime.connect kita ora bisa, amarga API dibutuhake ing kabeh domain, lan mung tartamtu bisa kasebut ing manifest. Akibaté, diagram bakal katon kaya iki:

Nulis ekstensi browser sing aman

Bakal ana skrip liyane - inpage, sing bakal kita injeksi menyang kaca. Bakal mbukak ing konteks lan nyedhiyakake API kanggo nggarap ekstensi kasebut.

Начало

Kabeh kode extension browser kasedhiya ing GitHub. Sajrone katrangan bakal ana pranala menyang commits.

Ayo miwiti karo 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"]
}

Gawe background.js kosong, popup.js, inpage.js lan contentscript.js. Kita nambah popup.html - lan aplikasi kita wis bisa dimuat menyang Google Chrome lan priksa manawa bisa digunakake.

Kanggo verifikasi iki, sampeyan bisa njupuk kode saka kene. Saliyane apa sing ditindakake, link kasebut ngatur perakitan proyek kasebut nggunakake webpack. Kanggo nambah aplikasi menyang browser, ing chrome: // extensions sampeyan kudu milih mbukak unpacked lan folder kanthi ekstensi sing cocog - ing kasus kita dist.

Nulis ekstensi browser sing aman

Saiki ekstensi kita wis diinstal lan digunakake. Sampeyan bisa mbukak alat pangembang kanggo macem-macem konteks kaya ing ngisor iki:

popup ->

Nulis ekstensi browser sing aman

Akses menyang konsol skrip konten ditindakake liwat konsol kaca kasebut dhewe sing diluncurake.Nulis ekstensi browser sing aman

Ganti pesen

Dadi, kita kudu nggawe rong saluran komunikasi: inpage <-> background lan popup <-> background. Sampeyan bisa, mesthi, mung ngirim pesen menyang port lan invent protokol dhewe, nanging aku luwih seneng pendekatan sing aku weruh ing metamask project open source.

Iki minangka ekstensi browser kanggo nggarap jaringan Ethereum. Ing kono, macem-macem bagean aplikasi komunikasi liwat RPC nggunakake perpustakaan dnode. Iki ngidini sampeyan ngatur ijol-ijolan kanthi cepet lan gampang yen sampeyan nyedhiyakake stream nodejs minangka transportasi (tegese obyek sing ngetrapake antarmuka sing padha):

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

Saiki kita bakal nggawe kelas aplikasi. Bakal nggawe obyek API kanggo popup lan kaca web, lan nggawe dnode kanggo wong-wong mau:

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

Ing kene lan ing ngisor iki, tinimbang obyek Chrome global, kita nggunakake extensionApi, sing ngakses Chrome ing browser Google lan browser liyane. Iki ditindakake kanggo kompatibilitas lintas-browser, nanging kanggo tujuan artikel iki, siji bisa nggunakake 'chrome.runtime.connect'.

Ayo nggawe conto aplikasi ing skrip latar mburi:

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

Wiwit dnode dianggo karo lepen, lan kita nampa port, perlu kelas adaptor. Iki digawe nggunakake perpustakaan sing bisa diwaca, sing ngetrapake aliran nodejs ing browser:

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

Saiki ayo nggawe sambungan ing UI:

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

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

setupUi().catch(console.error);

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

    const dnode = Dnode();

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

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

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

Banjur kita nggawe sambungan ing skrip isi:

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

Amarga kita butuh API ora ing skrip isi, nanging langsung ing kaca, kita nindakake rong perkara:

  1. Kita nggawe loro aliran. Siji - menyang kaca, ing ndhuwur postMessage. Kanggo iki kita nggunakake iki paket iki saka pangripta metamask. Aliran kapindho yaiku latar mburi port sing ditampa saka runtime.connect. Ayo padha tuku. Saiki kaca bakal duwe stream menyang latar mburi.
  2. Nyuntikake skrip menyang DOM. Unduh skrip (akses kasebut diidini ing manifest) lan gawe tag script karo isine:

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

Saiki kita nggawe obyek api ing inpage lan nyetel menyang global:

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

Kita siyap Telpon Prosedur Jarak Jauh (RPC) kanthi API kapisah kanggo kaca lan UI. Nalika nyambungake kaca anyar menyang latar mburi kita bisa ndeleng iki:

Nulis ekstensi browser sing aman

API kosong lan asal. Ing sisih kaca, kita bisa nelpon fungsi hello kaya iki:

Nulis ekstensi browser sing aman

Nggarap fungsi callback ing JS modern iku tumindak ala, mula ayo nulis helper cilik kanggo nggawe dnode sing ngidini sampeyan ngirim obyek API menyang utils.

Objek API saiki bakal katon kaya iki:

export class SignerApp {

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

...

}

Njupuk obyek saka remot kaya iki:

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

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

Lan fungsi nelpon ngasilake janji:

Nulis ekstensi browser sing aman

Versi kanthi fungsi asinkron kasedhiya kene.

Sakabèhé, pendekatan RPC lan stream katon cukup fleksibel: kita bisa nggunakake steam multiplexing lan nggawe sawetara API beda kanggo tugas beda. Ing asas, dnode bisa digunakake ing ngendi wae, sing utama yaiku mbungkus transportasi ing wangun stream nodejs.

Alternatif yaiku format JSON, sing ngleksanakake protokol JSON RPC 2. Nanging, kerjane karo transportasi tartamtu (TCP lan HTTP(S)), sing ora ditrapake ing kasus kita.

Negara internal lan panyimpenan lokal

Kita kudu nyimpen kahanan internal aplikasi - paling ora tombol teken. Kita bisa kanthi gampang nambah negara menyang aplikasi lan cara kanggo ngganti ing API popup:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

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

    ...

} 

Ing latar mburi, kita bakal mbungkus kabeh ing fungsi lan nulis obyek aplikasi menyang jendhela supaya kita bisa nggarap saka console:

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

Ayo nambah sawetara tombol saka konsol UI lan ndeleng apa sing kedadeyan karo negara:

Nulis ekstensi browser sing aman

Negara kudu digawe terus-terusan supaya tombol ora ilang nalika miwiti maneh.

Kita bakal nyimpen ing localStorage, nimpa karo saben owah-owahan. Sabanjure, akses menyang UI uga perlu, lan aku uga pengin langganan owah-owahan. Adhedhasar iki, bakal trep kanggo nggawe panyimpenan sing bisa diamati lan langganan owah-owahan kasebut.

Kita bakal nggunakake perpustakaan mobx (https://github.com/mobxjs/mobx). Pilihan kasebut amarga aku ora kudu nggarap, nanging aku kepengin banget sinau.

Ayo nambah initialization saka negara wiwitan lan nggawe toko diamati:

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

    ...

}

"Ing hood," mobx wis ngganti kabeh kothak nyimpen karo proxy lan intercepts kabeh telpon kanggo wong-wong mau. Bakal bisa langganan pesen kasebut.

Ing ngisor iki aku bakal kerep nggunakake istilah "nalika ganti", sanajan iki ora sakabehe bener. Mobx nglacak akses menyang lapangan. Getters lan setter obyek proxy sing perpustakaan nggawe digunakake.

Dekorator aksi duwe rong tujuan:

  1. Ing mode ketat karo flag enforceActions, mobx nglarang ngganti negara langsung. Iki dianggep minangka praktik sing apik kanggo kerja ing kahanan sing ketat.
  2. Sanajan fungsi ngganti negara kaping pirang-pirang - contone, kita ngganti sawetara kolom ing sawetara baris kode - pengamat mung diwenehi kabar yen wis rampung. Iki penting banget kanggo frontend, ing ngendi nganyari negara sing ora perlu nyebabake rendering unsur sing ora perlu. Ing kasus kita, ora sing pisanan utawa sing nomer loro utamane relevan, nanging kita bakal ngetutake praktik paling apik. Biasane masang dekorator kanggo kabeh fungsi sing ngganti kahanan lapangan sing diamati.

Ing latar mburi kita bakal nambah initialization lan nyimpen negara ing localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Fungsi reaksi menarik ing kene. Wis rong argumen:

  1. Pamilih data.
  2. A handler sing bakal disebut karo data iki saben-saben diganti.

Boten kados redux, ngendi kita tegas nampa negara minangka bantahan, mobx elinga kang observables kita akses nang pamilih, lan mung nelpon handler nalika padha ngganti.

Iku penting kanggo ngerti persis carane mobx mutusaké kang observables kita langganan. Yen aku nulis pamilih ing kode kaya iki() => app.store, banjur reaksi ora bakal disebut, amarga panyimpenan dhewe ora bisa diamati, mung lapangan.

Yen aku nulis kaya iki () => app.store.keys, banjur maneh ora bakal kelakon, wiwit nalika nambah / mbusak unsur Uploaded, referensi kanggo iku ora bakal ngganti.

Mobx tumindak minangka pamilih kanggo pisanan lan mung nglacak observasi sing wis diakses. Iki ditindakake liwat getter proxy. Mulane, fungsi sing dibangun ing kene digunakake toJS. Iki ngasilake obyek anyar kanthi kabeh proxy diganti karo kolom asli. Sajrone eksekusi, maca kabeh lapangan obyek - mula getter dipicu.

Ing console popup kita bakal maneh nambah sawetara tombol. Wektu iki uga ana ing localStorage:

Nulis ekstensi browser sing aman

Nalika kaca latar mburi dimuat maneh, informasi kasebut tetep ana.

Kabeh kode aplikasi nganti titik iki bisa dideleng kene.

Panyimpenan aman saka kunci pribadi

Nyimpen kunci pribadi ing teks sing cetha ora aman: mesthi ana kemungkinan sampeyan bakal disusupi, entuk akses menyang komputer, lan liya-liyane. Mulane, ing localStorage kita bakal nyimpen kunci ing wangun enkripsi sandi.

Kanggo keamanan sing luwih gedhe, kita bakal nambah negara sing dikunci ing aplikasi kasebut, sing ora bakal ana akses menyang tombol. Kita bakal kanthi otomatis nransfer extension menyang negara dikunci amarga wektu entek.

Mobx ngijini sampeyan kanggo nyimpen mung pesawat minimal data, lan liyane otomatis diwilang adhedhasar iku. Iki minangka properti sing diarani komputasi. Bisa dibandhingake karo tampilan ing basis data:

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

Saiki kita mung nyimpen kunci lan sandhi sing dienkripsi. Kabeh liyane diitung. Kita nindakake transfer menyang negara sing dikunci kanthi mbusak sandhi saka negara kasebut. API umum saiki duwe cara kanggo miwiti panyimpenan.

Ditulis kanggo enkripsi utilitas nggunakake 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)
}

Browser duwe API nganggur sing bisa sampeyan lengganan ing acara - owah-owahan negara. Negara, miturut, bisa uga idle, active и locked. Kanggo nganggur sampeyan bisa nyetel wektu entek, lan dikunci disetel nalika OS dhewe diblokir. Kita uga bakal ngganti pamilih kanggo nyimpen menyang localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Kode sadurunge langkah iki yaiku kene.

Transaksi

Dadi, kita teka ing bab sing paling penting: nggawe lan mlebu transaksi ing blockchain. Kita bakal nggunakake pamblokiran lan perpustakaan WAVES ombak-transaksi.

Pisanan, ayo nambahake pesen sing kudu ditandatangani menyang negara, banjur tambahake cara kanggo nambah pesen anyar, konfirmasi teken, lan nolak:

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

    ...
}

Nalika kita nampa pesen anyar, kita nambah metadata menyang, apa observable lan nambah kanggo store.messages.

Yen sampeyan ora observable kanthi manual, banjur mobx bakal nindakake dhewe nalika nambah pesen menyang Uploaded. Nanging, bakal nggawe obyek anyar sing ora bakal duwe referensi, nanging kita butuh kanggo langkah sabanjure.

Sabanjure, kita bali janji sing mutusaké nalika status pesen diganti. Status kasebut dipantau kanthi reaksi, sing bakal "mateni awake dhewe" nalika status diganti.

Kode metode approve и reject prasaja banget: kita mung ngganti status pesen, sawise mlebu yen perlu.

Kita sijine Setuju lan nolak ing UI API, newMessage ing kaca API:

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

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

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

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

    ...
}

Saiki ayo nyoba mlebu transaksi nganggo ekstensi:

Nulis ekstensi browser sing aman

Umumé, kabeh wis siyap, sing isih ana nambah UI prasaja.

UI

Antarmuka mbutuhake akses menyang negara aplikasi. Ing sisih UI kita bakal nindakake observable negara lan nambah fungsi kanggo API sing bakal ngganti negara iki. Ayo ditambahake observable menyang obyek API sing ditampa saka latar mburi:

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

Ing pungkasan kita miwiti nerjemahake antarmuka aplikasi. Iki minangka aplikasi reaksi. Obyek latar mburi mung liwati nggunakake peraga. Iku bakal bener, mesthi, kanggo nggawe layanan kapisah kanggo cara lan toko kanggo negara, nanging kanggo tujuan artikel iki cukup:

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

Kanthi mobx iku gampang banget kanggo miwiti rendering nalika data diganti. Kita mung nggantung dekorator pengamat saka paket kasebut mobx-react ing komponèn, lan nerjemahake bakal kanthi otomatis disebut nalika sembarang observables referensi dening owah-owahan komponen. Sampeyan ora butuh mapStateToProps utawa nyambung kaya ing redux. Kabeh bisa langsung metu saka kothak:

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

Komponen sing isih bisa dideleng ing kode ing folder UI.

Saiki ing kelas aplikasi sampeyan kudu nggawe pamilih negara kanggo UI lan ngabari UI nalika owah-owahan. Kanggo nindakake iki, ayo nambah cara getState и reactionnelpon 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())

        })
    }

    ...
}

Nalika nampa obyek remote digawe reaction kanggo ngganti negara sing nelpon fungsi ing sisih UI.

Sentuhan pungkasan yaiku nambahake tampilan pesen anyar ing lambang ekstensi:

function setupApp() {
...

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

...
}

Dadi, aplikasi wis siyap. Kaca web bisa njaluk teken kanggo transaksi:

Nulis ekstensi browser sing aman

Nulis ekstensi browser sing aman

Kode kasedhiya ing kene link.

kesimpulan

Yen sampeyan wis maca artikel nganti pungkasan, nanging isih duwe pitakonan, sampeyan bisa takon ing repositori kanthi ekstensi. Ing kono sampeyan uga bakal nemokake komitmen kanggo saben langkah sing wis ditemtokake.

Lan yen sampeyan kasengsem ing dipikir kode kanggo extension nyata, sampeyan bisa nemokake iki kene.

Kode, gudang lan gambaran proyek saka siemarell

Source: www.habr.com

Add a comment