Writing a Secure Browser Extension

Writing a Secure Browser Extension

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

  • The absence of a need to store a database of user logins and passwords. Access information is kept exclusively by the users themselves, and their authenticity is verified at the protocol level.
  • A lack of necessity to use a server. The application logic can run within the blockchain network, where it is also possible to store the necessary amount of data.

There are two relatively secure storage methods for user keys β€” hardware wallets and browser extensions. Hardware wallets are generally very secure but are difficult to use and not free, while browser extensions provide an ideal combination of security and ease of use, and can even be completely free for end-users.

Considering all this, we wanted to create a highly secure extension that simplifies the development of decentralized applications by providing a simple API for working with transactions and signatures.
We will discuss this experience further below.

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

A Brief History of Browser Extensions

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

It can be said that this standard emerged with the extensions in the fourth version of Google Chrome. Of course, there was no specification at that time, but the Chrome API became its foundation: having captured a large share of the browser market and featuring a built-in app store, Chrome effectively set the standard for browser extensions.

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

The existing API for Chrome extensions was used as a foundation. The work was supported by Microsoft (Google refused to participate in the development of the standard), resulting in a draft. the specification.

Formally, Edge, Firefox, and Opera support the specification (notably, Chrome is absent from this list). However, in practice, the standard is largely compatible with Chrome, as it was essentially written based on its extensions. You can read more about the WebExtensions API. here.

Structure of the Extension

The only file that is mandatory for an extension is the manifest (manifest.json). This file also serves as the 'entry point' into the extension.

Manifest

According to the specification, the manifest file must be a valid JSON file. A complete description of the manifest keys, including information on which keys are supported in which browsers, can be found. here.

Keys not specified can be ignored (both Chrome and Firefox log errors, but extensions continue to work).

I would like to point out a few aspects.

  1. background β€” an object that includes the following fields:
    1. scripts β€” an array of scripts that will be executed in the background context (we will discuss this a bit later);
    2. page β€” instead of scripts that will run on an empty page, you can specify 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 β€” a binary flag; if unspecified, the browser will 'kill' the background process when it deems it inactive, and restart it when necessary. Otherwise, the page will only be unloaded when the browser is closed. This is not supported in Firefox.
  2. content_scripts β€” an array of objects that allows loading different scripts for different web pages. Each object contains the following important fields:
    1. matches β€” URL pattern, which determines if a specific content script will be included or not.
    2. js β€” a list of scripts that will be loaded for this match;
    3. exclude_matches β€” excludes from the field match URLs that meet this field.
  3. page_action β€” is an object that is responsible for the icon that appears next to the address bar in the browser and interaction with it. It also allows showing a popup window defined with its own HTML, CSS, and JS.
    1. default_popup β€” the path to the HTML file with the popup interface, which may contain CSS and JS.
  4. After establishing who made the request, we need to determine what this subject is allowed to do. Some service meshes allow you to set basic policies (on who can do what) in the form of YAML files or via the command line, while others offer integration with frameworks like β€” an array for managing extension permissions. There are 3 types of permissions, which are described in detail. here
  5. web_accessible_resources β€” resources of the extension that can be requested by a webpage, such as images, JS files, CSS, and HTML.
  6. externally_connectable β€” here you can explicitly specify the IDs of other extensions and the domains of webpages from which connections can be made. The domain can be a second-level domain or higher. This feature does not work in Firefox.

Execution context

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

Extension context

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

  1. Background page β€” the 'backend' part of the extension. The file is specified in the manifest under the 'background' key.
  2. Popup page β€” the popup page that appears when clicking on the extension icon. In the manifest, browser_action -> default_popup.
  3. Custom page β€” the extension page that 'lives' in a separate tab like chrome-extension:///customPage.html.

This context exists independently of browser windows and tabs. Background page it exists in a single instance and is always running (exception β€” event page, where the background script starts on an event and 'dies' after execution). Popup page it exists when a popup window is open, and Custom page β€” while a tab with it is open. There is no access to other tabs and their content from this context.

Content script context

The content script file runs together with each browser tab. It has access to part of the extension's API and to the DOM tree of the webpage. Content scripts are responsible for interacting with the page. Extensions that manipulate the DOM tree do so in content scripts – for example, ad blockers or translators. A content script can also communicate with the page via the standard postMessage.

Web page context

This is the webpage itself. It has no relation to the extension and has no access to it, except in cases where the domain of this page is explicitly specified in the manifest (more on this below).

