Wir schreiben eine sichere Browsererweiterung

Wir schreiben eine sichere Browsererweiterung

Im Gegensatz zur verbreiteten „Client-Server“-Architektur sind dezentralisierte Anwendungen durch Folgendes gekennzeichnet:

  • Kein Bedarf, eine Datenbank mit Benutzernamen und Passwörtern zu speichern. Die Zugangsinformationen werden ausschließlich bei den Nutzern selbst gespeichert, und die Bestätigung ihrer Authentizität erfolgt auf Protokollebene.
  • Kein Bedarf, einen Server zu nutzen. Die Logik der Anwendung kann im Blockchain-Netzwerk ausgeführt werden, wo auch eine notwendige Menge an Daten gespeichert werden kann.

Es gibt zwei relativ sichere Lagermöglichkeiten für die Benutzerschlüssel – Hardware-Wallets und Browsererweiterungen. Hardware-Wallets sind größtenteils maximal sicher, jedoch schwierig zu bedienen und alles andere als kostenlos, während Browsererweiterungen die ideale Kombination aus Sicherheit und einfacher Nutzung bieten und zudem für Endbenutzer völlig kostenlos sein können.

Angesichts all dessen wollten wir eine möglichst sichere Erweiterung entwickeln, die die Entwicklung dezentralisierter Anwendungen vereinfacht, indem sie eine einfache API für die Arbeit mit Transaktionen und Signaturen bereitstellt.
Über diese Erfahrungen werden wir Ihnen im Folgenden berichten.

Im Artikel wird eine Schritt-für-Schritt-Anleitung präsentiert, wie man eine Browsererweiterung schreiben kann, mit Codebeispielen und Screenshots. Den gesamten Code finden Sie in das Repository. Jeder Commit entspricht logisch einem Abschnitt dieses Artikels.

Kurze Geschichte der Browsererweiterungen

Browsererweiterungen gibt es schon seit geraumer Zeit. Sie erschienen erstmals im Internet Explorer im Jahr 1999 und im Firefox im Jahr 2004. Dennoch gab es lange Zeit keinen einheitlichen Standard für Erweiterungen.

Man könnte sagen, dass er mit den Erweiterungen in der vierten Version von Google Chrome eingeführt wurde. Natürlich gab es damals noch keine Spezifikation, aber das Chrome API wurde zur Grundlage: Es eroberte einen Großteil des Marktes der Browser und hatte einen integrierten App-Store, wodurch Chrome faktisch den Standard für Browsererweiterungen festlegte.

Mozilla hatte einen eigenen Standard, aber angesichts der Popularität der Erweiterungen für Chrome entschied das Unternehmen, eine kompatible API zu schaffen. Im Jahr 2015 wurde auf Initiative von Mozilla innerhalb des World Wide Web Consortium (W3C) eine spezielle Gruppe zur Arbeit an den Spezifikationen für plattformübergreifende Erweiterungen gegründet.

Als Grundlage wurde die bereits bestehende API für Erweiterungen von Chrome verwendet. Die Arbeiten wurden mit Unterstützung von Microsoft (Google hat sich geweigert, an der Entwicklung des Standards teilzunehmen) durchgeführt, und das Ergebnis war ein Entwurf. Spezifikation.

Formell wird die Spezifikation von Edge, Firefox und Opera unterstützt (beachten Sie, dass Chrome in dieser Liste fehlt). Tatsächlich ist der Standard jedoch in vielerlei Hinsicht auch mit Chrome kompatibel, da er faktisch auf der Grundlage seiner Erweiterungen verfasst wurde. Weitere Informationen zur WebExtensions API finden Sie hier. hier.

Struktur der Erweiterung

Die einzige Datei, die für die Erweiterung unbedingt erforderlich ist, ist das Manifest (manifest.json). Es ist auch der "Einstiegspunkt" in die Erweiterung.

Manifest

Laut Spezifikation ist die Manifestdatei eine gültige JSON-Datei. Eine vollständige Beschreibung der Manifestschlüssel mit Informationen darüber, welche Schlüssel in welchem Browser unterstützt werden, kann hier eingesehen werden. hier.

Schlüssel, die in der Spezifikation fehlen, "können" ignoriert werden (sowohl Chrome als auch Firefox berichten über Fehler, aber die Erweiterungen funktionieren weiterhin).

