Menulis sambungan penyemak imbas selamat

Menulis sambungan penyemak imbas selamat

Tidak seperti seni bina "pelayan pelanggan" biasa, aplikasi terdesentralisasi dicirikan oleh:

  • Tidak perlu menyimpan pangkalan data dengan log masuk dan kata laluan pengguna. Maklumat capaian disimpan secara eksklusif oleh pengguna sendiri, dan pengesahan ketulenan mereka berlaku pada peringkat protokol.
  • Tidak perlu menggunakan pelayan. Logik aplikasi boleh dilaksanakan pada rangkaian blockchain, di mana ia adalah mungkin untuk menyimpan jumlah data yang diperlukan.

Terdapat 2 storan yang agak selamat untuk kunci pengguna - dompet perkakasan dan sambungan penyemak imbas. Dompet perkakasan kebanyakannya sangat selamat, tetapi sukar untuk digunakan dan jauh dari percuma, tetapi sambungan penyemak imbas ialah gabungan sempurna keselamatan dan kemudahan penggunaan, dan juga boleh menjadi percuma sepenuhnya untuk pengguna akhir.

Dengan mengambil kira semua ini, kami ingin membuat sambungan paling selamat yang memudahkan pembangunan aplikasi terdesentralisasi dengan menyediakan API mudah untuk bekerja dengan transaksi dan tandatangan.
Kami akan memberitahu anda tentang pengalaman ini di bawah.

Artikel itu akan mengandungi arahan langkah demi langkah tentang cara menulis sambungan penyemak imbas, dengan contoh kod dan tangkapan skrin. Anda boleh mencari semua kod dalam repositori. Setiap komit secara logik sepadan dengan bahagian artikel ini.

Sejarah Ringkas Sambungan Penyemak Imbas

Sambungan penyemak imbas telah wujud sejak sekian lama. Mereka muncul di Internet Explorer pada tahun 1999, di Firefox pada tahun 2004. Walau bagaimanapun, untuk masa yang sangat lama tiada standard tunggal untuk sambungan.

Kita boleh mengatakan bahawa ia muncul bersama-sama dengan sambungan dalam versi keempat Google Chrome. Sudah tentu, tiada spesifikasi ketika itu, tetapi API Chrome yang menjadi asasnya: setelah menakluki sebahagian besar pasaran penyemak imbas dan mempunyai gedung aplikasi terbina dalam, Chrome sebenarnya menetapkan standard untuk sambungan penyemak imbas.

Mozilla mempunyai standardnya sendiri, tetapi melihat populariti sambungan Chrome, syarikat itu memutuskan untuk membuat API yang serasi. Pada tahun 2015, atas inisiatif Mozilla, kumpulan khas telah diwujudkan dalam World Wide Web Consortium (W3C) untuk mengusahakan spesifikasi sambungan silang penyemak imbas.

Sambungan API sedia ada untuk Chrome telah diambil sebagai asas. Kerja itu dijalankan dengan sokongan Microsoft (Google enggan mengambil bahagian dalam pembangunan standard), dan akibatnya draf muncul spesifikasi.

Secara rasmi, spesifikasi disokong oleh Edge, Firefox dan Opera (perhatikan bahawa Chrome tiada dalam senarai ini). Tetapi sebenarnya, standard ini sebahagian besarnya serasi dengan Chrome, kerana ia sebenarnya ditulis berdasarkan sambungannya. Anda boleh membaca lebih lanjut mengenai API WebExtensions di sini.

Struktur lanjutan

Satu-satunya fail yang diperlukan untuk sambungan ialah manifes (manifest.json). Ia juga merupakan "titik masuk" kepada pengembangan.

Manifest

Menurut spesifikasi, fail manifes ialah fail JSON yang sah. Penerangan penuh kunci manifes dengan maklumat tentang kekunci mana yang disokong di mana penyemak imbas boleh dilihat di sini.

Kekunci yang tiada dalam spesifikasi "mungkin" diabaikan (kedua-dua Chrome dan Firefox melaporkan ralat, tetapi sambungan terus berfungsi).

