electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit c49335ed30aa10bb7434eceb8d1ed6ecc83cdfcc
parent e7dbdc040660c59d0e9201118eff0d9266340525
Author: ThomasV <thomasv@electrum.org>
Date:   Sat,  3 Feb 2018 11:02:14 +0100

Merge pull request #3346 from SomberNight/encrypt_watch_only_wallets

allow encrypting watch-only wallets
Diffstat:
Melectrum | 8+++++++-
Mgui/kivy/uix/dialogs/installwizard.py | 2+-
Mgui/qt/installwizard.py | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mgui/qt/main_window.py | 53+++++++++++++++++++++++++++++++++++++----------------
Mgui/qt/password_dialog.py | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mlib/base_wizard.py | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mlib/bitcoin.py | 14+++++++-------
Mlib/commands.py | 2+-
Mlib/keystore.py | 20+++++++++++---------
Mlib/storage.py | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mlib/wallet.py | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mplugins/cosigner_pool/qt.py | 2+-
Mplugins/digitalbitbox/digitalbitbox.py | 7++++---
Mplugins/greenaddress_instant/qt.py | 7++++++-
Mplugins/hw_wallet/plugin.py | 7+++++++
Mplugins/hw_wallet/qt.py | 7++++---
Mplugins/keepkey/plugin.py | 5+----
Mplugins/ledger/ledger.py | 2+-
Mplugins/trezor/plugin.py | 5+----
Mplugins/trustedcoin/trustedcoin.py | 15+++++++++++----
20 files changed, 508 insertions(+), 147 deletions(-)

