Pisanje bezbednog proširenja pretraživača

Pisanje bezbednog proširenja pretraživača

Za razliku od uobičajene arhitekture „klijent-server“, decentralizovane aplikacije karakteriše:

  • Nema potrebe za pohranjivanjem baze podataka sa korisničkim prijavama i lozinkama. Pristupne informacije pohranjuju isključivo sami korisnici, a potvrda njihove autentičnosti se događa na nivou protokola.
  • Nema potrebe za korištenjem servera. Logika aplikacije može se izvršiti na blockchain mreži, gdje je moguće pohraniti potrebnu količinu podataka.

Postoje 2 relativno sigurna skladišta za korisničke ključeve - hardverski novčanici i ekstenzije pretraživača. Hardverski novčanici su uglavnom izuzetno sigurni, ali teški za korištenje i daleko od besplatnih, ali ekstenzije pretraživača su savršena kombinacija sigurnosti i jednostavnosti korištenja, a mogu biti i potpuno besplatne za krajnje korisnike.

Uzimajući sve ovo u obzir, željeli smo napraviti najsigurnije proširenje koje pojednostavljuje razvoj decentraliziranih aplikacija pružajući jednostavan API za rad s transakcijama i potpisima.
O ovom iskustvu ćemo vam reći u nastavku.

Članak će sadržavati upute korak po korak o tome kako napisati ekstenziju preglednika, s primjerima koda i snimkama ekrana. Možete pronaći sav kod spremišta. Svako urezivanje logično odgovara dijelu ovog članka.

Kratka istorija proširenja pretraživača

Ekstenzije za pretraživače postoje već duže vrijeme. Pojavili su se u Internet Exploreru davne 1999. godine, u Firefoxu 2004. godine. Međutim, dugo vremena nije postojao jedinstveni standard za proširenja.

Možemo reći da se pojavio zajedno sa ekstenzijama u četvrtoj verziji Google Chrome-a. Naravno, tada nije bilo specifikacije, ali je Chrome API postao njegova osnova: osvojivši većinu tržišta pretraživača i imajući ugrađenu trgovinu aplikacija, Chrome je zapravo postavio standard za proširenja pretraživača.

Mozilla je imala svoj standard, ali s obzirom na popularnost Chrome ekstenzija, kompanija je odlučila napraviti kompatibilan API. 2015. godine, na inicijativu Mozille, stvorena je posebna grupa u okviru World Wide Web Consortium-a (W3C) za rad na specifikacijama proširenja za više pretraživača.

Postojeća API proširenja za Chrome uzeta su kao osnova. Rad je obavljen uz podršku Microsofta (Google je odbio sudjelovati u razvoju standarda), a kao rezultat pojavio se nacrt specifikacije.

Formalno, specifikaciju podržavaju Edge, Firefox i Opera (imajte na umu da Chrome nije na ovoj listi). Ali u stvari, standard je u velikoj mjeri kompatibilan s Chromeom, budući da je zapravo napisan na osnovu njegovih ekstenzija. Možete pročitati više o WebExtensions API-ju ovdje.

Struktura proširenja

Jedini fajl koji je potreban za ekstenziju je manifest (manifest.json). To je takođe „ulazna tačka“ u proširenje.

Manifest

Prema specifikaciji, datoteka manifesta je važeća JSON datoteka. Potpuni opis ključeva manifesta s informacijama o tome koji su ključevi podržani u kojem pretraživaču se može vidjeti ovdje.

Ključevi koji nisu u specifikaciji "mogu" biti zanemareni (i Chrome i Firefox prijavljuju greške, ali ekstenzije nastavljaju raditi).