Ich möchte auf einige Punkte hinweisen.

  1. background — ein Objekt, das die folgenden Felder umfasst:
    1. scripts — ein Array von Skripten, die im Hintergrundkontext ausgeführt werden (darüber sprechen wir später);
    2. page — anstelle von Skripten, die auf einer leeren Seite ausgeführt werden, kann HTML mit Inhalten angegeben werden. In diesem Fall wird das Skriptfeld ignoriert, und die Skripte müssen in die Seite mit dem Inhalt eingefügt werden;
    3. persistent — ein binäres Flag, wenn nicht angegeben, tötet der Browser den Hintergrundprozess, wenn er der Meinung ist, dass er nichts tut, und startet ihn bei Bedarf neu. Andernfalls wird die Seite nur beim Schließen des Browsers entladen. Wird in Firefox nicht unterstützt.
  2. content_scripts — ein Array von Objekten, das das Laden verschiedener Skripte für verschiedene Webseiten ermöglicht. Jedes Objekt enthält die folgenden wichtigen Felder:
    1. matchesURL-Muster, anhand dessen entschieden wird, ob ein bestimmtes Content-Skript geladen wird oder nicht.
    2. js — eine Liste von Skripten, die in diesem Match geladen werden sollen;
    3. exclude_matches — schließt aus dem Feld match URLs aus, die diesem Feld entsprechen.
  3. page_action — ist tatsächlich ein Objekt, das für das Symbol verantwortlich ist, das neben der Adressleiste im Browser angezeigt wird, sowie für die Interaktion damit. Es ermöglicht auch die Anzeige eines Pop-ups, das mit eigenen HTML-, CSS- und JS-Dateien definiert wird.
    1. default_popup — Pfad zur HTML-Datei mit dem Popup-Interface, kann CSS und JS enthalten.
  4. Berechtigungen — Array zur Verwaltung der Berechtigungen der Erweiterung. Es gibt 3 Arten von Berechtigungen, die im Detail beschrieben werden. hier
  5. web_accessible_resources — Ressourcen der Erweiterung, die von der Webseite angefordert werden können, wie z.B. Bilder, JS-Dateien, CSS, HTML.
  6. externally_connectable — Hier können explizit die IDs anderer Erweiterungen und die Domains der Webseiten angegeben werden, von denen eine Verbindung hergestellt werden kann. Die Domain kann mindestens zweitstufig sein. Funktioniert nicht in Firefox.

Ausführungskontext

Die Erweiterung hat drei Kontexte für die Ausführung von Code, das heißt, die Anwendung besteht aus drei Teilen mit unterschiedlichem Zugriff auf die Browser-API.

Erweiterungskontext

Hier ist der Großteil der API verfügbar. In diesem Kontext "leben":

  1. Hintergrundseite — die "Backend"-Komponente der Erweiterung. Die Datei wird im Manifest unter dem Schlüssel "background" angegeben.
  2. Popup-Seite — die Popup-Seite, die erscheint, wenn auf das Symbol der Erweiterung geklickt wird. Im Manifest browser_action -> default_popup.
  3. Benutzerdefinierte Seite — Seite der Erweiterung, die in einem separaten Tab im Format chrome-extension:///customPage.html.

Dieser Kontext existiert unabhängig von den Fenstern und Tabs des Browsers. Hintergrundseite Existiert in einem einzelnen Exemplar und läuft immer (Ausnahme — Event-Seite, wenn das Hintergrund-Skript durch ein Ereignis gestartet wird und "stirbt", nachdem es ausgeführt wurde). Popup-Seite Existiert, wenn ein Popup-Fenster geöffnet ist, und Benutzerdefinierte Seite — solange ein Tab mit diesem offen ist. Kein Zugriff auf andere Tabs und deren Inhalte aus diesem Kontext.

Kontext von Inhalts-Skripten

Die Datei des Inhalts-Skripts wird zusammen mit jedem Tab des Browsers ausgeführt. Sie hat Zugang zu einem Teil der API der Erweiterung und zum DOM-Baum der Webseite. Inhalts-Skripte sind dafür verantwortlich, mit der Seite zu interagieren. Erweiterungen, die den DOM-Baum manipulieren, tun dies in Inhalts-Skripten – zum Beispiel Werbeblocker oder Übersetzungsdienste. Auch kann das Inhalts-Skript über das Standardprotokoll postMessage.

Webseitenkontext

Dies ist die eigentliche Webseite. Sie hat keinerlei Beziehung zur Erweiterung und hat keinen Zugriff darauf, es sei denn, die Domain dieser Seite ist im Manifest ausdrücklich angegeben (darüber wird weiter unten gesprochen).