Dan saya ingin menarik perhatian kepada beberapa perkara.

  1. latar belakang — objek yang merangkumi medan berikut:
    1. skrip — tatasusunan skrip yang akan dilaksanakan dalam konteks latar belakang (kita akan membincangkannya sedikit kemudian);
    2. halaman - bukannya skrip yang akan dilaksanakan dalam halaman kosong, anda boleh menentukan html dengan kandungan. Dalam kes ini, medan skrip akan diabaikan dan skrip perlu dimasukkan ke dalam halaman kandungan;
    3. berterusan — bendera binari, jika tidak dinyatakan, penyemak imbas akan "mematikan" proses latar belakang apabila ia menganggap bahawa ia tidak melakukan apa-apa, dan memulakannya semula jika perlu. Jika tidak, halaman hanya akan dipunggah apabila penyemak imbas ditutup. Tidak disokong dalam Firefox.
  2. kandungan_skrip — susunan objek yang membolehkan anda memuatkan skrip yang berbeza ke halaman web yang berbeza. Setiap objek mengandungi medan penting berikut:
    1. perlawanan - url corak, yang menentukan sama ada skrip kandungan tertentu akan disertakan atau tidak.
    2. js — senarai skrip yang akan dimuatkan ke dalam perlawanan ini;
    3. exclude_matches - tidak termasuk dari padang match URL yang sepadan dengan medan ini.
  3. page_action - sebenarnya adalah objek yang bertanggungjawab untuk ikon yang dipaparkan di sebelah bar alamat dalam penyemak imbas dan interaksi dengannya. Ia juga membolehkan anda memaparkan tetingkap pop timbul, yang ditakrifkan menggunakan HTML, CSS dan JS anda sendiri.
    1. default_popup — laluan ke fail HTML dengan antara muka timbul, mungkin mengandungi CSS dan JS.
  4. kebenaran — tatasusunan untuk mengurus hak sambungan. Terdapat 3 jenis hak, yang diterangkan secara terperinci di sini
  5. web_accessible_resources — sumber sambungan yang boleh diminta oleh halaman web, contohnya, imej, JS, CSS, fail HTML.
  6. externally_connectable — di sini anda boleh menentukan secara eksplisit ID sambungan lain dan domain halaman web yang boleh anda sambungkan. Domain boleh menjadi tahap kedua atau lebih tinggi. Tidak berfungsi dalam Firefox.

Konteks pelaksanaan

Sambungan mempunyai tiga konteks pelaksanaan kod, iaitu, aplikasi terdiri daripada tiga bahagian dengan tahap akses yang berbeza kepada API penyemak imbas.

Konteks sambungan

Kebanyakan API tersedia di sini. Dalam konteks ini mereka "hidup":

  1. Halaman latar belakang — bahagian "belakang" sambungan. Fail ditentukan dalam manifes menggunakan kekunci "latar belakang".
  2. Halaman timbul — halaman timbul yang muncul apabila anda mengklik pada ikon sambungan. Dalam manifesto browser_action -> default_popup.
  3. Halaman tersuai — halaman sambungan, "hidup" dalam tab paparan yang berasingan chrome-extension://<id_расширения>/customPage.html.

Konteks ini wujud secara bebas daripada tetingkap dan tab penyemak imbas. Halaman latar belakang wujud dalam satu salinan dan sentiasa berfungsi (pengecualian adalah halaman acara, apabila skrip latar belakang dilancarkan oleh acara dan "mati" selepas pelaksanaannya). Halaman timbul wujud apabila tetingkap pop timbul dibuka, dan Halaman tersuai — semasa tab dengannya dibuka. Tiada akses kepada tab lain dan kandungannya daripada konteks ini.

Konteks skrip kandungan

Fail skrip kandungan dilancarkan bersama setiap tab penyemak imbas. Ia mempunyai akses kepada sebahagian daripada API sambungan dan kepada pepohon DOM halaman web. Ia adalah skrip kandungan yang bertanggungjawab untuk interaksi dengan halaman. Sambungan yang memanipulasi pepohon DOM melakukan ini dalam skrip kandungan - contohnya, penyekat iklan atau penterjemah. Selain itu, skrip kandungan boleh berkomunikasi dengan halaman melalui standard postMessage.

Konteks laman web