I želeo bih da skrenem pažnju na neke tačke.

  1. pozadina — objekat koji uključuje sljedeća polja:
    1. skripte — niz skripti koje će se izvršavati u pozadinskom kontekstu (o tome ćemo govoriti malo kasnije);
    2. Strana - umjesto skripti koje će se izvršavati na praznoj stranici, možete odrediti html sa sadržajem. U ovom slučaju, polje skripte će biti zanemareno, a skripte će morati biti umetnute u stranicu sa sadržajem;
    3. istrajati — binarna zastavica, ako nije navedena, pretraživač će „ubiti“ pozadinski proces kada smatra da ne radi ništa i ponovo ga pokrenuti ako je potrebno. U suprotnom, stranica će se učitati samo kada je pretraživač zatvoren. Nije podržano u Firefoxu.
  2. content_scripts — niz objekata koji vam omogućavaju učitavanje različitih skripti na različite web stranice. Svaki objekat sadrži sljedeća važna polja:
    1. šibice - obrazac url, koji određuje da li će određena skripta sadržaja biti uključena ili ne.
    2. js — lista skripti koje će biti učitane u ovaj meč;
    3. exclude_matches - isključuje sa terena match URL-ovi koji odgovaraju ovom polju.
  3. page_action - je zapravo objekat koji je odgovoran za ikonu koja se prikazuje pored adresne trake u pretraživaču i interakciju sa njom. Takođe vam omogućava da prikažete iskačući prozor, koji je definisan korišćenjem vašeg HTML, CSS i JS.
    1. default_popup — putanja do HTML datoteke sa popup interfejsom, može sadržati CSS i JS.
  4. dozvole — niz za upravljanje pravima proširenja. Postoje 3 vrste prava, koja su detaljno opisana ovdje
  5. web_accessible_resources — resursi proširenja koje web stranica može zatražiti, na primjer, slike, JS, CSS, HTML datoteke.
  6. externally_connectable — ovdje možete eksplicitno navesti ID-ove drugih ekstenzija i domena web stranica sa kojih se možete povezati. Domena može biti drugog nivoa ili više. Ne radi u Firefoxu.

Kontekst izvršenja

Ekstenzija ima tri konteksta izvršavanja koda, odnosno aplikacija se sastoji od tri dijela sa različitim nivoima pristupa API-ju pretraživača.

Kontekst proširenja

Većina API-ja je dostupna ovdje. U ovom kontekstu oni "žive":

  1. Pozadinska stranica — “backend” dio ekstenzije. Datoteka je navedena u manifestu pomoću ključa “background”.
  2. Popup stranica — iskačuća stranica koja se pojavljuje kada kliknete na ikonu ekstenzije. U manifestu browser_action -> default_popup.
  3. Prilagođena stranica — stranica proširenja, „živi“ u posebnoj kartici prikaza chrome-extension://<id_расширения>/customPage.html.

Ovaj kontekst postoji nezavisno od prozora i kartica pretraživača. Pozadinska stranica postoji u jednoj kopiji i uvijek radi (izuzetak je stranica događaja, kada je pozadinska skripta pokrenuta događajem i “umre” nakon njegovog izvršenja). Popup stranica postoji kada je iskačući prozor otvoren, i Prilagođena stranica — dok je kartica sa njom otvorena. Iz ovog konteksta nema pristupa drugim karticama i njihovom sadržaju.

Kontekst skripte sadržaja

Datoteka skripte sadržaja se pokreće zajedno sa svakom karticom pretraživača. Ima pristup dijelu API-ja ekstenzije i DOM stablu web stranice. Za interakciju sa stranicom odgovorne su skripte sadržaja. Ekstenzije koje manipuliraju DOM stablom to rade u skriptama sadržaja - na primjer, blokatorima oglasa ili prevodiocima. Također, skripta sadržaja može komunicirati sa stranicom putem standarda postMessage.

Kontekst web stranice

Ovo je sama web stranica. Nema nikakve veze sa ekstenzijom i nema pristup tamo, osim u slučajevima kada domen ove stranice nije eksplicitno naznačen u manifestu (više o tome u nastavku).

