Menulis ekstensi browser yang aman

Menulis ekstensi browser yang aman

Berbeda dengan arsitektur “client-server” pada umumnya, aplikasi terdesentralisasi dicirikan oleh:

  • Tidak perlu menyimpan database dengan login pengguna dan kata sandi. Informasi akses disimpan secara eksklusif oleh pengguna itu sendiri, dan keasliannya dikonfirmasi pada tingkat protokol.
  • Tidak perlu menggunakan server. Logika aplikasi dapat dijalankan pada jaringan blockchain, di mana dimungkinkan untuk menyimpan sejumlah data yang diperlukan.

Ada 2 penyimpanan yang relatif aman untuk kunci pengguna - dompet perangkat keras dan ekstensi browser. Dompet perangkat keras sebagian besar sangat aman, namun sulit digunakan dan jauh dari kata gratis, namun ekstensi browser adalah kombinasi sempurna antara keamanan dan kemudahan penggunaan, dan juga bisa sepenuhnya gratis untuk pengguna akhir.

Dengan mempertimbangkan semua ini, kami ingin membuat ekstensi paling aman yang menyederhanakan pengembangan aplikasi terdesentralisasi dengan menyediakan API sederhana untuk bekerja dengan transaksi dan tanda tangan.
Kami akan memberi tahu Anda tentang pengalaman ini di bawah.

Artikel ini akan berisi petunjuk langkah demi langkah tentang cara menulis ekstensi browser, dengan contoh kode dan tangkapan layar. Anda dapat menemukan semua kode di dalamnya repositori. Setiap penerapan secara logis sesuai dengan bagian artikel ini.

Sejarah Singkat Ekstensi Browser

Ekstensi browser sudah ada sejak lama. Mereka muncul di Internet Explorer pada tahun 1999, di Firefox pada tahun 2004. Namun, untuk waktu yang lama tidak ada standar tunggal untuk ekstensi.

Kita dapat mengatakan bahwa itu muncul bersama dengan ekstensi di Google Chrome versi keempat. Tentu saja, spesifikasinya belum ada saat itu, tetapi Chrome API-lah yang menjadi dasarnya: setelah menaklukkan sebagian besar pasar browser dan memiliki toko aplikasi bawaan, Chrome sebenarnya menetapkan standar untuk ekstensi browser.

Mozilla memiliki standarnya sendiri, tetapi melihat popularitas ekstensi Chrome, perusahaan memutuskan untuk membuat API yang kompatibel. Pada tahun 2015, atas inisiatif Mozilla, sebuah grup khusus dibentuk dalam World Wide Web Consortium (W3C) untuk mengerjakan spesifikasi ekstensi lintas-browser.

Ekstensi API yang ada untuk Chrome diambil sebagai dasar. Pekerjaan itu dilakukan dengan dukungan Microsoft (Google menolak untuk berpartisipasi dalam pengembangan standar), dan sebagai hasilnya muncullah rancangan spesifikasi.

Secara formal, spesifikasi ini didukung oleh Edge, Firefox dan Opera (perhatikan bahwa Chrome tidak ada dalam daftar ini). Namun kenyataannya, standar ini sebagian besar kompatibel dengan Chrome, karena sebenarnya ditulis berdasarkan ekstensinya. Anda dapat membaca lebih lanjut tentang WebExtensions API di sini.

Struktur ekstensi

Satu-satunya file yang diperlukan untuk ekstensi adalah manifes (manifest.json). Ini juga merupakan “titik masuk” menuju ekspansi.

Manifes

Menurut spesifikasinya, file manifes adalah file JSON yang valid. Deskripsi lengkap tentang kunci manifes dengan informasi tentang kunci mana yang didukung di browser mana yang dapat dilihat di sini.

Kunci yang tidak ada dalam spesifikasi “mungkin” diabaikan (Chrome dan Firefox melaporkan kesalahan, tetapi ekstensi tetap berfungsi).

