보안 브라우저 확장 프로그램 작성

보안 브라우저 확장 프로그램 작성

일반적인 "클라이언트-서버" 아키텍처와 달리 분산형 애플리케이션의 특징은 다음과 같습니다.

  • 사용자 로그인 및 비밀번호가 포함된 데이터베이스를 저장할 필요가 없습니다. 접근 정보는 사용자 자신에 의해서만 저장되며, 그 진위 여부는 프로토콜 수준에서 확인됩니다.
  • 서버를 사용할 필요가 없습니다. 애플리케이션 로직은 필요한 양의 데이터를 저장할 수 있는 블록체인 네트워크에서 실행될 수 있습니다.

사용자 키를 보관할 수 있는 비교적 안전한 저장소는 하드웨어 지갑과 브라우저 확장 프로그램 2가지가 있습니다. 하드웨어 지갑은 대부분 매우 안전하지만 사용하기 어렵고 무료와는 거리가 멀지만, 브라우저 확장 프로그램은 보안과 사용 편의성의 완벽한 조합이며 최종 사용자에게 완전히 무료일 수도 있습니다.

이 모든 것을 고려하여 우리는 트랜잭션 및 서명 작업을 위한 간단한 API를 제공하여 분산형 애플리케이션 개발을 단순화하는 가장 안전한 확장을 만들고 싶었습니다.
아래에서 이 경험에 대해 알려드리겠습니다.

이 기사에는 코드 예제 및 스크린샷과 함께 브라우저 확장 프로그램을 작성하는 방법에 대한 단계별 지침이 포함되어 있습니다. 모든 코드는 다음에서 찾을 수 있습니다. 저장소. 각 커밋은 논리적으로 이 문서의 섹션에 해당합니다.

브라우저 확장의 간략한 역사

브라우저 확장 프로그램은 오랫동안 사용되어 왔습니다. 1999년 Internet Explorer에 등장했고, 2004년에는 Firefox에 등장했습니다. 그러나 오랫동안 확장에 대한 단일 표준이 없었습니다.

Google Chrome 네 번째 버전에서는 확장 프로그램과 함께 등장했다고 말할 수 있습니다. 물론 당시에는 사양이 없었지만 기본이 된 것은 Chrome API였습니다. 대부분의 브라우저 시장을 정복하고 내장된 애플리케이션 스토어를 갖춘 Chrome은 실제로 브라우저 확장 프로그램의 표준을 설정했습니다.

Mozilla는 자체 표준이 있었지만 Chrome 확장 프로그램의 인기를 보고 호환되는 API를 만들기로 결정했습니다. 2015년에 Mozilla의 주도로 W3C(World Wide Web Consortium) 내에 브라우저 간 확장 사양을 작업하기 위한 특별 그룹이 만들어졌습니다.

기존 Chrome용 API 확장이 기본으로 사용되었습니다. 이 작업은 Microsoft의 지원으로 수행되었으며(Google은 표준 개발 참여를 거부함) 결과 초안이 나타났습니다. 명세서.

공식적으로 이 사양은 Edge, Firefox 및 Opera에서 지원됩니다(Chrome은 이 목록에 없음). 그러나 실제로 표준은 확장 기능을 기반으로 작성되었기 때문에 Chrome과 거의 호환됩니다. WebExtensions API에 대해 자세히 알아볼 수 있습니다. 여기에.

확장 구조

확장에 필요한 유일한 파일은 매니페스트(manifest.json)입니다. 이는 또한 확장의 "진입점"이기도 합니다.

선언문

사양에 따르면 매니페스트 파일은 유효한 JSON 파일입니다. 어떤 브라우저에서 어떤 키가 지원되는지에 대한 정보가 포함된 매니페스트 키에 대한 전체 설명 여기에.

사양에 없는 키는 "무시될 수 있습니다"(Chrome과 Firefox 모두 오류를 보고하지만 확장 프로그램은 계속 작동합니다).