Razmjena poruka

Različiti dijelovi aplikacije moraju međusobno razmjenjivati ​​poruke. Za ovo postoji API runtime.sendMessage da pošaljete poruku background и tabs.sendMessage za slanje poruke stranici (skripta sadržaja, popup ili web stranica ako je dostupna externally_connectable). Ispod je primjer pristupa Chrome API-ju.

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

Za potpunu komunikaciju, možete stvoriti veze putem runtime.connect. Kao odgovor ćemo dobiti runtime.Port, na koji, dok je otvoren, možete slati bilo koji broj poruka. Na strani klijenta, npr. contentscript, izgleda ovako:

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

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

Tu je i događaj onDisconnect i metod disconnect.

Aplikacioni dijagram

Napravimo ekstenziju pretraživača koja čuva privatne ključeve, omogućava pristup javnim informacijama (adresa, javni ključ komunicira sa stranicom i omogućava aplikacijama trećih strana da zatraže potpis za transakcije.

Razvoj aplikacija

Naša aplikacija mora biti u interakciji s korisnikom i pružiti stranici API za pozivanje metoda (na primjer, za potpisivanje transakcija). Zadovoljite se samo jednim contentscript neće raditi, jer ima pristup samo DOM-u, ali ne i JS-u stranice. Povežite se putem runtime.connect ne možemo, jer je API potreban na svim domenama, a samo određeni mogu biti navedeni u manifestu. Kao rezultat, dijagram će izgledati ovako:

Pisanje bezbednog proširenja pretraživača

Biće još jedan scenario - inpage, koje ćemo ubaciti na stranicu. Pokrenut će se u svom kontekstu i pružiti API za rad s ekstenzijom.

Начало

Sav kod ekstenzije pretraživača dostupan je na adresi GitHub. Tokom opisa će biti linkovi za urezivanje.

Počnimo sa manifestom:

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

Kreirajte prazan background.js, popup.js, inpage.js i contentscript.js. Dodamo popup.html - i naša aplikacija se već može učitati u Google Chrome i uvjeriti se da radi.

Da biste to potvrdili, možete uzeti kod odavde. Pored onoga što smo uradili, link je konfigurisao montažu projekta koristeći webpack. Da biste dodali aplikaciju u pretraživač, u chrome://extensions morate odabrati load unpacked i folder sa odgovarajućom ekstenzijom - u našem slučaju dist.

Pisanje bezbednog proširenja pretraživača

Sada je naša ekstenzija instalirana i radi. Možete pokrenuti alate za programere za različite kontekste na sljedeći način:

popup ->

Pisanje bezbednog proširenja pretraživača

Pristup konzoli skripte sadržaja vrši se preko konzole same stranice na kojoj je pokrenuta.Pisanje bezbednog proširenja pretraživača

Razmjena poruka

Dakle, moramo uspostaviti dva kanala komunikacije: inpage <-> background i popup <-> background. Možete, naravno, samo slati poruke na port i izmisliti svoj vlastiti protokol, ali ja više volim pristup koji sam vidio u metamask projektu otvorenog koda.

Ovo je proširenje pretraživača za rad sa Ethereum mrežom. U njemu različiti dijelovi aplikacije komuniciraju putem RPC-a koristeći dnode biblioteku. Omogućava vam da organizirate razmjenu prilično brzo i povoljno ako joj dostavite nodejs stream kao transport (što znači objekt koji implementira isto sučelje):

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

Sada ćemo kreirati klasu aplikacije. To će kreirati API objekte za popup i web stranicu i kreirati dnode za njih:

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

Ovdje i ispod, umjesto globalnog Chrome objekta, koristimo ekstenziju Api, koja pristupa Chromeu u Google pretraživaču i pretraživaču u drugim. Ovo se radi radi kompatibilnosti između pretraživača, ali za potrebe ovog članka jednostavno se može koristiti 'chrome.runtime.connect'.

Kreirajmo instancu aplikacije u pozadinskoj skripti:

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

Pošto dnode radi sa streamovima, a mi primamo port, potrebna je klasa adaptera. Napravljen je pomoću biblioteke readable-stream, koja implementira nodejs streamove u pretraživaču:

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

Sada kreirajmo vezu u korisničkom sučelju:

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

Zatim kreiramo vezu u skripti sadržaja:

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

Budući da nam API nije potreban u skripti sadržaja, već direktno na stranici, radimo dvije stvari:

  1. Kreiramo dva toka. Jedan - prema stranici, na vrhu poruke. Za ovo koristimo ovo ovaj paket od kreatora metamaska. Drugi tok je u pozadini preko primljenog porta runtime.connect. Hajde da ih kupimo. Sada će stranica imati stream u pozadini.
  2. Ubacite skriptu u DOM. Preuzmite skriptu (pristup joj je bio dozvoljen u manifestu) i kreirajte oznaku script sa sadržajem unutra:

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

Sada kreiramo api objekat u inpage i postavljamo ga na globalno:

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

Spremni smo Poziv udaljene procedure (RPC) sa zasebnim API-jem za stranicu i korisničko sučelje. Prilikom povezivanja nove stranice sa pozadinom možemo vidjeti ovo:

Pisanje bezbednog proširenja pretraživača

Prazan API i porijeklo. Na strani stranice, funkciju hello možemo pozvati ovako:

Pisanje bezbednog proširenja pretraživača

Rad sa funkcijama povratnog poziva u modernom JS-u je loš način, pa hajde da napišemo mali pomoćnik za kreiranje dnodea koji vam omogućava da proslijedite API objekt utilima.

API objekti će sada izgledati ovako:

export class SignerApp {

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

...

}

Dobivanje objekta sa daljinskog upravljača ovako:

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

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

A pozivanje funkcija vraća obećanje:

Pisanje bezbednog proširenja pretraživača

Dostupna verzija sa asinkronim funkcijama ovdje.

Sve u svemu, RPC i stream pristup izgleda prilično fleksibilan: možemo koristiti parno multipleksiranje i kreirati nekoliko različitih API-ja za različite zadatke. U principu, dnode se može koristiti bilo gdje, glavna stvar je omotati transport u obliku nodejs stream-a.

Alternativa je JSON format, koji implementira protokol JSON RPC 2. Međutim, radi sa specifičnim transportima (TCP i HTTP(S)), što u našem slučaju nije primjenjivo.

Interno stanje i lokalna pohrana

Morat ćemo pohraniti interno stanje aplikacije - barem ključeve za potpisivanje. Možemo prilično lako dodati stanje aplikaciji i metode za promjenu u popup API-ju:

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

    ...

} 