Nachrichtenübertragung

Verschiedene Teile der Anwendung müssen Nachrichten untereinander austauschen. Dafür gibt es die API runtime.sendMessage zum Senden von Nachrichten background und tabs.sendMessage zum Senden von Nachrichten an die Seite (Inhalts-Skript, Popup oder Webseite, wenn vorhanden). externally_connectable). Im Folgenden finden Sie ein Beispiel für den Zugriff auf die 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))
    }
)

Für eine umfassende Interaktion können Verbindungen über runtime.connect. Als Antwort erhalten wir runtime.Port, an den, solange er geöffnet ist, beliebig viele Nachrichten gesendet werden können. Auf der Client-Seite, zum Beispiel, contentscript, sieht das so aus:

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

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

Es gibt auch ein Ereignis onDisconnect und die Methode disconnect.

Anwendungsschema

Lassen Sie uns eine Browsererweiterung erstellen, die private Schlüssel speichert, öffentlichen Zugriff auf Informationen bereitstellt (Adresse, öffentlicher Schlüssel kommuniziert mit der Seite und ermöglicht es Drittanwendungen, Transaktionsunterschriften anzufordern.

Anwendungsentwicklung

Unsere Anwendung sollte sowohl mit dem Benutzer interagieren als auch der Seite eine API zur Verfügung stellen, um Methoden (z. B. zum Unterzeichnen von Transaktionen) aufzurufen. Es reicht nicht aus, nur contentscript zu verwenden, da es nur Zugriff auf den DOM, jedoch nicht auf den JS der Seite hat. Eine Verbindung über runtime.connect kann nicht hergestellt werden, da die API auf allen Domains erforderlich ist, im Manifest jedoch nur spezifische angegeben werden können. Das Schema wird folgendermaßen aussehen:

Wir schreiben eine sichere Browsererweiterung

Es wird ein weiteres Skript geben — inpage, das wir in die Seite einfügen werden. Es wird im Kontext der Seite ausgeführt und stellt eine API zur Verfügung, um mit der Erweiterung zu arbeiten.

Einleitung

Der gesamte Code der Browsererweiterung ist verfügbar unter GitHub. Im Verlauf der Beschreibung werden Links zu den Commits enthalten sein.

Lassen Sie uns mit dem Manifest beginnen:

{
  // Name und Beschreibung, Version. All dies wird im Browser unter chrome://extensions/?id= sichtbar sein.
  "name": "Signer",
  "description": "Erweiterungsdemonstration",
  "version": "0.0.1",
  "manifest_version": 2,

  // Skripte, die im Hintergrund ausgeführt werden, es kann mehrere geben
  "background": {
    "scripts": ["background.js"]
  },

  // Welches HTML für das Popup verwendet werden soll
  "browser_action": {
    "default_title": "Meine Erweiterung",
    "default_popup": "popup.html"
  },

  // Inhalts-Skripte.
  // Wir haben ein Objekt: Für alle URLs, die mit http oder https beginnen, führen wir
  // den Inhaltsskript-Kontext mit dem Skript contentscript.js aus. Ausführen bei Erhalt des Dokuments für alle Frames
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Zugriff auf localStorage und idle API ist erlaubt
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Hier werden die Ressourcen angegeben, auf die die Webseite Zugriff hat. Das bedeutet, dass sie über fetch oder einfach xhr angefordert werden können
  "web_accessible_resources": ["inpage.js"]
}

Erstellen Sie leere Dateien für background.js, popup.js, inpage.js und contentscript.js. Fügen Sie popup.html hinzu – und unsere Anwendung kann bereits in Google Chrome geladen werden, um sicherzustellen, dass sie funktioniert.

Um das zu überprüfen, kann man den Code nehmen von hier. Außerdem ist über den Link eine Projektkonfiguration mit webpack eingerichtet. Um die Anwendung im Browser hinzuzufügen, muss man in chrome://extensions auf "entpackte Erweiterung laden" gehen und den entsprechenden Ordner mit der Erweiterung auswählen – in unserem Fall dist.

Wir schreiben eine sichere Browsererweiterung

Jetzt ist unsere Erweiterung installiert und funktioniert. Die Entwicklertools für die verschiedenen Kontexte können folgendermaßen gestartet werden:

popup ->

Wir schreiben eine sichere Browsererweiterung

Der Zugriff auf die Konsole des Content-Skripts erfolgt über die Konsole der Seite, auf der es ausgeführt wird.Wir schreiben eine sichere Browsererweiterung

Nachrichtenübertragung

Wir müssen also zwei Kommunikationskanäle einrichten: inpage background und popup background. Natürlich könnte man einfach Nachrichten über den Port senden und ein eigenes Protokoll erfinden, aber ich bevorzuge den Ansatz, den ich in dem Open-Source-Projekt metamask gesehen habe.

Dies ist eine Browsererweiterung zur Interaktion mit dem Ethereum-Netzwerk. In ihr kommunizieren verschiedene Teile der Anwendung über RPC mithilfe der Bibliothek dnode. Sie ermöglicht einen schnellen und bequemen Austausch, wenn man als Transport einen Node.js-Stream bereitstellt (d.h. ein Objekt, das dasselbe Interface implementiert):

import Dnode from "dnode/browser";

// In diesem Beispiel nehmen wir an, dass der Client Funktionen auf dem Server entfernt aufruft, obwohl uns nichts daran hindert, dies bidirektional zu gestalten.

// Server
// API, die wir anbieten möchten.
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Transport, über dem dnode arbeiten wird. Jeder Node.js-Stream. Im Browser gibt es die Bibliothek 'readable-stream'.
connectionStream.pipe(dnode).pipe(connectionStream)

// Client
const dnodeClient = Dnode() // Der Aufruf ohne Argument bedeutet, dass wir keine API auf der anderen Seite bereitstellen.

// Gibt 'world' in der Konsole aus.
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Jetzt erstellen wir eine Applikationsklasse. Sie wird API-Objekte für Popup und Webseite erstellen sowie dnode für sie aufsetzen.

import Dnode von 'dnode/browser';

export class SignerApp {

    
    // Gibt das API-Objekt für das UI zurück
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    
    // Gibt das API-Objekt für die Seite zurück
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    
    // Verbindet das Popup-UI
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    
    // Verbindet die Seite
    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)
        })
    }
}