Message passing

Different parts of the application need to exchange messages with each other. For this, there is the API runtime.sendMessage to send a message background and tabs.sendMessage to send a message to the page (to the content script, popup, or webpage if there is 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))
    }
)

To enable full communication, connections can be created through runtime.connect. In response, we will receive runtime.Port, into which, while it is open, any number of messages can be sent. 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 the event onDisconnect and the method disconnect.

Application schema

Let's create 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 transaction signatures.

Application development

Our application must both interact with the user and provide the page's API for method calls (for example, for signing transactions). Simply relying on contentscript will not work, as it has access only to the DOM, but not to the page's JS. We cannot connect through runtime.connect because the API is needed on all domains, and the manifest can specify only specific ones. As a result, the schema will look like this:

Writing a Secure Browser Extension

There will be another script β€” inpage, which we will inject into the page. It will execute in its context and provide an API for interacting with the extension.

Beginning

The entire code of the browser extension is available at GitHub. There will be links to commits throughout the description.

Let's start with the manifest:

{
  // Name and description, version. All this will be visible in the browser at chrome://extensions/?id=
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Scripts that will run in the background, there can be several
  "background": {
    "scripts": ["background.js"]
  },

  // What HTML to use for the popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Content scripts.
  // We have one object: for all URLs starting with http or https, we run
  // contenscript context with the script contentscript.js. Start immediately upon document acquisition for all frames
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Access to localStorage and idle api is allowed
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Here we specify the resources that the web page will have access to. That is, they can be requested with fetch or simply 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 to ensure that it works.

To verify this, we can take the code from here. In addition to what we have done, the project build is configured via a link using webpack. To add the application to the browser, go to chrome://extensions, select load unpacked, and choose the folder with the corresponding extension β€” in our case, dist.

Writing a Secure Browser Extension

Now our extension is installed and working. The developer tools for different contexts can be launched as follows:

popup ->

Writing a Secure Browser Extension

Access to the content script console is done through the console of the page on which it is running.Writing a Secure Browser Extension

Message passing

So, we need to establish two communication channels: inpage background and popup background. Of course, we can simply send messages to the port and invent our protocol, but I prefer the approach I observed in the open-source project metamask.

This is a browser extension for working with the Ethereum network. In it, different parts of the application communicate through RPC using the dnode library. It allows for quick and convenient data exchange if we provide a nodejs stream as the transport (referring to an object that implements the same interface):

import Dnode from "dnode/browser";

// In this example, let's agree that the client remotely calls functions on the server, although nothing prevents us from making it bidirectional

// Server
// API that we want to provide
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// The transport over which dnode will operate. Any nodejs stream. In the browser, there is the 'readable-stream' library
connectionStream.pipe(dnode).pipe(connectionStream)

// Client
const dnodeClient = Dnode() // Call without argument means that we are not providing an API on the other side

// It will log world to the console
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Now we will create the application class. It will create API objects for popup and web pages, as well as create dnode for them:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Returns the API object for ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Returns the API object for the page
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Connects the popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Connects the page
    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)
        })
    }
}

Here and further, instead of the global Chrome object, we use extentionApi, which interacts with Chrome in the Google browser and with browser in others. This is done for cross-browser compatibility, but within this article, we could also use just 'chrome.runtime.connect'.

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

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

const app = new SignerApp();

// onConnect triggers when 'processes' (contentscript, popup, or extension page) connect
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // When establishing a connection, a name can be specified, based on which we determine who connected to us, content script or ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Since dnode works with streams and we receive a port, an adapter class is needed. This is made using the readable-stream library, which implements Node.js 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 let's 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() {
    // Similarly, as in the application class, we create a port, wrap it in a stream, and do 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)
        })
    });

    // Make the API object accessible from the console
    if (DEV_MODE) {
        global.background = background;
    }
}

Next, we create a 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 β€” towards the page, on top of postMessage. To do this, we use this package from the creators of Metamask. The second stream β€” to the background on top of the port received from runtime.connect. We pipe them. Now the page will have a stream to the background.
  2. We inject the script into the DOM. We fetch 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() {
    // Stream to the background
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Stream to the page
    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 the API object in inpage and make it global:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream to the content script
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    // Obtain the API object
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Access through window
    global.SignerApp = pageApi;
}

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

Writing a Secure Browser Extension

An 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 outdated, so let's write a small helper to create dnode that allows passing into the API object in utils.