U pozadini ćemo sve umotati u funkciju i zapisati objekt aplikacije u prozor tako da možemo raditi s njim iz konzole:

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

Dodajmo nekoliko ključeva iz UI konzole i vidimo šta se dešava sa stanjem:

Pisanje bezbednog proširenja pretraživača

Stanje treba učiniti postojanim kako se ključevi ne bi izgubili prilikom ponovnog pokretanja.

Pohranit ćemo ga u localStorage, prepisivati ​​ga sa svakom promjenom. Nakon toga, pristup će mu također biti neophodan za korisničko sučelje, a ja bih se također želio pretplatiti na promjene. Na osnovu toga, biće zgodno kreirati vidljivo skladište i pretplatiti se na njegove promene.

Koristićemo mobx biblioteku (https://github.com/mobxjs/mobx). Izbor je pao na to jer nisam morao da radim sa njim, ali sam zaista želeo da ga proučavam.

Dodajmo inicijalizaciju početnog stanja i učinimo skladište vidljivim:

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

    ...

}

“Ispod haube”, mobx je zamijenio sva polja trgovine proxyjem i presreće sve pozive prema njima. Biće moguće pretplatiti se na ove poruke.

U nastavku ću često koristiti izraz „prilikom promjene“, iako to nije sasvim tačno. Mobx prati pristup poljima. Koriste se getteri i setteri proxy objekata koje kreira biblioteka.