diff --git a/electrum b/electrum @@ -193,6 +193,8 @@ def init_daemon(config_options): print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option") sys.exit(0) if storage.is_encrypted(): + if storage.is_encrypted_with_hw_device(): + raise NotImplementedError("CLI functionality of encrypted hw wallets") if config.get('password'): password = config.get('password') else: @@ -237,6 +239,8 @@ def init_cmdline(config_options, server): # commands needing password if (cmd.requires_wallet and storage.is_encrypted() and server is None)\ or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())): + if storage.is_encrypted_with_hw_device(): + raise NotImplementedError("CLI functionality of encrypted hw wallets") if config.get('password'): password = config.get('password') else: @@ -263,12 +267,14 @@ def run_offline_command(config, config_options): if cmd.requires_wallet: storage = WalletStorage(config.get_wallet_path()) if storage.is_encrypted(): + if storage.is_encrypted_with_hw_device(): + raise NotImplementedError("CLI functionality of encrypted hw wallets") storage.decrypt(password) wallet = Wallet(storage) else: wallet = None # check password - if cmd.requires_password and storage.get('use_encryption'): + if cmd.requires_password and wallet.has_password(): try: seed = wallet.check_password(password) except InvalidPassword: diff --git a/gui/kivy/uix/dialogs/installwizard.py b/gui/kivy/uix/dialogs/installwizard.py @@ -807,7 +807,7 @@ class InstallWizard(BaseWizard, Widget): popup.init(message, callback) popup.open() - def request_password(self, run_next): + def request_password(self, run_next, force_disable_encrypt_cb=False): def callback(pin): if pin: self.run('confirm_password', pin, run_next) diff --git a/gui/qt/installwizard.py b/gui/qt/installwizard.py @@ -10,13 +10,13 @@ from PyQt5.QtWidgets import * from electrum import Wallet, WalletStorage from electrum.util import UserCancelled, InvalidPassword -from electrum.base_wizard import BaseWizard +from electrum.base_wizard import BaseWizard, HWD_SETUP_DECRYPT_WALLET from electrum.i18n import _ from .seed_dialog import SeedLayout, KeysLayout from .network_dialog import NetworkChoiceLayout from .util import * -from .password_dialog import PasswordLayout, PW_NEW +from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW class GoBack(Exception): @@ -29,6 +29,10 @@ MSG_ENTER_SEED_OR_MPK = _("Please enter a seed phrase or a master key (xpub or x MSG_COSIGNER = _("Please enter the master public key of cosigner #%d:") MSG_ENTER_PASSWORD = _("Choose a password to encrypt your wallet keys.") + '\n'\ + _("Leave this field empty if you want to disable encryption.") +MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + + _("Your wallet file does not contain secrets, mostly just metadata. ") \ + + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ + + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") MSG_RESTORE_PASSPHRASE = \ _("Please enter your seed derivation passphrase. " "Note: this is NOT your encryption password. " @@ -196,12 +200,18 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): msg =_("This file does not exist.") + '\n' \ + _("Press 'Next' to create this wallet, or choose another file.") pw = False - elif self.storage.file_exists() and self.storage.is_encrypted(): - msg = _("This file is encrypted.") + '\n' + _('Enter your password or choose another file.') - pw = True else: - msg = _("Press 'Next' to open this wallet.") - pw = False + if self.storage.is_encrypted_with_user_pw(): + msg = _("This file is encrypted with a password.") + '\n' \ + + _('Enter your password or choose another file.') + pw = True + elif self.storage.is_encrypted_with_hw_device(): + msg = _("This file is encrypted using a hardware device.") + '\n' \ + + _("Press 'Next' to choose device to decrypt.") + pw = False + else: + msg = _("Press 'Next' to open this wallet.") + pw = False else: msg = _('Cannot read file') pw = False @@ -227,17 +237,40 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): if not self.storage.file_exists(): break if self.storage.file_exists() and self.storage.is_encrypted(): - password = self.pw_e.text() - try: - self.storage.decrypt(password) - break - except InvalidPassword as e: - QMessageBox.information(None, _('Error'), str(e)) - continue - except BaseException as e: - traceback.print_exc(file=sys.stdout) - QMessageBox.information(None, _('Error'), str(e)) - return + if self.storage.is_encrypted_with_user_pw(): + password = self.pw_e.text() + try: + self.storage.decrypt(password) + break + except InvalidPassword as e: + QMessageBox.information(None, _('Error'), str(e)) + continue + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + elif self.storage.is_encrypted_with_hw_device(): + try: + self.run('choose_hw_device', HWD_SETUP_DECRYPT_WALLET) + except InvalidPassword as e: + # FIXME if we get here because of mistyped passphrase + # then that passphrase gets "cached" + QMessageBox.information( + None, _('Error'), + _('Failed to decrypt using this hardware device.') + '\n' + + _('If you use a passphrase, make sure it is correct.')) + self.stack = [] + return self.run_and_get_wallet() + except BaseException as e: + traceback.print_exc(file=sys.stdout) + QMessageBox.information(None, _('Error'), str(e)) + return + if self.storage.is_past_initial_decryption(): + break + else: + return + else: + raise Exception('Unexpected encryption version') path = self.storage.path if self.storage.requires_split(): @@ -386,17 +419,25 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): self.exec_layout(slayout) return slayout.is_ext - def pw_layout(self, msg, kind): - playout = PasswordLayout(None, msg, kind, self.next_button) + def pw_layout(self, msg, kind, force_disable_encrypt_cb): + playout = PasswordLayout(None, msg, kind, self.next_button, + force_disable_encrypt_cb=force_disable_encrypt_cb) playout.encrypt_cb.setChecked(True) self.exec_layout(playout.layout()) return playout.new_password(), playout.encrypt_cb.isChecked() @wizard_dialog - def request_password(self, run_next): + def request_password(self, run_next, force_disable_encrypt_cb=False): """Request the user enter a new password and confirm it. Return the password or None for no password.""" - return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW) + return self.pw_layout(MSG_ENTER_PASSWORD, PW_NEW, force_disable_encrypt_cb) + + @wizard_dialog + def request_storage_encryption(self, run_next): + playout = PasswordLayoutForHW(None, MSG_HW_STORAGE_ENCRYPTION, PW_NEW, self.next_button) + playout.encrypt_cb.setChecked(True) + self.exec_layout(playout.layout()) + return playout.encrypt_cb.isChecked() def show_restore(self, wallet, network): # FIXME: these messages are shown after the install wizard is diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py @@ -383,7 +383,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): extra.append(_('watching only')) title += ' [%s]'% ', '.join(extra) self.setWindowTitle(title) - self.password_menu.setEnabled(self.wallet.can_change_password()) + self.password_menu.setEnabled(self.wallet.may_have_password()) self.import_privkey_menu.setVisible(self.wallet.can_import_privkey()) self.import_address_menu.setVisible(self.wallet.can_import_address()) self.export_menu.setEnabled(self.wallet.can_export()) @@ -888,14 +888,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if alias_addr: if self.wallet.is_mine(alias_addr): msg = _('This payment request will be signed.') + '\n' + _('Please enter your password') - password = self.password_dialog(msg) - if password: - try: - self.wallet.sign_payment_request(addr, alias, alias_addr, password) - except Exception as e: - self.show_error(str(e)) + password = None + if self.wallet.has_keystore_encryption(): + password = self.password_dialog(msg) + if not password: return - else: + try: + self.wallet.sign_payment_request(addr, alias, alias_addr, password) + except Exception as e: + self.show_error(str(e)) return else: return @@ -1383,7 +1384,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def request_password(self, *args, **kwargs): parent = self.top_level_window() password = None - while self.wallet.has_password(): + while self.wallet.has_keystore_encryption(): password = self.password_dialog(parent=parent) if password is None: # User cancelled password input @@ -1518,7 +1519,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if fee > confirm_rate * tx.estimated_size() / 1000: msg.append(_('Warning') + ': ' + _("The fee for this transaction seems unusually high.")) - if self.wallet.has_password(): + if self.wallet.has_keystore_encryption(): msg.append("") msg.append(_("Enter your password to proceed")) password = self.password_dialog('\n'.join(msg)) @@ -1921,17 +1922,37 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def update_buttons_on_seed(self): self.seed_button.setVisible(self.wallet.has_seed()) - self.password_button.setVisible(self.wallet.can_change_password()) + self.password_button.setVisible(self.wallet.may_have_password()) self.send_button.setVisible(not self.wallet.is_watching_only()) def change_password_dialog(self): - from .password_dialog import ChangePasswordDialog - d = ChangePasswordDialog(self, self.wallet) - ok, password, new_password, encrypt_file = d.run() + from electrum.storage import STO_EV_XPUB_PW + if self.wallet.get_available_storage_encryption_version() == STO_EV_XPUB_PW: + from .password_dialog import ChangePasswordDialogForHW + d = ChangePasswordDialogForHW(self, self.wallet) + ok, encrypt_file = d.run() + if not ok: + return + + try: + hw_dev_pw = self.wallet.keystore.get_password_for_storage_encryption() + except UserCancelled: + return + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.show_error(str(e)) + return + old_password = hw_dev_pw if self.wallet.has_password() else None + new_password = hw_dev_pw if encrypt_file else None + else: + from .password_dialog import ChangePasswordDialogForSW + d = ChangePasswordDialogForSW(self, self.wallet) + ok, old_password, new_password, encrypt_file = d.run() + if not ok: return try: - self.wallet.update_password(password, new_password, encrypt_file) + self.wallet.update_password(old_password, new_password, encrypt_file) except BaseException as e: self.show_error(str(e)) return @@ -1939,7 +1960,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): traceback.print_exc(file=sys.stdout) self.show_error(_('Failed to update password')) return - msg = _('Password was updated successfully') if new_password else _('Password is disabled, this wallet is not protected') + msg = _('Password was updated successfully') if self.wallet.has_password() else _('Password is disabled, this wallet is not protected') self.show_message(msg, title=_("Success")) self.update_lock_icon() diff --git a/gui/qt/password_dialog.py b/gui/qt/password_dialog.py @@ -57,7 +57,7 @@ class PasswordLayout(object): titles = [_("Enter Password"), _("Change Password"), _("Enter Passphrase")] - def __init__(self, wallet, msg, kind, OK_button): + def __init__(self, wallet, msg, kind, OK_button, force_disable_encrypt_cb=False): self.wallet = wallet self.pw = QLineEdit() @@ -126,7 +126,8 @@ class PasswordLayout(object): def enable_OK(): ok = self.new_pw.text() == self.conf_pw.text() OK_button.setEnabled(ok) - self.encrypt_cb.setEnabled(ok and bool(self.new_pw.text())) + self.encrypt_cb.setEnabled(ok and bool(self.new_pw.text()) + and not force_disable_encrypt_cb) self.new_pw.textChanged.connect(enable_OK) self.conf_pw.textChanged.connect(enable_OK) @@ -163,11 +164,84 @@ class PasswordLayout(object): return pw -class ChangePasswordDialog(WindowModalDialog): +class PasswordLayoutForHW(object): + + def __init__(self, wallet, msg, kind, OK_button): + self.wallet = wallet + + self.kind = kind + self.OK_button = OK_button + + vbox = QVBoxLayout() + label = QLabel(msg + "\n") + label.setWordWrap(True) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 150) + grid.setColumnMinimumWidth(1, 100) + grid.setColumnStretch(1,1) + + logo_grid = QGridLayout() + logo_grid.setSpacing(8) + logo_grid.setColumnMinimumWidth(0, 70) + logo_grid.setColumnStretch(1,1) + + logo = QLabel() + logo.setAlignment(Qt.AlignCenter) + + logo_grid.addWidget(logo, 0, 0) + logo_grid.addWidget(label, 0, 1, 1, 2) + vbox.addLayout(logo_grid) + + if wallet and wallet.has_storage_encryption(): + lockfile = ":icons/lock.png" + else: + lockfile = ":icons/unlock.png" + logo.setPixmap(QPixmap(lockfile).scaledToWidth(36)) + + vbox.addLayout(grid) + + self.encrypt_cb = QCheckBox(_('Encrypt wallet file')) + grid.addWidget(self.encrypt_cb, 1, 0, 1, 2) + + self.vbox = vbox + + def title(self): + return _("Toggle Encryption") + + def layout(self): + return self.vbox + + +class ChangePasswordDialogBase(WindowModalDialog): def __init__(self, parent, wallet): WindowModalDialog.__init__(self, parent) - is_encrypted = wallet.storage.is_encrypted() + is_encrypted = wallet.has_storage_encryption() + OK_button = OkButton(self) + + self.create_password_layout(wallet, is_encrypted, OK_button) + + self.setWindowTitle(self.playout.title()) + vbox = QVBoxLayout(self) + vbox.addLayout(self.playout.layout()) + vbox.addStretch(1) + vbox.addLayout(Buttons(CancelButton(self), OK_button)) + self.playout.encrypt_cb.setChecked(is_encrypted) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + raise NotImplementedError() + + +class ChangePasswordDialogForSW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + if not wallet.has_password(): + self.playout.encrypt_cb.setChecked(True) + + def create_password_layout(self, wallet, is_encrypted, OK_button): if not wallet.has_password(): msg = _('Your wallet is not protected.') msg += ' ' + _('Use this dialog to add a password to your wallet.') @@ -177,14 +251,9 @@ class ChangePasswordDialog(WindowModalDialog): else: msg = _('Your wallet is password protected and encrypted.') msg += ' ' + _('Use this dialog to change your password.') - OK_button = OkButton(self) - self.playout = PasswordLayout(wallet, msg, PW_CHANGE, OK_button) - self.setWindowTitle(self.playout.title()) - vbox = QVBoxLayout(self) - vbox.addLayout(self.playout.layout()) - vbox.addStretch(1) - vbox.addLayout(Buttons(CancelButton(self), OK_button)) - self.playout.encrypt_cb.setChecked(is_encrypted or not wallet.has_password()) + self.playout = PasswordLayout( + wallet, msg, PW_CHANGE, OK_button, + force_disable_encrypt_cb=not wallet.can_have_keystore_encryption()) def run(self): if not self.exec_(): @@ -192,6 +261,26 @@ class ChangePasswordDialog(WindowModalDialog): return True, self.playout.old_password(), self.playout.new_password(), self.playout.encrypt_cb.isChecked() +class ChangePasswordDialogForHW(ChangePasswordDialogBase): + + def __init__(self, parent, wallet): + ChangePasswordDialogBase.__init__(self, parent, wallet) + + def create_password_layout(self, wallet, is_encrypted, OK_button): + if not is_encrypted: + msg = _('Your wallet file is NOT encrypted.') + else: + msg = _('Your wallet file is encrypted.') + msg += '\n' + _('Note: If you enable this setting, you will need your hardware device to open your wallet.') + msg += '\n' + _('Use this dialog to toggle encryption.') + self.playout = PasswordLayoutForHW(wallet, msg, PW_CHANGE, OK_button) + + def run(self): + if not self.exec_(): + return False, None + return True, self.playout.encrypt_cb.isChecked() + + class PasswordDialog(WindowModalDialog): def __init__(self, parent=None, msg=None): diff --git a/lib/base_wizard.py b/lib/base_wizard.py @@ -24,12 +24,19 @@ # SOFTWARE. import os +import sys +import traceback + from . import bitcoin from . import keystore from .keystore import bip44_derivation from .wallet import Imported_Wallet, Standard_Wallet, Multisig_Wallet, wallet_types +from .storage import STO_EV_USER_PW, STO_EV_XPUB_PW, get_derivation_used_for_hw_device_encryption from .i18n import _ +from .util import UserCancelled +# hardware device setup purpose +HWD_SETUP_NEW_WALLET, HWD_SETUP_DECRYPT_WALLET = range(0, 2) class ScriptTypeNotSupported(Exception): pass @@ -147,17 +154,22 @@ class BaseWizard(object): is_valid=v, allow_multi=True) def on_import(self, text): + # create a temporary wallet and exploit that modifications + # will be reflected on self.storage if keystore.is_address_list(text): - self.wallet = Imported_Wallet(self.storage) + w = Imported_Wallet(self.storage) for x in text.split(): - self.wallet.import_address(x) + w.import_address(x) elif keystore.is_private_key_list(text): k = keystore.Imported_KeyStore({}) self.storage.put('keystore', k.dump()) - self.wallet = Imported_Wallet(self.storage) + w = Imported_Wallet(self.storage) for x in text.split(): - self.wallet.import_private_key(x, None) - self.terminate() + w.import_private_key(x, None) + self.keystores.append(w.keystore) + else: + return self.terminate() + return self.run('create_wallet') def restore_from_key(self): if self.wallet_type == 'standard': @@ -176,7 +188,7 @@ class BaseWizard(object): k = keystore.from_master_key(text) self.on_keystore(k) - def choose_hw_device(self): + def choose_hw_device(self, purpose=HWD_SETUP_NEW_WALLET): title = _('Hardware Keystore') # check available plugins support = self.plugins.get_hardware_support() @@ -185,7 +197,7 @@ class BaseWizard(object): _('No hardware wallet support found on your system.'), _('Please install the relevant libraries (eg python-trezor for Trezor).'), ]) - self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device()) + self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device(purpose)) return # scan devices devices = [] @@ -205,7 +217,7 @@ class BaseWizard(object): _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", and do "Remove device". Then, plug your device again.') + ' ', _('On Linux, you might have to add a new permission to your udev rules.'), ]) - self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device()) + self.confirm_dialog(title=title, message=msg, run_next= lambda x: self.choose_hw_device(purpose)) return # select device self.devices = devices @@ -216,23 +228,31 @@ class BaseWizard(object): descr = "%s [%s, %s]" % (label, name, state) choices.append(((name, info), descr)) msg = _('Select a device') + ':' - self.choice_dialog(title=title, message=msg, choices=choices, run_next=self.on_device) + self.choice_dialog(title=title, message=msg, choices=choices, run_next= lambda *args: self.on_device(*args, purpose=purpose)) - def on_device(self, name, device_info): + def on_device(self, name, device_info, *, purpose): self.plugin = self.plugins.get_plugin(name) try: - self.plugin.setup_device(device_info, self) + self.plugin.setup_device(device_info, self, purpose) except BaseException as e: self.show_error(str(e)) - self.choose_hw_device() + self.choose_hw_device(purpose) return - if self.wallet_type=='multisig': - # There is no general standard for HD multisig. - # This is partially compatible with BIP45; assumes index=0 - self.on_hw_derivation(name, device_info, "m/45'/0") + if purpose == HWD_SETUP_NEW_WALLET: + if self.wallet_type=='multisig': + # There is no general standard for HD multisig. + # This is partially compatible with BIP45; assumes index=0 + self.on_hw_derivation(name, device_info, "m/45'/0") + else: + f = lambda x: self.run('on_hw_derivation', name, device_info, str(x)) + self.derivation_dialog(f) + elif purpose == HWD_SETUP_DECRYPT_WALLET: + derivation = get_derivation_used_for_hw_device_encryption() + xpub = self.plugin.get_xpub(device_info.device.id_, derivation, 'standard', self) + password = keystore.Xpub.get_pubkey_from_xpub(xpub, ()) + self.storage.decrypt(password) else: - f = lambda x: self.run('on_hw_derivation', name, device_info, str(x)) - self.derivation_dialog(f) + raise Exception('unknown purpose: %s' % purpose) def derivation_dialog(self, f): default = bip44_derivation(0, bip43_purpose=44) @@ -365,13 +385,45 @@ class BaseWizard(object): self.run('create_wallet') def create_wallet(self): - if any(k.may_have_password() for k in self.keystores): - self.request_password(run_next=self.on_password) + encrypt_keystore = any(k.may_have_password() for k in self.keystores) + # note: the following condition ("if") is duplicated logic from + # wallet.get_available_storage_encryption_version() + if self.wallet_type == 'standard' and isinstance(self.keystores[0], keystore.Hardware_KeyStore): + # offer encrypting with a pw derived from the hw device + k = self.keystores[0] + try: + k.handler = self.plugin.create_handler(self) + password = k.get_password_for_storage_encryption() + except UserCancelled: + devmgr = self.plugins.device_manager + devmgr.unpair_xpub(k.xpub) + self.choose_hw_device() + return + except BaseException as e: + traceback.print_exc(file=sys.stderr) + self.show_error(str(e)) + return + self.request_storage_encryption( + run_next=lambda encrypt_storage: self.on_password( + password, + encrypt_storage=encrypt_storage, + storage_enc_version=STO_EV_XPUB_PW, + encrypt_keystore=False)) else: - self.on_password(None, False) - - def on_password(self, password, encrypt): - self.storage.set_password(password, encrypt) + # prompt the user to set an arbitrary password + self.request_password( + run_next=lambda password, encrypt_storage: self.on_password( + password, + encrypt_storage=encrypt_storage, + storage_enc_version=STO_EV_USER_PW, + encrypt_keystore=encrypt_keystore), + force_disable_encrypt_cb=not encrypt_keystore) + + def on_password(self, password, *, encrypt_storage, + storage_enc_version=STO_EV_USER_PW, encrypt_keystore): + self.storage.set_keystore_encryption(bool(password) and encrypt_keystore) + if encrypt_storage: + self.storage.set_password(password, enc_version=storage_enc_version) for k in self.keystores: if k.may_have_password(): k.update_password(None, password) @@ -387,6 +439,13 @@ class BaseWizard(object): self.storage.write() self.wallet = Multisig_Wallet(self.storage) self.run('create_addresses') + elif self.wallet_type == 'imported': + if len(self.keystores) > 0: + keys = self.keystores[0].dump() + self.storage.put('keystore', keys) + self.wallet = Imported_Wallet(self.storage) + self.wallet.storage.write() + self.terminate() def show_xpub_and_add_cosigners(self, xpub): self.show_xpub_dialog(xpub=xpub, run_next=lambda x: self.run('choose_keystore')) diff --git a/lib/bitcoin.py b/lib/bitcoin.py @@ -643,8 +643,8 @@ def verify_message(address, sig, message): return False -def encrypt_message(message, pubkey): - return EC_KEY.encrypt_message(message, bfh(pubkey)) +def encrypt_message(message, pubkey, magic=b'BIE1'): + return EC_KEY.encrypt_message(message, bfh(pubkey), magic) def chunks(l, n): @@ -789,7 +789,7 @@ class EC_KEY(object): # ECIES encryption/decryption methods; AES-128-CBC with PKCS7 is used as the cipher; hmac-sha256 is used as the mac @classmethod - def encrypt_message(self, message, pubkey): + def encrypt_message(self, message, pubkey, magic=b'BIE1'): assert_bytes(message) pk = ser_to_point(pubkey) @@ -803,20 +803,20 @@ class EC_KEY(object): iv, key_e, key_m = key[0:16], key[16:32], key[32:] ciphertext = aes_encrypt_with_iv(key_e, iv, message) ephemeral_pubkey = bfh(ephemeral.get_public_key(compressed=True)) - encrypted = b'BIE1' + ephemeral_pubkey + ciphertext + encrypted = magic + ephemeral_pubkey + ciphertext mac = hmac.new(key_m, encrypted, hashlib.sha256).digest() return base64.b64encode(encrypted + mac) - def decrypt_message(self, encrypted): + def decrypt_message(self, encrypted, magic=b'BIE1'): encrypted = base64.b64decode(encrypted) if len(encrypted) < 85: raise Exception('invalid ciphertext: length') - magic = encrypted[:4] + magic_found = encrypted[:4] ephemeral_pubkey = encrypted[4:37] ciphertext = encrypted[37:-32] mac = encrypted[-32:] - if magic != b'BIE1': + if magic_found != magic: raise Exception('invalid ciphertext: invalid magic bytes') try: ephemeral_pubkey = ser_to_point(ephemeral_pubkey) diff --git a/lib/commands.py b/lib/commands.py @@ -82,7 +82,7 @@ def command(s): password = kwargs.get('password') if c.requires_wallet and wallet is None: raise BaseException("wallet not loaded. Use 'electrum daemon load_wallet'") - if c.requires_password and password is None and wallet.storage.get('use_encryption'): + if c.requires_password and password is None and wallet.has_password(): return {'error': 'Password required' } return func(*args, **kwargs) return func_wrapper diff --git a/lib/keystore.py b/lib/keystore.py @@ -45,6 +45,10 @@ class KeyStore(PrintError): def can_import(self): return False + def may_have_password(self): + """Returns whether the keystore can be encrypted with a password.""" + raise NotImplementedError() + def get_tx_derivations(self, tx): keypairs = {} for txin in tx.inputs(): @@ -116,9 +120,6 @@ class Imported_KeyStore(Software_KeyStore): def is_deterministic(self): return False - def can_change_password(self): - return True - def get_master_public_key(self): return None @@ -196,9 +197,6 @@ class Deterministic_KeyStore(Software_KeyStore): def is_watching_only(self): return not self.has_seed() - def can_change_password(self): - return not self.is_watching_only() - def add_seed(self, seed): if self.seed: raise Exception("a seed exists") @@ -522,9 +520,13 @@ class Hardware_KeyStore(KeyStore, Xpub): assert not self.has_seed() return False - def can_change_password(self): - return False - + def get_password_for_storage_encryption(self): + from .storage import get_derivation_used_for_hw_device_encryption + client = self.plugin.get_client(self) + derivation = get_derivation_used_for_hw_device_encryption() + xpub = client.get_xpub(derivation, "standard") + password = self.get_pubkey_from_xpub(xpub, ()) + return password def bip39_normalize_passphrase(passphrase): diff --git a/lib/storage.py b/lib/storage.py @@ -33,7 +33,7 @@ import pbkdf2, hmac, hashlib import base64 import zlib -from .util import PrintError, profiler +from .util import PrintError, profiler, InvalidPassword from .plugins import run_hook, plugin_loaders from .keystore import bip44_derivation from . import bitcoin @@ -56,6 +56,13 @@ def multisig_type(wallet_type): match = [int(x) for x in match.group(1, 2)] return match +def get_derivation_used_for_hw_device_encryption(): + return ("m" + "/4541509'" # ascii 'ELE' as decimal ("BIP43 purpose") + "/1112098098'") # ascii 'BIE2' as decimal + +# storage encryption version +STO_EV_PLAINTEXT, STO_EV_USER_PW, STO_EV_XPUB_PW = range(0, 3) class WalletStorage(PrintError): @@ -70,9 +77,11 @@ class WalletStorage(PrintError): if self.file_exists(): with open(self.path, "r") as f: self.raw = f.read() + self._encryption_version = self._init_encryption_version() if not self.is_encrypted(): self.load_data(self.raw) else: + self._encryption_version = STO_EV_PLAINTEXT # avoid new wallets getting 'upgraded' self.put('seed_version', FINAL_SEED_VERSION) @@ -106,11 +115,47 @@ class WalletStorage(PrintError): if self.requires_upgrade(): self.upgrade() + def is_past_initial_decryption(self): + """Return if storage is in a usable state for normal operations. + + The value is True exactly + if encryption is disabled completely (self.is_encrypted() == False), + or if encryption is enabled but the contents have already been decrypted. + """ + return bool(self.data) + def is_encrypted(self): + """Return if storage encryption is currently enabled.""" + return self.get_encryption_version() != STO_EV_PLAINTEXT + + def is_encrypted_with_user_pw(self): + return self.get_encryption_version() == STO_EV_USER_PW + + def is_encrypted_with_hw_device(self): + return self.get_encryption_version() == STO_EV_XPUB_PW + + def get_encryption_version(self): + """Return the version of encryption used for this storage. + + 0: plaintext / no encryption + + ECIES, private key derived from a password, + 1: password is provided by user + 2: password is derived from an xpub; used with hw wallets + """ + return self._encryption_version + + def _init_encryption_version(self): try: - return base64.b64decode(self.raw)[0:4] == b'BIE1' + magic = base64.b64decode(self.raw)[0:4] + if magic == b'BIE1': + return STO_EV_USER_PW + elif magic == b'BIE2': + return STO_EV_XPUB_PW + else: + return STO_EV_PLAINTEXT except: - return False + return STO_EV_PLAINTEXT def file_exists(self): return self.path and os.path.exists(self.path) @@ -120,20 +165,50 @@ class WalletStorage(PrintError): ec_key = bitcoin.EC_KEY(secret) return ec_key + def _get_encryption_magic(self): + v = self._encryption_version + if v == STO_EV_USER_PW: + return b'BIE1' + elif v == STO_EV_XPUB_PW: + return b'BIE2' + else: + raise Exception('no encryption magic for version: %s' % v) + def decrypt(self, password): ec_key = self.get_key(password) - s = zlib.decompress(ec_key.decrypt_message(self.raw)) if self.raw else None + if self.raw: + enc_magic = self._get_encryption_magic() + s = zlib.decompress(ec_key.decrypt_message(self.raw, enc_magic)) + else: + s = None self.pubkey = ec_key.get_public_key() s = s.decode('utf8') self.load_data(s) - def set_password(self, password, encrypt): - self.put('use_encryption', bool(password)) - if encrypt and password: + def check_password(self, password): + """Raises an InvalidPassword exception on invalid password""" + if not self.is_encrypted(): + return + if self.pubkey and self.pubkey != self.get_key(password).get_public_key(): + raise InvalidPassword() + + def set_keystore_encryption(self, enable): + self.put('use_encryption', enable) + + def set_password(self, password, enc_version=None): + """Set a password to be used for encrypting this storage.""" + if enc_version is None: + enc_version = self._encryption_version + if password and enc_version != STO_EV_PLAINTEXT: ec_key = self.get_key(password) self.pubkey = ec_key.get_public_key() + self._encryption_version = enc_version else: self.pubkey = None + self._encryption_version = STO_EV_PLAINTEXT + # make sure next storage.write() saves changes + with self.lock: + self.modified = True def get(self, key, default=None): with self.lock: @@ -175,7 +250,8 @@ class WalletStorage(PrintError): if self.pubkey: s = bytes(s, 'utf8') c = zlib.compress(s) - s = bitcoin.encrypt_message(c, self.pubkey) + enc_magic = self._get_encryption_magic() + s = bitcoin.encrypt_message(c, self.pubkey, enc_magic) s = s.decode('utf8') temp_path = "%s.tmp.%s" % (self.path, os.getpid()) diff --git a/lib/wallet.py b/lib/wallet.py @@ -48,7 +48,7 @@ from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler, from .bitcoin import * from .version import * from .keystore import load_keystore, Hardware_KeyStore -from .storage import multisig_type +from .storage import multisig_type, STO_EV_PLAINTEXT, STO_EV_USER_PW, STO_EV_XPUB_PW from . import transaction from .transaction import Transaction @@ -1383,10 +1383,65 @@ class Abstract_Wallet(PrintError): self.synchronizer.add(address) def has_password(self): - return self.storage.get('use_encryption', False) + return self.has_keystore_encryption() or self.has_storage_encryption() + + def can_have_keystore_encryption(self): + return self.keystore and self.keystore.may_have_password() + + def get_available_storage_encryption_version(self): + """Returns the type of storage encryption offered to the user. + + A wallet file (storage) is either encrypted with this version + or is stored in plaintext. + """ + if isinstance(self.keystore, Hardware_KeyStore): + return STO_EV_XPUB_PW + else: + return STO_EV_USER_PW + + def has_keystore_encryption(self): + """Returns whether encryption is enabled for the keystore. + + If True, e.g. signing a transaction will require a password. + """ + if self.can_have_keystore_encryption(): + return self.storage.get('use_encryption', False) + return False + + def has_storage_encryption(self): + """Returns whether encryption is enabled for the wallet file on disk.""" + return self.storage.is_encrypted() + + @classmethod + def may_have_password(cls): + return True def check_password(self, password): - self.keystore.check_password(password) + if self.has_keystore_encryption(): + self.keystore.check_password(password) + self.storage.check_password(password) + + def update_password(self, old_pw, new_pw, encrypt_storage=False): + if old_pw is None and self.has_password(): + raise InvalidPassword() + self.check_password(old_pw) + + if encrypt_storage: + enc_version = self.get_available_storage_encryption_version() + else: + enc_version = STO_EV_PLAINTEXT + self.storage.set_password(new_pw, enc_version) + + # note: Encrypting storage with a hw device is currently only + # allowed for non-multisig wallets. Further, + # Hardware_KeyStore.may_have_password() == False. + # If these were not the case, + # extra care would need to be taken when encrypting keystores. + self._update_password_for_keystore(old_pw, new_pw) + encrypt_keystore = self.can_have_keystore_encryption() + self.storage.set_keystore_encryption(bool(new_pw) and encrypt_keystore) + + self.storage.write() def sign_message(self, address, message, password): index = self.get_address_index(address) @@ -1420,16 +1475,10 @@ class Simple_Wallet(Abstract_Wallet): def is_watching_only(self): return self.keystore.is_watching_only() - def can_change_password(self): - return self.keystore.can_change_password() - - def update_password(self, old_pw, new_pw, encrypt=False): - if old_pw is None and self.has_password(): - raise InvalidPassword() - self.keystore.update_password(old_pw, new_pw) - self.save_keystore() - self.storage.set_password(new_pw, encrypt) - self.storage.write() + def _update_password_for_keystore(self, old_pw, new_pw): + if self.keystore and self.keystore.may_have_password(): + self.keystore.update_password(old_pw, new_pw) + self.save_keystore() def save_keystore(self): self.storage.put('keystore', self.keystore.dump()) @@ -1468,9 +1517,6 @@ class Imported_Wallet(Simple_Wallet): def save_addresses(self): self.storage.put('addresses', self.addresses) - def can_change_password(self): - return not self.is_watching_only() - def can_import_address(self): return self.is_watching_only() @@ -1849,22 +1895,28 @@ class Multisig_Wallet(Deterministic_Wallet): def get_keystores(self): return [self.keystores[i] for i in sorted(self.keystores.keys())] - def update_password(self, old_pw, new_pw, encrypt=False): - if old_pw is None and self.has_password(): - raise InvalidPassword() + def can_have_keystore_encryption(self): + return any([k.may_have_password() for k in self.get_keystores()]) + + def _update_password_for_keystore(self, old_pw, new_pw): for name, keystore in self.keystores.items(): - if keystore.can_change_password(): + if keystore.may_have_password(): keystore.update_password(old_pw, new_pw) self.storage.put(name, keystore.dump()) - self.storage.set_password(new_pw, encrypt) - self.storage.write() + + def check_password(self, password): + for name, keystore in self.keystores.items(): + if keystore.may_have_password(): + keystore.check_password(password) + self.storage.check_password(password) + + def get_available_storage_encryption_version(self): + # multisig wallets are not offered hw device encryption + return STO_EV_USER_PW def has_seed(self): return self.keystore.has_seed() - def can_change_password(self): - return self.keystore.can_change_password() - def is_watching_only(self): return not any([not k.is_watching_only() for k in self.get_keystores()]) diff --git a/plugins/cosigner_pool/qt.py b/plugins/cosigner_pool/qt.py @@ -194,7 +194,7 @@ class Plugin(BasePlugin): return wallet = window.wallet - if wallet.has_password(): + if wallet.has_keystore_encryption(): password = window.password_dialog('An encrypted transaction was retrieved from cosigning pool.\nPlease enter your password to decrypt it.') if not password: return diff --git a/plugins/digitalbitbox/digitalbitbox.py b/plugins/digitalbitbox/digitalbitbox.py @@ -12,7 +12,7 @@ try: from electrum.keystore import Hardware_KeyStore from ..hw_wallet import HW_PluginBase from electrum.util import print_error, to_string, UserCancelled - from electrum.base_wizard import ScriptTypeNotSupported + from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET import time import hid @@ -670,12 +670,13 @@ class DigitalBitboxPlugin(HW_PluginBase): return None - def setup_device(self, device_info, wizard): + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) client.handler = self.create_handler(wizard) - client.setupRunning = True + if purpose == HWD_SETUP_NEW_WALLET: + client.setupRunning = True client.get_xpub("m/44'/0'", 'standard') diff --git a/plugins/greenaddress_instant/qt.py b/plugins/greenaddress_instant/qt.py @@ -65,9 +65,14 @@ class Plugin(BasePlugin): tx = d.tx wallet = d.wallet window = d.main_window + + if wallet.is_watching_only(): + d.show_critical(_('This feature is not available for watch-only wallets.')) + return + # 1. get the password and sign the verification request password = None - if wallet.has_password(): + if wallet.has_keystore_encryption(): msg = _('GreenAddress requires your signature \n' 'to verify that transaction is instant.\n' 'Please enter your password to sign a\n' diff --git a/plugins/hw_wallet/plugin.py b/plugins/hw_wallet/plugin.py @@ -51,3 +51,10 @@ class HW_PluginBase(BasePlugin): for keystore in wallet.get_keystores(): if isinstance(keystore, self.keystore_class): self.device_manager().unpair_xpub(keystore.xpub) + + def setup_device(self, device_info, wizard, purpose): + """Called when creating a new wallet or when using the device to decrypt + an existing wallet. Select the device to use. If the device is + uninitialized, go through the initialization process. + """ + raise NotImplementedError() diff --git a/plugins/hw_wallet/qt.py b/plugins/hw_wallet/qt.py @@ -70,9 +70,10 @@ class QtHandlerBase(QObject, PrintError): self.status_signal.emit(paired) def _update_status(self, paired): - button = self.button - icon = button.icon_paired if paired else button.icon_unpaired - button.setIcon(QIcon(icon)) + if hasattr(self, 'button'): + button = self.button + icon = button.icon_paired if paired else button.icon_unpaired + button.setIcon(QIcon(icon)) def query_choice(self, msg, labels): self.done.clear() diff --git a/plugins/keepkey/plugin.py b/plugins/keepkey/plugin.py @@ -194,10 +194,7 @@ class KeepKeyCompatiblePlugin(HW_PluginBase): label, language) wizard.loop.exit(0) - def setup_device(self, device_info, wizard): - '''Called when creating a new wallet. Select the device to use. If - the device is uninitialized, go through the intialization - process.''' + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) diff --git a/plugins/ledger/ledger.py b/plugins/ledger/ledger.py @@ -522,7 +522,7 @@ class LedgerPlugin(HW_PluginBase): client = Ledger_Client(client) return client - def setup_device(self, device_info, wizard): + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) diff --git a/plugins/trezor/plugin.py b/plugins/trezor/plugin.py @@ -214,10 +214,7 @@ class TrezorCompatiblePlugin(HW_PluginBase): label, language) wizard.loop.exit(0) - def setup_device(self, device_info, wizard): - '''Called when creating a new wallet. Select the device to use. If - the device is uninitialized, go through the intialization - process.''' + def setup_device(self, device_info, wizard, purpose): devmgr = self.device_manager() device_id = device_info.device.id_ client = devmgr.client_by_id(device_id) diff --git a/plugins/trustedcoin/trustedcoin.py b/plugins/trustedcoin/trustedcoin.py @@ -40,6 +40,7 @@ from electrum.wallet import Multisig_Wallet, Deterministic_Wallet from electrum.i18n import _ from electrum.plugins import BasePlugin, hook from electrum.util import NotEnoughFunds +from electrum.storage import STO_EV_USER_PW # signing_xpub is hardcoded so that the wallet can be restored from seed, without TrustedCoin's server signing_xpub = "xpub661MyMwAqRbcGnMkaTx2594P9EDuiEqMq25PM2aeG6UmwzaohgA6uDmNsvSUV8ubqwA3Wpste1hg69XHgjUuCD5HLcEp2QPzyV1HMrPppsL" @@ -420,9 +421,11 @@ class TrustedCoinPlugin(BasePlugin): k2 = keystore.from_xpub(xpub2) wizard.request_password(run_next=lambda pw, encrypt: self.on_password(wizard, pw, encrypt, k1, k2)) - def on_password(self, wizard, password, encrypt, k1, k2): + def on_password(self, wizard, password, encrypt_storage, k1, k2): k1.update_password(None, password) - wizard.storage.set_password(password, encrypt) + wizard.storage.set_keystore_encryption(bool(password)) + if encrypt_storage: + wizard.storage.set_password(password, enc_version=STO_EV_USER_PW) wizard.storage.put('x1/', k1.dump()) wizard.storage.put('x2/', k2.dump()) wizard.storage.write() @@ -470,7 +473,7 @@ class TrustedCoinPlugin(BasePlugin): else: self.create_keystore(wizard, seed, passphrase) - def on_restore_pw(self, wizard, seed, passphrase, password, encrypt): + def on_restore_pw(self, wizard, seed, passphrase, password, encrypt_storage): storage = wizard.storage xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(seed, passphrase) k1 = keystore.from_xprv(xprv1) @@ -484,7 +487,11 @@ class TrustedCoinPlugin(BasePlugin): xpub3 = make_xpub(signing_xpub, long_user_id) k3 = keystore.from_xpub(xpub3) storage.put('x3/', k3.dump()) - storage.set_password(password, encrypt) + + storage.set_keystore_encryption(bool(password)) + if encrypt_storage: + storage.set_password(password, enc_version=STO_EV_USER_PW) + wizard.wallet = Wallet_2fa(storage) wizard.create_addresses()