Ini adalah halaman web sebenar itu sendiri. Ia tiada kaitan dengan sambungan dan tidak mempunyai akses di sana, kecuali dalam kes di mana domain halaman ini tidak dinyatakan secara eksplisit dalam manifes (lebih lanjut mengenai perkara ini di bawah).

Pertukaran mesej

Bahagian aplikasi yang berbeza mesti bertukar-tukar mesej antara satu sama lain. Terdapat API untuk ini runtime.sendMessage untuk menghantar mesej background и tabs.sendMessage untuk menghantar mesej ke halaman (skrip kandungan, pop timbul atau halaman web jika tersedia externally_connectable). Di bawah ialah contoh semasa mengakses API Chrome.

// Сообщением может быть любой 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 boleh membuat sambungan melalui runtime.connect. Sebagai tindak balas kami akan menerima runtime.Port, yang, semasa ia dibuka, anda boleh menghantar sebarang bilangan mesej. Di sisi pelanggan, sebagai contoh, contentscript, ia kelihatan 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"});

Pelayan 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 kaedah disconnect.

Gambar rajah aplikasi

Mari buat sambungan penyemak imbas yang menyimpan kunci peribadi, menyediakan akses kepada maklumat awam (alamat, kunci awam berkomunikasi dengan halaman dan membenarkan aplikasi pihak ketiga meminta tandatangan untuk transaksi.

Pembangunan aplikasi

Aplikasi kami mesti berinteraksi dengan pengguna dan menyediakan halaman dengan kaedah API untuk memanggil (contohnya, untuk menandatangani transaksi). Buat dengan hanya satu contentscript tidak akan berfungsi, kerana ia hanya mempunyai akses kepada DOM, tetapi tidak kepada JS halaman. Sambung melalui runtime.connect kami tidak boleh, kerana API diperlukan pada semua domain dan hanya yang khusus boleh ditentukan dalam manifes. Hasilnya, rajah akan kelihatan seperti ini:

Menulis sambungan penyemak imbas selamat

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

bermula

Semua kod sambungan penyemak imbas tersedia di GitHub. Semasa penerangan akan terdapat pautan ke komitmen.

Mari kita mulakan dengan 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"]
}

Buat background.js kosong, popup.js, inpage.js dan contentscript.js. Kami menambah popup.html - dan aplikasi kami sudah boleh dimuatkan ke dalam Google Chrome dan memastikan ia berfungsi.

Untuk mengesahkan ini, anda boleh mengambil kod oleh itu. Sebagai tambahan kepada apa yang kami lakukan, pautan itu mengkonfigurasi pemasangan projek menggunakan webpack. Untuk menambah aplikasi pada penyemak imbas, dalam chrome://extensions anda perlu memilih load unpacked dan folder dengan sambungan yang sepadan - dalam kes kami dist.

Menulis sambungan penyemak imbas selamat

Sekarang sambungan kami dipasang dan berfungsi. Anda boleh menjalankan alat pembangun untuk konteks yang berbeza seperti berikut:

timbul ->

Menulis sambungan penyemak imbas selamat

Akses kepada konsol skrip kandungan dijalankan melalui konsol halaman itu sendiri di mana ia dilancarkan.Menulis sambungan penyemak imbas selamat

Pertukaran mesej

Jadi, kita perlu mewujudkan dua saluran komunikasi: dalam halaman latar belakang dan pop timbul latar belakang. Anda boleh, sudah tentu, hanya menghantar mesej ke pelabuhan dan mencipta protokol anda sendiri, tetapi saya lebih suka pendekatan yang saya lihat dalam projek sumber terbuka metamask.

Ini adalah sambungan penyemak imbas untuk bekerja dengan rangkaian Ethereum. Di dalamnya, bahagian aplikasi yang berlainan berkomunikasi melalui RPC menggunakan perpustakaan dnode. Ia membolehkan anda mengatur pertukaran dengan agak cepat dan mudah jika anda menyediakannya dengan aliran nodejs sebagai pengangkutan (bermaksud objek yang melaksanakan antara muka 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. Ia akan mencipta objek API untuk pop timbul dan halaman web, dan mencipta dnod 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, bukannya objek Chrome global, kami menggunakan extensionApi, yang mengakses Chrome dalam penyemak imbas Google dan penyemak imbas lain. Ini dilakukan untuk keserasian merentas pelayar, tetapi untuk tujuan artikel ini, anda hanya boleh menggunakan 'chrome.runtime.connect'.

Mari buat contoh aplikasi dalam 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)
    }
}