Akcioni dekorateri imaju dvije svrhe:

  1. U strogom režimu sa zastavicom enforceActions, mobx zabranjuje direktnu promenu stanja. Smatra se da je dobra forma raditi pod strogim uslovima.
  2. Čak i ako funkcija nekoliko puta promijeni stanje - na primjer, promijenimo nekoliko polja u nekoliko redova koda - promatrači su obaviješteni tek kada se završi. Ovo je posebno važno za frontend, gdje nepotrebna ažuriranja stanja dovode do nepotrebnog prikazivanja elemenata. U našem slučaju, ni prvo ni drugo nije posebno relevantno, ali ćemo slijediti najbolju praksu. Uobičajeno je da se svim funkcijama koje mijenjaju stanje posmatranih polja pridružuju dekoratori.

U pozadini ćemo dodati inicijalizaciju i spremanje stanja u 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)
        }
    }
}

Ovdje je zanimljiva funkcija reakcije. Ima dva argumenta:

  1. Birač podataka.
  2. Rukovalac koji će biti pozvan sa ovim podacima svaki put kada se promene.

Za razliku od redux-a, gdje eksplicitno primamo stanje kao argument, mobx pamti kojim promatračima pristupamo unutar selektora i poziva rukovaoce samo kada se promijene.

Važno je razumjeti tačno kako mobx odlučuje na koje promatrače se pretplatimo. Da sam napisao selektor u kodu kao što je ovaj() => app.store, tada reakcija nikada neće biti pozvana, pošto sama memorija nije vidljiva, već samo njena polja.

Da sam ovako napisao () => app.store.keys, onda se opet ništa ne bi dogodilo, jer se prilikom dodavanja/uklanjanja elemenata niza referenca na njega neće promijeniti.

Mobx po prvi put djeluje kao selektor i prati samo one opservable kojima smo pristupili. Ovo se radi putem proxy gettera. Stoga se ovdje koristi ugrađena funkcija toJS. Vraća novi objekat sa svim proksijima zamenjenim originalnim poljima. Tokom izvršavanja, čita sva polja objekta - stoga se pokreću getteri.

U iskačućoj konzoli ćemo ponovo dodati nekoliko ključeva. Ovaj put su također završili u localStorage:

Pisanje bezbednog proširenja pretraživača

Kada se pozadinska stranica ponovo učita, informacije ostaju na mjestu.

Svi kodovi aplikacije do ove tačke mogu se vidjeti ovdje.

Sigurno skladištenje privatnih ključeva

Čuvanje privatnih ključeva u čistom tekstu nije bezbedno: uvek postoji šansa da budete hakovani, da dobijete pristup svom računaru i tako dalje. Stoga ćemo u localStorage pohraniti ključeve u šifriranom obliku.

Radi veće sigurnosti, aplikaciji ćemo dodati zaključano stanje u kojem uopće neće biti pristupa ključevima. Automatski ćemo prebaciti ekstenziju u zaključano stanje zbog isteka vremena.

Mobx vam omogućava da pohranite samo minimalni skup podataka, a ostatak se automatski izračunava na osnovu njega. To su takozvana izračunata svojstva. Mogu se uporediti sa prikazima u bazama podataka:

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

Sada pohranjujemo samo šifrirane ključeve i lozinku. Sve ostalo je izračunato. Vršimo prijenos u zaključano stanje uklanjanjem lozinke iz stanja. Javni API sada ima metodu za inicijalizaciju memorije.

Napisano za enkripciju uslužni programi koji koriste 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)
}

