Writing a secure browser extension

Writing a secure browser extension

Unlike the common "client-server" architecture, decentralized applications are characterized by:

  • No need to store a database with usernames and passwords. Access information is stored exclusively by the users themselves, and their validity is confirmed at the protocol level.
  • No need to use a server. The application logic can be executed in the blockchain network, where it is possible to store the required amount of data.

There are 2 relatively secure storages for user keys - hardware wallets and browser extensions. While hardware wallets are mostly secure but difficult to use and far from free, browser extensions are the perfect combination of security and ease of use, and can be completely free for end users.

Given all this, we wanted to make the most secure extension that simplifies the development of decentralized applications by providing a simple API for working with transactions and signatures.
We will tell you about this experience below.

The article will provide step-by-step instructions on how to write a browser extension, with code examples and screenshots. You can find all the code in repositories. Each commit logically corresponds to a section of this article.

A Brief History of Browser Extensions

Browser extensions have been around for a long time. They appeared in Internet Explorer in 1999, and in Firefox in 2004. However, for a very long time there was no single standard for extensions.

We can say that it appeared along with extensions in the fourth version of Google Chrome. Of course, there was no specification then, but it was the Chrome API that became its basis: having won a large part of the browser market and having a built-in application store, Chrome actually set the standard for browser extensions.

Mozilla had its own standard, but seeing the popularity of Chrome extensions, the company decided to make a compatible API. In 2015, at the initiative of Mozilla, a special group was created within the World Wide Web Consortium (W3C) to work on specifications for cross-browser extensions.

The existing Chrome extension API was taken as a basis. The work was carried out with the support of Microsoft (Google refused to participate in the development of the standard), and as a result, a draft appeared. spit-up.

Formally, the specification is supported by Edge, Firefox, and Opera (note that Chrome is not included in this list). But in fact, the standard is largely compatible with Chrome, as it is actually written based on its extensions. You can read more about the WebExtensions API here.

Extension structure

The only file that is required for the extension is the manifest (manifest.json). It is also the “entry point” to the extension.

Manifesto

By specification, the manifest file is a valid JSON file. A full description of the manifest keys with information about which keys are supported in which browser can be viewed here.

Keys that are not in the specification “may” be ignored (both Chrome and Firefox report errors, but extensions continue to work).

And I would like to draw attention to some points.

  1. background — an object that includes the following fields:
    1. scripts - an array of scripts that will be executed in the background context (we'll talk about this a little later);
    2. page - instead of scripts that will be executed in an empty page, you can set html with content. In this case, the script field will be ignored, and the scripts will need to be inserted into the content page;
    3. persistent - binary flag, if not specified, then the browser will "kill" the background process when it considers that it is not doing anything, and restart if necessary. Otherwise, the page will be unloaded only when the browser is closed. Not supported in Firefox.
  2. content_scripts - an array of objects that allows you to load different scripts to different web pages. Each object contains the following important fields:
    1. Playedurl pattern, which determines whether a particular content script will be included or not.
    2. js — a list of scripts that will be loaded into this match;
    3. exclude_matches - excludes from the field match URLs that match this field.
  3. page_action - is actually an object that is responsible for the icon that is displayed next to the address bar in the browser, and interaction with it. Allows you to also show a popup window, which is set using your HTML, CSS and JS.
    1. default_popup — path to HTML file with popup interface, may contain CSS and JS.
  4. permissions - an array for managing extension rights. There are 3 types of rights, which are described in detail here
  5. web_accessible_resources - extension resources that a web page can request, for example, images, JS, CSS, HTML files.
  6. externally_connectable - here you can explicitly specify the IDs of other extensions and web page domains from which you can connect. The domain can be of the second level and higher. Doesn't work in Firefox.

Execution context

The extension has three code execution contexts, that is, the application consists of three parts with different levels of access to the browser API.

extension context

Most of the API is available here. In this context, "live":

  1. background page - "backend" part of the extension. The file is specified in the manifest by the "background" key.
  2. Popup page - popup page that appears when you click on the extension icon. In the manifest browser_action -> default_popup.
  3. custom page - extension page, "living" in a separate tab of the view chrome-extension://<id_расширения>/customPage.html.

This context exists independently of browser windows and tabs. background page exists in a single instance and always works (the exception is the event page, when the background script is triggered by an event and “dies” after it is executed). Popup page exists when the popup window is open, and custom page - while the tab with it is open. There is no access to other tabs and their contents from this context.

Content script context

The content script file is launched along with each browser tab. It has access to part of the extension's API and to the DOM tree of the web page. It is the content scripts that are responsible for interacting with the page. Extensions that manipulate the DOM tree do so in content scripts, such as ad blockers or translators. Also, the content script can communicate with the page through the standard postMessage.

Web page context

This is the actual web page itself. It has nothing to do with the extension and has no access there, except when the domain of this page is not explicitly specified in the manifest (more on that below).

Message exchange

Different parts of the application must exchange messages with each other. There is an API for this. runtime.sendMessage to send a message background и tabs.sendMessage to send a message to a page (content script, popup or web page if available) externally_connectable). Below is an example when accessing the Chrome API.

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