Memandangkan dnode berfungsi dengan strim, dan kami menerima port, kelas penyesuai diperlukan. Ia dibuat menggunakan perpustakaan strim boleh dibaca, yang melaksanakan aliran nodejs dalam penyemak imbas:

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 kita buat sambungan dalam 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 kami membuat sambungan dalam skrip kandungan:

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

Memandangkan kami memerlukan API bukan dalam skrip kandungan, tetapi terus pada halaman, kami melakukan dua perkara:

  1. Kami mencipta dua aliran. Satu - ke arah halaman, di atas postMessage. Untuk ini kami menggunakan ini pakej ini daripada pencipta metamask. Aliran kedua adalah untuk latar belakang port yang diterima daripada runtime.connect. Jom beli. Sekarang halaman akan mempunyai aliran ke latar belakang.
  2. Suntikan skrip ke dalam DOM. Muat turun skrip (akses kepadanya dibenarkan dalam manifes) dan buat teg script dengan kandungannya di dalam:

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 mencipta objek api dalam halaman masuk dan menetapkannya kepada 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 bersedia Panggilan Prosedur Jauh (RPC) dengan API berasingan untuk halaman dan UI. Apabila menyambungkan halaman baharu ke latar belakang kita dapat melihat ini:

Menulis sambungan penyemak imbas selamat

API kosong dan asal. Di sebelah halaman, kita boleh memanggil fungsi hello seperti ini:

Menulis sambungan penyemak imbas selamat

Bekerja dengan fungsi panggil balik dalam JS moden adalah tingkah laku yang tidak baik, jadi mari tulis pembantu kecil untuk mencipta dnod yang membolehkan anda menghantar objek API kepada utils.

Objek API kini akan kelihatan seperti ini:

export class SignerApp {

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

...

}

Mendapatkan objek dari 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 panggilan mengembalikan janji:

Menulis sambungan penyemak imbas selamat

Versi dengan fungsi tak segerak tersedia di sini.

Secara keseluruhan, pendekatan RPC dan aliran kelihatan agak fleksibel: kita boleh menggunakan pemultipleksan stim dan mencipta beberapa API berbeza untuk tugasan yang berbeza. Pada dasarnya, dnode boleh digunakan di mana-mana, perkara utama adalah untuk membungkus pengangkutan dalam bentuk aliran nodejs.

Alternatifnya ialah format JSON, yang melaksanakan protokol JSON RPC 2. Walau bagaimanapun, ia berfungsi dengan pengangkutan tertentu (TCP dan HTTP(S)), yang tidak berkenaan dalam kes kami.

Keadaan dalaman dan localStorage

Kami perlu menyimpan keadaan dalaman aplikasi - sekurang-kurangnya kunci tandatangan. Kita boleh dengan mudah menambah keadaan pada aplikasi dan kaedah untuk mengubahnya dalam API pop timbul:

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, kami akan membungkus semuanya dalam fungsi dan menulis objek aplikasi ke tetingkap supaya kami boleh 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 daripada konsol UI dan lihat apa yang berlaku dengan keadaan:

Menulis sambungan penyemak imbas selamat

Keadaan perlu dibuat berterusan supaya kunci tidak hilang apabila dimulakan semula.

Kami akan menyimpannya dalam localStorage, menimpanya dengan setiap perubahan. Selepas itu, akses kepadanya juga diperlukan untuk UI, dan saya juga ingin melanggan perubahan. Berdasarkan ini, adalah mudah untuk membuat storan yang boleh diperhatikan dan melanggan perubahannya.