Hier und im Folgenden verwenden wir anstelle des globalen Chrome-Objekts das extentionApi, das auf Chrome im Google-Browser und auf browser in anderen zugreift. Dies geschieht zur Browserkompatibilität, aber im Rahmen dieses Artikels könnte man auch einfach 'chrome.runtime.connect' verwenden.

Lassen Sie uns eine Instanz der Anwendung im Hintergrund-Skript erstellen:

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

const app = new SignerApp();

// onConnect wird aufgerufen, wenn 'Prozesse' (contentscript, Popup oder Erweiterungsseite) verbunden werden)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Bei der Verbindung kann ein Name angegeben werden, mit dem wir bestimmen, wer sich verbunden hat, Inhaltsskript oder UI
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Da dnode mit Streams arbeitet und wir einen Port erhalten, ist eine Adapter-Klasse erforderlich. Diese wurde mit der Bibliothek readable-stream erstellt, die Node.js-Streams im Browser implementiert:

import {Duplex} von '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()
    }
}

Jetzt erstellen wir die Verbindung im 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() {
    // Wie im Anwendungsklasse erstellen wir den Port, wickeln ihn in einen Stream und machen 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)
        })
    });

    // Stellen Sie das API-Objekt aus der Konsole zur Verfügung
    if (DEV_MODE) {
        global.background = background;
    }
}

Dann erstellen wir eine Verbindung im Content-Skript:

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 {
        // In-Page-Skript injizieren
        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 fehlgeschlagen.', e);
    }
}

Da wir das API nicht im Content-Skript, sondern direkt auf der Seite benötigen, unternehmen wir zwei Dinge:

  1. Wir erstellen zwei Streams. Einen – in Richtung Seite, über postMessage. Dazu verwenden wir dieses Paket von den Machern von Metamask. Der zweite Stream – zu Background über den Port, der von runtime.connect. Wir pipen sie. Jetzt hat die Seite einen Stream zum Hintergrund.
  2. Wir injizieren das Skript in das DOM. Wir laden das Skript herunter (der Zugriff darauf wurde im Manifest genehmigt) und erstellen ein Tag script mit dessen Inhalt innerhalb:

import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";

setupConnection();
injectScript();