Dan saya ingin menarik perhatian pada beberapa hal.

  1. latar belakang — objek yang mencakup bidang berikut:
    1. script — serangkaian skrip yang akan dieksekusi dalam konteks latar belakang (kita akan membicarakannya nanti);
    2. halaman - alih-alih skrip yang akan dieksekusi di halaman kosong, Anda dapat menentukan html dengan konten. Dalam hal ini, bidang skrip akan diabaikan, dan skrip harus dimasukkan ke dalam halaman konten;
    3. bersikeras — bendera biner, jika tidak ditentukan, browser akan "mematikan" proses latar belakang ketika dianggap tidak melakukan apa pun, dan memulai ulang jika perlu. Jika tidak, halaman hanya akan dibongkar saat browser ditutup. Tidak didukung di Firefox.
  2. skrip_konten — serangkaian objek yang memungkinkan Anda memuat skrip berbeda ke halaman web berbeda. Setiap objek berisi bidang penting berikut:
    1. korek api - url pola, yang menentukan apakah skrip konten tertentu akan disertakan atau tidak.
    2. js — daftar skrip yang akan dimuat ke dalam pertandingan ini;
    3. kecualikan_kecocokan - dikecualikan dari lapangan match URL yang cocok dengan bidang ini.
  3. halaman_aksi - sebenarnya adalah objek yang bertanggung jawab atas ikon yang ditampilkan di sebelah bilah alamat di browser dan interaksi dengannya. Ini juga memungkinkan Anda untuk menampilkan jendela popup, yang ditentukan menggunakan HTML, CSS, dan JS Anda sendiri.
    1. default_popup — jalur ke file HTML dengan antarmuka popup, mungkin berisi CSS dan JS.
  4. Izin — sebuah array untuk mengelola hak ekstensi. Ada 3 jenis hak yang dijelaskan secara rinci di sini
  5. sumber daya_dapat diakses_web — sumber daya ekstensi yang dapat diminta oleh halaman web, misalnya gambar, JS, CSS, file HTML.
  6. secara eksternal_dapat dihubungkan — di sini Anda dapat secara eksplisit menentukan ID ekstensi lain dan domain halaman web yang dapat Anda sambungkan. Sebuah domain bisa berada pada level kedua atau lebih tinggi. Tidak berfungsi di Firefox.

Konteks eksekusi

Ekstensi ini memiliki tiga konteks eksekusi kode, yaitu aplikasi terdiri dari tiga bagian dengan tingkat akses berbeda ke API browser.

Konteks ekstensi

Sebagian besar API tersedia di sini. Dalam konteks ini mereka “hidup”:

  1. Halaman latar belakang — bagian “backend” dari ekstensi. File ditentukan dalam manifes menggunakan kunci “latar belakang”.
  2. Halaman munculan — halaman popup yang muncul ketika Anda mengklik ikon ekstensi. Dalam manifesto tersebut browser_action -> default_popup.
  3. Halaman khusus — halaman ekstensi, “hidup” di tab tampilan terpisah chrome-extension://<id_расширения>/customPage.html.

Konteks ini ada secara independen dari jendela dan tab browser. Halaman latar belakang ada dalam satu salinan dan selalu berfungsi (pengecualian adalah halaman acara, ketika skrip latar belakang diluncurkan oleh suatu acara dan "mati" setelah pelaksanaannya). Halaman munculan ada ketika jendela popup terbuka, dan Halaman khusus — saat tabnya terbuka. Tidak ada akses ke tab lain dan kontennya dari konteks ini.

Konteks skrip konten

File skrip konten diluncurkan bersama dengan setiap tab browser. Ia memiliki akses ke bagian API ekstensi dan pohon DOM halaman web. Skrip kontenlah yang bertanggung jawab untuk interaksi dengan halaman. Ekstensi yang memanipulasi pohon DOM melakukan hal ini dalam skrip konten - misalnya, pemblokir iklan atau penerjemah. Selain itu, skrip konten dapat berkomunikasi dengan halaman melalui standar postMessage.

Konteks halaman web

Ini adalah halaman web sebenarnya. Itu tidak ada hubungannya dengan ekstensi dan tidak memiliki akses ke sana, kecuali domain halaman ini tidak secara eksplisit ditunjukkan dalam manifes (lebih lanjut tentang ini di bawah).

Pertukaran pesan

