electrum

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

commit fcc92c1ebdee77a4a414384f23f85d37152fbe7b
parent 7e76e4ac556bdce1d3405f63db1e9db44a1bb013
Author: ThomasV <thomasv@electrum.org>
Date:   Thu,  9 Feb 2017 17:08:27 +0100

Wallet file encryption:
 - a keypair is derived from the wallet password
 - only the public key is retained in memory
 - wallets must opened and closed explicitly with the daemon

Diffstat:
Melectrum | 38++++++++++++++++++++++++++++++++------
Mgui/qt/__init__.py | 20++++++++++++++------
Mgui/qt/installwizard.py | 3++-
Mgui/qt/main_window.py | 37++++++++-----------------------------
Mgui/qt/password_dialog.py | 57++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mgui/stdio.py | 2++
Mgui/text.py | 4+++-
Mlib/base_wizard.py | 4++--
Mlib/bitcoin.py | 10+---------
Mlib/commands.py | 2+-
Mlib/daemon.py | 46+++++++++++++++++++++++++++++++++++-----------
Mlib/storage.py | 73++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mlib/wallet.py | 6+++---
13 files changed, 197 insertions(+), 105 deletions(-)

diff --git a/electrum b/electrum @@ -179,7 +179,27 @@ def run_non_RPC(config): sys.exit(0) -def init_cmdline(config_options): +def init_daemon(config_options): + config = SimpleConfig(config_options) + storage = WalletStorage(config.get_wallet_path()) + if not storage.file_exists: + print_msg("Error: Wallet file not found.") + 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 config.get('password'): + password = config.get('password') + else: + password = prompt_password('Password:', False) + if not password: + print_msg("Error: Password required") + sys.exit(1) + else: + password = None + config_options['password'] = password + + +def init_cmdline(config_options, server): config = SimpleConfig(config_options) cmdname = config.get('cmd') cmd = known_commands[cmdname] @@ -208,8 +228,11 @@ def init_cmdline(config_options): print_stderr("Exposing a single private key can compromise your entire wallet!") print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.") + if not storage.is_encrypted(): + storage.read(None) # commands needing password - if cmd.requires_password and storage.get('use_encryption'): + if (storage.is_encrypted() and server is None)\ + or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())): if config.get('password'): password = config.get('password') else: @@ -232,18 +255,19 @@ def init_cmdline(config_options): def run_offline_command(config, config_options): cmdname = config.get('cmd') cmd = known_commands[cmdname] + password = config_options.get('password') storage = WalletStorage(config.get_wallet_path()) + storage.read(password if storage.is_encrypted() else None) wallet = Wallet(storage) if cmd.requires_wallet else None # check password if cmd.requires_password and storage.get('use_encryption'): - password = config_options.get('password') try: seed = wallet.check_password(password) except InvalidPassword: print_msg("Error: This password does not decode this wallet.") sys.exit(1) if cmd.requires_network: - print_stderr("Warning: running command offline") + print_msg("Warning: running command offline") # arguments passed to function args = map(lambda x: config.get(x), cmd.params) # decode json arguments @@ -347,7 +371,9 @@ if __name__ == '__main__': elif cmdname == 'daemon': subcommand = config.get('subcommand') - assert subcommand in [None, 'start', 'stop', 'status'] + if subcommand in ['open']: + init_daemon(config_options) + if subcommand in [None, 'start']: fd, server = daemon.get_fd_or_server(config) if fd is not None: @@ -377,8 +403,8 @@ if __name__ == '__main__': sys.exit(1) else: # command line - init_cmdline(config_options) server = daemon.get_server(config) + init_cmdline(config_options, server) if server is not None: result = server.run_cmdline(config_options) else: diff --git a/gui/qt/__init__.py b/gui/qt/__init__.py @@ -159,18 +159,26 @@ class ElectrumGui: w.bring_to_top() break else: - try: - wallet = self.daemon.load_wallet(path) - except BaseException as e: - QMessageBox.information(None, _('Error'), str(e), _('OK')) - return - if wallet is None: + if not os.path.exists(path): wizard = InstallWizard(self.config, self.app, self.plugins, path) wallet = wizard.run_and_get_wallet() if not wallet: return wallet.start_threads(self.daemon.network) self.daemon.add_wallet(wallet) + else: + from password_dialog import PasswordDialog + msg = _("The file '%s' is encrypted.") % os.path.basename(path) + password_getter = lambda: PasswordDialog(msg=msg).run() + while True: + try: + wallet = self.daemon.load_wallet(path, password_getter) + break + except UserCancelled: + return + except BaseException as e: + QMessageBox.information(None, _('Error'), str(e), _('OK')) + continue w = self.create_window_for_wallet(wallet) if uri: w.pay_to_URI(uri) diff --git a/gui/qt/installwizard.py b/gui/qt/installwizard.py @@ -294,8 +294,9 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard): def pw_layout(self, msg, kind): playout = PasswordLayout(None, msg, kind, self.next_button) + playout.encrypt_cb.setChecked(True) self.set_main_layout(playout.layout()) - return playout.new_password() + return playout.new_password(), playout.encrypt_cb.isChecked() @wizard_dialog def request_password(self, run_next): diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py @@ -1680,19 +1680,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.send_button.setVisible(not self.wallet.is_watching_only()) def change_password_dialog(self): - from password_dialog import PasswordDialog, PW_CHANGE - - msg = (_('Your wallet is encrypted. Use this dialog to change your ' - 'password. To disable wallet encryption, enter an empty new ' - 'password.') if self.wallet.has_password() - else _('Your wallet keys are not encrypted')) - d = PasswordDialog(self, self.wallet, msg, PW_CHANGE) - ok, password, new_password = d.run() + from password_dialog import ChangePasswordDialog + d = ChangePasswordDialog(self, self.wallet) + ok, password, new_password, encrypt_file = d.run() if not ok: return - try: - self.wallet.update_password(password, new_password) + self.wallet.update_password(password, new_password, encrypt_file) except BaseException as e: self.show_error(str(e)) return @@ -1700,8 +1694,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 _('This wallet is not encrypted') + msg = _('Password was updated successfully') if new_password else _('Password is disabled, this wallet is not protected') self.show_message(msg, title=_("Success")) self.update_lock_icon() @@ -1972,24 +1965,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): d.exec_() def password_dialog(self, msg=None, parent=None): + from password_dialog import PasswordDialog parent = parent or self - d = WindowModalDialog(parent, _("Enter Password")) - pw = QLineEdit() - pw.setEchoMode(2) - vbox = QVBoxLayout() - if not msg: - msg = _('Please enter your password') - vbox.addWidget(QLabel(msg)) - grid = QGridLayout() - grid.setSpacing(8) - grid.addWidget(QLabel(_('Password')), 1, 0) - grid.addWidget(pw, 1, 1) - vbox.addLayout(grid) - vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) - d.setLayout(vbox) - run_hook('password_dialog', pw, grid, 1) - if not d.exec_(): return - return unicode(pw.text()) + d = PasswordDialog(parent, msg) + return d.run() def tx_from_text(self, txt): diff --git a/gui/qt/password_dialog.py b/gui/qt/password_dialog.py @@ -30,6 +30,8 @@ from util import * import re import math +from electrum.plugins import run_hook + def check_password_strength(password): ''' @@ -92,7 +94,7 @@ class PasswordLayout(object): logo_grid.addWidget(label, 0, 1, 1, 2) vbox.addLayout(logo_grid) - m1 = _('New Password:') if kind == PW_NEW else _('Password:') + m1 = _('New Password:') if kind == PW_CHANGE else _('Password:') msgs = [m1, _('Confirm Password:')] if wallet and wallet.has_password(): grid.addWidget(QLabel(_('Current Password:')), 0, 0) @@ -115,8 +117,15 @@ class PasswordLayout(object): grid.addWidget(self.pw_strength, 3, 0, 1, 2) self.new_pw.textChanged.connect(self.pw_changed) + self.encrypt_cb = QCheckBox(_('Encrypt wallet file')) + self.encrypt_cb.setEnabled(False) + grid.addWidget(self.encrypt_cb, 4, 0, 1, 2) + self.encrypt_cb.setVisible(kind != PW_PASSPHRASE) + def enable_OK(): - OK_button.setEnabled(self.new_pw.text() == self.conf_pw.text()) + 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.new_pw.textChanged.connect(enable_OK) self.conf_pw.textChanged.connect(enable_OK) @@ -153,20 +162,54 @@ class PasswordLayout(object): return pw -class PasswordDialog(WindowModalDialog): +class ChangePasswordDialog(WindowModalDialog): - def __init__(self, parent, wallet, msg, kind): + def __init__(self, parent, wallet): WindowModalDialog.__init__(self, parent) + is_encrypted = wallet.storage.is_encrypted() + if not wallet.has_password(): + msg = _('Your wallet is not protected.') + msg += ' ' + _('Use this dialog to add a password to your wallet.') + else: + if not is_encrypted: + msg = _('Your bitcoins are password protected. However, your wallet file is not encrypted.') + 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, kind, OK_button) + 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()) def run(self): if not self.exec_(): - return False, None, None + return False, None, None, None + return True, self.playout.old_password(), self.playout.new_password(), self.playout.encrypt_cb.isChecked() + + +class PasswordDialog(WindowModalDialog): - return True, self.playout.old_password(), self.playout.new_password() + def __init__(self, parent=None, msg=None): + msg = msg or _('Please enter your password') + WindowModalDialog.__init__(self, parent, _("Enter Password")) + self.pw = pw = QLineEdit() + pw.setEchoMode(2) + vbox = QVBoxLayout() + vbox.addWidget(QLabel(msg)) + grid = QGridLayout() + grid.setSpacing(8) + grid.addWidget(QLabel(_('Password')), 1, 0) + grid.addWidget(pw, 1, 1) + vbox.addLayout(grid) + vbox.addLayout(Buttons(CancelButton(self), OkButton(self))) + self.setLayout(vbox) + run_hook('password_dialog', pw, grid, 1) + + def run(self): + if not self.exec_(): + return + return unicode(self.pw.text()) diff --git a/gui/stdio.py b/gui/stdio.py @@ -19,6 +19,8 @@ class ElectrumGui: if not storage.file_exists: print "Wallet not found. try 'electrum create'" exit() + password = getpass.getpass('Password:', stream=None) if storage.is_encrypted() else None + storage.read(password) self.done = 0 self.last_balance = "" diff --git a/gui/text.py b/gui/text.py @@ -1,6 +1,7 @@ import tty, sys import curses, datetime, locale from decimal import Decimal +import getpass from electrum.util import format_satoshis, set_verbosity from electrum.util import StoreDict @@ -21,7 +22,8 @@ class ElectrumGui: if not storage.file_exists: print "Wallet not found. try 'electrum create'" exit() - + password = getpass.getpass('Password:', stream=None) if storage.is_encrypted() else None + storage.read(password) self.wallet = Wallet(storage) self.wallet.start_threads(self.network) self.contacts = StoreDict(self.config, 'contacts') diff --git a/lib/base_wizard.py b/lib/base_wizard.py @@ -331,8 +331,8 @@ class BaseWizard(object): else: self.on_password(None) - def on_password(self, password): - self.storage.put('use_encryption', bool(password)) + def on_password(self, password, encrypt): + self.storage.set_password(password, encrypt) for k in self.keystores: if k.may_have_password(): k.update_password(None, password) diff --git a/lib/bitcoin.py b/lib/bitcoin.py @@ -653,34 +653,26 @@ class EC_KEY(object): def decrypt_message(self, encrypted): - encrypted = base64.b64decode(encrypted) - if len(encrypted) < 85: raise Exception('invalid ciphertext: length') - magic = encrypted[:4] ephemeral_pubkey = encrypted[4:37] ciphertext = encrypted[37:-32] mac = encrypted[-32:] - if magic != 'BIE1': raise Exception('invalid ciphertext: invalid magic bytes') - try: ephemeral_pubkey = ser_to_point(ephemeral_pubkey) except AssertionError, e: raise Exception('invalid ciphertext: invalid ephemeral pubkey') - if not ecdsa.ecdsa.point_is_valid(generator_secp256k1, ephemeral_pubkey.x(), ephemeral_pubkey.y()): raise Exception('invalid ciphertext: invalid ephemeral pubkey') - ecdh_key = point_to_ser(ephemeral_pubkey * self.privkey.secret_multiplier) key = hashlib.sha512(ecdh_key).digest() iv, key_e, key_m = key[0:16], key[16:32], key[32:] if mac != hmac.new(key_m, encrypted[:-32], hashlib.sha256).digest(): - raise Exception('invalid ciphertext: invalid mac') - + raise InvalidPassword() return aes_decrypt_with_iv(key_e, iv, ciphertext) diff --git a/lib/commands.py b/lib/commands.py @@ -796,7 +796,7 @@ def get_parser(): add_global_options(parser_gui) # daemon parser_daemon = subparsers.add_parser('daemon', help="Run Daemon") - parser_daemon.add_argument("subcommand", choices=['start', 'status', 'stop'], nargs='?') + parser_daemon.add_argument("subcommand", choices=['start', 'status', 'stop', 'open', 'close'], nargs='?') #parser_daemon.set_defaults(func=run_daemon) add_network_options(parser_daemon) add_global_options(parser_daemon) diff --git a/lib/daemon.py b/lib/daemon.py @@ -34,7 +34,7 @@ from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer, SimpleJSONRPCReq from version import ELECTRUM_VERSION from network import Network from util import json_decode, DaemonThread -from util import print_msg, print_error, print_stderr +from util import print_msg, print_error, print_stderr, UserCancelled from wallet import WalletStorage, Wallet from commands import known_commands, Commands from simple_config import SimpleConfig @@ -115,8 +115,7 @@ class Daemon(DaemonThread): self.gui = None self.wallets = {} # Setup JSONRPC server - path = config.get_wallet_path() - default_wallet = self.load_wallet(path) + default_wallet = None self.cmd_runner = Commands(self.config, default_wallet, self.network) self.init_server(config, fd) @@ -145,11 +144,24 @@ class Daemon(DaemonThread): def ping(self): return True - def run_daemon(self, config): + def run_daemon(self, config_options): + config = SimpleConfig(config_options) sub = config.get('subcommand') - assert sub in [None, 'start', 'stop', 'status'] + assert sub in [None, 'start', 'stop', 'status', 'open', 'close'] if sub in [None, 'start']: response = "Daemon already running" + elif sub == 'open': + path = config.get_wallet_path() + self.load_wallet(path, lambda: config.get('password')) + response = True + elif sub == 'close': + path = config.get_wallet_path() + if path in self.wallets: + wallet = self.wallets.pop(path) + wallet.stop_threads() + response = True + else: + response = False elif sub == 'status': if self.network: p = self.network.get_parameters() @@ -185,7 +197,7 @@ class Daemon(DaemonThread): response = "Error: Electrum is running in daemon mode. Please stop the daemon first." return response - def load_wallet(self, path): + def load_wallet(self, path, password_getter): # wizard will be launched if we return if path in self.wallets: wallet = self.wallets[path] @@ -193,6 +205,13 @@ class Daemon(DaemonThread): storage = WalletStorage(path) if not storage.file_exists: return + if storage.is_encrypted(): + password = password_getter() + if not password: + raise UserCancelled() + else: + password = None + storage.read(password) if storage.requires_split(): return if storage.requires_upgrade(): @@ -214,20 +233,25 @@ class Daemon(DaemonThread): wallet.stop_threads() def run_cmdline(self, config_options): + password = config_options.get('password') + new_password = config_options.get('new_password') config = SimpleConfig(config_options) cmdname = config.get('cmd') cmd = known_commands[cmdname] - path = config.get_wallet_path() - wallet = self.load_wallet(path) if cmd.requires_wallet else None + if cmd.requires_wallet: + path = config.get_wallet_path() + wallet = self.wallets.get(path) + if wallet is None: + return {'error': 'Wallet not open. Use "electrum daemon open -w wallet"'} + else: + wallet = None # arguments passed to function args = map(lambda x: config.get(x), cmd.params) # decode json arguments args = map(json_decode, args) # options args += map(lambda x: config.get(x), cmd.options) - cmd_runner = Commands(config, wallet, self.network, - password=config_options.get('password'), - new_password=config_options.get('new_password')) + cmd_runner = Commands(config, wallet, self.network, password=password, new_password=new_password) func = getattr(cmd_runner, cmd.name) result = func(*args) return result diff --git a/lib/storage.py b/lib/storage.py @@ -32,11 +32,15 @@ import json import copy import re import stat +import pbkdf2, hmac, hashlib +import base64 +import zlib from i18n import _ from util import NotEnoughFunds, PrintError, profiler from plugins import run_hook, plugin_loaders from keystore import bip44_derivation +import bitcoin # seed_version is now used for the version of the wallet file @@ -63,50 +67,57 @@ class WalletStorage(PrintError): self.lock = threading.RLock() self.data = {} self.path = path - self.file_exists = False + self.file_exists = os.path.exists(self.path) self.modified = False - self.print_error("wallet path", self.path) - if self.path: - self.read(self.path) + self.pubkey = None # check here if I need to load a plugin t = self.get('wallet_type') l = plugin_loaders.get(t) if l: l() + def decrypt(self, s, password): + # Note: hardware wallets should use a seed-derived key and not require a password. + # Thus, we need to expose keystore metadata + if password is None: + self.pubkey = None + return s + secret = pbkdf2.PBKDF2(password, '', iterations = 1024, macmodule = hmac, digestmodule = hashlib.sha512).read(64) + ec_key = bitcoin.EC_KEY(secret) + self.pubkey = ec_key.get_public_key() + return zlib.decompress(ec_key.decrypt_message(s)) if s else None + + def set_password(self, pw, encrypt): + """Set self.pubkey""" + self.put('use_encryption', (pw is not None)) + self.decrypt(None, pw if encrypt else None) + + def is_encrypted(self): + try: + with open(self.path, "r") as f: + s = f.read(8) + except IOError: + return + try: + return base64.b64decode(s).startswith('BIE1') + except: + return False - def read(self, path): + def read(self, password): """Read the contents of the wallet file.""" + self.print_error("wallet path", self.path) try: with open(self.path, "r") as f: - data = f.read() + s = f.read() except IOError: return - if not data: + if not s: return + # Decrypt wallet. + s = self.decrypt(s, password) try: - self.data = json.loads(data) + self.data = json.loads(s) except: - try: - d = ast.literal_eval(data) #parse raw data from reading wallet file - labels = d.get('labels', {}) - except Exception as e: - raise IOError("Cannot read wallet file '%s'" % self.path) - self.data = {} - # In old versions of Electrum labels were latin1 encoded, this fixes breakage. - for i, label in labels.items(): - try: - unicode(label) - except UnicodeDecodeError: - d['labels'][i] = unicode(label.decode('latin1')) - for key, value in d.items(): - try: - json.dumps(key) - json.dumps(value) - except: - self.print_error('Failed to convert label to json format', key) - continue - self.data[key] = value - self.file_exists = True + raise IOError("Cannot read wallet file '%s'" % self.path) def get(self, key, default=None): with self.lock: @@ -133,6 +144,7 @@ class WalletStorage(PrintError): self.modified = True self.data.pop(key) + @profiler def write(self): # this ensures that previous versions of electrum won't open the wallet self.put('seed_version', FINAL_SEED_VERSION) @@ -147,6 +159,9 @@ class WalletStorage(PrintError): if not self.modified: return s = json.dumps(self.data, indent=4, sort_keys=True) + if self.pubkey: + s = bitcoin.encrypt_message(zlib.compress(s), self.pubkey) + temp_path = "%s.tmp.%s" % (self.path, os.getpid()) with open(temp_path, "w") as f: f.write(s) diff --git a/lib/wallet.py b/lib/wallet.py @@ -1577,10 +1577,10 @@ class Simple_Deterministic_Wallet(Deterministic_Wallet, Simple_Wallet): def check_password(self, password): self.keystore.check_password(password) - def update_password(self, old_pw, new_pw): + def update_password(self, old_pw, new_pw, encrypt=False): self.keystore.update_password(old_pw, new_pw) self.save_keystore() - self.storage.put('use_encryption', (new_pw is not None)) + self.storage.set_password(new_pw, encrypt) self.storage.write() def save_keystore(self): @@ -1686,7 +1686,7 @@ class Multisig_Wallet(Deterministic_Wallet, P2SH): if keystore.can_change_password(): keystore.update_password(old_pw, new_pw) self.storage.put(name, keystore.dump()) - self.storage.put('use_encryption', (new_pw is not None)) + self.storage.set_password(new_pw) def check_password(self, password): self.keystore.check_password(password)