Biztonságos böngészőbővítmény írása

Biztonságos böngészőbővítmény írása

A decentralizált alkalmazásokra jellemző, ellentétben a megszokott "kliens-szerver" architektúrával,

  • Nem szükséges tárolni a felhasználók bejelentkezési adatait és jelszavait. A hozzáférési információk kizárólag a felhasználóknál tárolódnak, és az érvényességük megerősítése a protokoll szintjén történik.
  • Nincs szükség szerver használatára. Az alkalmazás logikája a blokklánc-hálózaton is végrehajtható, ahol a szükséges adatok tárolása is lehetséges.

Két viszonylag biztonságos tároló létezik a felhasználói kulcsok számára — hardveres pénztárcák és böngészőbővítmények. A hardveres pénztárcák többsége maximálisan biztonságos, de nehezen használhatóak és nem ingyenesek, míg a böngészőbővítmények ideális választást jelentenek a biztonság és a könnyű használhatóság között, és lehetnek teljesen ingyenesek a végfelhasználók számára.

Figyelembe véve mindezt, egy maximálisan biztonságos bővítmény fejlesztésére tettük a kísérletünket, amely megkönnyíti a decentralizált alkalmazások fejlesztését, egyszerű API-t biztosítva a tranzakciók és aláírások kezeléséhez.
Erről az élményről fogunk beszélni Önnek az alábbiakban.

A cikk tartalmazni fog egy lépésről lépésre útmutatót, bemutató kódpéldákkal és képernyőfelvételekkel. Az összes kódot itt találhatja tárhelyen. Minden egyes commit logikai szempontból megfelel a cikk egy-egy részének.

A böngészőbővítmények rövid története

A böngészőbővítmények már régen léteznek. Az Internet Explorerben 1999-ben jelentek meg, a Firefox-ban pedig 2004-ben. Ennek ellenére hosszú ideig nem volt egységes szabvány a bővítmények számára.

Azt lehet mondani, hogy ez együtt jelent meg a bővítményekkel a Google Chrome negyedik verziójában. Természetesen akkor még nem volt szabvány, de éppen a Chrome API-ja lett annak alapja: a böngészőpiac nagy részét megszerezve és beépített alkalmazásboltjával a Chrome gyakorlatilag megteremtette a böngészőbővítmények szabványát.

A Mozilla saját szabvánnyal rendelkezett, de látva a Chrome bővítményeinek népszerűségét, a cég úgy döntött, hogy kompatibilis API-t készít. 2015-ben a Mozilla kezdeményezésére a World Wide Web Consortium (W3C) keretében egy speciális csoport alakult a keresztböngészős bővítmények specifikációinak kidolgozására.

A meglévő Chrome böngésző-kiterjesztések API-ját vették alapul. A munka a Microsoft támogatásával zajlott (a Google a szabvány kidolgozásában való részvételt megtagadta), ennek eredményeként megszületett a vázlat. specifikáció.

Formálisan az Edge, a Firefox és az Opera támogatja a specifikációt (jegyezzük meg, hogy a Chrome nincs ebben a listában). De valójában a szabvány sok szempontból a Chrome-mal is kompatibilis, mivel gyakorlatilag annak kiterjesztései alapján írták. További információt a WebExtensions API-ról lehet olvasni. itt.

A kiterjesztés struktúrája

Az egyetlen fájl, amelyre feltétlenül szükség van a kiterjesztéshez, a manifeszt (manifest.json). Ez a „belépési pont” a kiterjesztéshez.

Manifeszt

A specifikáció szerint a manifeszt fájl érvényes JSON fájl. A manifeszt kulcsainak teljes leírását, hogy melyik kulcsokat támogatják melyik böngészőben, itt lehet megtekinteni. itt.

A specifikációban nem szereplő kulcsok „figyelmen kívül hagyhatóak” (a Chrome és a Firefox is jelezheti a hibákat, de a kiterjesztések továbbra is működnek).