For full communication, you can create connections through runtime.connect. In response we will receive runtime.Port, to which, while it is open, you can send any number of messages. On the client side, for example, contentscript, it looks like this:

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

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

There is also an event onDisconnect and method disconnect.

Application schema

Let's make a browser extension that stores private keys, provides access to public information (address, public key communicates with the page and allows third-party applications to request a transaction signature.

Application development

Our application must both interact with the user and provide an API page for calling methods (for example, for signing transactions). Get by with just one contentscript will fail, since it only has access to the DOM, not to the JS of the page. Connect via runtime.connect we cannot, because the API is needed on all domains, and only specific ones can be specified in the manifest. As a result, the scheme will look like this:

Writing a secure browser extension

There will be another script - inpage, which we will inject into the page. It will run in its context and provide an API for working with the extension.

Home

All browser extension code is available at GitHub. In the description process, there will be links to commits.

Let's start with the manifest:

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

We create empty background.js, popup.js, inpage.js and contentscript.js. We add popup.html - and our application can already be loaded into Google Chrome and make sure that it works.

To verify this, you can take the code hence. In addition to what we did, the link is configured to build the project using webpack. To add the application to the browser, in chrome://extensions, you need to select load unpacked and a folder with the appropriate extension - in our case, dist.

Writing a secure browser extension

Now our extension is installed and working. You can launch developer tools for different contexts as follows:

popup ->

Writing a secure browser extension

Access to the console of the content script is carried out through the console of the page itself, on which it is launched.Writing a secure browser extension

Message exchange

So, we need to set up two communication channels: inpage <-> background and popup <-> background. You can, of course, just send messages to the port and invent your own protocol, but I prefer the approach that I spied on the metamask open source project.

This is a browser extension for working with the Ethereum network. In it, different parts of the application communicate via RPC using the dnode library. It allows you to quickly and conveniently organize the exchange, if you provide it with a nodejs stream as a transport (meaning an object that implements the same interface):

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

Now we will create an application class. It will create the API objects for the popup and the web page, and create a dnode for them:

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

Hereinafter, instead of the global Chrome object, we use the extentionApi, which refers to Chrome in the browser from Google and to the browser in others. This is done for cross-browser compatibility, but within the framework of this article, one could simply use 'chrome.runtime.connect'.

Let's create an application instance in the background script:

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

Since dnode works with streams, and we get a port, an adapter class is needed. It is made using the readable-stream library, which implements nodejs streams in the browser:

import {Duplex} from 'readable-stream';

export class PortStream extends Duplex{
    constructor(port){
        super({objectMode: true});
        this._port = port;
        port.onMessage.addListener(this._onMessage.bind(this));
        port.onDisconnect.addListener(this._onDisconnect.bind(this))
    }