Bagian aplikasi yang berbeda harus saling bertukar pesan. Ada API untuk ini runtime.sendMessage untuk mengirim pesan background и tabs.sendMessage untuk mengirim pesan ke halaman (skrip konten, popup atau halaman web jika tersedia externally_connectable). Di bawah ini adalah contoh saat mengakses 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))
    }
)

Untuk komunikasi penuh, Anda dapat membuat koneksi melalui runtime.connect. Sebagai tanggapan, kami akan menerima runtime.Port, yang mana, ketika terbuka, Anda dapat mengirim sejumlah pesan. Di sisi klien, misalnya, contentscript, tampilannya seperti ini:

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

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

Ada juga acara onDisconnect dan metode disconnect.

Diagram aplikasi

Mari kita buat ekstensi browser yang menyimpan kunci pribadi, menyediakan akses ke informasi publik (alamat, kunci publik berkomunikasi dengan halaman dan memungkinkan aplikasi pihak ketiga meminta tanda tangan untuk transaksi.

Pengembangan aplikasi

Aplikasi kita harus berinteraksi dengan pengguna dan menyediakan halaman dengan API untuk memanggil metode (misalnya, untuk menandatangani transaksi). Puaskan hanya dengan satu contentscript tidak akan berfungsi, karena hanya memiliki akses ke DOM, tetapi tidak ke JS halaman. Terhubung melalui runtime.connect kami tidak bisa melakukannya, karena API diperlukan di semua domain, dan hanya domain tertentu yang dapat ditentukan dalam manifes. Hasilnya, diagramnya akan terlihat seperti ini:

Menulis ekstensi browser yang aman

Akan ada skrip lain - inpage, yang akan kami masukkan ke dalam halaman. Ini akan berjalan dalam konteksnya dan menyediakan API untuk bekerja dengan ekstensi.

awal

Semua kode ekstensi browser tersedia di GitHub. Selama deskripsi akan ada tautan ke komitmen.

Mari kita mulai dengan manifestonya:

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

Buat background.js, popup.js, inpage.js, dan contentscript.js kosong. Kami menambahkan popup.html - dan aplikasi kami sudah dapat dimuat ke Google Chrome dan memastikannya berfungsi.

Untuk memverifikasi ini, Anda dapat mengambil kodenya karenanya. Selain apa yang kami lakukan, tautan tersebut mengonfigurasi perakitan proyek menggunakan webpack. Untuk menambahkan aplikasi ke browser, di chrome://extensions Anda harus memilih load unpacked dan folder dengan ekstensi yang sesuai - dalam kasus kami dist.

Menulis ekstensi browser yang aman

Sekarang ekstensi kami telah terpasang dan berfungsi. Anda dapat menjalankan alat pengembang untuk konteks berbeda sebagai berikut:

munculan ->

Menulis ekstensi browser yang aman

Akses ke konsol skrip konten dilakukan melalui konsol halaman tempat skrip diluncurkan.Menulis ekstensi browser yang aman

Pertukaran pesan

Jadi, kita perlu membuat dua saluran komunikasi: inpage <-> background dan popup <-> background. Anda tentu saja dapat mengirim pesan ke port dan membuat protokol Anda sendiri, tetapi saya lebih suka pendekatan yang saya lihat di proyek sumber terbuka metamask.

Ini adalah ekstensi browser untuk bekerja dengan jaringan Ethereum. Di dalamnya, berbagai bagian aplikasi berkomunikasi melalui RPC menggunakan perpustakaan dnode. Ini memungkinkan Anda untuk mengatur pertukaran dengan cukup cepat dan nyaman jika Anda menyediakannya dengan aliran nodejs sebagai transportasi (artinya objek yang mengimplementasikan antarmuka yang sama):

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

Sekarang kita akan membuat kelas aplikasi. Ini akan membuat objek API untuk popup dan halaman web, dan membuat dnode untuknya:

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 sini dan di bawah, alih-alih objek Chrome global, kami menggunakan extensionApi, yang mengakses Chrome di browser Google dan browser di browser lain. Hal ini dilakukan untuk kompatibilitas lintas-browser, namun untuk keperluan artikel ini kita cukup menggunakan 'chrome.runtime.connect'.

Mari buat instance aplikasi di skrip latar belakang:

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

Karena dnode bekerja dengan aliran, dan kami menerima port, diperlukan kelas adaptor. Itu dibuat menggunakan perpustakaan aliran yang dapat dibaca, yang mengimplementasikan aliran nodejs di 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()
    }
}