function setupConnection() {
    // Stream zu Hintergrund
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Stream zur Seite
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript() {
    try {
        // In-Page-Skript injizieren
        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 fehlgeschlagen.', e);
    }
}

Jetzt erstellen wir das API-Objekt in inpage und machen es global:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Stream zum Content-Skript
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Wir bekommen das API-Objekt
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Zugriff über window
    global.SignerApp = pageApi;
}

Wir sind bereit Remote Procedure Call (RPC) mit einer separaten API für die Seite und die Benutzeroberfläche. Beim Verbinden einer neuen Seite mit dem Hintergrund können wir das sehen:

Wir schreiben eine sichere Browsererweiterung

Leeres API und Origin. Auf der Seite können wir die Funktion hello so aufrufen:

Wir schreiben eine sichere Browsererweiterung

Mit Callback-Funktionen in modernem JS zu arbeiten, ist nicht ideal, deshalb schreiben wir einen kleinen Helper zur Erstellung von dnode, der es ermöglicht, das API-Objekt in utils zu übergeben.

API-Objekte sehen jetzt so aus:

export class SignerApp {

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

...

}

Der Erhalt des Objekts von remote erfolgt wie folgt:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Mit den Utilities ändern wir alle Callback in Promises
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

Und der Aufruf von Funktionen gibt ein Promise zurück:

Wir schreiben eine sichere Browsererweiterung

Eine Version mit asynchronen Funktionen ist verfügbar hier.

Insgesamt scheint der Ansatz mit RPC und Streams ausreichend flexibel: Wir können Stream-Multiplexing verwenden und mehrere verschiedene APIs für verschiedene Aufgaben erstellen. Grundsätzlich kann dnode überall verwendet werden, wichtig ist, den Transport als Node.js Stream zu verpacken.

Eine Alternative ist das JSON-Format, das das JSON RPC 2-Protokoll implementiert. Es funktioniert jedoch mit bestimmten Transportprotokollen (TCP und HTTP(S)), was in unserem Fall nicht anwendbar ist.

Interner Zustand und localStorage

Wir müssen den internen Zustand der Anwendung speichern – mindestens die Schlüssel zum Signieren. Wir können den Zustand der Anwendung und Methoden zu seiner Änderung im Popup-API ziemlich einfach hinzufügen:

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

    ...

} 

Im Hintergrund verpacken wir alles in eine Funktion und schreiben das Anwendungsobjekt in das Window, damit wir damit aus der Konsole arbeiten können:

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

Lass uns aus der UI-Konsole einige Schlüssel hinzufügen und sehen, was mit dem Status passiert ist:

Wir schreiben eine sichere Browsererweiterung

Der Status muss persistent sein, damit die Schlüssel beim Neustart nicht verloren gehen.

Wir werden ihn im localStorage speichern und bei jeder Änderung überschreiben. In der Folge wird der Zugriff darauf auch für die UI erforderlich sein, und man möchte sich ebenfalls auf Änderungen abonnieren. Daher wäre es praktisch, ein beobachtbares Speichersystem (observable storage) zu erstellen und sich auf dessen Änderungen zu abonnieren.