    _onMessage(msg) {
        if (Buffer.isBuffer(msg)) {
            delete msg._isBuffer;
            const data = new Buffer(msg);
            this.push(data)
        } else {
            this.push(msg)
        }
    }

    _onDisconnect() {
        this.destroy()
    }

    _read(){}

    _write(msg, encoding, cb) {
        try {
            if (Buffer.isBuffer(msg)) {
                const data = msg.toJSON();
                data._isBuffer = true;
                this._port.postMessage(data)
            } else {
                this._port.postMessage(msg)
            }
        } catch (err) {
            return cb(new Error('PortStream - disconnected'))
        }
        cb()
    }
}

Now we create a connection in the 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;
    }
}

We then create the connection in the content script:

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

Since we need the API not in the content script, but directly on the page, we do two things:

  1. We create two streams. One is towards the page, on top of postMessage. For this we use here this package from the creators of metamask. The second stream is to background over the port received from runtime.connect. We pipe them. The page will now stream to the background.
  2. Inject the script into the DOM. Download the script (access to it was allowed in the manifest) and create a tag script with its content inside:

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

Now we create an api object in inpage and start it 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;
}

We are ready Remote Procedure Call (RPC) with separate API for page and UI. When connecting a new page to the background, we can see this:

Writing a secure browser extension

Empty API and origin. On the page side, we can call the hello function like this:

Writing a secure browser extension

Working with callback functions in modern JS is bad manners, so let's write a small helper to create a dnode that allows you to pass it to the API object in utils.

The API objects will now look like this:

export class SignerApp {

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

...

}

Getting an object from remote like this:

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

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

And the function call returns a promise:

Writing a secure browser extension

Version with asynchronous functions available here.

In general, the approach with RPC and streaming seems to be quite flexible: we can use steam multiplexing and create several different APIs for different tasks. In principle, dnode can be used anywhere, the main thing is to wrap the transport in the form of a nodejs stream.

An alternative is the JSON format, which implements the JSON RPC 2 protocol. However, it works with specific transports (TCP and HTTP(S)), which is not applicable in our case.

Internal state and localStorage

We will need to store the internal state of the application - at least the keys for signing. We can easily add a state to the application and methods to change it in the popup API:

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

    ...

} 

In background, we wrap everything in a function and write the application object to window so that we can work with it from the console:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

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

setupApp();

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

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

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

Let's add some keys from the UI console and see what happens with the state:

Writing a secure browser extension

The state must be made persistent so that the keys are not lost upon restart.

We will store in localStorage, overwriting with each change. Subsequently, access to it will also be necessary for the UI, and I also want to subscribe to changes. Based on this, it will be convenient to make observable storage and subscribe to its changes.

We will use the mobx library (https://github.com/mobxjs/mobx). The choice fell on her, because I didn’t have to work with her, but I really wanted to study her.

Let's add the initialization of the initial state and make the store observable:

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

    ...

}

"Under the hood" mobx replaced all store fields with proxy and intercepts all calls to them. You can subscribe to these messages.

In the following, I will often use the term “on change”, although this is not entirely correct. Mobx tracks access to fields. The getters and setters of the proxy objects that the library creates are used.

The action decorators serve two purposes:

  1. In strict mode with the enforceActions flag, mobx forbids changing the state directly. It is considered good form to work in strict mode.
  2. Even if a function changes state multiple times—for example, we change multiple fields in multiple lines of code—observers are only notified when it completes. This is especially important for the frontend, where extra state updates result in unnecessary rendering of elements. In our case, neither the first nor the second is particularly relevant, but we will follow the best practices. It is customary to hang decorators on all functions that change the state of observable fields.

In the background, add initialization and saving the state in 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)
        }
    }
}

The reaction function is interesting here. It has two arguments:

  1. Data selector.
  2. A handler that will be called with this data each time it changes.