그리고 몇 가지 사항에 주목하고 싶습니다.

  1. 배경 — 다음 필드를 포함하는 객체:
    1. 스크립트 — 백그라운드 컨텍스트에서 실행될 스크립트 배열(이에 대해서는 나중에 설명하겠습니다)
    2. 페이지 — 빈 페이지에서 실행될 스크립트 대신 콘텐츠와 함께 html을 지정할 수 있습니다. 이 경우 스크립트 필드는 무시되며 스크립트를 콘텐츠 페이지에 삽입해야 합니다.
    3. 지속 — 바이너리 플래그가 지정되지 않은 경우 브라우저는 백그라운드 프로세스가 아무것도 하지 않는다고 판단할 때 백그라운드 프로세스를 "종료"하고 필요한 경우 다시 시작합니다. 그렇지 않으면 브라우저가 닫힐 때만 페이지가 언로드됩니다. Firefox에서는 지원되지 않습니다.
  2. content_scripts — 다양한 웹 페이지에 다양한 스크립트를 로드할 수 있는 개체 배열입니다. 각 개체에는 다음과 같은 중요한 필드가 포함되어 있습니다.
    1. 성냥 - 패턴 URL, 특정 콘텐츠 스크립트가 포함될지 여부를 결정합니다.
    2. js — 이 일치 항목에 로드될 스크립트 목록입니다.
    3. 제외_일치 - 현장에서 제외됩니다. match 이 필드와 일치하는 URL입니다.
  3. page_action - 실제로 브라우저의 주소 표시줄 옆에 표시되는 아이콘과 상호 작용을 담당하는 개체입니다. 또한 사용자 고유의 HTML, CSS 및 JS를 사용하여 정의된 팝업 창을 표시할 수도 있습니다.
    1. default_popup — 팝업 인터페이스가 있는 HTML 파일의 경로이며 CSS 및 JS를 포함할 수 있습니다.
  4. 권한 — 확장 권한을 관리하기 위한 배열입니다. 권리에는 3가지 유형이 있으며 이에 대해 자세히 설명되어 있습니다. 여기에
  5. web_accessible_resources — 웹 페이지가 요청할 수 있는 확장 리소스(예: 이미지, JS, CSS, HTML 파일)
  6. 외부 연결 가능 — 여기에서 연결할 수 있는 웹페이지의 다른 확장 프로그램 및 도메인의 ID를 명시적으로 지정할 수 있습니다. 도메인은 두 번째 수준 이상이 될 수 있습니다. 파이어폭스에서는 작동하지 않습니다.

실행 컨텍스트

확장에는 세 가지 코드 실행 컨텍스트가 있습니다. 즉, 애플리케이션은 브라우저 API에 대한 액세스 수준이 서로 다른 세 부분으로 구성됩니다.

확장 컨텍스트

대부분의 API는 여기에서 사용할 수 있습니다. 이러한 맥락에서 그들은 "살아있다":

  1. 배경 페이지 — 확장의 "백엔드" 부분. 파일은 "백그라운드" 키를 사용하여 매니페스트에 지정됩니다.
  2. 팝업 페이지 — 확장 아이콘을 클릭하면 나타나는 팝업 페이지입니다. 선언문에서 browser_action -> default_popup.
  3. 맞춤 페이지 — 확장 페이지, 보기의 별도 탭에 있는 "living" chrome-extension://<id_расширения>/customPage.html.

이 컨텍스트는 브라우저 창 및 탭과 별개로 존재합니다. 배경 페이지 단일 복사본으로 존재하며 항상 작동합니다(이벤트에 의해 백그라운드 스크립트가 시작되고 실행 후 "죽는" 이벤트 페이지는 예외입니다). 팝업 페이지 팝업창이 열려 있을 때 존재하며, 맞춤 페이지 — 해당 탭이 열려 있는 동안. 이 컨텍스트에서는 다른 탭과 해당 내용에 액세스할 수 없습니다.

콘텐츠 스크립트 컨텍스트

콘텐츠 스크립트 파일은 각 브라우저 탭과 함께 실행됩니다. 확장 API의 일부와 웹 페이지의 DOM 트리에 액세스할 수 있습니다. 페이지와의 상호 작용을 담당하는 콘텐츠 스크립트입니다. DOM 트리를 조작하는 확장 프로그램(예: 광고 차단기 또는 번역기)은 콘텐츠 스크립트에서 이 작업을 수행합니다. 또한 콘텐츠 스크립트는 표준을 통해 페이지와 통신할 수 있습니다. postMessage.

웹페이지 컨텍스트

이것은 실제 웹 페이지 자체입니다. 이 페이지의 도메인이 매니페스트에 명시적으로 표시되지 않은 경우를 제외하고는 확장 프로그램과 아무 관련이 없으며 거기에 액세스할 수 없습니다(자세한 내용은 아래 참조).

메시징