Wir werden die Bibliothek mobx verwenden (https://github.com/mobxjs/mobx). Die Wahl fiel darauf, da ich bisher nicht mit ihr gearbeitet habe, ich sie aber unbedingt kennenlernen wollte.

Wir fügen die Initialisierung des Ausgangszustands hinzu und machen den Store beobachtbar:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";

export class SignerApp {

    constructor(initState = {}) {
        // Nach außen bleibt der Store dasselbe Objekt, nur sind jetzt alle seine Felder Proxys, die den Zugriff auf sie überwachen
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // Methoden, die ein observables verändern, sollten mit einem Dekorator versehen werden
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

„Unter der Haube“ hat mobx alle Felder des Stores durch Proxys ersetzt und überwacht alle Zugriffe auf sie. Auf diese Zugriffe kann man sich abonnieren.

Ich werde in der Folge oft den Begriff „bei Änderungen“ verwenden, obwohl das nicht ganz korrekt ist. Mobx überwacht tatsächlich den Zugriff auf die Felder. Es kommen Getter und Setter der von der Bibliothek erzeugten Proxy-Objekte zum Einsatz.

Die @action-Dekoratoren haben zwei Ziele:

  1. Im strengen Modus mit dem Flag enforceActions verbietet mobx, den Status direkt zu ändern. Es gilt als gute Praxis, tatsächlich im strengen Modus zu arbeiten.
  2. Selbst wenn die Funktion den Status mehrere Male ändert – zum Beispiel, wenn wir mehrere Felder in mehreren Codezeilen ändern – werden die Observer nur nach ihrer Beendigung benachrichtigt. Dies ist besonders wichtig für das Frontend, wo überflüssige Statusaktualisierungen zu unnötigem Rendering von Elementen führen. In unserem Fall sind weder das eine noch das andere besonders relevant, dennoch werden wir die besten Praktiken befolgen. Es ist üblich, Dekoratoren auf alle Funktionen zu legen, die den Status beobachteter Felder ändern.

Im Hintergrund fügen wir die Initialisierung und Speicherung des Status in localStorage hinzu:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Hilfsmethoden. Speichern/lesen ein Objekt in/von localStorage als JSON-String unter dem Schlüssel '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;
    }

    // Einrichten der Zustandspersitente

    // Das Ergebnis der Reaktion wird einer Variablen zugewiesen, damit das Abonnieren abgebrochen werden kann. Wir benötigen das nicht, es bleibt als Beispiel erhalten
    const localStorageReaction = reaction(
        () => toJS(app.store), // Datenwahlfunktion
        saveState // Funktion, die aufgerufen wird, wenn die Daten, die der Selektor zurückgibt, geändert werden
    );

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

Die Funktion reaction ist hier interessant. Sie hat zwei Argumente:

  1. Datenwahl.
  2. Handler, der mit diesen Daten jedes Mal aufgerufen wird, wenn sie sich ändern.

Im Gegensatz zu Redux, wo wir den Status explizit als Argument erhalten, merkt sich MobX, auf welche observable wir innerhalb des Selektors zugreifen, und ruft den Handler nur dann auf, wenn sie sich ändern.

Es ist wichtig zu verstehen, wie MobX entscheidet, auf welche observable wir uns abonnieren. Wenn ich im Code den Selektor so geschrieben hätte() => app.store, würde die Reaktion niemals aufgerufen werden, da das Speicherobjekt selbst nicht beobachtbar ist, sondern nur seine Felder.

Wenn ich es so geschrieben hätte () => app.store.keys, würde erneut nichts passieren, da beim Hinzufügen/Entfernen von Elementen im Array die Referenz darauf nicht geändert wird.

Mobx führt erstmals die Selektorfunktion aus und überwacht nur die observable, auf die wir zugegriffen haben. Dies geschieht über Proxy-Getter. Daher wird hier die integrierte Funktion verwendet toJS. Sie gibt ein neues Objekt zurück, in dem alle Proxys durch die Originalfelder ersetzt sind. Während der Ausführung liest sie alle Felder des Objekts – folglich werden die Getter ausgelöst.

In der Popup-Konsole fügen wir erneut einige Schlüssel hinzu. Dieses Mal sind sie auch im localStorage gelandet:

Wir schreiben eine sichere Browsererweiterung

Beim Neuladen der Hintergrundseite bleibt die Information an Ort und Stelle.

Den gesamten Anwendungscode bis zu diesem Punkt können Sie einsehen hier.

Sichere Aufbewahrung von privaten Schlüsseln

Private Schlüssel im Klartext zu speichern, ist unsicher: Es besteht immer das Risiko, dass Sie gehackt werden, jemand Zugriff auf Ihren Computer erhält usw. Daher werden wir die Schlüssel im localStorage verschlüsselt speichern.

Zur Erhöhung der Sicherheit fügen wir der Anwendung den Status locked hinzu, bei dem der Zugriff auf die Schlüssel vollständig gesperrt ist. Wir werden die Erweiterung automatisch nach einer Zeitüberschreitung in den Status locked versetzen.

Mobx ermöglicht es, nur die minimalen Datenmengen zu speichern, während der Rest automatisch auf deren Grundlage berechnet wird. Dies sind die sogenannten computed properties. Sie können mit Views in Datenbanken verglichen werden:

import {observable, action} from 'mobx';
import {setupDnode} from ".\/utils\/setupDnode";
// Hilfskräfte für die sichere Verschlüsselung von Zeichenfolgen. Nutzt crypto-js
import {encrypt, decrypt} from ".\/utils\/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Wir speichern das Passwort und die verschlüsselten Schlüssel. Wenn das Passwort null ist, ist die Anwendung gesperrt
            password: null,
            vault: initState.vault,

            // Getter für berechenbare Felder. Ähnlich wie Ansichten in der Datenbank.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Initialisierung eines leeren Speichers mit einem neuen Passwort
    @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 zur Verbindung und API

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

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

    // Methoden zum Verschlüsseln/Entschlüsseln des Speichers
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Tresor nicht initialisiert')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Falsches Passwort')
        }
    }
}