Sekarang mari buat koneksi di 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;
    }
}

Kemudian kita membuat koneksi di skrip konten:

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

Karena kami membutuhkan API bukan di skrip konten, tetapi langsung di halaman, kami melakukan dua hal:

  1. Kami membuat dua aliran. Satu - menuju halaman, di atas postMessage. Untuk ini kami menggunakan ini paket ini dari pencipta metamask. Aliran kedua adalah melatar belakangi port yang diterima runtime.connect. Ayo beli. Sekarang halaman tersebut akan memiliki aliran ke latar belakang.
  2. Suntikkan skrip ke dalam DOM. Unduh skrip (akses ke sana diizinkan dalam manifes) dan buat tag script dengan isinya di dalamnya:

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

Sekarang kita membuat objek api di inpage dan mengaturnya ke 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 Panggilan Prosedur Jarak Jauh (RPC) dengan API terpisah untuk halaman dan UI. Saat menghubungkan halaman baru ke latar belakang kita dapat melihat ini:

Menulis ekstensi browser yang aman

Kosongkan API dan asal. Di sisi halaman, kita dapat memanggil fungsi hello seperti ini:

Menulis ekstensi browser yang aman

Bekerja dengan fungsi panggilan balik di JS modern adalah perilaku yang buruk, jadi mari kita tulis pembantu kecil untuk membuat dnode yang memungkinkan Anda meneruskan objek API ke utils.

Objek API sekarang akan terlihat seperti ini:

export class SignerApp {

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

...

}

Mendapatkan objek dari jarak jauh seperti ini:

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

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

Dan fungsi pemanggilan mengembalikan janji:

Menulis ekstensi browser yang aman

Versi dengan fungsi asinkron tersedia di sini.

Secara keseluruhan, pendekatan RPC dan aliran tampaknya cukup fleksibel: kita dapat menggunakan steam multiplexing dan membuat beberapa API berbeda untuk tugas berbeda. Pada prinsipnya dnode bisa digunakan dimana saja, yang utama adalah membungkus transport dalam bentuk aliran nodejs.

Alternatifnya adalah format JSON, yang mengimplementasikan protokol JSON RPC 2. Namun, format ini berfungsi dengan transport tertentu (TCP dan HTTP(S)), yang tidak berlaku dalam kasus kami.

Status internal dan Penyimpanan lokal

Kita perlu menyimpan status internal aplikasi - setidaknya kunci penandatanganan. Kita dapat dengan mudah menambahkan status ke aplikasi dan metode untuk mengubahnya di 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)
        }
    }

    ...

} 

Di latar belakang, kita akan menggabungkan semuanya dalam sebuah fungsi dan menulis objek aplikasi ke window sehingga kita dapat bekerja dengannya dari 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)
        }
    }
}

Mari tambahkan beberapa kunci dari konsol UI dan lihat apa yang terjadi dengan statusnya:

Menulis ekstensi browser yang aman

Statusnya perlu dibuat persisten agar kunci tidak hilang saat restart.

Kami akan menyimpannya di Penyimpanan lokal, menimpanya dengan setiap perubahan. Selanjutnya, akses ke sana juga diperlukan untuk UI, dan saya juga ingin berlangganan perubahan. Berdasarkan hal ini, akan lebih mudah untuk membuat penyimpanan yang dapat diamati dan berlangganan perubahannya.

Kami akan menggunakan perpustakaan mobx (https://github.com/mobxjs/mobx). Pilihannya jatuh pada itu karena saya tidak harus mengerjakannya, tetapi saya sangat ingin mempelajarinya.

Mari tambahkan inisialisasi keadaan awal dan buat penyimpanan dapat 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)
    }

    ...

}

“Di balik terpal,” mobx telah mengganti semua bidang penyimpanan dengan proxy dan menyadap semua panggilan ke bidang tersebut. Dimungkinkan untuk berlangganan pesan-pesan ini.