애플리케이션의 서로 다른 부분은 서로 메시지를 교환해야 합니다. 이에 대한 API가 있습니다 runtime.sendMessage 메시지를 보내다 background и tabs.sendMessage 페이지(콘텐츠 스크립트, 팝업 또는 웹페이지(사용 가능한 경우))에 메시지 보내기 externally_connectable). 아래는 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))
    }
)

완전한 의사소통을 위해서는 다음을 통해 연결을 만들 수 있습니다. runtime.connect. 이에 대한 응답으로 우리는 받게 될 것입니다 runtime.Port, 열려 있는 동안 원하는 만큼 메시지를 보낼 수 있습니다. 예를 들어 클라이언트 측에서는 contentscript, 다음과 같습니다.

// Опять же 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"});

서버 또는 배경:

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

이벤트도 있어요 onDisconnect 및 방법 disconnect.

응용 다이어그램

개인 키를 저장하고 공개 정보(주소, 공개 키가 페이지와 통신하고 제XNUMX자 애플리케이션이 거래에 대한 서명을 요청할 수 있도록 허용)에 대한 액세스를 제공하는 브라우저 확장을 만들어 보겠습니다.

응용 프로그램 개발

우리 애플리케이션은 사용자와 상호 작용해야 하며 메소드 호출(예: 트랜잭션 서명)을 위한 API가 있는 페이지를 제공해야 합니다. 하나만으로 해결하세요 contentscript DOM에만 액세스할 수 있고 페이지의 JS에는 액세스할 수 없기 때문에 작동하지 않습니다. 다음을 통해 연결 runtime.connect API는 모든 도메인에 필요하고 특정 도메인만 매니페스트에 지정할 수 있기 때문에 그렇게 할 수 없습니다. 결과적으로 다이어그램은 다음과 같습니다.

보안 브라우저 확장 프로그램 작성

또 다른 스크립트가 있을 것입니다. inpage, 페이지에 삽입할 것입니다. 해당 컨텍스트에서 실행되며 확장 작업을 위한 API를 제공합니다.

처음

모든 브라우저 확장 코드는 다음에서 사용할 수 있습니다. GitHub의. 설명 중에 커밋에 대한 링크가 있습니다.

선언문부터 시작해 보겠습니다.

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

빈 background.js, popup.js, inpage.js 및 contentscript.js를 만듭니다. popup.html을 추가하면 애플리케이션이 이미 Google Chrome에 로드되어 작동하는지 확인할 수 있습니다.

이를 확인하려면 코드를 가져오세요. 따라서. 우리가 한 일 외에도 링크는 webpack을 사용하여 프로젝트 어셈블리를 구성했습니다. 브라우저에 애플리케이션을 추가하려면 chrome://extensions에서 압축을 푼 로드와 해당 확장이 있는 폴더(이 경우 dist)를 선택해야 합니다.

보안 브라우저 확장 프로그램 작성

이제 확장 기능이 설치되어 작동합니다. 다음과 같이 다양한 컨텍스트에 대해 개발자 도구를 실행할 수 있습니다.

팝업 ->

보안 브라우저 확장 프로그램 작성

콘텐츠 스크립트 콘솔에 대한 액세스는 콘텐츠 스크립트 콘솔이 실행되는 페이지 자체의 콘솔을 통해 수행됩니다.보안 브라우저 확장 프로그램 작성

메시징

따라서 인페이지 <-> 배경과 팝업 <-> 배경이라는 두 가지 통신 채널을 설정해야 합니다. 물론 포트에 메시지를 보내고 자신만의 프로토콜을 만들 수도 있지만 저는 메타마스크 오픈 소스 프로젝트에서 본 접근 방식을 선호합니다.

이것은 Ethereum 네트워크 작업을 위한 브라우저 확장입니다. 여기에서 애플리케이션의 여러 부분은 dnode 라이브러리를 사용하여 RPC를 통해 통신합니다. nodejs 스트림을 전송(동일한 인터페이스를 구현하는 객체를 의미)으로 제공하면 매우 빠르고 편리하게 교환을 구성할 수 있습니다.

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

이제 애플리케이션 클래스를 생성하겠습니다. 팝업 및 웹 페이지에 대한 API 객체를 생성하고 이에 대한 dnode를 생성합니다.

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

여기와 아래에서는 전역 Chrome 개체 대신 Google 브라우저와 다른 브라우저에서 Chrome에 액세스하는 ExtensionApi를 사용합니다. 이는 브라우저 간 호환성을 위해 수행되지만 이 문서의 목적에 따라 간단히 'chrome.runtime.connect'를 사용할 수도 있습니다.