Unlike redux, where we explicitly receive the state as an argument, mobx remembers exactly which observables we are accessing inside the selector, and only when they change, it calls the handler.

It is important to understand how mobx decides which observables we subscribe to. If in code I wrote a selector like this() => app.store, then the reaction will never be called, since the storage itself is not observable, only its fields are.

If I wrote like this () => app.store.keys, then nothing would happen again, since when adding / removing array elements, the reference to it will not change.

Mobx performs the selector function for the first time and only keeps track of the observables we have accessed. This is done through proxy getters. So here is the built-in function toJS. It returns a new object with all proxies replaced with the original fields. During execution, it reads all the fields of the object - therefore, the getters work.

In the popup console, let's add some keys again. This time they also ended up in localStorage:

Writing a secure browser extension

When the background page is reloaded, the information remains in place.

All application code up to this point can be viewed here.

Secure storage of private keys

Keeping private keys in the clear is not safe: there is always the possibility that you will be hacked, gain access to your computer, and so on. Therefore, in localStorage we will store the keys in a password-encrypted form.

For greater security, we will add a locked state to the application, in which there will be no access to the keys at all. We will automatically transfer the extension to the locked state by timeout.

Mobx allows you to store only a minimal set of data, and the rest is automatically calculated based on them. These are the so-called computed properties. They can be compared to views in databases:

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

Now we store only encrypted keys and password. Everything else is calculated. We do the transfer to the locked state by removing the password from the state. The public API has a method for initializing the store.

Written for encryption utilities using 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)
}

The browser has an idle API through which you can subscribe to an event - state changes. State, respectively, can be idle, active и locked. For idle, you can set a timeout, and locked is set when the OS itself is locked. We will also change the selector for storing in 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)
        }
    }
}

The code up to this step is here.

  Transactions

So, we come to the most important thing: the creation and signing of transactions in the blockchain. We will use the WAVES blockchain and the library waves-transactions.

First, let's add to the state an array of messages that need to be signed, then - methods for adding a new message, confirming the signature and refusing:

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

    ...
}

When a new message is received, we add metadata to it, do observable and add to store.messages.

If not done observable manually, then mobx will do it itself when added to the messages array. However, it will create a new object to which we will not have a reference, but we will need it for the next step.

Next, we return a promise that resolves when the message status changes. The status is monitored by a reaction that will "kill" itself when the status changes.

Method code approve и reject is very simple: we just change the status of the message, having previously signed it, if necessary.

Approve and reject we take out in the UI API, newMessage - in the page API:

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

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

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

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

    ...
}

Now let's try to sign the transaction with the extension:

Writing a secure browser extension

In general, everything is ready, it remains add a simple UI.

UI

The interface needs access to the application state. On the UI side we will do observable state and add a function to the API that will change this state. Let's add observable to the API object received from background:

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

At the end, we start rendering the application interface. This is a react application. The background object is simply passed using props. It is correct, of course, to make a separate service for methods and a store for the state, but this is enough for this article:

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

With mobx, it's very easy to trigger a render when the data changes. We just hang the observer decorator from the package mobx-react on the component, and render will automatically be called when any observables referenced by the component change. No need for any mapStateToProps or connect like in redux. Everything works right out of the box:

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

The rest of the components can be seen in the code in ui folder.

Now in the application class, you need to make a state selector for the UI and notify the UI when it changes. To do this, add a method getState и reactioncalling 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())

        })
    }

    ...
}

When receiving an object remote is created reaction to change the state that calls the function on the UI side.

The final touch is to add the display of new messages on the extension icon:

function setupApp() {
...

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

...
}

So, the application is ready. Web pages can request to sign transactions:

Writing a secure browser extension

Writing a secure browser extension

The code is available at this link.

Conclusion

If you have read the article to the end, but you still have questions, you can ask them in repositories with extension. In the same place you will find commits under each designated step.

And if you're interested in seeing the code for the real extension, you can find this here.

Code, repository and job description from siemarell

Source: habr.com

Add a comment