Pregledač ima neaktivni API preko kojeg se možete pretplatiti na promjene stanja događaja. Država, shodno tome, može biti idle, active и locked. Za mirovanje možete podesiti vremensko ograničenje, a zaključano se postavlja kada je sam OS blokiran. Također ćemo promijeniti selektor za spremanje u 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 prije ovog koraka je ovdje.

Transakcije

Dakle, dolazimo do najvažnije stvari: kreiranja i potpisivanja transakcija na blockchainu. Koristit ćemo WAVES blockchain i biblioteku talasi-transakcije.

Prvo, dodajmo stanju niz poruka koje treba potpisati, zatim dodajmo metode za dodavanje nove poruke, potvrđivanje potpisa i odbijanje:

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

    ...
}

Kada primimo novu poruku, dodajemo joj metapodatke, uradite observable i dodati u store.messages.

Ako ne observable ručno, tada će mobx to učiniti sam kada dodaje poruke u niz. Međutim, kreirat će novi objekt na koji nećemo imati referencu, ali će nam trebati za sljedeći korak.

Zatim vraćamo obećanje koje se rješava kada se status poruke promijeni. Status se prati reakcijom, koja će se “ubiti” kada se status promijeni.

Šifra metode approve и reject vrlo jednostavno: jednostavno mijenjamo status poruke, nakon što je potpišemo ako je potrebno.

Stavljamo Approve i Reject u UI API, newMessage u API stranice:

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

    ...
}

Sada pokušajmo da potpišemo transakciju sa ekstenzijom:

Pisanje bezbednog proširenja pretraživača

Generalno, sve je spremno, ostaje samo dodajte jednostavno korisničko sučelje.

UI

Interfejsu je potreban pristup stanju aplikacije. Na strani korisničkog sučelja to ćemo učiniti observable stanje i dodajte funkciju API-ju koja će promijeniti ovo stanje. Hajde da dodamo observable na API objekt primljen iz pozadine:

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

Na kraju počinjemo renderiranje sučelja aplikacije. Ovo je react aplikacija. Pozadinski objekat se jednostavno prenosi pomoću props-a. Bilo bi ispravno, naravno, napraviti zaseban servis za metode i trgovinu za državu, ali za potrebe ovog članka ovo je dovoljno:

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

Sa mobx-om je vrlo lako započeti renderiranje kada se podaci promijene. Jednostavno okačimo posmatrač dekorater sa paketa mobx-react na komponenti, a render će biti automatski pozvan kada se promijene bilo koji vidljivi elementi na koje komponenta upućuje. Ne treba vam nikakav mapStateToProps ili povezivanje kao u reduxu. Sve radi odmah iz kutije:

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

Preostale komponente se mogu vidjeti u kodu u UI folderu.

Sada u klasi aplikacije trebate napraviti selektor stanja za korisničko sučelje i obavijestiti korisničko sučelje kada se promijeni. Da bismo to učinili, dodajmo metodu getState и reactionpozivanje 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())

        })
    }

    ...
}

Prilikom prijema objekta remote je kreirano reaction da promijenite stanje koje poziva funkciju na strani korisničkog sučelja.

Poslednji dodir je dodavanje prikaza novih poruka na ikoni ekstenzije:

function setupApp() {
...

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

...
}

Dakle, aplikacija je spremna. Web stranice mogu zahtijevati potpis za transakcije:

Pisanje bezbednog proširenja pretraživača

Pisanje bezbednog proširenja pretraživača

Kod je dostupan ovdje link.

zaključak

Ako ste pročitali članak do kraja, ali i dalje imate pitanja, možete ih postaviti na spremišta sa ekstenzijom. Tamo ćete također pronaći urezivanje za svaki određeni korak.

A ako ste zainteresirani da pogledate kod za stvarnu ekstenziju, možete pronaći ovo ovdje.

Šifra, spremište i opis posla od siemarell

izvor: www.habr.com

Dodajte komentar