Nulis ekstensi browser aman

Nulis ekstensi browser aman

Teu kawas arsitéktur "klien-server" umum, aplikasi desentralisasi dicirikeun ku:

  • Teu kedah nyimpen database sareng login sareng kecap akses pangguna. Inpormasi aksés disimpen sacara éksklusif ku pangguna sorangan, sareng konfirmasi kaaslianana lumangsung dina tingkat protokol.
  • Henteu kedah nganggo server. Logika aplikasi bisa dieksekusi dina jaringan blockchain, dimana kasebut nyaéta dimungkinkeun pikeun nyimpen jumlah diperlukeun data.

Aya 2 panyimpen anu kawilang aman pikeun konci pangguna - dompét hardware sareng ekstensi browser. Dompét hardware lolobana aman pisan, tapi hésé dianggo sareng tebih tina gratis, tapi ekstensi browser mangrupikeun kombinasi sampurna kaamanan sareng betah dianggo, sareng ogé tiasa gratis pikeun pangguna akhir.

Nyandak sadayana ieu kana akun, kami hoyong ngadamel ekstensi anu paling aman anu nyederhanakeun pamekaran aplikasi desentralisasi ku nyayogikeun API saderhana pikeun damel sareng transaksi sareng tanda tangan.
Kami bakal nyarioskeun ka anjeun ngeunaan pangalaman ieu di handap.

Tulisan bakal ngandung pitunjuk léngkah-léngkah ngeunaan cara nyerat ekstensi browser, kalayan conto kode sareng layar. Anjeun tiasa mendakan sadaya kode dina repositories. Unggal commit logis pakait jeung bagian tina artikel ieu.

Sajarah Singkat Ekstensi Browser

Ekstensi browser parantos lami. Éta muncul dina Internet Explorer deui dina 1999, dina Firefox dina 2004. Nanging, pikeun waktos anu lami teu aya standar tunggal pikeun ekstensi.

Urang tiasa nyarios yén éta muncul sareng ekstensi dina versi kaopat Google Chrome. Tangtosna, henteu aya spésifikasi harita, tapi éta API Chrome anu janten dasarna: nalukkeun kalolobaan pasar browser sareng gaduh toko aplikasi anu diwangun, Chrome leres-leres nyetél standar pikeun ekstensi browser.

Mozilla ngagaduhan standar sorangan, tapi ningali popularitas ekstensi Chrome, perusahaan mutuskeun ngadamel API anu cocog. Dina 2015, dina prakarsa Mozilla, grup husus dijieun dina World Wide Web Consortium (W3C) pikeun gawé dina spésifikasi extension cross-browser.

Ekstensi API anu aya pikeun Chrome dicandak salaku dasar. Karya dilaksanakeun kalayan dukungan Microsoft (Google nampik ilubiung dina pamekaran standar), sareng salaku hasilna draf muncul. spésifikasi.

Sacara resmi, spésifikasi dirojong ku Edge, Firefox sareng Opera (catetan yén Chrome henteu aya dina daptar ieu). Tapi dina kanyataanana, standar ieu sakitu legana cocog sareng Chrome, sabab sabenerna ditulis dumasar kana ekstensi na. Anjeun tiasa maca langkung seueur ngeunaan API WebExtensions di dieu.

Struktur éksténsif

Hiji-hijina file anu diperyogikeun pikeun ekstensi nyaéta manifes (manifest.json). Éta ogé "titik éntri" pikeun ékspansi.

Ngaganggu

Numutkeun spésifikasi, file manifest nyaéta file JSON anu valid. Katerangan lengkep ngeunaan konci manifest sareng inpormasi ngeunaan konci mana anu dirojong dimana browser tiasa ditingali di dieu.

Konci anu henteu aya dina spésifikasi "bisa" teu dipaliré (duanana Chrome sareng Firefox ngalaporkeun kasalahan, tapi ekstensi terus jalan).