Jetzt speichern wir nur verschlüsselte Schlüssel und das Passwort. Alles andere wird berechnet. Der Übergang zum Status "locked" erfolgt durch das Entfernen des Passworts aus dem Status. Im öffentlichen API ist eine Methode zur Initialisierung des Speichers hinzugekommen.

Für die Verschlüsselung wurden Hilfsprogramme mit der Verwendung von crypto-js:

import CryptoJS from 'crypto-js'

// Wird verwendet, um das Knacken des Passworts durch Brute-Force zu erschweren. Für jede Passwortvariante muss der Angreifer 5000 Hashes erstellen
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)
}

Der Browser verfügt über eine Idle-API, über die man sich auf das Event - Statusänderungen - abonnieren kann. Der Status kann entsprechend sein idle, active und locked. Für Idle kann ein Timeout eingestellt werden, während locked aktiviert wird, wenn das Betriebssystem selbst gesperrt ist. Wir werden auch den Selektor für die Speicherung im localStorage ändern:

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

    // Jetzt rufen wir explizit das Feld auf, auf das zugegriffen wird, die Reaktion wird ordnungsgemäß funktionieren
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Idle-Timeout, wenn das Ereignis eintritt
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Wenn der Benutzer den Bildschirm gesperrt hat oder während des angegebenen Zeitraums inaktiv war, sperren wir die Anwendung
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Verbindung zu anderen Kontexten herstellen
    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)
        }
    }
}

Der Code bis zu diesem Schritt befindet sich hier.

Transaktionen

Nun sind wir beim Wichtigsten angelangt: der Erstellung und Unterzeichnung von Transaktionen in der Blockchain. Wir werden die WAVES-Blockchain und die Bibliothek verwenden waves-transactions.

Zuerst fügen wir dem Status ein Array von Nachrichten hinzu, die unterzeichnet werden müssen, und anschließend — Methoden zum Hinzufügen neuer Nachrichten, Bestätigen der Unterschrift und Ablehnen:

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) {
        // Für jede Nachricht erstellen wir Metadaten mit ID, Status, Erstellungszeit usw.
        const message = observable.object({
            id: uuid(), // Identifikator, benutze uuid
            origin, // Den Ursprung werden wir später in der Benutzeroberfläche anzeigen
            data, //
            status: 'new', // Es wird vier Status geben: neu, signiert, abgelehnt und fehlgeschlagen
            timestamp: Date.now()
        });
        console.log(`neue Nachricht: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Wir geben ein Versprechen zurück, in dem mobx die Änderungen der Nachricht überwacht. Sobald sich der Status ändert, lösen wir es auf.
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Wir werden den Status der Nachricht beobachten
                (status, reaction) => { // der zweite Parameter ist eine Referenz auf die Reaktion selbst, die wir innerhalb des Aufrufs zerstören können
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('Benutzer hat die Nachricht abgelehnt'));
                            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(`Keine Nachricht mit 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(`Keine Nachricht mit ID:${id}`);
        message.status = 'rejected'
    }

    ...
}

Beim Empfang einer neuen Nachricht fügen wir Metadaten hinzu, machen sie observable und fügen sie zu store.messages.

Wenn wir es nicht manuell machen, wird mobx es selbst erledigen, wenn es dem Array messages hinzugefügt wird. Es wird jedoch ein neues Objekt erstellen, auf das wir keine Referenz haben, und diese wird für den nächsten Schritt benötigt. observable Anschließend geben wir ein Versprechen zurück, das aufgelöst wird, wenn sich der Status der Nachricht ändert. Der Status wird durch eine Reaktion überwacht, die sich selbst "tötet", wenn sich der Status ändert.

Der Code der Methoden

approve reject und ist sehr einfach: Wir ändern einfach den Status der Nachricht, nachdem wir sie, falls erforderlich, signiert haben. Es ist ganz einfach: Wir ändern einfach den Status der Nachricht und unterschreiben sie, wenn nötig.

Genehmigen und Ablehnen bringen wir in die API-UI, neueNachricht – in die API-Seiten:

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

    ...
}

Jetzt versuchen wir, die Transaktion über das Erweiterungsskript zu signieren:

Wir schreiben eine sichere Browsererweiterung