Kami akan menggunakan perpustakaan mobx (https://github.com/mobxjs/mobx). Pilihan jatuh padanya kerana saya tidak perlu bekerja dengannya, tetapi saya benar-benar mahu mempelajarinya.

Mari tambahkan permulaan keadaan awal dan jadikan kedai boleh diperhatikan:

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 bawah tudung," mobx telah menggantikan semua medan kedai dengan proksi dan memintas semua panggilan kepada mereka. Anda boleh melanggan mesej ini.

Di bawah saya sering menggunakan istilah "apabila menukar", walaupun ini tidak betul sepenuhnya. Mobx menjejaki akses kepada medan. Pengambil dan penetap objek proksi yang dicipta oleh perpustakaan digunakan.

Penghias aksi mempunyai dua tujuan:

  1. Dalam mod ketat dengan bendera enforceActions, mobx melarang menukar keadaan secara langsung. Ia dianggap amalan yang baik untuk bekerja dalam keadaan yang ketat.
  2. Walaupun fungsi menukar keadaan beberapa kali - sebagai contoh, kami menukar beberapa medan dalam beberapa baris kod - pemerhati hanya diberitahu apabila ia selesai. Ini amat penting untuk bahagian hadapan, di mana kemas kini keadaan yang tidak perlu membawa kepada pemaparan elemen yang tidak perlu. Dalam kes kami, baik yang pertama mahupun yang kedua tidak begitu relevan, tetapi kami akan mengikut amalan terbaik. Adalah lazim untuk melampirkan penghias ke semua fungsi yang mengubah keadaan medan yang diperhatikan.

Di latar belakang kami akan menambah permulaan dan menyimpan keadaan dalam 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 tindak balas adalah menarik di sini. Ia mempunyai dua hujah:

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

Tidak seperti redux, di mana kami menerima keadaan secara eksplisit sebagai hujah, mobx mengingati pemerhatian yang kami akses di dalam pemilih dan hanya memanggil pengendali apabila ia berubah.

Adalah penting untuk memahami dengan tepat cara mobx memutuskan yang boleh diperhatikan yang kami langgan. Jika saya menulis pemilih dalam kod seperti ini() => app.store, maka tindak balas tidak akan dipanggil, kerana storan itu sendiri tidak boleh diperhatikan, hanya medannya sahaja.

Kalau saya tulis macam ni () => app.store.keys, sekali lagi tiada apa yang akan berlaku, kerana apabila menambah/mengalih keluar elemen tatasusunan, rujukan kepadanya tidak akan berubah.

Mobx bertindak sebagai pemilih buat kali pertama dan hanya menjejaki pemerhatian yang telah kami akses. Ini dilakukan melalui pengambil proksi. Oleh itu, fungsi terbina dalam digunakan di sini toJS. Ia mengembalikan objek baharu dengan semua proksi digantikan dengan medan asal. Semasa pelaksanaan, ia membaca semua medan objek - oleh itu getter dicetuskan.

Dalam konsol pop timbul kami sekali lagi akan menambah beberapa kekunci. Kali ini mereka juga berakhir di localStorage:

Menulis sambungan penyemak imbas selamat

Apabila halaman latar belakang dimuatkan semula, maklumat itu kekal di tempatnya.

Semua kod aplikasi sehingga tahap ini boleh dilihat di sini.

Penyimpanan selamat kunci peribadi

Menyimpan kunci peribadi dalam teks yang jelas adalah tidak selamat: sentiasa ada kemungkinan anda akan digodam, mendapat akses kepada komputer anda dan sebagainya. Oleh itu, dalam localStorage kami akan menyimpan kunci dalam bentuk yang disulitkan kata laluan.

Untuk keselamatan yang lebih baik, kami akan menambah keadaan terkunci pada aplikasi, di mana tidak akan ada akses kepada kunci sama sekali. Kami akan memindahkan sambungan secara automatik ke keadaan terkunci kerana tamat masa.

Mobx membenarkan anda menyimpan hanya set minimum data, dan selebihnya dikira secara automatik berdasarkannya. Ini adalah apa yang dipanggil sifat yang dikira. Ia boleh dibandingkan dengan paparan dalam 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')
        }
    }
}

Kini kami hanya menyimpan kunci dan kata laluan yang disulitkan. Semua yang lain dikira. Kami melakukan pemindahan ke keadaan terkunci dengan mengalih keluar kata laluan daripada keadaan. API awam kini mempunyai kaedah untuk memulakan storan.

Ditulis untuk penyulitan utiliti 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)
}

