1#!/usr/local/bin/python3.8
2#
3# Electrum - lightweight Bitcoin client
4# Copyright (C) 2013 ecdsa@github
5#
6# Permission is hereby granted, free of charge, to any person
7# obtaining a copy of this software and associated documentation files
8# (the "Software"), to deal in the Software without restriction,
9# including without limitation the rights to use, copy, modify, merge,
10# publish, distribute, sublicense, and/or sell copies of the Software,
11# and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be
15# included in all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
25
26from typing import TYPE_CHECKING
27
28from PyQt5.QtCore import Qt
29from PyQt5.QtGui import QPixmap
30from PyQt5.QtWidgets import (QVBoxLayout, QCheckBox, QHBoxLayout, QLineEdit,
31                             QLabel, QCompleter, QDialog, QStyledItemDelegate,
32                             QScrollArea, QWidget, QPushButton)
33
34from electrum.i18n import _
35from electrum.mnemonic import Mnemonic, seed_type
36from electrum import old_mnemonic
37from electrum import slip39
38
39from .util import (Buttons, OkButton, WWLabel, ButtonsTextEdit, icon_path,
40                   EnterButton, CloseButton, WindowModalDialog, ColorScheme,
41                   ChoicesLayout)
42from .qrtextedit import ShowQRTextEdit, ScanQRTextEdit
43from .completion_text_edit import CompletionTextEdit
44
45if TYPE_CHECKING:
46    from electrum.simple_config import SimpleConfig
47
48
49def seed_warning_msg(seed):
50    return ''.join([
51        "<p>",
52        _("Please save these {0} words on paper (order is important). "),
53        _("This seed will allow you to recover your wallet in case "
54          "of computer failure."),
55        "</p>",
56        "<b>" + _("WARNING") + ":</b>",
57        "<ul>",
58        "<li>" + _("Never disclose your seed.") + "</li>",
59        "<li>" + _("Never type it on a website.") + "</li>",
60        "<li>" + _("Do not store it electronically.") + "</li>",
61        "</ul>"
62    ]).format(len(seed.split()))
63
64
65class SeedLayout(QVBoxLayout):
66
67    def seed_options(self):
68        dialog = QDialog()
69        vbox = QVBoxLayout(dialog)
70
71        seed_types = [
72            (value, title) for value, title in (
73                ('electrum', _('Electrum')),
74                ('bip39', _('BIP39 seed')),
75                ('slip39', _('SLIP39 seed')),
76            )
77            if value in self.options or value == 'electrum'
78        ]
79        seed_type_values = [t[0] for t in seed_types]
80
81        if 'ext' in self.options:
82            cb_ext = QCheckBox(_('Extend this seed with custom words'))
83            cb_ext.setChecked(self.is_ext)
84            vbox.addWidget(cb_ext)
85        if len(seed_types) >= 2:
86            def f(choices_layout):
87                self.seed_type = seed_type_values[choices_layout.selected_index()]
88                self.is_seed = (lambda x: bool(x)) if self.seed_type != 'electrum' else self.saved_is_seed
89                self.slip39_current_mnemonic_invalid = None
90                self.seed_status.setText('')
91                self.on_edit()
92                if self.seed_type == 'bip39':
93                    msg = ' '.join([
94                        '<b>' + _('Warning') + ':</b>  ',
95                        _('BIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
96                        _('However, we do not generate BIP39 seeds, because they do not meet our safety standard.'),
97                        _('BIP39 seeds do not include a version number, which compromises compatibility with future software.'),
98                        _('We do not guarantee that BIP39 imports will always be supported in Electrum.'),
99                    ])
100                elif self.seed_type == 'slip39':
101                    msg = ' '.join([
102                        '<b>' + _('Warning') + ':</b>  ',
103                        _('SLIP39 seeds can be imported in Electrum, so that users can access funds locked in other wallets.'),
104                        _('However, we do not generate SLIP39 seeds.'),
105                    ])
106                else:
107                    msg = ''
108                self.update_share_buttons()
109                self.initialize_completer()
110                self.seed_warning.setText(msg)
111
112            checked_index = seed_type_values.index(self.seed_type)
113            titles = [t[1] for t in seed_types]
114            clayout = ChoicesLayout(_('Seed type'), titles, on_clicked=f, checked_index=checked_index)
115            vbox.addLayout(clayout.layout())
116
117        vbox.addLayout(Buttons(OkButton(dialog)))
118        if not dialog.exec_():
119            return None
120        self.is_ext = cb_ext.isChecked() if 'ext' in self.options else False
121        self.seed_type = seed_type_values[clayout.selected_index()] if len(seed_types) >= 2 else 'electrum'
122
123    def __init__(
124            self,
125            seed=None,
126            title=None,
127            icon=True,
128            msg=None,
129            options=None,
130            is_seed=None,
131            passphrase=None,
132            parent=None,
133            for_seed_words=True,
134            *,
135            config: 'SimpleConfig',
136    ):
137        QVBoxLayout.__init__(self)
138        self.parent = parent
139        self.options = options
140        self.config = config
141        self.seed_type = 'electrum'
142        if title:
143            self.addWidget(WWLabel(title))
144        if seed:  # "read only", we already have the text
145            if for_seed_words:
146                self.seed_e = ButtonsTextEdit()
147            else:  # e.g. xpub
148                self.seed_e = ShowQRTextEdit(config=self.config)
149            self.seed_e.setReadOnly(True)
150            self.seed_e.setText(seed)
151        else:  # we expect user to enter text
152            assert for_seed_words
153            self.seed_e = CompletionTextEdit()
154            self.seed_e.setTabChangesFocus(False)  # so that tab auto-completes
155            self.is_seed = is_seed
156            self.saved_is_seed = self.is_seed
157            self.seed_e.textChanged.connect(self.on_edit)
158            self.initialize_completer()
159
160        self.seed_e.setMaximumHeight(75)
161        hbox = QHBoxLayout()
162        if icon:
163            logo = QLabel()
164            logo.setPixmap(QPixmap(icon_path("seed.png"))
165                           .scaledToWidth(64, mode=Qt.SmoothTransformation))
166            logo.setMaximumWidth(60)
167            hbox.addWidget(logo)
168        hbox.addWidget(self.seed_e)
169        self.addLayout(hbox)
170        hbox = QHBoxLayout()
171        hbox.addStretch(1)
172        self.seed_type_label = QLabel('')
173        hbox.addWidget(self.seed_type_label)
174
175        # options
176        self.is_ext = False
177        if options:
178            opt_button = EnterButton(_('Options'), self.seed_options)
179            hbox.addWidget(opt_button)
180            self.addLayout(hbox)
181        if passphrase:
182            hbox = QHBoxLayout()
183            passphrase_e = QLineEdit()
184            passphrase_e.setText(passphrase)
185            passphrase_e.setReadOnly(True)
186            hbox.addWidget(QLabel(_("Your seed extension is") + ':'))
187            hbox.addWidget(passphrase_e)
188            self.addLayout(hbox)
189
190        # slip39 shares
191        self.slip39_mnemonic_index = 0
192        self.slip39_mnemonics = [""]
193        self.slip39_seed = None
194        self.slip39_current_mnemonic_invalid = None
195        hbox = QHBoxLayout()
196        hbox.addStretch(1)
197        self.prev_share_btn = QPushButton(_("Previous share"))
198        self.prev_share_btn.clicked.connect(self.on_prev_share)
199        hbox.addWidget(self.prev_share_btn)
200        self.next_share_btn = QPushButton(_("Next share"))
201        self.next_share_btn.clicked.connect(self.on_next_share)
202        hbox.addWidget(self.next_share_btn)
203        self.update_share_buttons()
204        self.addLayout(hbox)
205
206        self.addStretch(1)
207        self.seed_status = WWLabel('')
208        self.addWidget(self.seed_status)
209        self.seed_warning = WWLabel('')
210        if msg:
211            self.seed_warning.setText(seed_warning_msg(seed))
212        self.addWidget(self.seed_warning)
213
214    def initialize_completer(self):
215        if self.seed_type != 'slip39':
216            bip39_english_list = Mnemonic('en').wordlist
217            old_list = old_mnemonic.wordlist
218            only_old_list = set(old_list) - set(bip39_english_list)
219            self.wordlist = list(bip39_english_list) + list(only_old_list)  # concat both lists
220            self.wordlist.sort()
221
222            class CompleterDelegate(QStyledItemDelegate):
223                def initStyleOption(self, option, index):
224                    super().initStyleOption(option, index)
225                    # Some people complained that due to merging the two word lists,
226                    # it is difficult to restore from a metal backup, as they planned
227                    # to rely on the "4 letter prefixes are unique in bip39 word list" property.
228                    # So we color words that are only in old list.
229                    if option.text in only_old_list:
230                        # yellow bg looks ~ok on both light/dark theme, regardless if (un)selected
231                        option.backgroundBrush = ColorScheme.YELLOW.as_color(background=True)
232
233            delegate = CompleterDelegate(self.seed_e)
234        else:
235            self.wordlist = list(slip39.get_wordlist())
236            delegate = None
237
238        self.completer = QCompleter(self.wordlist)
239        if delegate:
240            self.completer.popup().setItemDelegate(delegate)
241        self.seed_e.set_completer(self.completer)
242
243    def get_seed_words(self):
244        return self.seed_e.text().split()
245
246    def get_seed(self):
247        if self.seed_type != 'slip39':
248            return ' '.join(self.get_seed_words())
249        else:
250            return self.slip39_seed
251
252    def on_edit(self):
253        s = ' '.join(self.get_seed_words())
254        b = self.is_seed(s)
255        if self.seed_type == 'bip39':
256            from electrum.keystore import bip39_is_checksum_valid
257            is_checksum, is_wordlist = bip39_is_checksum_valid(s)
258            status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist'
259            label = 'BIP39' + ' (%s)'%status
260        elif self.seed_type == 'slip39':
261            self.slip39_mnemonics[self.slip39_mnemonic_index] = s
262            try:
263                slip39.decode_mnemonic(s)
264            except slip39.Slip39Error as e:
265                share_status = str(e)
266                current_mnemonic_invalid = True
267            else:
268                share_status = _('Valid.')
269                current_mnemonic_invalid = False
270
271            label = _('SLIP39 share') + ' #%d: %s' % (self.slip39_mnemonic_index + 1, share_status)
272
273            # No need to process mnemonics if the current mnemonic remains invalid after editing.
274            if not (self.slip39_current_mnemonic_invalid and current_mnemonic_invalid):
275                self.slip39_seed, seed_status = slip39.process_mnemonics(self.slip39_mnemonics)
276                self.seed_status.setText(seed_status)
277            self.slip39_current_mnemonic_invalid = current_mnemonic_invalid
278
279            b = self.slip39_seed is not None
280            self.update_share_buttons()
281        else:
282            t = seed_type(s)
283            label = _('Seed Type') + ': ' + t if t else ''
284
285        self.seed_type_label.setText(label)
286        self.parent.next_button.setEnabled(b)
287
288        # disable suggestions if user already typed an unknown word
289        for word in self.get_seed_words()[:-1]:
290            if word not in self.wordlist:
291                self.seed_e.disable_suggestions()
292                return
293        self.seed_e.enable_suggestions()
294
295    def update_share_buttons(self):
296        if self.seed_type != 'slip39':
297            self.prev_share_btn.hide()
298            self.next_share_btn.hide()
299            return
300
301        finished = self.slip39_seed is not None
302        self.prev_share_btn.show()
303        self.next_share_btn.show()
304        self.prev_share_btn.setEnabled(self.slip39_mnemonic_index != 0)
305        self.next_share_btn.setEnabled(
306            # already pressed "prev" and undoing that:
307            self.slip39_mnemonic_index < len(self.slip39_mnemonics) - 1
308            # finished entering latest share and starting new one:
309            or (bool(self.seed_e.text().strip()) and not self.slip39_current_mnemonic_invalid and not finished)
310        )
311
312    def on_prev_share(self):
313        if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
314            del self.slip39_mnemonics[self.slip39_mnemonic_index]
315
316        self.slip39_mnemonic_index -= 1
317        self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
318        self.slip39_current_mnemonic_invalid = None
319
320    def on_next_share(self):
321        if not self.slip39_mnemonics[self.slip39_mnemonic_index]:
322            del self.slip39_mnemonics[self.slip39_mnemonic_index]
323        else:
324            self.slip39_mnemonic_index += 1
325
326        if len(self.slip39_mnemonics) <= self.slip39_mnemonic_index:
327            self.slip39_mnemonics.append("")
328            self.seed_e.setFocus()
329        self.seed_e.setText(self.slip39_mnemonics[self.slip39_mnemonic_index])
330        self.slip39_current_mnemonic_invalid = None
331
332
333class KeysLayout(QVBoxLayout):
334    def __init__(
335            self,
336            parent=None,
337            header_layout=None,
338            is_valid=None,
339            allow_multi=False,
340            *,
341            config: 'SimpleConfig',
342    ):
343        QVBoxLayout.__init__(self)
344        self.parent = parent
345        self.is_valid = is_valid
346        self.text_e = ScanQRTextEdit(allow_multi=allow_multi, config=config)
347        self.text_e.textChanged.connect(self.on_edit)
348        if isinstance(header_layout, str):
349            self.addWidget(WWLabel(header_layout))
350        else:
351            self.addLayout(header_layout)
352        self.addWidget(self.text_e)
353
354    def get_text(self):
355        return self.text_e.text()
356
357    def on_edit(self):
358        valid = False
359        try:
360            valid = self.is_valid(self.get_text())
361        except Exception as e:
362            self.parent.next_button.setToolTip(f'{_("Error")}: {str(e)}')
363        else:
364            self.parent.next_button.setToolTip('')
365        self.parent.next_button.setEnabled(valid)
366
367
368class SeedDialog(WindowModalDialog):
369
370    def __init__(self, parent, seed, passphrase, *, config: 'SimpleConfig'):
371        WindowModalDialog.__init__(self, parent, ('Electrum - ' + _('Seed')))
372        self.setMinimumWidth(400)
373        vbox = QVBoxLayout(self)
374        title =  _("Your wallet generation seed is:")
375        slayout = SeedLayout(
376            title=title,
377            seed=seed,
378            msg=True,
379            passphrase=passphrase,
380            config=config,
381        )
382        vbox.addLayout(slayout)
383        vbox.addLayout(Buttons(CloseButton(self)))
384