API objects will now look like this:

export class SignerApp {

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

...

}

Receiving the object from remote as follows:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Using utilities, we change all callbacks to promises
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

And calling functions returns a promise:

Writing a Secure Browser Extension

The version with asynchronous functions is available here.

Overall, the approach with RPC and streams seems quite flexible: we can use stream 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 the 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 the background, let's wrap everything in a function and write the application object to window so it can be accessed 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 a few keys from the UI console and see what happened to the state:

Writing a Secure Browser Extension

The state needs to be persistent so that the keys are not lost upon restart.

We will store it in localStorage, overwriting it with each change. Later, access to it will also be necessary for the UI, and we would also like to subscribe to changes. Based on this, it will be convenient to create an observable storage and subscribe to its changes.

We will use the mobx library (https://github.com/mobxjs/mobx). It was chosen because I hadn't worked with it before, and I really wanted to learn it.

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 = {}) {
        // Externally, the store will remain the same object, but now all its fields have become proxies that track access to them
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Methods that modify observables are usually wrapped with a decorator
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

    @action
    removeKey(index) {
        this.store.keys.splice(index, 1)
    }

    ...

}

"Under the hood", mobx has replaced all fields in the store with proxies and intercepts all access to them. You will be able to subscribe to these accesses.

Going forward, I will frequently use the term "on change", although this is not entirely accurate. Mobx tracks access to the fields. It uses getters and setters for the proxy objects created by the library.

The action decorators serve two purposes:

  1. In strict mode with the enforceActions flag, mobx prohibits changing the state directly. It is considered good practice to work in strict mode.
  2. Even if the function changes the state multiple times – for example, we change several fields in several lines of code – observers are notified only after its completion. This is particularly important for the frontend, where unnecessary state updates lead to redundant rendering of elements. In our case, neither the first nor the second is particularly relevant, but we will adhere to best practices. Decorators are typically applied to all functions that change the state of observed fields.

In the background, we will add initialization and storage of the state in localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from './utils/extensionApi';
import {PortStream} from './utils/PortStream';
import {SignerApp} from './SignerApp';
// Utility methods. Read/write the object to/from localStorage as a JSON string by the key '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

    // The result of the reaction is assigned to a variable so that the subscription can be canceled. We do not need this, it is left as an example
    const localStorageReaction = reaction(
        () => toJS(app.store), // Data selector function
        saveState // Function to be called when the data returned by the selector changes
    );

    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 invoked with this data each time they change.

Unlike redux, where we explicitly receive the state as an argument, mobx remembers which observable we access within the selector and only calls the handler when they change.

It is important to understand how exactly mobx determines which observables we subscribe to. If I had written the selector like this() => app.store, then the reaction would never be called because the storage itself is not observable; only its fields are.

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

Mobx executes the selector function for the first time and only monitors those observables that we accessed. This is done through proxy getters. Therefore, the built-in function is used here. toJS. It returns a new object where all proxies are replaced with the original fields. During execution, it reads all fields of the object – thus, the getters are triggered.

In the console popup, we will again add a few keys. This time they also made it into localStorage:

Writing a Secure Browser Extension

Upon reloading the background page, the information remains in place.

You can view all the application code up to this point. here.

Safe storage of private keys

Storing private keys in plain text is unsafe: there is always a risk that you could be hacked, and someone could gain access to your computer and so on. Therefore, we will store keys in localStorage in an encrypted format secured by a password.

For added security, we will add a locked state to the application, where there will be no access to the keys at all. We will automatically switch the extension to the locked state after a timeout.

Mobx allows for the storage of only a minimal set of data, while the rest is automatically calculated based on it. These are known as computed properties. They can be compared to views in databases:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Utilities for safe string encryption. Uses crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Storing password and encrypted keys. If password is null - app is locked
            password: null,
            vault: initState.vault,

            // Getters for computed fields. Can be likened to views in a database.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Initializing an empty vault with a new password
    @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
        )
    }

    ... // code for connection and API

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Methods for encrypting/decrypting the vault
    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 only store encrypted keys and a password. Everything else is computed. The transition to the locked state is achieved by removing the password from the state. A method for initializing the vault has been added to the public API.

For encryption, the following have been written utilities using crypto-js:

import CryptoJS from 'crypto-js'