Penyemak imbas mempunyai API terbiar di mana anda boleh melanggan acara - perubahan keadaan. Negeri, sewajarnya, mungkin idle, active и locked. Untuk melahu anda boleh menetapkan tamat masa, dan dikunci ditetapkan apabila OS itu sendiri disekat. Kami juga akan menukar pemilih untuk disimpan ke 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)
        }
    }
}

Kod sebelum langkah ini ialah di sini.

Transaksi

Jadi, kami sampai kepada perkara yang paling penting: mencipta dan menandatangani transaksi pada blockchain. Kami akan menggunakan rangkaian dan perpustakaan WAVES gelombang-transaksi.

Mula-mula, mari tambahkan pada keadaan susunan mesej yang perlu ditandatangani, kemudian tambah kaedah untuk menambah mesej baharu, mengesahkan tandatangan 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'
    }

    ...
}

Apabila kami menerima mesej baharu, kami menambah metadata padanya, lakukan observable dan menambah kepada store.messages.

Jika anda tidak observable secara manual, maka mobx akan melakukannya sendiri apabila menambahkan mesej pada tatasusunan. Walau bagaimanapun, ia akan mencipta objek baharu yang kami tidak akan mempunyai rujukan, tetapi kami memerlukannya untuk langkah seterusnya.

Seterusnya, kami mengembalikan janji yang diselesaikan apabila status mesej berubah. Status dipantau oleh reaksi, yang akan "membunuh diri" apabila status berubah.

Kod kaedah approve и reject sangat mudah: kami hanya menukar status mesej, selepas menandatanganinya jika perlu.

Kami meletakkan Approve dan reject dalam API UI, newMessage dalam 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)
        }
    }

    ...
}

Sekarang mari kita cuba menandatangani transaksi dengan sambungan:

Menulis sambungan penyemak imbas selamat

Secara umum, semuanya sudah siap, yang tinggal hanyalah tambah UI mudah.

UI

Antara muka memerlukan akses kepada keadaan aplikasi. Di bahagian UI kami akan lakukan observable nyatakan dan tambahkan fungsi pada API yang akan mengubah keadaan ini. Jom tambah 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 mula memberikan antara muka aplikasi. Ini adalah aplikasi tindak balas. Objek latar belakang hanya diluluskan menggunakan prop. Sudah tentu, adalah betul untuk membuat perkhidmatan berasingan untuk kaedah dan kedai untuk negeri, tetapi untuk tujuan artikel ini 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 mula membuat apabila data berubah. Kami hanya menggantung penghias pemerhati dari bungkusan mobx-react pada komponen, dan render akan dipanggil secara automatik apabila sebarang pemerhatian yang dirujuk oleh komponen berubah. Anda tidak memerlukan sebarang mapStateToProps atau menyambung seperti dalam redux. Semuanya berfungsi di luar 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>
    }
}

Komponen yang selebihnya boleh dilihat dalam kod dalam folder UI.

Sekarang dalam kelas aplikasi anda perlu membuat pemilih keadaan untuk UI dan memberitahu UI apabila ia berubah. Untuk melakukan ini, mari tambah kaedah getState и reactionmemanggil 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())

        })
    }

    ...
}

Apabila menerima objek remote sedang dicipta reaction untuk menukar keadaan yang memanggil fungsi pada bahagian UI.

Sentuhan terakhir ialah menambah paparan mesej baharu pada ikon sambungan:

function setupApp() {
...

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

...
}

Jadi, permohonan sudah siap. Halaman web boleh meminta tandatangan untuk transaksi:

Menulis sambungan penyemak imbas selamat

Menulis sambungan penyemak imbas selamat

Kod boleh didapati di sini pautan.

Kesimpulan

Jika anda telah membaca artikel itu hingga akhir, tetapi masih mempunyai soalan, anda boleh bertanya kepada mereka di repositori dengan sambungan. Di sana anda juga akan menemui komitmen untuk setiap langkah yang ditetapkan.

Dan jika anda berminat untuk melihat kod untuk sambungan sebenar, anda boleh mencari ini di sini.

Kod, repositori dan huraian kerja daripada siemarell

Sumber: www.habr.com

Tambah komen