Jeung Abdi hoyong ngagambar perhatian ka sababaraha titik.

  1. kasang tukang - hiji obyék anu ngawengku widang di handap ieu:
    1. naskah — Asép Sunandar Sunarya skrip anu bakal dieksekusi dina konteks latar tukang (urang bakal ngobrol ngeunaan ieu engké);
    2. kaca - tinimbang Aksara nu bakal dieksekusi dina kaca kosong, Anjeun bisa nangtukeun html kalawan eusi. Dina hal ieu, widang naskah bakal dipaliré, sarta naskah bakal perlu diselapkeun kana kaca eusi;
    3. tahan - bandéra binér, upami henteu dieusian, browser bakal "maéhan" prosés tukang nalika nganggap yén éta henteu ngalakukeun nanaon, sareng balikan deui upami diperyogikeun. Upami teu kitu, kaca ngan bakal unloaded nalika browser ditutup. Henteu dirojong dina Firefox.
  2. eusi_skrip — sakumpulan objék anu ngamungkinkeun anjeun ngamuat skrip anu béda-béda ka halaman wéb anu béda. Unggal objék ngandung widang penting ieu:
    1. patandingan - pola url, nu nangtukeun naha naskah eusi nu tangtu bakal kaasup atawa henteu.
    2. js - daptar skrip anu bakal dimuat kana pertandingan ieu;
    3. exclude_matches - teu kaasup ti lapangan match URL nu cocog widang ieu.
  3. page_action - saleresna mangrupikeun obyék anu tanggung jawab pikeun ikon anu ditampilkeun di gigireun bar alamat dina browser sareng interaksi sareng éta. Ogé ngidinan Anjeun pikeun nembongkeun jandela popup, nu diartikeun maké HTML sorangan, CSS jeung JS.
    1. default_popup — jalur ka file HTML jeung panganteur popup, bisa ngandung CSS jeung JS.
  4. idin - susunan pikeun ngatur hak extension. Aya 3 jinis hak, anu dijelaskeun sacara rinci di dieu
  5. web_accessible_resources - sumber ekstensi anu tiasa dipénta halaman wéb, contona, gambar, JS, CSS, file HTML.
  6. externally_connectable — Di dieu anjeun tiasa sacara eksplisit netepkeun ID ekstensi sareng domain sanés halaman wéb anu anjeun tiasa nyambungkeun. Domain tiasa tingkat kadua atanapi langkung luhur. Teu dianggo dina Firefox.

Kontéks palaksanaan

Extension ngagaduhan tilu kontéks palaksanaan kode, nyaéta, aplikasina diwangun ku tilu bagian kalayan tingkat aksés anu béda kana API browser.

Kontéks éksténsif

Kalolobaan API sadia di dieu. Dina kontéks ieu aranjeunna "hirup":

  1. Kaca latar - "backend" bagian tina extension nu. Berkasna ditunjuk dina manifes nganggo konci "background".
  2. Kaca popup - halaman pop-up anu muncul nalika anjeun ngaklik ikon ekstensi. Dina manifesto browser_action -> default_popup.
  3. Kaca custom - kaca extension, "hirup" dina tab misah tina pintonan chrome-extension://<id_расширения>/customPage.html.

Kontéks ieu aya sacara mandiri tina jandéla sareng tab browser. Kaca latar aya dina salinan tunggal sareng tiasa dianggo (pangecualian nyaéta halaman acara, nalika naskah latar diluncurkeun ku acara sareng "maot" saatos palaksanaan). Kaca popup aya nalika jandela popup dibuka, jeung Kaca custom — bari tab jeung eta dibuka. Teu aya aksés ka tab sanés sareng eusina tina kontéks ieu.

Konteks naskah eusi

Berkas skrip eusi diluncurkeun sareng unggal tab browser. Éta ngagaduhan aksés kana bagian tina API ekstensi sareng kana tangkal DOM halaman wéb. Éta naskah eusi anu tanggung jawab pikeun interaksi sareng halaman éta. Ekstensi anu ngamanipulasi tangkal DOM ngalakukeun ieu dina naskah eusi - contona, pameungpeuk iklan atanapi penerjemah. Ogé, naskah eusi tiasa komunikasi sareng halamanna nganggo standar postMessage.

Konteks halaman wéb

Ieu mangrupikeun halaman wéb anu saleresna. Henteu aya hubunganana sareng ekstensi sareng henteu gaduh aksés ka dinya, kecuali dina kasus dimana domain halaman ieu henteu sacara eksplisit dituduhkeun dina manifest (langkung seueur ngeunaan ieu di handap).

Tukeuran pesen

Bagian anu béda tina aplikasi kedah silih tukeur pesen. Aya API pikeun ieu runtime.sendMessage pikeun ngirim pesen background и tabs.sendMessage pikeun ngirim pesen ka kaca (skrip eusi, popup atanapi halaman wéb upami sayogi externally_connectable). Di handap ieu 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))
    }
)