Szeretnék felhívni a figyelmet néhány pontra.

  1. background — egy objektum, amely a következő mezőket tartalmazza:
    1. scripts — egy tömb, amely a háttérkörnyezetben végrehajtandó skripteket tartalmazza (erről majd később beszélünk);
    2. page — ahelyett, hogy a skriptek egy üres oldalon futnának, html-t is meg lehet adni a tartalommal. Ebben az esetben a script mező figyelmen kívül lesz hagyva, és a skripteket a tartalommal rendelkező oldalra kell beilleszteni;
    3. persistent — bináris jelző, ha nem adták meg, akkor a böngésző «megölni» fogja a háttérfolyamatot, amikor úgy ítéli meg, hogy az semmit sem csinál, és szükség esetén újraindítja. Ellenkező esetben az oldal csak a böngésző bezárásakor lesz kiszabadítva. Nem támogatott a Firefox-ban.
  2. content_scripts — objektumok tömbje, amely lehetővé teszi különböző skriptek betöltését különböző weboldalakra. Minden objektum tartalmazza a következő fontos mezőket:
    1. matches — url minta, amely meghatározza, hogy a konkrét content script be lesz-e kapcsolva vagy sem.
    2. js — a betöltendő skriptek listája az adott meccshez;
    3. exclude_matches — kizárja a mezőből match URL-eket, amelyek megfelelnek ennek a mezőnek.
  3. page_action — gyakorlatilag egy objektum, amely a böngésző címsora mellett megjelenő ikonért és annak interakciójáért felel. Lehetővé teszi a popup ablak megjelenítését, amelyet a saját HTML, CSS és JS segítségével állítanak be.
    1. default_popup — útvonal a popup interfész HTML fájljához, CSS-t és JS-t is tartalmazhat.
  4. A végcél az, hogy a szolgáltatásai bármilyen kérést elfogadjanak, magabiztosan feltételezve, hogy megbízható forrásból származnak — tömb a bővítmény jogainak kezelésére. Háromféle jog létezik, amelyek részletesen le vannak írva itt
  5. web_accessible_resources — a bővítmény olyan erőforrásait, amelyeket a weboldal igényelhet, például képek, JS, CSS, HTML fájlok.
  6. externally_connectable — itt egyértelműen megadhatók más bővítmények ID-jai és azok domainjei, ahonnan csatlakozni lehet. A domain másodlagos szinten és fölötte lehet. Firefoxban nem működik.

Végrehajtási kontextus

A bővítménynek három kódvégrehajtási kontextusa van, vagyis az alkalmazás három részből áll, amelyek eltérő hozzáférést biztosítanak a böngésző API-hoz.

Bővítmény kontextus

Itt a legtöbb API elérhető. Ebben a kontextusban találhatóak:

  1. Háttér oldal — a bővítmény „háttér” része. A fájl a „background” kulcs alatt van megadva a manifesztben.
  2. Popup oldal — a popup oldal, amely akkor jelenik meg, amikor a bővítmény ikonjára kattintanak. A manifesztben browser_action -> default_popup.
  3. Egyedi oldal — a bővítmény oldala, amely külön fülön létezik, a következő formában chrome-extension:///customPage.html.

Ez a kontextus független a böngésző ablakaitól és füleitől. Háttér oldal egyetlen példányban létezik és mindig működik (kivétel az eseményoldal, amikor a háttérszkript esemény hatására indul és a végrehajtás után „meghal”). Popup oldal akkor létezik, amikor a popup ablak nyitva van, Egyedi oldal — amíg a lapon van nyitva. Ebből a kontextusból nincs hozzáférés más fülekhez és azok tartalmához.

Tartalomscript kontextus

A tartalomscript fájl a böngésző minden fülével együtt fut. Hozzáféréssel bír a bővítmény API egy részéhez és a weboldal DOM fájához. A tartalomscript-ek felelősek az oldal interakciójáért. A DOM fáj manipuláló bővítmények a tartalomscript-ekben hajtják végre, például hirdetésblokkolók vagy fordítók. Emellett a tartalomscript kommunikálhat az oldalra a szabványos postMessage.

Weboldal kontextus

Ez magának az weboldalnak a kontextusa. Nincs kapcsolata a bővítménnyel, és nincs hozzáférése, kivéve, ha a manifesztben kifejezetten meg van adva az oldal domainje (erről a későbbiekben).

Üzenetváltás

A különböző alkalmazásrészek közötti üzenetváltásra van szükség. Ehhez létezik az API runtime.sendMessage üzenet küldésére background és tabs.sendMessage üzenet küldésére az oldalra (tartalomscriptnek, popupnak vagy weboldalnak, ha elérhető) externally_connectable). Itt van egy példa a Chrome API-jának hívására.

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