백그라운드 스크립트에서 애플리케이션 인스턴스를 생성해 보겠습니다.

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

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

dnode는 스트림과 함께 작동하고 포트를 수신하므로 어댑터 클래스가 필요합니다. 브라우저에서 nodejs 스트림을 구현하는 읽기 가능한 스트림 라이브러리를 사용하여 만들어졌습니다.

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

이제 UI에서 연결을 만들어 보겠습니다.

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

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

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

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

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

그런 다음 콘텐츠 스크립트에서 연결을 만듭니다.

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

콘텐츠 스크립트가 아닌 페이지에서 직접 API가 필요하므로 다음 두 가지 작업을 수행합니다.

  1. 우리는 두 개의 스트림을 생성합니다. 하나 - 페이지 방향, postMessage 상단. 이를 위해 우리는 이것을 사용합니다 이 패키지 메타마스크 제작자로부터. 두 번째 스트림은 수신된 포트를 통해 백그라운드로 전송됩니다. runtime.connect. 사자. 이제 페이지에는 백그라운드로의 스트림이 있습니다.
  2. DOM에 스크립트를 삽입합니다. 스크립트를 다운로드하고(매니페스트에서 액세스가 허용됨) 태그를 생성합니다. script 내용은 다음과 같습니다.

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

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

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

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

이제 인페이지에서 API 객체를 생성하고 이를 전역으로 설정합니다.

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

우린 준비되었다 페이지와 UI에 대해 별도의 API를 사용하는 원격 프로시저 호출(RPC). 새 페이지를 배경에 연결하면 다음을 볼 수 있습니다.

보안 브라우저 확장 프로그램 작성

API와 출처가 비어 있습니다. 페이지 측에서 다음과 같이 hello 함수를 호출할 수 있습니다.

보안 브라우저 확장 프로그램 작성

최신 JS에서 콜백 함수를 사용하는 것은 나쁜 방식이므로 API 객체를 utils에 전달할 수 있는 dnode를 생성하는 작은 도우미를 작성해 보겠습니다.

이제 API 개체는 다음과 같습니다.

export class SignerApp {

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

...

}

다음과 같이 원격에서 객체를 가져옵니다.

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

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

그리고 함수를 호출하면 약속이 반환됩니다.

보안 브라우저 확장 프로그램 작성

비동기 기능을 사용할 수 있는 버전 여기에.

전반적으로 RPC 및 스트림 접근 방식은 매우 유연해 보입니다. Steam 멀티플렉싱을 사용하고 다양한 작업에 대해 여러 가지 API를 만들 수 있습니다. 원칙적으로 dnode는 어디에서나 사용할 수 있으며, 가장 중요한 것은 nodejs 스트림 형태로 전송을 래핑하는 것입니다.

대안은 JSON RPC 2 프로토콜을 구현하는 JSON 형식이지만 특정 전송(TCP 및 HTTP(S))에서는 작동하지만 우리의 경우에는 적용할 수 없습니다.

내부 상태 및 localStorage

애플리케이션의 내부 상태(적어도 서명 키)를 저장해야 합니다. 팝업 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)
        }
    }

    ...

} 

백그라운드에서는 모든 것을 함수로 래핑하고 애플리케이션 개체를 창에 작성하여 콘솔에서 작업할 수 있습니다.

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

UI 콘솔에서 몇 가지 키를 추가하고 상태에 어떤 일이 일어나는지 살펴보겠습니다.

보안 브라우저 확장 프로그램 작성

다시 시작할 때 키가 손실되지 않도록 상태를 지속적으로 유지해야 합니다.

localStorage에 저장하고 변경 사항이 있을 때마다 덮어씁니다. 이후에는 UI에도 액세스해야 하며 변경 사항을 구독하고 싶습니다. 이를 기반으로 관찰 가능한 스토리지를 생성하고 해당 변경 사항을 구독하는 것이 편리할 것입니다.