Pikeun komunikasi pinuh, Anjeun bisa nyieun sambungan ngaliwatan runtime.connect. Dina respon kami bakal nampa runtime.Port, anu, nalika dibuka, anjeun tiasa ngirim sajumlah pesen. Di sisi klien, contona, contentscript, Sigana mah kieu:

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

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

Aya ogé acara onDisconnect jeung métode disconnect.

Diagram aplikasi

Hayu urang ngadamel ekstensi browser anu nyimpen konci pribadi, nyayogikeun aksés kana inpormasi umum (alamat, konci umum komunikasi sareng halaman sareng ngamungkinkeun aplikasi pihak katilu naroskeun tanda tangan pikeun transaksi.

Pangwangunan aplikasi

Aplikasi urang kedah duanana berinteraksi sareng pangguna sareng nyayogikeun halaman nganggo API pikeun nelepon metode (contona, pikeun ngadaptarkeun transaksi). Jieun do kalawan ngan hiji contentscript moal jalan, sabab ngan boga aksés ka DOM, tapi teu ka JS kaca. Sambungkeun via runtime.connect urang teu bisa, sabab API diperlukeun dina sakabéh domain, sarta ngan husus bisa dieusian dina manifest. Hasilna, diagram bakal kasampak kawas kieu:

Nulis ekstensi browser aman

Bakal aya naskah sanés - inpage, nu urang bakal nyuntik kana kaca. Éta bakal dijalankeun dina kontéksna sareng nyayogikeun API pikeun damel sareng ekstensi.

ngamimitian

Sadaya kode extension browser sayogi di GitHub. Salila pedaran bakal aya tumbu ka commits.

Hayu urang mimitian ku 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"]
}

Jieun background.js kosong, popup.js, inpage.js sareng contentscript.js. Kami nambihan popup.html - sareng aplikasi kami parantos tiasa dimuat kana Google Chrome sareng pastikeun yén éta tiasa dianggo.

Pikeun pariksa ieu, anjeun tiasa nyandak kodeu di dieu. Salian naon anu urang laksanakeun, tautan ngonpigurasikeun rakitan proyék nganggo webpack. Pikeun nambihan aplikasi kana browser, dina chrome: // extensions anjeun kedah milih beban unpacked sareng folder kalayan ekstensi anu saluyu - dina kasus kami dist.

Nulis ekstensi browser aman

Ayeuna ekstensi kami dipasang sareng jalan. Anjeun tiasa ngajalankeun alat pamekar pikeun kontéks anu béda sapertos kieu:

popup ->

Nulis ekstensi browser aman

Aksés ka konsol skrip eusi dilumangsungkeun ngaliwatan konsol kaca sorangan dimana eta dibuka.Nulis ekstensi browser aman

Tukeuran pesen

Janten, urang kedah ngadamel dua saluran komunikasi: inpage <-> background sareng popup <-> background. Anjeun tiasa, tangtosna, ngan ngirim pesen ka port jeung invent protokol sorangan, tapi kuring resep pendekatan nu kuring nempo dina proyék open source metamask.

Ieu extension browser pikeun gawé bareng jaringan Ethereum. Di jerona, bagian anu béda tina aplikasi komunikasi via RPC nganggo perpustakaan dnode. Eta ngidinan Anjeun pikeun ngatur hiji bursa cukup gancang tur merenah lamun nyadiakeun aliran nodejs salaku angkutan a (hartina hiji obyék anu implements interface sarua):

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

Ayeuna urang bakal nyieun hiji kelas aplikasi. Éta bakal nyiptakeun objék API pikeun popup sareng halaman wéb, sareng nyiptakeun dnode pikeun aranjeunna:

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

Di dieu sareng di handap, gaganti obyék Chrome global, kami nganggo extensionApi, anu ngaksés Chrome dina browser Google sareng browser anu sanés. Hal ieu dilakukeun pikeun kasaluyuan cross-browser, tapi pikeun kaperluan artikel ieu, anjeun bisa kalayan gampang ngagunakeun 'chrome.runtime.connect'.

Hayu urang ngadamel conto aplikasi dina skrip latar tukang:

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