Di bawah ini saya akan sering menggunakan istilah “saat berubah”, meskipun hal ini tidak sepenuhnya benar. Mobx melacak akses ke bidang. Pengambil dan penyetel objek proxy yang dibuat perpustakaan digunakan.

Dekorator tindakan memiliki dua tujuan:

  1. Dalam mode ketat dengan bendera penegakActions, mobx melarang perubahan status secara langsung. Bekerja dalam kondisi yang ketat dianggap sebagai praktik yang baik.
  2. Bahkan jika suatu fungsi mengubah statusnya beberapa kali - misalnya, kita mengubah beberapa bidang dalam beberapa baris kode - pengamat hanya diberi tahu ketika fungsi tersebut selesai. Hal ini sangat penting terutama untuk frontend, di mana pembaruan status yang tidak perlu menyebabkan rendering elemen yang tidak diperlukan. Dalam kasus kami, baik yang pertama maupun yang kedua tidak terlalu relevan, namun kami akan mengikuti praktik terbaik. Merupakan kebiasaan untuk melampirkan dekorator ke semua fungsi yang mengubah keadaan bidang yang diamati.

Di latar belakang kami akan menambahkan inisialisasi dan menyimpan status di Penyimpanan lokal:

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 di sini. Ini memiliki dua argumen:

  1. Pemilih data.
  2. Penangan yang akan dipanggil dengan data ini setiap kali berubah.

Tidak seperti redux, di mana kita secara eksplisit menerima status sebagai argumen, mobx mengingat observasi mana yang kita akses di dalam pemilih, dan hanya memanggil pengendali ketika ada perubahan.

Penting untuk memahami dengan tepat bagaimana mobx memutuskan observasi mana yang kita langgani. Jika saya menulis pemilih dalam kode seperti ini() => app.store, maka reaksi tidak akan pernah dipanggil, karena penyimpanan itu sendiri tidak dapat diamati, hanya bidangnya saja yang dapat diamati.

Jika saya menulisnya seperti ini () => app.store.keys, sekali lagi tidak akan terjadi apa-apa, karena ketika menambahkan/menghapus elemen array, referensi ke elemen tersebut tidak akan berubah.

Mobx bertindak sebagai pemilih untuk pertama kalinya dan hanya melacak observasi yang telah kita akses. Ini dilakukan melalui pengambil proxy. Oleh karena itu, fungsi bawaan digunakan di sini toJS. Ia mengembalikan objek baru dengan semua proxy diganti dengan bidang asli. Selama eksekusi, ia membaca semua bidang objek - karenanya pengambil terpicu.

Di konsol popup kami akan menambahkan beberapa kunci lagi. Kali ini mereka juga berakhir di Penyimpanan lokal:

Menulis ekstensi browser yang aman

Saat halaman latar belakang dimuat ulang, informasinya tetap ada.

Semua kode aplikasi hingga saat ini dapat dilihat di sini.

Penyimpanan kunci pribadi yang aman

Menyimpan kunci pribadi dalam teks biasa tidak aman: selalu ada kemungkinan Anda akan diretas, mendapatkan akses ke komputer Anda, dan sebagainya. Oleh karena itu, di localStorage kami akan menyimpan kunci dalam bentuk terenkripsi kata sandi.

Untuk keamanan yang lebih baik, kami akan menambahkan status terkunci ke aplikasi, di mana tidak akan ada akses ke kunci sama sekali. Kami akan secara otomatis mentransfer ekstensi ke keadaan terkunci karena batas waktu.

Mobx memungkinkan Anda untuk menyimpan hanya kumpulan data minimum, dan sisanya dihitung secara otomatis berdasarkan data tersebut. Inilah yang disebut properti yang dihitung. Mereka dapat dibandingkan dengan tampilan di database:

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

Sekarang kami hanya menyimpan kunci dan kata sandi terenkripsi. Segala sesuatu yang lain dihitung. Kami melakukan transfer ke keadaan terkunci dengan menghapus kata sandi dari negara tersebut. API publik sekarang memiliki metode untuk menginisialisasi penyimpanan.