A teljes kommunikációhoz kapcsolatok létrehozása lehetséges runtime.connect. Válaszul runtime.Port, amelybe, amíg nyitva van, tetszőleges számú üzenetet küldhetünk. Az ügyfélen, például contentscript, ez így néz ki:

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

Szerver vagy háttér:

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

Van egy esemény is onDisconnect és egy metódus N másodperc után.

Alkalmazás séma

Készítsünk egy böngészőbővítményt, amely privát kulcsokat tárol, hozzáférést biztosít a nyilvános információkhoz (cím, nyilvános kulcs beszél a lappal, és lehetővé teszi harmadik féltől származó alkalmazások számára a tranzakciók aláírásának igénylését.

Alkalmazás fejlesztése

Alkalmazásunknak interakcióval kell rendelkeznie a felhasználóval, és API-t kell nyújtania az oldal számára a metódusok hívásához (például tranzakciók aláírásához). Csak a contentscript nem elegendő, mivel csak a DOM-hoz fér hozzá, de nem a lap JS-hez. Nem tudunk csatlakozni a runtime.connect , mert az API-ra minden domainek szüksége van, és a manifestben csak konkrétokat lehet megadni. Az eredményül kapott séma így fog kinézni:

Biztonságos böngészőbővítmény írása

Lesz még egy szkript — inpage, amelyet injektálni fogunk az oldalba. Ez a kontextusában fog futni, és API-t biztosít a bővítménnyel való munkához.

Kezdet

A böngészőbővítmény teljes kódja elérhető a GitHub. A leírás során hivatkozások lesznek a commitokra.

Kezdjük a manifesztel:

{
  // Név és leírás, verzió. Mindez látható lesz a böngészőben a chrome://extensions/?id=<<bővítmény azonosítója>
  "name": "Signer",
  "description": "Bővítmény bemutató",
  "version": "0.0.1",
  "manifest_version": 2,

  // Szkriptek, amelyek háttérben fognak futni, lehetnek többen is
  "background": {
    "scripts": ["background.js"]
  },

  // Milyen HTML-t használjunk a popuphoz
  "browser_action": {
    "default_title": "Saját Bővítmény",
    "default_popup": "popup.html"
  },

  // Tartalomszkriptek.
  // Van egy objektumunk: minden http vagy https kezdő URL-re elindítjuk
  // a contenscript kontextust a contentscript.js szkripttel. Azonnal indítani a dokumentum beszerzésekor minden keretrendszer számára
  "content_scripts": [
    {
      "matches": [
        "http:/*/*",
        "https:/*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Hozzáférést biztosít a localStorage-hoz és idle API-hoz
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Itt adjuk meg az erőforrásokat, amelyekhez hozzáférést kap a weboldal. Tehát ezeket lehet fetchelni vagy egyszerűen xhr-el lekérni
  "web_accessible_resources": ["inpage.js"]
}

Üres background.js, popup.js, inpage.js és contentscript.js fájlokat hozunk létre. Hozzáadjuk a popup.html-t — és az alkalmazásunkat máris fel lehet tölteni a Google Chrome-ba, hogy ellenőrizzük, hogy működik.

Ennek ellenőrzéséhez vegyük az alábbi kódot: innen. Ezen kívül a projekt fejlesztéséhez webpack-ot használtunk. Az alkalmazás böngészőbe való hozzáadásához a chrome://extensions oldalon kell megválasztani a load unpacked lehetőséget és a megfelelő kiterjesztés mappáját — esetünkben a dist mappát.

Biztonságos böngészőbővítmény írása

Most a kiterjesztésünk telepítve van és működik. A fejlesztői eszközök különböző kontextusokhoz a következőképpen indíthatók:

popup ->

Biztonságos böngészőbővítmény írása

A tartalomscript konzoljához való hozzáférés az adott oldal konzolján keresztül történik, amelyen fut.Biztonságos böngészőbővítmény írása

Üzenetváltás

Tehát két kommunikációs csatornát kell beállítanunk: inpage background és popup background. Természetesen a porton keresztül is küldhetünk üzeneteket, és kitalálhatunk egy saját protokollt, de nekem jobban tetszik az a megközelítés, amit a metamask nyílt forráskódú projektjéből lestem el.

Ez egy böngészőbővítmény, amely az Ethereum hálózattal való munkára szolgál. Az alkalmazás különböző részei RPC-n keresztül kommunikálnak a dnode könyvtár segítségével. Ez lehetővé teszi, hogy elég gyorsan és könnyen szervezzük meg az adatcserét, ha mint szállító nodejs stream-et adunk meg (azt jelenti, hogy olyan objektumot, amely ugyanazt az interfészt valósítja meg):

import Dnode from "dnode/browser";

// Ebben a példában elfogadjuk, hogy az ügyfél távolról hívja meg a szerveren található funkciókat, bár semmi nem akadályoz meg minket abban, hogy ezt kétirányúvá tegyük.

// Szerver
// Az API, amelyet meg szeretnénk adni
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Az a szállító, amelyen a dnode működni fog. Bármilyen nodejs stream. A böngészőben elérhető a 'readable-stream' könyvtár.
connectionStream.pipe(dnode).pipe(connectionStream)

// Ügyfél
const dnodeClient = Dnode() // A hívás argumentum nélküli, így nem adunk API-t a másik oldalon.

// A világot ki fogja írni a konzolra
dnodeClient.once('remote', remote => {
    remote.hello((err, value) => console.log(value))
})

Most létrehozunk egy alkalmazásosztályt. Ez API objektumokat fog létrehozni a popup és a weboldal számára, valamint dnode-ot is generál számukra:

import Dnode from 'dnode/browser';

export class SignerApp {

    // API objektumot ad vissza a ui számára
    popupApi(){
        return {
            hello: cb => cb(null, 'világ')
        }
    }

    // API objektumot ad vissza az oldal számára
    pageApi(){
        return {
            hello: cb => cb(null, 'világ')
        }
    }

    // Popup ui csatlakoztatása
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

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

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

    // Oldal csatlakoztatása
    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)
        })
    }
}