Kusabab dnode jalan kalawan aliran, sarta kami nampi port a, hiji kelas adaptor diperlukeun. Éta dilakukeun nganggo perpustakaan aliran anu tiasa dibaca, anu ngalaksanakeun aliran nodejs dina 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()
    }
}

Ayeuna hayu urang nyieun sambungan dina 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;
    }
}

Teras we nyieun sambungan dina naskah eusi:

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

Kusabab urang peryogi API teu dina naskah eusi, tapi langsung dina kaca, urang ngalakukeun dua hal:

  1. Urang nyieun dua aliran. Hiji - nuju kaca, dina luhureun postMessage. Pikeun ieu kami nganggo ieu pakét ieu ti panyipta metamask. Aliran kadua nyaéta latar tukang port anu ditampi tina runtime.connect. Hayu urang meuli eta. Ayeuna halaman bakal ngagaduhan aliran ka latar tukang.
  2. Nyuntikkeun naskah kana DOM. Unduh naskah (aksés ka dinya diidinan dina manifest) sareng jieun tag script kalawan eusina di jero:

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

Ayeuna urang nyieun hiji objek api di inpage tur nyetel ka 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;
}

Kami siap Telepon Prosedur Jauh (RPC) kalayan API anu misah pikeun halaman sareng UI. Nalika nyambungkeun halaman anyar ka latar tukang urang tiasa ningali ieu:

Nulis ekstensi browser aman

API kosong tur asal. Di sisi halaman, urang tiasa nyauran fungsi halo sapertos kieu:

Nulis ekstensi browser aman

Gawe sareng fungsi callback di JS modern nyaeta kabiasaan goréng, jadi hayu urang nulis hiji asisten leutik pikeun nyieun hiji dnode nu ngidinan Anjeun pikeun lulus hiji objek API ka utils.

Objek API ayeuna bakal katingali sapertos kieu:

export class SignerApp {

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

...

}

Kéngingkeun obyék tina jarak jauh sapertos kieu:

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

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

Sareng fungsi nelepon ngabalikeun jangji:

Nulis ekstensi browser aman

Vérsi kalawan fungsi Asynchronous sadia di dieu.

Gemblengna, pendekatan RPC sareng aliran sigana rada fleksibel: urang tiasa nganggo multiplexing uap sareng nyiptakeun sababaraha API anu béda pikeun tugas anu béda. Sacara prinsip, dnode tiasa dianggo dimana waé, anu utama nyaéta ngabungkus angkutan dina bentuk aliran nodejs.

Alternatipna nyaéta format JSON, anu ngalaksanakeun protokol JSON RPC 2. Nanging, éta tiasa dianggo sareng angkutan khusus (TCP sareng HTTP (S)), anu henteu tiasa dianggo dina kasus urang.

kaayaan internal sarta localStorage

Urang kedah nyimpen kaayaan internal aplikasi - sahenteuna konci tandatangan. Urang tiasa rada gampang nambihan kaayaan kana aplikasi sareng metode pikeun ngarobih dina API pop-up:

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

    ...

} 

Di latar tukang, urang bakal mungkus sadayana dina fungsi sareng nyerat obyék aplikasi kana jandela supados urang tiasa damel sareng éta tina konsol:

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

Hayu urang tambahkeun sababaraha konci tina konsol UI sareng tingali naon anu lumangsung dina kaayaan:

Nulis ekstensi browser aman

Kaayaan kedah dilakukeun pengkuh supados konci henteu leungit nalika ngamimitian deui.

Urang bakal nyimpen eta di localStorage, overwriting eta kalawan unggal robah. Salajengna, aksés ka éta ogé bakal dipikabutuh pikeun UI, sareng kuring ogé hoyong ngalanggan parobahan. Dumasar ieu, bakal merenah pikeun nyieun gudang observasi sarta ngalanggan parobahanana.

Urang bakal ngagunakeun perpustakaan mobx (https://github.com/mobxjs/mobx). Pilihanna murag kana éta kusabab kuring henteu kedah damel sareng éta, tapi kuring hoyong pisan diajar.

Hayu urang tambahkeun initialization tina kaayaan awal jeung nyieun toko observasi:

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

    ...

}

"Dina tiung," mobx parantos ngagentos sadaya widang toko sareng proxy sareng nyegat sadaya telepon ka aranjeunna. Ieu bakal mungkin pikeun ngalanggan pesen ieu.