우리는 mobx 라이브러리(https://github.com/mobxjs/mobx). 작업할 필요가 없었기 때문에 선택이 떨어졌지만 정말 공부하고 싶었습니다.

초기 상태의 초기화를 추가하고 저장소를 관찰 가능하게 만들어 보겠습니다.

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

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

“내부적으로” mobx는 모든 상점 필드를 프록시로 대체하고 해당 필드에 대한 모든 호출을 차단합니다. 이 메시지를 구독할 수 있습니다.

아래에서는 "변경 시"라는 용어를 자주 사용하지만 이것이 완전히 정확하지는 않습니다. Mobx는 필드에 대한 액세스를 추적합니다. 라이브러리가 생성하는 프록시 개체의 Getter 및 Setter가 사용됩니다.

액션 데코레이터는 두 가지 용도로 사용됩니다.

  1. ApplyActions 플래그가 있는 엄격 모드에서는 mobx가 상태를 직접 변경하는 것을 금지합니다. 엄격한 조건에서 작업하는 것이 좋은 습관으로 간주됩니다.
  2. 함수가 상태를 여러 번 변경하더라도(예를 들어 여러 줄의 코드에서 여러 필드를 변경하는 경우) 관찰자는 함수가 완료될 때만 알림을 받습니다. 이는 불필요한 상태 업데이트로 인해 불필요한 요소 렌더링이 발생하는 프런트엔드에 특히 중요합니다. 우리의 경우 첫 번째와 두 번째는 특별히 관련이 없지만 모범 사례를 따를 것입니다. 관찰된 필드의 상태를 변경하는 모든 함수에 데코레이터를 연결하는 것이 일반적입니다.

백그라운드에서 초기화를 추가하고 localStorage에 상태를 저장합니다.

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

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

여기서 반응 함수는 흥미롭습니다. 여기에는 두 가지 인수가 있습니다.

  1. 데이터 선택기.
  2. 데이터가 변경될 때마다 이 데이터로 호출되는 핸들러입니다.

상태를 인수로 명시적으로 받는 redux와 달리 mobx는 선택기 내에서 액세스하는 관찰 가능 항목을 기억하고 변경될 때만 핸들러를 호출합니다.

mobx가 우리가 구독할 Observable을 결정하는 방법을 정확히 이해하는 것이 중요합니다. 다음과 같은 코드로 선택기를 작성했다면() => app.store, 저장소 자체는 관찰할 수 없고 해당 필드만 관찰할 수 있으므로 반응은 호출되지 않습니다.

내가 이렇게 썼다면 () => app.store.keys, 배열 요소를 추가/제거할 때 이에 대한 참조가 변경되지 않으므로 다시 아무 일도 일어나지 않습니다.

Mobx는 처음으로 선택기 역할을 하며 우리가 액세스한 Observable만 추적합니다. 이는 프록시 게터를 통해 수행됩니다. 따라서 여기서는 내장 함수를 사용합니다. toJS. 모든 프록시가 원래 필드로 대체된 새 객체를 반환합니다. 실행 중에 객체의 모든 필드를 읽으므로 getter가 트리거됩니다.

팝업 콘솔에서 다시 여러 키를 추가하겠습니다. 이번에는 localStorage에도 포함되었습니다.

보안 브라우저 확장 프로그램 작성

배경 페이지가 다시 로드되면 정보는 그대로 유지됩니다.

현재까지의 모든 애플리케이션 코드를 볼 수 있습니다. 여기에.

개인 키의 안전한 저장

개인 키를 일반 텍스트로 저장하는 것은 안전하지 않습니다. 해킹당하거나 컴퓨터에 액세스할 수 있는 가능성이 항상 존재합니다. 따라서 localStorage에서는 키를 비밀번호로 암호화된 형식으로 저장합니다.

보안을 강화하기 위해 키에 전혀 접근할 수 없는 잠금 상태를 애플리케이션에 추가할 것입니다. 시간 초과로 인해 확장 프로그램이 자동으로 잠금 상태로 전환됩니다.

Mobx를 사용하면 최소한의 데이터 세트만 저장할 수 있으며 나머지는 이를 기반으로 자동 계산됩니다. 이것이 소위 계산된 속성입니다. 데이터베이스의 뷰와 비교할 수 있습니다.

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

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

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

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

이제 암호화된 키와 비밀번호만 저장합니다. 다른 모든 것은 계산됩니다. 상태에서 비밀번호를 제거하여 잠금 상태로 전환합니다. 이제 공개 API에는 저장소를 초기화하는 방법이 있습니다.

암호화를 위해 작성됨 crypto-js를 사용하는 유틸리티:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

브라우저에는 이벤트(상태 변경)를 구독할 수 있는 유휴 API가 있습니다. 따라서 상태는 다음과 같습니다. idle, active и locked. 유휴 상태의 경우 시간 초과를 설정할 수 있으며, OS 자체가 차단되면 잠금이 설정됩니다. 또한 localStorage에 저장하기 위한 선택기를 변경합니다.

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

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

setupApp();

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

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

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

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

이 단계 이전의 코드는 다음과 같습니다. 여기에.

거래

그래서 우리는 가장 중요한 것에 도달했습니다: 블록체인에서 거래를 생성하고 서명하는 것입니다. 우리는 WAVES 블록체인과 라이브러리를 사용할 것입니다 파도 거래.

먼저 서명이 필요한 메시지 배열을 상태에 추가한 다음 새 메시지 추가, 서명 확인 및 거부를 위한 메서드를 추가해 보겠습니다.

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

새 메시지를 받으면 메타데이터를 추가합니다. observable 그리고 추가하세요 store.messages.

그렇지 않으면 observable 수동으로 설정하면 mobx는 배열에 메시지를 추가할 때 자체적으로 이를 수행합니다. 그러나 참조가 없는 새 개체를 생성하지만 다음 단계에서는 해당 개체가 필요합니다.

다음으로, 메시지 상태가 변경되면 해결되는 Promise를 반환합니다. 상태는 반응에 의해 모니터링되며 상태가 변경되면 "자살"됩니다.

메소드 코드 approve и reject 매우 간단합니다. 필요한 경우 서명한 후 메시지 상태를 변경하기만 하면 됩니다.

UI API에 승인 및 거부를 넣고 페이지 API에 newMessage를 넣습니다.

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

    ...
}