Itt és ezután a globális Chrome objektum helyett az extentionApi-t használjuk, amely a Chrome-hoz a Google böngészőjében, és a browser-hoz más böngészőkben fordul. Ezt a cross-browser kompatibilitás érdekében tesszük, de a cikk keretein belül egyszerűen a 'chrome.runtime.connect'-t is használhatnánk.

Hozzunk létre egy alkalmazás példányt a háttér scriptben:

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

const app = new SignerApp();

// onConnect esemény aktiválódik a 'processzek' (contentscript, popup, vagy kiterjesztés oldala) csatlakozásakor
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // Csatlakozáskor megadhatjuk a nevet, amely alapján meghatározzuk, hogy ki csatlakozott, a tartalom script vagy a ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Mivel a dnode stream-ekkel dolgozik, és portot kapunk, szükség van egy adapter osztályra. Ezt a readable-stream könyvtár segítségével készítettük, amely a nodejs stream-eket valósítja meg a böngészőben:

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 - megszakítva'))
        }
        cb()
    }
}

Most csatlakozást hozunk létre az UI-ban:

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(){
    // Hasonlóan, mint az alkalmazás osztályban, portot hozunk létre, streambe burkolva, dnode-t készítünk
    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)
        })
    });

    // Az API objektumot elérhetővé tesszük a konzolból
    if (DEV_MODE){
        global.background = background;
    }
}

A következő lépésben csatlakozást hozunk létre a tartalom scriptben:

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 {
        // beilleszthetjük az oldalon lévő scriptet
        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);
    }
}

Mivel az API-ra szükségünk van nem a tartalom scriptben, hanem közvetlenül az oldalon, két dolgot teszünk:

  1. Két stream-et hozunk létre. Az egyik az oldal irányába, a postMessage-t felülírva. Ehhez ezt használjuk ezt a csomagot a metamask fejlesztőitől. A második stream a háttérhez, a port fölött, amit a runtime.connect. Összekötjük őket. Most az oldalnak streamje lesz a háttérhez.
  2. Beillesztjük a scriptet a DOM-ba. Lekérjük a scriptet (hozzáférését a manifest engedélyezte) és létrehozunk egy script témét a tartalmával együtt:

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

setupConnection();
injectScript();

function setupConnection(){
    // Stream a háttérhez
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

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

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

function injectScript(){
    try {
        // beilleszthetjük az oldalon lévő scriptet
        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);
    }
}

Most létrehozunk egy api objektumot az inpage-ben és beállítjuk a global-ba:

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

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Kapcsolati stream a tartalom scripthez
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

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

    // API objektum megszerzése
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Hozzáférés a window-on keresztül
    global.SignerApp = pageApi;
}

Készen állunk Távoli eljárás hívás (RPC) külön API-val az oldal és a UI számára. Egy új oldal csatlakoztatásakor a háttérhez ezt láthatjuk:

Biztonságos böngészőbővítmény írása

Üres API és origin. Az oldalon a hello funkciót így hívhatjuk meg:

Biztonságos böngészőbővítmény írása

A callback-funkciókkal való munka a modern JS-ben nem ajánlott, ezért írjunk egy kis segédprogramot a dnode létrehozásához, amely lehetővé teszi, hogy az API objektumba átadjuk a utils-ot.

Mostantól az API objektumok így fognak kinézni:

export class SignerApp {

    popupApi() {
        return {
            hello: async () => "világ"
        }
    }

...

}

Az objektum megszerzése a remote függvényével a következőképpen:

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

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // Az utility segítségével minden callbacket promise-ra alakítunk
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

A függvények hívása ígéreteket ad vissza:

Biztonságos böngészőbővítmény írása

Az aszinkron funkciókkal rendelkező verzió elérhető itt.

Összességében az RPC-vel és stream-ekkel való megközelítés elég rugalmasnak tűnik: használhatjuk a stream multiplexing-et, és több különböző API-t hozhatunk létre különböző feladatokhoz. A dnode gyakorlatilag bárhol használható, a lényeg, hogy a szállítást nodejs stream-ként csomagoljuk.

Alternatívát jelent a JSON formátum, amely a JSON RPC 2 protokollt valósítja meg. Azonban ez konkrét szállítókkal (TCP és HTTP(S)) működik, ami a mi esetünkben nem alkalmazható.

Belső állapot és localStorage

Szükségünk lesz az alkalmazás belső állapotának tárolására - legalább a digitális aláírások kulcsaira. Elég könnyen hozzáadhatunk állapotot az alkalmazáshoz és módokat a módosításához a popup API-ban:

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

    ...

} 

A háttérben mindent egy funkcióba csomagolunk, és az alkalmazás objektumát a window-ba írjuk, hogy a konzolból tudjunk vele dolgozni:

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

Hozzáadunk néhány kulcsot az UI konzolból, és megnézzük, mi történt az állapottal:

Biztonságos böngészőbővítmény írása

Az állapotot tartósítani kell, hogy a kulcsok ne vesszenek el újraindításkor.

A localStorage-ban fogjuk tárolni, minden változáskor felülírva. Később az UI-nak is szüksége lesz rá, ezért jó lenne, ha figyelhetnénk a változásokra is. Erre célszerű lenne egy megfigyelhető tárolót (observable storage) létrehozni, és előfizetni a változásokra.

A mobx könyvtárat fogjuk használni (https://github.com/mobxjs/mobx). Ezt választottuk, mert eddig nem dolgoztunk vele, de nagyon szeretnénk megismerni.

Hozzáadjuk a kezdeti állapot inicializálását, és az tárolót megfigyelhetővé tesszük:

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

export class SignerApp {

    constructor(initState = {}) {
        // A tároló külsőleg ugyanaz az objektum marad, csak most minden mezője proxy, ami figyeli a hozzáféréseket
        this.store = observable.object({
            keys: initState.keys || [],
        });
    }

    // A megfigyelhető állapotot módosító metódusokat érdemes dekorátorral körbevenni
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

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

    ...

}

A mobx „motorháztetője” alatt minden tárolómezőt proxy-ra cserél, és interceptálja a hozzáféréseket. Ezekre a hozzáférésekre előfizethetünk.

Továbbiakban gyakran fogom használni a “változáskor” kifejezést, bár ez nem teljesen helyes. A mobx a mezőkhez való hozzáférést figyeli. A könyvtár által létrehozott proxy-objektumok gettereit és settereit használja.

Az action dekorátorok két célt szolgálnak:

  1. Szigorú módban, az enforceActions flaggal a mobx megtiltja az állapot közvetlen módosítását. Jó stílusnak számít éppen a szigorú módban való munkavégzés.
  2. Még ha a funkció többször is megváltoztatja az állapotot – például több mezőn is változtatunk néhány kódsoron keresztül – a megfigyelők csak annak befejezésekor értesülnek róla. Ez különösen fontos a frontend számára, ahol a felesleges állapotfrissítések nem kívánt elemek újrarendeléséhez vezetnek. Az esetünkben sem az első, sem a második nem különösebben releváns, ugyanakkor a legjobb gyakorlatokat fogjuk követni. A dekorátorokat minden olyan funkcióra érdemes alkalmazni, amelyek megváltoztatják a megfigyelt mezők állapotát.

A háttérben hozzáadjuk az állapot inicializálását és helyi tárolásba mentését:

import {reaction, toJS} from 'mobx';
import {extensionApi} from ".\/utils\/extensionApi";
import {PortStream} from ".\/utils\/PortStream";
import {SignerApp} from ".\/SignerApp";
// Segédmetódusok. Objektum írása/olvasása a helyi tárolásba JSON string formájában a 'store' kulcs alatt
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;
    }

    // Állapotmegőrzés beállítása

    // A reaction eredménye egy változónak van hozzárendelva, hogy a feliratkozást le lehessen mondani. Erre nincs szükségünk, példának van benne hagyva
    const localStorageReaction = reaction(
        () => toJS(app.store), // Adatválasztó funkció
        saveState // Funkció, amely akkor fog meghívódni, amikor a választó által visszaadott adatok változnak
    );

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

Itt érdekes a reaction funkció. Két argumentuma van:

  1. Adatválasztó.
  2. Olyan kezelő, amely meghívódik ezekkel az adatokkal minden alkalommal, amikor megváltoznak.

A reduxszal ellentétben, ahol kifejezetten megkapjuk az állapotot argumentumként, a mobx megjegyzi, hogy pontosan mely observable-hoz fordulunk az adatválasztón belül, és csak azok megváltozása esetén hívja meg a kezelőt.

Fontos megérteni, hogyan dönti el a mobx, hogy mely observable-ra iratkozunk fel. Ha a kódban így írnám az adatválasztót() => app.store, akkor a reaction soha nem hívódna meg, mivel magában a tárolóban nem megfigyelhető, csak a mezői azok.

Ha így írnám () => app.store.keys, akkor megint semmi nem történne, mivel a tömb elemeinek hozzáadása/törlésekor a hivatkozás nem fog megváltozni.

A Mobx először végrehajtja a szeletelő funkciót, és csak azokat az observable-okat figyeli, amelyekhez hozzáfértünk. Ezt proxy gettereken keresztül valósítják meg. Ezért itt a beépített funkció van használva. toJS. Ez egy új objektumot ad vissza, amelyben minden proxy az eredeti mezőkre van cserélve. A végrehajtás során beolvassa az objektum minden mezőjét – így a getterek is aktiválódnak.

A popup konzolon újra hozzáadunk néhány kulcsot. Ezúttal ezek a localStorage-ba is bekerültek:

Biztonságos böngészőbővítmény írása

Az háttéroldal újraindításakor az információk a helyükön maradnak.

Az alkalmazás eddigi teljes kódja megtekinthető itt.

Privát kulcsok biztonságos tárolása

A privát kulcsokat nyílt formában tárolni nem biztonságos: mindig fennáll a lehetősége, hogy feltörnek, és hozzáférést nyernek a számítógéphez, stb. Ezért a localStorage-ban titkosítva, jelszóval fogjuk tárolni a kulcsokat.

A nagyobb biztonság érdekében hozzáadunk egy locked állapotot az alkalmazáshoz, amelyben a kulcsokhoz való hozzáférés teljesen meg lesz szüntetve. Az kiterjesztést automatikusan locked állapotra váltjuk időzítő alapján.

A Mobx lehetővé teszi, hogy csak a minimális adatokat tároljuk, míg a többit automatikusan ezek alapján számítsuk ki. Ezeket úgynevezett computed properties-ként lehet a database nézetekhez hasonlítani:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Eszközök biztonságos szövegek titkosításához. A crypto-js-t használja
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // A jelszót és a titkosított kulcsokat tároljuk. Ha a jelszó null - az alkalmazás zárolva van
            password: null,
            vault: initState.vault,

            // Getterek a számított mezőkhöz. Analógiát vonhatunk a bázis view-jához.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Üres tároló inicializálása új jelszóval
    @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
        )
    }

    ... // kód csatlakozásra és API-ra

    // privát
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('Az alkalmazás zárolva van')
        }
    }

    // Metódusok a tároló titkosításához/dekódolásához
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('A tároló nincs inicializálva')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Helytelen jelszó')
        }
    }
}

Most már csak a titkosított kulcsokat és a jelszót tároljuk. Minden más számított. A locked állapotba való átállítást a jelszó eltávolításával végezzük. A nyilvános API-ban megjelent egy metódus a tároló inicializálására.

A titkosításhoz az alábbiakat írtuk a crypto-js használatával készült eszközök:

import CryptoJS from 'crypto-js'

// A jelszó próbálkozások erejének növelésére szolgál. Minden egyes jelszóváltozathoz a támadónak 5000 hash-t kell végrehajtania
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)
}

A böngésző rendelkezik idle API-val, amelyen keresztül feliratkozhatunk az eseményre - állapotváltozások. Az állapot tehát lehet tétlen, aktív és lezárva. Az idle állapothoz beállítható egy időtúllépés, míg a locked akkor aktiválódik, amikor az operációs rendszer blokkolva van. Emellett megváltoztatjuk a szelektort a localStorage-ba való mentéshez:

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

    // Most már kifejezetten hívjuk azt a mezőt, amelyhez hozzáférést kapunk, a reaction normálisan fog működni.
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Befejeződési időtúllépés, amikor bekapcsol egy esemény
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Ha a felhasználó lezárta a képernyőt vagy inaktív volt az adott intervallumban, lezárjuk az alkalmazást
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Kapcsolódás más kontextusokhoz
    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)
        }
    }
}

A kód eddig az lépésig található itt.

Tranzakciók

Tehát eljutottunk a legfontosabb ponthoz: tranzakciók létrehozása és aláírása a blokkláncon. A WAVES blokkláncot és a waves-transactions.

használatával kezdetben hozzáadunk egy tömböt az állapothoz, amely tartalmazza az aláírásra váró üzeneteket, majd - új üzenet hozzáadása, aláírás megerősítése és elutasítása:

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) {
        // Minden üzenethez metadata-t hozunk létre id-val, státusszal, létrehozás idejével stb.
        const message = observable.object({
            id: uuid(), // Azonosító, uuid-t használunk
            origin, // Az origin-t később megjelenítjük az interfészen
            data, //
            status: 'new', // A státuszok négyféle lehet: new, signed, rejected és failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Visszaadunk egy promist, amelyben a mobx figyeli az üzenet változásait. Amint a státusz megváltozik, meg fogjuk oldani
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, // Figyelni fogjuk az üzenet státuszát
                (status, reaction) => { // A második argumentum az önálló reakcióra való hivatkozás, amelyet el lehet távolítani a hívás belsejében
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('A felhasználó elutasította az üzenetet'));
                            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(`Nincs msg az id:${id} azonosítóval`);
        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(`Nincs msg az id:${id} azonosítóval`);
        message.status = 'rejected'
    }

    ...
}

Új üzenet érkezésekor metadata-t adunk hozzá, csinálunk observable és hozzáadjuk a store.messages-hez.

Ha nem történik manuálisan, akkor a mobx automatikusan megteszi, amikor hozzáadja az üzenetek tömbhöz. Azonban ez egy új objektumot hoz létre, amellyel nem lesz hivatkozásunk, pedig szükség lesz rá a következő lépéshez. observable Ezután visszaadunk egy promist, amely a státusz változása esetén oldódik meg. A státuszt egy reakcó figyeli, amely saját magát "megöli" a státuszváltáskor.

A metódusok kódja

nagyon egyszerű: csak megváltoztatjuk az üzenet státuszát, előtte aláírva, ha szükséges. approve és reject nagyon egyszerű: csak megváltoztatjuk az üzenet státuszát, előtte aláírva, ha szükséges.

Az engedélyezést és az elutasítást az API UI-be, az új üzeneteket pedig az API oldalára helyezzük.

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

    ...
}

Most próbáljuk aláírni a tranzakciót a kiterjesztéssel:

Biztonságos böngészőbővítmény írása

Összességében minden készen áll, még hozzá kell adni egy egyszerű UI-t.

UI

Az interfészt hozzá kell férni az alkalmazás állapotához. A UI oldalán ezt fogjuk csinálni: observable állapotot létrehozunk, és hozzáadunk egy API funkciót, ami ezt az állapotot változtatni fogja. Hozzáadjuk observable az API-hoz, amit a háttérből kapunk:

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() {
    // Csatlakozunk a portra, és stream-t készítünk belőle
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Létrehozunk egy üres observable-t a háttér állapotához
    let backgroundState = observable.object({});
    const api = {
        // Adunk a háttérnek egy funkciót, amely frissíti az observable-t
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // RPC objektumot készítünk
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Hozzáadjuk a háttérhez az observable-t az állapottal
    background.state = backgroundState;

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

    // Az interfész elindítása
    await initApp(background)
}

A végén elindítjuk az alkalmazás interfészének renderelését. Ez egy react-alapú alkalmazás. A háttér objektumot egyszerűen props-on keresztül adjuk át. Természetesen helyesebb lenne egy külön szolgáltatást készíteni a metódusokhoz és egy store-t az állapothoz, de ebben a cikkben ez elegendő:

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

// Inicializáljuk az alkalmazást a háttér objektummal props-ként
export async function initApp(background){
    render(
        ,
        document.getElementById('app-content')
    );
}

A mobx segítségével nagyon egyszerű a renderelés indítása az adatok változásakor. Csak a package-ből származó observer dekorátort kell felfüggesztenünk. mobx-react a komponensre, és a renderelés automatikusan meghívódik, amikor bármely observable, amire a komponens hivatkozik, módosul. Nincs szükség mapStateToProps vagy connect használatára, mint a redux esetében. Minden azonnal működik 'do it yourself' alapon:

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 \/\/ A komponens esetén, amelyiknek ez a dekorátora van, automatikusan meghívódik a render metódus, ha az általa hivatkozott observable-ok megváltoznak
export default class App extends Component {

    \/\/ Természetesen helyes kivenni az oldalak render logikáját a routolásba, és nem használni a beágyazott ternáris operátorokat,
    \/\/ valamint közvetlenül összekapcsolni az observable-okat és a background metódusait azokkal a komponensekkel, amelyek használják őket
    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;Zárd le az alkalmazást</button>}
                {initialized &amp;&amp; <button onclick="{()" > deleteVault()}&gt;Töröld az összes kulcsot és kezdd el újra</button>}
            </div>
        </Fragment>
    }
}

A többi komponens a kódban megtekinthető a UI mappában.

Most az alkalmazás osztályában állítsunk be egy UI állapotválasztót, és amikor az megváltozik, értesítsük a UI-t. Ehhez hozzáadunk egy metódust getState és reaction, amely meghívja 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) => {
            // Létrehozunk egy reaction-t az állapotváltozásokra, amely meghívja a távoli eljárást és frissíti az állapotot az UI folyamatban
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // A harmadik argumentumban átadhatók paraméterek. A fireImmediately azt jelenti, hogy a reaction először azonnal végrehajtódik.
                // Ez szükséges az alapállapot megszerzéséhez. A delay lehetővé teszi a debouce beállítását
                {fireImmediately: true, delay: 500}
            );
            // Eltávolítjuk az előfizetést az ügyfél lekapcsolásakor
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Amikor az objektumot remote létrehozzuk reaction az állapotváltozásra, amely meghívja a funkciót az UI oldalán.

Az utolsó simítás – adjuk hozzá az új üzenetek megjelenítését a kiterjesztés ikonján:

function setupApp() {
...

    // Reaction a badge szöveg beállítására.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Tehát az alkalmazás készen áll. A weboldalak kérhetik a tranzakciók aláírását:

Biztonságos böngészőbővítmény írása

Biztonságos böngészőbővítmény írása

A kód ezen a linkre kattintva.

Zárás

Ha eljutottál a cikk végére, de maradtak kérdéseid, azokat felteheted a kiterjesztés repository-jában. Ott találod a commitokat minden megjelölt lépéshez.

Ha pedig érdekel, hogy egy valódi kiterjesztés kódját szeretnéd megnézni, azt itt találod itt.

A kód, a repository és a működés leírása tól/től siemarell

Forrás: habr.com

Megbízható hosting vásárlása weboldalak számára, DDoS védelemmel, VPS VDS szerverek. 🔥 Megbízható webtárhely vásárlása DDoS védelemmel, VPS VDS szerverek | ProHoster