Di handap ieu kuring bakal sering nganggo istilah "nalika ngarobih", sanaos ieu henteu leres pisan. Mobx ngalacak aksés ka sawah. Getters na setter objék proxy yén perpustakaan nyiptakeun dipaké.

Dekorator aksi ngagaduhan dua tujuan:

  1. Dina modeu ketat kalayan bandéra enforceActions, mobx nyaram ngarobah kaayaan sacara langsung. Éta dianggap prakték anu saé pikeun digawé dina kaayaan anu ketat.
  2. Malah lamun hiji fungsi robah kaayaan sababaraha kali - contona, urang ngarobah sababaraha widang dina sababaraha baris kode - panitén bakal dibere beja ngan lamun eta geus réngsé. Ieu hususna penting pikeun frontend, dimana apdet kaayaan teu perlu ngakibatkeun rendering elemen perlu. Dina kasus urang, boh anu kahiji atanapi anu kadua sabagian relevan, tapi urang bakal nuturkeun prakték anu pangsaéna. Biasana pikeun ngagantelkeun dekorator ka sadaya fungsi anu ngarobih kaayaan lapangan anu dititénan.

Di latar tukang kami bakal nambihan initialization sareng nyimpen kaayaan di 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 réaksi metot dieu. Aya dua argumen:

  1. Pamilih data.
  2. A pawang anu bakal disebut ku data ieu unggal waktos eta robah.

Teu kawas redux, dimana urang eksplisit narima kaayaan salaku argumen, mobx apal nu observables kami aksés di jero pamilih, sarta ngan nelepon Handler nalika maranéhna robah.

Penting pikeun ngartos persis kumaha mobx mutuskeun observasi mana anu urang langgan. Upami kuring nyerat pamilih dina kode sapertos kieu() => app.store, mangka réaksi moal pernah disebut, saprak gudang sorangan teu observasi, ngan widang na.

Upami kuring nyerat sapertos kieu () => app.store.keys, lajeng deui nanaon bakal kajadian, saprak nalika nambahkeun / nyoplokkeun elemen Asép Sunandar Sunarya, rujukan pikeun eta moal robah.

Mobx tindakan minangka pamilih pikeun kahiji kalina sarta ngan ngalacak observables yén kami geus diakses. Hal ieu dilakukeun ngaliwatan getters proxy. Ku alatan éta, fungsi diwangun-di dipaké di dieu toJS. Ieu mulih obyék anyar kalayan sagala proxy diganti ku widang aslina. Salila palaksanaan, éta maca sadaya widang obyék - ku kituna getter dipicu.

Dina konsol pop-up urang deui bakal nambahan sababaraha kenop. Waktos ieu aranjeunna ogé réngsé dina localStorage:

Nulis ekstensi browser aman

Nalika halaman tukang dimuat deui, inpormasi tetep aya.

Sadaya kode aplikasi dugi ka titik ieu tiasa ditingali di dieu.

Panyimpenan aman tina konci pribadi

Nyimpen konci swasta dina téks jelas teu aman: sok aya kasempetan nu bakal hacked, meunang aksés ka komputer, jeung saterusna. Ku alatan éta, dina localStorage urang bakal nyimpen konci dina formulir sandi-énkripsi.

Pikeun kaamanan anu langkung ageung, kami bakal nambihan kaayaan anu dikonci kana aplikasi, dimana moal aya aksés kana konci éta. Urang bakal otomatis nransper extension kana kaayaan dikonci alatan timeout a.

Mobx ngidinan Anjeun pikeun nyimpen ngan hiji set minimum data, sarta sésana otomatis diitung dumasar kana eta. Ieu nu disebut sipat diitung. Éta tiasa dibandingkeun sareng pandangan dina pangkalan 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')
        }
    }
}

Ayeuna urang ngan ukur nyimpen konci sareng kecap akses énkripsi. Sagalana sejenna diitung. Urang ngalakukeun mindahkeun ka kaayaan dikonci ku nyoplokkeun sandi ti nagara. API umum ayeuna gaduh padika pikeun ngamimitian neundeun.

Ditulis pikeun énkripsi Utiliti ngagunakeun 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 gaduh API dianggurkeun dimana anjeun tiasa ngalanggan acara - parobahan kaayaan. Nagara, sasuai, bisa jadi idle, active и locked. Pikeun dianggurkeun anjeun tiasa nyetél waktos waktos, sareng dikonci diatur nalika OS sorangan diblokir. Urang ogé bakal ngarobah pamilih pikeun nyimpen kana 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 saméméh hambalan ieu di dieu.