Im Großen und Ganzen ist alles bereit, es bleibt nur noch eine einfache UI hinzuzufügen.

UI

Die Benutzeroberfläche benötigt Zugriff auf den Anwendungszustand. Auf der UI-Seite werden wir observable den Zustand erstellen und eine API-Funktion hinzufügen, die diesen Zustand ändert. Fügen wir hinzu observable zum Objekt API, das von background erhalten wurde:

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() {
    // Verbinden mit dem Port, erstellen einen Stream daraus
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Erstellen Sie ein leeres observable für den Zustand im Hintergrund
    let backgroundState = observable.object({});
    const api = {
        // Gebe dem Hintergrund eine Funktion, die das Observable aktualisiert
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Erstellen Sie ein RPC-Objekt
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Fügen Sie dem Hintergrund ein Observable mit dem Zustand hinzu
    background.state = backgroundState;

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

    // Start der Benutzeroberfläche
    await initApp(background)
}

Am Ende starten wir das Rendering der Benutzeroberfläche der Anwendung. Es handelt sich um eine React-Anwendung. Das Background-Objekt wird einfach über Props übergeben. Richtig wäre es, einen separaten Dienst für die Methoden und ein Store für den Zustand zu erstellen, aber im Rahmen dieses Artikels reicht es aus:

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

// Initialisieren der Anwendung mit dem Hintergrundobjekt als Props
export async function initApp(background){
    render(
        , 
        document.getElementById('app-content')
    );
}

Mit mobx ist es sehr einfach, das Rendering bei Datenänderungen zu starten. Wir hängen einfach den Decorator observer aus dem Paket daran. mobx-react auf das Komponenten, und die Darstellung wird automatisch ausgelöst, wenn sich irgendein Observable ändert, auf das die Komponente verweist. Es sind keine mapStateToProps oder connect wie bei Redux erforderlich. Alles funktioniert sofort „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 \/\/ Bei diesem Dekorator wird die render-Methode automatisch aufgerufen, wenn sich die verwendeten observable ändern
export default class App extends Component {

    \/\/ Es wäre besser, die Logik des Seitenrenderings in das Routing auszulagern und keine geschachtelten ternären Operatoren zu verwenden,
    \/\/ und observables und Methoden zur Hintergrundverarbeitung direkt an die Komponenten zu binden, die sie verwenden
    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;App sperren</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Alle Schlüssel löschen und initialisieren</button>}
            </div>
        </Fragment>
    }
}

Die anderen Komponenten können im Code angesehen werden im UI-Ordner.

Jetzt muss im Anwendungs-Klassen ein Zustand-Selektor für die UI erstellt werden, und bei dessen Änderung muss die UI benachrichtigt werden. Dazu fügen wir eine Methode hinzu getState und Reaktion, die 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 {

    ...

    // öffentlich
    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) => {
            // Erstellen einer Reaktion auf Änderungen des Zustands, die die entfernte Prozedur aufruft und den Zustand im UI-Prozess aktualisiert
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Als drittes Argument können Parameter übergeben werden. fireImmediately bedeutet, dass die Reaktion beim ersten Mal sofort ausgeführt wird.
                // Dies ist notwendig, um den Anfangszustand zu erhalten. Delay erlaubt es, die Debounce-Zeit einzustellen
                {fireImmediately: true, delay: 500}
            );
            // Abonnierung beim Trennen des Clients entfernen
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Beim Empfang des Objekts remote wird erstellt Reaktion auf Änderungen des Zustands, der eine Funktion auf der UI-Seite aufruft.

Der letzte Schliff — wir fügen die Anzeige neuer Nachrichten auf dem Erweiterungssymbol hinzu:

function setupApp() {
...

    // Reaktion auf das Setzen des Badge-Texts.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

So, die Anwendung ist bereit. Webseiten können Transaktionssignaturen anfordern:

Wir schreiben eine sichere Browsererweiterung

Wir schreiben eine sichere Browsererweiterung

Der Code ist hier verfügbar dem Link.

Fazit

Wenn Sie den Artikel bis zum Ende gelesen haben, aber noch Fragen haben, können Sie diese in dem Repository mit der Erweiterung. Dort finden Sie auch Commits für jeden angegebenen Schritt.

Und wenn Sie den Code einer echten Erweiterung sehen möchten, können Sie das hier.

Code, Repository und Funktionsbeschreibung von siemarell

Quelle: habr.com

60GB SSD 8Gb DDR4