Ditulis untuk enkripsi utilitas menggunakan 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 memiliki API menganggur yang dapat digunakan untuk berlangganan suatu peristiwa - perubahan status. Oleh karena itu, mungkin saja demikian idle, active и locked. Untuk idle Anda dapat mengatur batas waktu, dan terkunci diatur ketika OS itu sendiri diblokir. Kami juga akan mengubah pemilih untuk menyimpan ke Penyimpanan lokal:

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 sebelum langkah ini adalah di sini.

Transaksi

Jadi, kita sampai pada hal yang paling penting: membuat dan menandatangani transaksi di blockchain. Kami akan menggunakan blockchain dan perpustakaan WAVES transaksi gelombang.

Pertama, mari kita tambahkan array pesan yang perlu ditandatangani ke negara bagian, lalu tambahkan metode untuk menambahkan pesan baru, mengonfirmasi tanda tangan, dan menolak:

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

    ...
}

Saat kami menerima pesan baru, kami menambahkan metadata ke dalamnya, lakukan observable dan tambahkan ke store.messages.

Jika tidak observable secara manual, maka mobx akan melakukannya sendiri saat menambahkan pesan ke array. Namun, ini akan membuat objek baru yang tidak kita miliki referensinya, tetapi kita memerlukannya untuk langkah selanjutnya.

Selanjutnya, kami mengembalikan janji yang terselesaikan ketika status pesan berubah. Status dipantau oleh reaksi, yang akan “membunuh dirinya sendiri” ketika status berubah.

Kode metode approve и reject sangat sederhana: kita cukup mengubah status pesan, setelah menandatanganinya jika perlu.

Kami menempatkan Setuju dan menolak di UI API, newMessage di halaman 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)
        }
    }

    ...
}

Sekarang mari kita coba menandatangani transaksi dengan ekstensi:

Menulis ekstensi browser yang aman

Secara umum semuanya sudah siap, yang tersisa hanyalah tambahkan UI sederhana.

UI

Antarmuka memerlukan akses ke status aplikasi. Di sisi UI kita akan melakukannya observable nyatakan dan tambahkan fungsi ke API yang akan mengubah keadaan ini. Mari kita tambahkan observable ke objek API yang diterima dari latar belakang:

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

Pada akhirnya kami mulai merender antarmuka aplikasi. Ini adalah aplikasi reaksi. Objek latar belakang dilewatkan begitu saja menggunakan props. Tentu saja benar untuk membuat layanan terpisah untuk metode dan penyimpanan untuk negara, tetapi untuk keperluan artikel ini ini sudah 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')
    );
}

Dengan mobx sangat mudah untuk memulai rendering ketika data berubah. Kami cukup menggantung dekorator pengamat dari paketnya reaksi mobx pada komponen, dan render akan dipanggil secara otomatis ketika ada observasi yang direferensikan oleh komponen berubah. Anda tidak memerlukan mapStateToProps atau koneksi seperti di redux. Semuanya langsung berfungsi:

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 lainnya dapat dilihat di kode di folder UI.

Sekarang di kelas aplikasi Anda perlu membuat pemilih status untuk UI dan memberi tahu UI ketika ada perubahan. Untuk melakukan ini, mari tambahkan metode getState и reactionpanggilan 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())

        })
    }

    ...
}

Saat menerima suatu benda remote dibuat reaction untuk mengubah status yang memanggil fungsi di sisi UI.

Sentuhan terakhir adalah menambahkan tampilan pesan baru pada ikon ekstensi:

function setupApp() {
...

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

...
}

Jadi, aplikasinya sudah siap. Halaman web dapat meminta tanda tangan untuk transaksi:

Menulis ekstensi browser yang aman

Menulis ekstensi browser yang aman

Kode tersedia di sini link.

Kesimpulan

Jika Anda sudah membaca artikel sampai habis, namun masih ada pertanyaan, Anda bisa menanyakannya di repositori dengan ekstensi. Di sana Anda juga akan menemukan komitmen untuk setiap langkah yang ditentukan.

Dan jika Anda tertarik untuk melihat kode ekstensi sebenarnya, Anda dapat menemukannya di sini.

Kode, repositori, dan deskripsi pekerjaan dari simarell

Sumber: www.habr.com

Tambah komentar