// Used to complicate password guessing by brute force. For each password option, the attacker will have to create 5000 hashes
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 the state change event. The state can be accordingly idle, active and locked. For idle, you can set a timeout, while locked is set when the OS itself is blocked. We will also change the selector for saving to 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;
    }

    // Now we explicitly call the field that will be accessed; the reaction will work correctly
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Inactivity timeout when the event will trigger
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // If the user locks the screen or remains inactive for the specified interval, we lock the app
    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 located here.

Transactions

So, we have reached the most important part: creating and signing transactions on the blockchain. We will use the WAVES blockchain and the waves-transactions.

First, we will add an array of messages that need to be signed to the state, then methods for adding a new message, confirming the signature, and declining:

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) {
        // For each message, we create metadata with id, status, creation time, etc.
        const message = observable.object({
            id: uuid(), // Identifier, using uuid
            origin, // Origin will be displayed in the interface later
            data, //
            status: 'new', // There will be four statuses: new, signed, rejected, and failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // We return a promise in which mobx monitors the message changes. As soon as the status changes, we will resolve it
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // We will observe the message status
                (status, reaction) => { // the second argument is the reference to the reaction itself, so it can be disposed of inside the call
                    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 receiving a new message, we add metadata to it, creating observable and adding it to store.messages.

If you don't do this manually, mobx will handle it itself when adding to the messages array. However, it will create a new object, which we won't have a reference to, and we'll need it for the next step. observable Next, we return a promise that resolves when the message status changes. The status is monitored by a reaction that will dispose of itself when the status changes.

The methods' code

approve reject and is very simple: we just change the message status, signing it beforehand if necessary. It's very simple: we just change the message status after signing it, if necessary.

We handle approve and reject in the API UI, newMessage β€” in the API pages:

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 a transaction with the extension:

Writing a Secure Browser Extension

Overall, everything is ready; we just need to add a simple UI.

UI

The interface needs access to the application's state. On the UI side, we will create observable a state and add a function to the API that will change this state. We will add observable to the API object received from the 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() {
    // Connecting to the port, creating a stream from it
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Creating an empty observable for the background state
    let backgroundState = observable.object({});
    const api = {
        // Providing the background with a function that will update the observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Creating an RPC object
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Adding an observable with state to the background
    background.state = backgroundState;

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

    // Starting the interface
    await initApp(background)
}

In the end, we render the application's interface. This is a React application. The background object is simply passed using props. Ideally, it would be better to create a separate service for methods and a store for state, but for the purposes of this article, this is sufficient:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Initializing the application with the background object as props
export async function initApp(background){
    render(
        , 
        document.getElementById('app-content')
    );
}

With MobX, it’s very easy to trigger a render when data changes. We simply use the observer decorator from the package mobx-react The component will automatically trigger a render whenever any observables it references change. No need for mapStateToProps or connect as 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 // The component with this decorator will automatically call the render method if the observables it references change
export default class App extends Component {

    // It is indeed better to move page render logic to routing and not to use nested ternary operators,
    // and to bind observables and methods directly to the components that use them
    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 &gt; 0
                        ?
                        <sign keys="{keys}" message="{messages[messages.length" - 1]} onapprove="{approve}" onreject="{reject}/">
                        :
                        <keys keys="{keys}" onadd="{addKey}" onremove="{removeKey}/">
            }
            <div>
                {!locked &amp;&amp; <button onclick="{()" > lock()}&gt;Lock App</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

Other components can be viewed in the code in the UI folder.

Now, in the application class, it's necessary to create a state selector for the UI and notify the UI upon changes. For this, we will add a method getState and reaction, calling 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) => {
            // Create a reaction on state changes that will call a remote procedure and update the state in the UI process
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // The third argument can pass parameters. fireImmediately means that the reaction will execute immediately for the first time.
                // This is necessary to get the initial state. Delay allows for debounce
                {fireImmediately: true, delay: 500}
            );
            // Remove the subscription when the client disconnects
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Upon receiving the object remote a reaction is created reaction to the state change, which calls a function on the UI side.

The final touch β€” let's add the display of new messages on the extension icon:

function setupApp() {
...

    // Reaction for setting the badge text.
    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 transaction signatures:

Writing a Secure Browser Extension

Writing a Secure Browser Extension

The code is available at this this link.

Conclusion

If you've read this article to the end but still have questions, you can ask them in the repository with the extension. There you will also find commits for each indicated step.

And if you're interested in looking at the code of a real extension, you will find it here.

The code, the repository, and the description of the work by siemarell

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers πŸ”₯ Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster