1import asyncio
2import hashlib
3import json
4import sys
5import traceback
6from typing import Union, TYPE_CHECKING
7
8import base64
9
10from electrum.plugin import BasePlugin, hook
11from electrum.crypto import aes_encrypt_with_iv, aes_decrypt_with_iv
12from electrum.i18n import _
13from electrum.util import log_exceptions, ignore_exceptions, make_aiohttp_session
14from electrum.network import Network
15
16if TYPE_CHECKING:
17    from electrum.wallet import Abstract_Wallet
18
19
20class ErrorConnectingServer(Exception):
21    def __init__(self, reason: Union[str, Exception] = None):
22        self.reason = reason
23
24    def __str__(self):
25        header = _("Error connecting to {} server").format('Labels')
26        reason = self.reason
27        if isinstance(reason, BaseException):
28            reason = repr(reason)
29        return f"{header}: {reason}" if reason else header
30
31
32class LabelsPlugin(BasePlugin):
33
34    def __init__(self, parent, config, name):
35        BasePlugin.__init__(self, parent, config, name)
36        self.target_host = 'labels.electrum.org'
37        self.wallets = {}
38
39    def encode(self, wallet: 'Abstract_Wallet', msg: str) -> str:
40        password, iv, wallet_id = self.wallets[wallet]
41        encrypted = aes_encrypt_with_iv(password, iv, msg.encode('utf8'))
42        return base64.b64encode(encrypted).decode()
43
44    def decode(self, wallet: 'Abstract_Wallet', message: str) -> str:
45        password, iv, wallet_id = self.wallets[wallet]
46        decoded = base64.b64decode(message)
47        decrypted = aes_decrypt_with_iv(password, iv, decoded)
48        return decrypted.decode('utf8')
49
50    def get_nonce(self, wallet: 'Abstract_Wallet'):
51        # nonce is the nonce to be used with the next change
52        nonce = wallet.db.get('wallet_nonce')
53        if nonce is None:
54            nonce = 1
55            self.set_nonce(wallet, nonce)
56        return nonce
57
58    def set_nonce(self, wallet: 'Abstract_Wallet', nonce):
59        self.logger.info(f"set {wallet.basename()} nonce to {nonce}")
60        wallet.db.put("wallet_nonce", nonce)
61
62    @hook
63    def set_label(self, wallet: 'Abstract_Wallet', item, label):
64        if wallet not in self.wallets:
65            return
66        if not item:
67            return
68        nonce = self.get_nonce(wallet)
69        wallet_id = self.wallets[wallet][2]
70        bundle = {"walletId": wallet_id,
71                  "walletNonce": nonce,
72                  "externalId": self.encode(wallet, item),
73                  "encryptedLabel": self.encode(wallet, label)}
74        asyncio.run_coroutine_threadsafe(self.do_post_safe("/label", bundle), wallet.network.asyncio_loop)
75        # Caller will write the wallet
76        self.set_nonce(wallet, nonce + 1)
77
78    @ignore_exceptions
79    @log_exceptions
80    async def do_post_safe(self, *args):
81        await self.do_post(*args)
82
83    async def do_get(self, url = "/labels"):
84        url = 'https://' + self.target_host + url
85        network = Network.get_instance()
86        proxy = network.proxy if network else None
87        async with make_aiohttp_session(proxy) as session:
88            async with session.get(url) as result:
89                return await result.json()
90
91    async def do_post(self, url = "/labels", data=None):
92        url = 'https://' + self.target_host + url
93        network = Network.get_instance()
94        proxy = network.proxy if network else None
95        async with make_aiohttp_session(proxy) as session:
96            async with session.post(url, json=data) as result:
97                try:
98                    return await result.json()
99                except Exception as e:
100                    raise Exception('Could not decode: ' + await result.text()) from e
101
102    async def push_thread(self, wallet: 'Abstract_Wallet'):
103        wallet_data = self.wallets.get(wallet, None)
104        if not wallet_data:
105            raise Exception('Wallet {} not loaded'.format(wallet))
106        wallet_id = wallet_data[2]
107        bundle = {"labels": [],
108                  "walletId": wallet_id,
109                  "walletNonce": self.get_nonce(wallet)}
110        for key, value in wallet.get_all_labels().items():
111            try:
112                encoded_key = self.encode(wallet, key)
113                encoded_value = self.encode(wallet, value)
114            except:
115                self.logger.info(f'cannot encode {repr(key)} {repr(value)}')
116                continue
117            bundle["labels"].append({'encryptedLabel': encoded_value,
118                                     'externalId': encoded_key})
119        await self.do_post("/labels", bundle)
120
121    async def pull_thread(self, wallet: 'Abstract_Wallet', force: bool):
122        wallet_data = self.wallets.get(wallet, None)
123        if not wallet_data:
124            raise Exception('Wallet {} not loaded'.format(wallet))
125        wallet_id = wallet_data[2]
126        nonce = 1 if force else self.get_nonce(wallet) - 1
127        self.logger.info(f"asking for labels since nonce {nonce}")
128        try:
129            response = await self.do_get("/labels/since/%d/for/%s" % (nonce, wallet_id))
130        except Exception as e:
131            raise ErrorConnectingServer(e) from e
132        if response["labels"] is None:
133            self.logger.info('no new labels')
134            return
135        result = {}
136        for label in response["labels"]:
137            try:
138                key = self.decode(wallet, label["externalId"])
139                value = self.decode(wallet, label["encryptedLabel"])
140            except:
141                continue
142            try:
143                json.dumps(key)
144                json.dumps(value)
145            except:
146                self.logger.info(f'error: no json {key}')
147                continue
148            result[key] = value
149
150        for key, value in result.items():
151            if force or not wallet.get_label(key):
152                wallet._set_label(key, value)
153
154        self.logger.info(f"received {len(response)} labels")
155        self.set_nonce(wallet, response["nonce"] + 1)
156        self.on_pulled(wallet)
157
158    def on_pulled(self, wallet: 'Abstract_Wallet') -> None:
159        raise NotImplementedError()
160
161    @ignore_exceptions
162    @log_exceptions
163    async def pull_safe_thread(self, wallet: 'Abstract_Wallet', force: bool):
164        try:
165            await self.pull_thread(wallet, force)
166        except ErrorConnectingServer as e:
167            self.logger.info(repr(e))
168
169    def pull(self, wallet: 'Abstract_Wallet', force: bool):
170        if not wallet.network: raise Exception(_('You are offline.'))
171        return asyncio.run_coroutine_threadsafe(self.pull_thread(wallet, force), wallet.network.asyncio_loop).result()
172
173    def push(self, wallet: 'Abstract_Wallet'):
174        if not wallet.network: raise Exception(_('You are offline.'))
175        return asyncio.run_coroutine_threadsafe(self.push_thread(wallet), wallet.network.asyncio_loop).result()
176
177    def start_wallet(self, wallet: 'Abstract_Wallet'):
178        if not wallet.network: return  # 'offline' mode
179        mpk = wallet.get_fingerprint()
180        if not mpk:
181            return
182        mpk = mpk.encode('ascii')
183        password = hashlib.sha1(mpk).hexdigest()[:32].encode('ascii')
184        iv = hashlib.sha256(password).digest()[:16]
185        wallet_id = hashlib.sha256(mpk).hexdigest()
186        self.wallets[wallet] = (password, iv, wallet_id)
187        nonce = self.get_nonce(wallet)
188        self.logger.info(f"wallet {wallet.basename()} nonce is {nonce}")
189        # If there is an auth token we can try to actually start syncing
190        asyncio.run_coroutine_threadsafe(self.pull_safe_thread(wallet, False), wallet.network.asyncio_loop)
191
192    def stop_wallet(self, wallet):
193        self.wallets.pop(wallet, None)
194