Transaksi

Janten, urang dugi ka hal anu paling penting: nyiptakeun sareng nandatanganan transaksi dina blockchain. Urang bakal nganggo WAVES blockchain sareng perpustakaan gelombang-transaksi.

Kahiji, hayu urang tambahkeun kana kaayaan hiji Asép Sunandar Sunarya ti pesen nu kudu ditandatanganan, lajeng nambahkeun métode pikeun nambahkeun pesen anyar, confirming signature, sarta 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 kami nampi pesen anyar, urang tambahkeun metadata eta, ngalakukeun observable jeung nambahan ka store.messages.

Lamun henteu observable sacara manual, lajeng mobx bakal ngalakukeun eta sorangan nalika nambahkeun pesen ka Asép Sunandar Sunarya dina. Nanging, éta bakal nyiptakeun obyék énggal anu kami henteu gaduh rujukan, tapi kami peryogina pikeun léngkah salajengna.

Salajengna, urang balikkeun jangji anu ngabéréskeun nalika status pesen robih. Statusna diawaskeun ku réaksi, anu bakal "maéhan sorangan" nalika statusna robih.

Kodeu métode approve и reject basajan pisan: urang ngan saukur ngarobah status suratna, sanggeus asup eta lamun perlu.

Kami nempatkeun Approve sareng nampik dina API UI, newMessage dina API halaman:

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

    ...
}

Ayeuna hayu urang cobian ngadaptarkeun transaksi nganggo ekstensi:

Nulis ekstensi browser aman

Sacara umum, sagalana geus siap, sagalana tetep nambahkeun UI basajan.

UI

Antarbeungeut peryogi aksés kana kaayaan aplikasi. Di sisi UI urang bakal ngalakukeun observable kaayaan tur nambahkeun fungsi ka API anu bakal ngarobah kaayaan ieu. Hayu urang tambahkeun observable ka obyék API anu ditampi tina latar tukang:

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

Dina tungtungna urang mimitian rendering antarbeungeut aplikasi. Ieu mangrupikeun aplikasi réaksi. Objék latar ngan saukur diliwatan maké prop. Éta leres, tangtosna, ngadamel jasa anu misah pikeun metode sareng toko pikeun nagara, tapi pikeun tujuan tulisan ieu cekap:

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

Kalawan mobx éta pisan gampang pikeun ngamimitian rendering nalika data robah. Urang ngan saukur ngagantung decorator panitén tina bungkusan mobx-réaksi dina komponén, sarta ngajadikeun otomatis bakal disebut lamun sagala observables referenced ku parobahan komponén. Anjeun henteu peryogi mapStateToProps atanapi sambungkeun sapertos dina redux. Sadayana jalan langsung tina kotak:

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

Komponén sésana bisa ditempo dina kode dina folder UI.

Ayeuna di kelas aplikasi anjeun kedah ngadamel pamilih kaayaan pikeun UI sareng ngabéjaan UI nalika robih. Jang ngalampahkeun ieu, hayu urang tambahkeun métode getState и reactionnelepon 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 narima hiji obyék remote dijieun reaction pikeun ngarobah kaayaan nu nelepon fungsi dina sisi UI.

Sentuhan terakhir nyaéta pikeun nambihan tampilan pesen anyar dina ikon ekstensi:

function setupApp() {
...

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

...
}

Janten, aplikasina parantos siap. Kaca wéb tiasa menta tanda tangan pikeun transaksi:

Nulis ekstensi browser aman

Nulis ekstensi browser aman

Kodeu sayogi di dieu link.

kacindekan

Upami anjeun parantos maca tulisan dugi ka akhir, tapi masih gaduh patarosan, anjeun tiasa naroskeun ka aranjeunna repositories kalawan extension. Di dinya anjeun ogé bakal mendakan komitmen pikeun tiap léngkah anu ditunjuk.

Tur upami Anjeun salah museurkeun katingal dina kode pikeun extension sabenerna, anjeun tiasa manggihan ieu di dieu.

Kode, Repository sarta gambaran proyék ti siemarell

sumber: www.habr.com

Tambahkeun komentar