1from functools import partial
2import threading
3
4from PyQt5.QtCore import Qt, pyqtSignal, QRegExp
5from PyQt5.QtGui import QRegExpValidator
6from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton,
7                             QHBoxLayout, QButtonGroup, QGroupBox,
8                             QTextEdit, QLineEdit, QRadioButton, QCheckBox, QWidget,
9                             QMessageBox, QFileDialog, QSlider, QTabWidget)
10
11from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton,
12                                  OkButton, CloseButton, getOpenFileName)
13from electrum.i18n import _
14from electrum.plugin import hook
15from electrum.util import bh2u
16
17from ..hw_wallet.qt import QtHandlerBase, QtPluginBase
18from ..hw_wallet.plugin import only_hook_if_libraries_available
19from .safe_t import SafeTPlugin, TIM_NEW, TIM_RECOVER, TIM_MNEMONIC
20
21
22PASSPHRASE_HELP_SHORT =_(
23    "Passphrases allow you to access new wallets, each "
24    "hidden behind a particular case-sensitive passphrase.")
25PASSPHRASE_HELP = PASSPHRASE_HELP_SHORT + "  " + _(
26    "You need to create a separate Electrum wallet for each passphrase "
27    "you use as they each generate different addresses.  Changing "
28    "your passphrase does not lose other wallets, each is still "
29    "accessible behind its own passphrase.")
30RECOMMEND_PIN = _(
31    "You should enable PIN protection.  Your PIN is the only protection "
32    "for your bitcoins if your device is lost or stolen.")
33PASSPHRASE_NOT_PIN = _(
34    "If you forget a passphrase you will be unable to access any "
35    "bitcoins in the wallet behind it.  A passphrase is not a PIN. "
36    "Only change this if you are sure you understand it.")
37
38
39class QtHandler(QtHandlerBase):
40
41    pin_signal = pyqtSignal(object, object)
42
43    def __init__(self, win, pin_matrix_widget_class, device):
44        super(QtHandler, self).__init__(win, device)
45        self.pin_signal.connect(self.pin_dialog)
46        self.pin_matrix_widget_class = pin_matrix_widget_class
47
48    def get_pin(self, msg, *, show_strength=True):
49        self.done.clear()
50        self.pin_signal.emit(msg, show_strength)
51        self.done.wait()
52        return self.response
53
54    def pin_dialog(self, msg, show_strength):
55        # Needed e.g. when resetting a device
56        self.clear_dialog()
57        dialog = WindowModalDialog(self.top_level_window(), _("Enter PIN"))
58        matrix = self.pin_matrix_widget_class(show_strength)
59        vbox = QVBoxLayout()
60        vbox.addWidget(QLabel(msg))
61        vbox.addWidget(matrix)
62        vbox.addLayout(Buttons(CancelButton(dialog), OkButton(dialog)))
63        dialog.setLayout(vbox)
64        dialog.exec_()
65        self.response = str(matrix.get_value())
66        self.done.set()
67
68
69class QtPlugin(QtPluginBase):
70    # Derived classes must provide the following class-static variables:
71    #   icon_file
72    #   pin_matrix_widget_class
73
74    @only_hook_if_libraries_available
75    @hook
76    def receive_menu(self, menu, addrs, wallet):
77        if len(addrs) != 1:
78            return
79        for keystore in wallet.get_keystores():
80            if type(keystore) == self.keystore_class:
81                def show_address(keystore=keystore):
82                    keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore))
83                device_name = "{} ({})".format(self.device, keystore.label)
84                menu.addAction(_("Show on {}").format(device_name), show_address)
85
86    def show_settings_dialog(self, window, keystore):
87        def connect():
88            device_id = self.choose_device(window, keystore)
89            return device_id
90        def show_dialog(device_id):
91            if device_id:
92                SettingsDialog(window, self, keystore, device_id).exec_()
93        keystore.thread.add(connect, on_success=show_dialog)
94
95    def request_safe_t_init_settings(self, wizard, method, device):
96        vbox = QVBoxLayout()
97        next_enabled = True
98        label = QLabel(_("Enter a label to name your device:"))
99        name = QLineEdit()
100        hl = QHBoxLayout()
101        hl.addWidget(label)
102        hl.addWidget(name)
103        hl.addStretch(1)
104        vbox.addLayout(hl)
105
106        def clean_text(widget):
107            text = widget.toPlainText().strip()
108            return ' '.join(text.split())
109
110        if method in [TIM_NEW, TIM_RECOVER]:
111            gb = QGroupBox()
112            hbox1 = QHBoxLayout()
113            gb.setLayout(hbox1)
114            vbox.addWidget(gb)
115            gb.setTitle(_("Select your seed length:"))
116            bg = QButtonGroup()
117            for i, count in enumerate([12, 18, 24]):
118                rb = QRadioButton(gb)
119                rb.setText(_("{:d} words").format(count))
120                bg.addButton(rb)
121                bg.setId(rb, i)
122                hbox1.addWidget(rb)
123                rb.setChecked(True)
124            cb_pin = QCheckBox(_('Enable PIN protection'))
125            cb_pin.setChecked(True)
126        else:
127            text = QTextEdit()
128            text.setMaximumHeight(60)
129            if method == TIM_MNEMONIC:
130                msg = _("Enter your BIP39 mnemonic:")
131            else:
132                msg = _("Enter the master private key beginning with xprv:")
133                def set_enabled():
134                    from electrum.bip32 import is_xprv
135                    wizard.next_button.setEnabled(is_xprv(clean_text(text)))
136                text.textChanged.connect(set_enabled)
137                next_enabled = False
138
139            vbox.addWidget(QLabel(msg))
140            vbox.addWidget(text)
141            pin = QLineEdit()
142            pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,9}')))
143            pin.setMaximumWidth(100)
144            hbox_pin = QHBoxLayout()
145            hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):")))
146            hbox_pin.addWidget(pin)
147            hbox_pin.addStretch(1)
148
149        if method in [TIM_NEW, TIM_RECOVER]:
150            vbox.addWidget(WWLabel(RECOMMEND_PIN))
151            vbox.addWidget(cb_pin)
152        else:
153            vbox.addLayout(hbox_pin)
154
155        passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT)
156        passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN)
157        passphrase_warning.setStyleSheet("color: red")
158        cb_phrase = QCheckBox(_('Enable passphrases'))
159        cb_phrase.setChecked(False)
160        vbox.addWidget(passphrase_msg)
161        vbox.addWidget(passphrase_warning)
162        vbox.addWidget(cb_phrase)
163
164        wizard.exec_layout(vbox, next_enabled=next_enabled)
165
166        if method in [TIM_NEW, TIM_RECOVER]:
167            item = bg.checkedId()
168            pin = cb_pin.isChecked()
169        else:
170            item = ' '.join(str(clean_text(text)).split())
171            pin = str(pin.text())
172
173        return (item, name.text(), pin, cb_phrase.isChecked())
174
175
176class Plugin(SafeTPlugin, QtPlugin):
177    icon_unpaired = "safe-t_unpaired.png"
178    icon_paired = "safe-t.png"
179
180    def create_handler(self, window):
181        return QtHandler(window, self.pin_matrix_widget_class(), self.device)
182
183    @classmethod
184    def pin_matrix_widget_class(self):
185        from safetlib.qt.pinmatrix import PinMatrixWidget
186        return PinMatrixWidget
187
188
189class SettingsDialog(WindowModalDialog):
190    '''This dialog doesn't require a device be paired with a wallet.
191    We want users to be able to wipe a device even if they've forgotten
192    their PIN.'''
193
194    def __init__(self, window, plugin, keystore, device_id):
195        title = _("{} Settings").format(plugin.device)
196        super(SettingsDialog, self).__init__(window, title)
197        self.setMaximumWidth(540)
198
199        devmgr = plugin.device_manager()
200        config = devmgr.config
201        handler = keystore.handler
202        thread = keystore.thread
203        hs_cols, hs_rows = (128, 64)
204
205        def invoke_client(method, *args, **kw_args):
206            unpair_after = kw_args.pop('unpair_after', False)
207
208            def task():
209                client = devmgr.client_by_id(device_id)
210                if not client:
211                    raise RuntimeError("Device not connected")
212                if method:
213                    getattr(client, method)(*args, **kw_args)
214                if unpair_after:
215                    devmgr.unpair_id(device_id)
216                return client.features
217
218            thread.add(task, on_success=update)
219
220        def update(features):
221            self.features = features
222            set_label_enabled()
223            if features.bootloader_hash:
224                bl_hash = bh2u(features.bootloader_hash)
225                bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]])
226            else:
227                bl_hash = "N/A"
228            noyes = [_("No"), _("Yes")]
229            endis = [_("Enable Passphrases"), _("Disable Passphrases")]
230            disen = [_("Disabled"), _("Enabled")]
231            setchange = [_("Set a PIN"), _("Change PIN")]
232
233            version = "%d.%d.%d" % (features.major_version,
234                                    features.minor_version,
235                                    features.patch_version)
236
237            device_label.setText(features.label)
238            pin_set_label.setText(noyes[features.pin_protection])
239            passphrases_label.setText(disen[features.passphrase_protection])
240            bl_hash_label.setText(bl_hash)
241            label_edit.setText(features.label)
242            device_id_label.setText(features.device_id)
243            initialized_label.setText(noyes[features.initialized])
244            version_label.setText(version)
245            clear_pin_button.setVisible(features.pin_protection)
246            clear_pin_warning.setVisible(features.pin_protection)
247            pin_button.setText(setchange[features.pin_protection])
248            pin_msg.setVisible(not features.pin_protection)
249            passphrase_button.setText(endis[features.passphrase_protection])
250            language_label.setText(features.language)
251
252        def set_label_enabled():
253            label_apply.setEnabled(label_edit.text() != self.features.label)
254
255        def rename():
256            invoke_client('change_label', label_edit.text())
257
258        def toggle_passphrase():
259            title = _("Confirm Toggle Passphrase Protection")
260            currently_enabled = self.features.passphrase_protection
261            if currently_enabled:
262                msg = _("After disabling passphrases, you can only pair this "
263                        "Electrum wallet if it had an empty passphrase.  "
264                        "If its passphrase was not empty, you will need to "
265                        "create a new wallet with the install wizard.  You "
266                        "can use this wallet again at any time by re-enabling "
267                        "passphrases and entering its passphrase.")
268            else:
269                msg = _("Your current Electrum wallet can only be used with "
270                        "an empty passphrase.  You must create a separate "
271                        "wallet with the install wizard for other passphrases "
272                        "as each one generates a new set of addresses.")
273            msg += "\n\n" + _("Are you sure you want to proceed?")
274            if not self.question(msg, title=title):
275                return
276            invoke_client('toggle_passphrase', unpair_after=currently_enabled)
277
278        def change_homescreen():
279            filename = getOpenFileName(
280                parent=self,
281                title=_("Choose Homescreen"),
282                config=config,
283            )
284            if not filename:
285                return  # user cancelled
286
287            if filename.endswith('.toif'):
288                img = open(filename, 'rb').read()
289                if img[:8] != b'TOIf\x90\x00\x90\x00':
290                    handler.show_error('File is not a TOIF file with size of 144x144')
291                    return
292            else:
293                from PIL import Image # FIXME
294                im = Image.open(filename)
295                if im.size != (128, 64):
296                    handler.show_error('Image must be 128 x 64 pixels')
297                    return
298                im = im.convert('1')
299                pix = im.load()
300                img = bytearray(1024)
301                for j in range(64):
302                    for i in range(128):
303                        if pix[i, j]:
304                            o = (i + j * 128)
305                            img[o // 8] |= (1 << (7 - o % 8))
306                img = bytes(img)
307            invoke_client('change_homescreen', img)
308
309        def clear_homescreen():
310            invoke_client('change_homescreen', b'\x00')
311
312        def set_pin():
313            invoke_client('set_pin', remove=False)
314
315        def clear_pin():
316            invoke_client('set_pin', remove=True)
317
318        def wipe_device():
319            wallet = window.wallet
320            if wallet and sum(wallet.get_balance()):
321                title = _("Confirm Device Wipe")
322                msg = _("Are you SURE you want to wipe the device?\n"
323                        "Your wallet still has bitcoins in it!")
324                if not self.question(msg, title=title,
325                                     icon=QMessageBox.Critical):
326                    return
327            invoke_client('wipe_device', unpair_after=True)
328
329        def slider_moved():
330            mins = timeout_slider.sliderPosition()
331            timeout_minutes.setText(_("{:2d} minutes").format(mins))
332
333        def slider_released():
334            config.set_session_timeout(timeout_slider.sliderPosition() * 60)
335
336        # Information tab
337        info_tab = QWidget()
338        info_layout = QVBoxLayout(info_tab)
339        info_glayout = QGridLayout()
340        info_glayout.setColumnStretch(2, 1)
341        device_label = QLabel()
342        pin_set_label = QLabel()
343        passphrases_label = QLabel()
344        version_label = QLabel()
345        device_id_label = QLabel()
346        bl_hash_label = QLabel()
347        bl_hash_label.setWordWrap(True)
348        language_label = QLabel()
349        initialized_label = QLabel()
350        rows = [
351            (_("Device Label"), device_label),
352            (_("PIN set"), pin_set_label),
353            (_("Passphrases"), passphrases_label),
354            (_("Firmware Version"), version_label),
355            (_("Device ID"), device_id_label),
356            (_("Bootloader Hash"), bl_hash_label),
357            (_("Language"), language_label),
358            (_("Initialized"), initialized_label),
359        ]
360        for row_num, (label, widget) in enumerate(rows):
361            info_glayout.addWidget(QLabel(label), row_num, 0)
362            info_glayout.addWidget(widget, row_num, 1)
363        info_layout.addLayout(info_glayout)
364
365        # Settings tab
366        settings_tab = QWidget()
367        settings_layout = QVBoxLayout(settings_tab)
368        settings_glayout = QGridLayout()
369
370        # Settings tab - Label
371        label_msg = QLabel(_("Name this {}.  If you have multiple devices "
372                             "their labels help distinguish them.")
373                           .format(plugin.device))
374        label_msg.setWordWrap(True)
375        label_label = QLabel(_("Device Label"))
376        label_edit = QLineEdit()
377        label_edit.setMinimumWidth(150)
378        label_edit.setMaxLength(plugin.MAX_LABEL_LEN)
379        label_apply = QPushButton(_("Apply"))
380        label_apply.clicked.connect(rename)
381        label_edit.textChanged.connect(set_label_enabled)
382        settings_glayout.addWidget(label_label, 0, 0)
383        settings_glayout.addWidget(label_edit, 0, 1, 1, 2)
384        settings_glayout.addWidget(label_apply, 0, 3)
385        settings_glayout.addWidget(label_msg, 1, 1, 1, -1)
386
387        # Settings tab - PIN
388        pin_label = QLabel(_("PIN Protection"))
389        pin_button = QPushButton()
390        pin_button.clicked.connect(set_pin)
391        settings_glayout.addWidget(pin_label, 2, 0)
392        settings_glayout.addWidget(pin_button, 2, 1)
393        pin_msg = QLabel(_("PIN protection is strongly recommended.  "
394                           "A PIN is your only protection against someone "
395                           "stealing your bitcoins if they obtain physical "
396                           "access to your {}.").format(plugin.device))
397        pin_msg.setWordWrap(True)
398        pin_msg.setStyleSheet("color: red")
399        settings_glayout.addWidget(pin_msg, 3, 1, 1, -1)
400
401        # Settings tab - Homescreen
402        homescreen_label = QLabel(_("Homescreen"))
403        homescreen_change_button = QPushButton(_("Change..."))
404        homescreen_clear_button = QPushButton(_("Reset"))
405        homescreen_change_button.clicked.connect(change_homescreen)
406        try:
407            import PIL
408        except ImportError:
409            homescreen_change_button.setDisabled(True)
410            homescreen_change_button.setToolTip(
411                _("Required package 'PIL' is not available - Please install it.")
412            )
413        homescreen_clear_button.clicked.connect(clear_homescreen)
414        homescreen_msg = QLabel(_("You can set the homescreen on your "
415                                  "device to personalize it.  You must "
416                                  "choose a {} x {} monochrome black and "
417                                  "white image.").format(hs_cols, hs_rows))
418        homescreen_msg.setWordWrap(True)
419        settings_glayout.addWidget(homescreen_label, 4, 0)
420        settings_glayout.addWidget(homescreen_change_button, 4, 1)
421        settings_glayout.addWidget(homescreen_clear_button, 4, 2)
422        settings_glayout.addWidget(homescreen_msg, 5, 1, 1, -1)
423
424        # Settings tab - Session Timeout
425        timeout_label = QLabel(_("Session Timeout"))
426        timeout_minutes = QLabel()
427        timeout_slider = QSlider(Qt.Horizontal)
428        timeout_slider.setRange(1, 60)
429        timeout_slider.setSingleStep(1)
430        timeout_slider.setTickInterval(5)
431        timeout_slider.setTickPosition(QSlider.TicksBelow)
432        timeout_slider.setTracking(True)
433        timeout_msg = QLabel(
434            _("Clear the session after the specified period "
435              "of inactivity.  Once a session has timed out, "
436              "your PIN and passphrase (if enabled) must be "
437              "re-entered to use the device."))
438        timeout_msg.setWordWrap(True)
439        timeout_slider.setSliderPosition(config.get_session_timeout() // 60)
440        slider_moved()
441        timeout_slider.valueChanged.connect(slider_moved)
442        timeout_slider.sliderReleased.connect(slider_released)
443        settings_glayout.addWidget(timeout_label, 6, 0)
444        settings_glayout.addWidget(timeout_slider, 6, 1, 1, 3)
445        settings_glayout.addWidget(timeout_minutes, 6, 4)
446        settings_glayout.addWidget(timeout_msg, 7, 1, 1, -1)
447        settings_layout.addLayout(settings_glayout)
448        settings_layout.addStretch(1)
449
450        # Advanced tab
451        advanced_tab = QWidget()
452        advanced_layout = QVBoxLayout(advanced_tab)
453        advanced_glayout = QGridLayout()
454
455        # Advanced tab - clear PIN
456        clear_pin_button = QPushButton(_("Disable PIN"))
457        clear_pin_button.clicked.connect(clear_pin)
458        clear_pin_warning = QLabel(
459            _("If you disable your PIN, anyone with physical access to your "
460              "{} device can spend your bitcoins.").format(plugin.device))
461        clear_pin_warning.setWordWrap(True)
462        clear_pin_warning.setStyleSheet("color: red")
463        advanced_glayout.addWidget(clear_pin_button, 0, 2)
464        advanced_glayout.addWidget(clear_pin_warning, 1, 0, 1, 5)
465
466        # Advanced tab - toggle passphrase protection
467        passphrase_button = QPushButton()
468        passphrase_button.clicked.connect(toggle_passphrase)
469        passphrase_msg = WWLabel(PASSPHRASE_HELP)
470        passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN)
471        passphrase_warning.setStyleSheet("color: red")
472        advanced_glayout.addWidget(passphrase_button, 3, 2)
473        advanced_glayout.addWidget(passphrase_msg, 4, 0, 1, 5)
474        advanced_glayout.addWidget(passphrase_warning, 5, 0, 1, 5)
475
476        # Advanced tab - wipe device
477        wipe_device_button = QPushButton(_("Wipe Device"))
478        wipe_device_button.clicked.connect(wipe_device)
479        wipe_device_msg = QLabel(
480            _("Wipe the device, removing all data from it.  The firmware "
481              "is left unchanged."))
482        wipe_device_msg.setWordWrap(True)
483        wipe_device_warning = QLabel(
484            _("Only wipe a device if you have the recovery seed written down "
485              "and the device wallet(s) are empty, otherwise the bitcoins "
486              "will be lost forever."))
487        wipe_device_warning.setWordWrap(True)
488        wipe_device_warning.setStyleSheet("color: red")
489        advanced_glayout.addWidget(wipe_device_button, 6, 2)
490        advanced_glayout.addWidget(wipe_device_msg, 7, 0, 1, 5)
491        advanced_glayout.addWidget(wipe_device_warning, 8, 0, 1, 5)
492        advanced_layout.addLayout(advanced_glayout)
493        advanced_layout.addStretch(1)
494
495        tabs = QTabWidget(self)
496        tabs.addTab(info_tab, _("Information"))
497        tabs.addTab(settings_tab, _("Settings"))
498        tabs.addTab(advanced_tab, _("Advanced"))
499        dialog_vbox = QVBoxLayout(self)
500        dialog_vbox.addWidget(tabs)
501        dialog_vbox.addLayout(Buttons(CloseButton(self)))
502
503        # Update information
504        invoke_client(None)
505