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