이제 확장 기능을 사용하여 트랜잭션에 서명해 보겠습니다.

보안 브라우저 확장 프로그램 작성

일반적으로 모든 것이 준비되었으며 남은 것은 간단한 UI 추가.

UI

인터페이스는 애플리케이션 상태에 액세스해야 합니다. UI 측면에서 우리는 할 것입니다 observable 상태를 변경하고 이 상태를 변경하는 함수를 API에 추가합니다. 추가하자 observable 백그라운드에서 수신된 API 객체에:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

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

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

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

    // Запуск интерфейса
    await initApp(background)
}

마지막에는 애플리케이션 인터페이스 렌더링을 시작합니다. 이것은 반응 응용 프로그램입니다. 배경 객체는 단순히 props를 사용하여 전달됩니다. 물론 메서드에 대한 별도의 서비스와 상태에 대한 저장소를 만드는 것이 옳을 것입니다. 그러나 이 기사의 목적에 따르면 이것으로 충분합니다.

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

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

mobx를 사용하면 데이터가 변경될 때 렌더링을 시작하는 것이 매우 쉽습니다. 우리는 단순히 패키지에서 관찰자 데코레이터를 걸어 놓습니다. mobx 반응 구성 요소에서 참조하는 관찰 가능 항목이 변경되면 렌더링이 자동으로 호출됩니다. mapStateToProps가 필요하지 않으며 redux처럼 연결할 필요도 없습니다. 모든 것이 즉시 사용 가능합니다.

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

나머지 구성 요소는 코드에서 볼 수 있습니다. UI 폴더에.

이제 애플리케이션 클래스에서 UI에 대한 상태 선택기를 만들고 상태가 변경되면 UI에 알려야 합니다. 이를 위해 메소드를 추가해 보겠습니다. getState и reaction부름 remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

물건을 받을 때 remote 만들어졌다. reaction UI 측에서 함수를 호출하는 상태를 변경합니다.

마지막 터치는 확장 아이콘에 새 메시지 표시를 추가하는 것입니다.

function setupApp() {
...

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

...
}

그럼 신청이 준비되었습니다. 웹페이지에서는 거래에 대한 서명을 요청할 수 있습니다.

보안 브라우저 확장 프로그램 작성

보안 브라우저 확장 프로그램 작성

코드는 여기에서 확인할 수 있습니다. 링크.

결론

기사를 끝까지 읽었지만 여전히 질문이 있는 경우 다음 페이지에서 질문할 수 있습니다. 확장 기능이 있는 저장소. 여기에서는 지정된 각 단계에 대한 커밋도 찾을 수 있습니다.

실제 확장에 대한 코드를 보고 싶다면 다음을 참조하세요. 여기에.

코드, 저장소 및 작업 설명 시마렐

출처 : habr.com

코멘트를 추가