URI: 
       tMerge pull request #4838 from SomberNight/keystore_pw_hash2b - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 2484c52611defa96ed060af4152707b3d1bfdea5
   DIR parent 1165d3f330c9be0318a262856af89938a1257641
  HTML Author: ThomasV <thomasv@electrum.org>
       Date:   Fri, 30 Nov 2018 11:48:03 +0100
       
       Merge pull request #4838 from SomberNight/keystore_pw_hash2b
       
       keystore: stronger pbkdf for encryption
       Diffstat:
         M electrum/crypto.py                  |      95 ++++++++++++++++++++++++++-----
         M electrum/keystore.py                |      58 ++++++++++++++++++++-----------
         M electrum/plugins/digitalbitbox/dig… |       8 ++++----
         M electrum/tests/test_bitcoin.py      |      27 ++++++++++++++++-----------
       
       4 files changed, 138 insertions(+), 50 deletions(-)
       ---
   DIR diff --git a/electrum/crypto.py b/electrum/crypto.py
       t@@ -32,6 +32,7 @@ from typing import Union
        import pyaes
        
        from .util import assert_bytes, InvalidPassword, to_bytes, to_string
       +from .i18n import _
        
        
        try:
       t@@ -90,37 +91,103 @@ def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
                raise InvalidPassword()
        
        
       -def EncodeAES(secret: bytes, msg: bytes) -> bytes:
       +def EncodeAES_base64(secret: bytes, msg: bytes) -> bytes:
            """Returns base64 encoded ciphertext."""
       +    e = EncodeAES_bytes(secret, msg)
       +    return base64.b64encode(e)
       +
       +
       +def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
            assert_bytes(msg)
            iv = bytes(os.urandom(16))
            ct = aes_encrypt_with_iv(secret, iv, msg)
       -    e = iv + ct
       -    return base64.b64encode(e)
       +    return iv + ct
       +
        
       +def DecodeAES_base64(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
       +    ciphertext = bytes(base64.b64decode(ciphertext_b64))
       +    return DecodeAES_bytes(secret, ciphertext)
        
       -def DecodeAES(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
       -    e = bytes(base64.b64decode(ciphertext_b64))
       -    iv, e = e[:16], e[16:]
       +
       +def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
       +    assert_bytes(ciphertext)
       +    iv, e = ciphertext[:16], ciphertext[16:]
            s = aes_decrypt_with_iv(secret, iv, e)
            return s
        
        
       -def pw_encode(data: str, password: Union[bytes, str]) -> str:
       +PW_HASH_VERSION_LATEST = 2
       +KNOWN_PW_HASH_VERSIONS = (1, 2)
       +assert PW_HASH_VERSION_LATEST in KNOWN_PW_HASH_VERSIONS
       +
       +
       +class UnexpectedPasswordHashVersion(InvalidPassword):
       +    def __init__(self, version):
       +        self.version = version
       +
       +    def __str__(self):
       +        return "{unexpected}: {version}\n{please_update}".format(
       +            unexpected=_("Unexpected password hash version"),
       +            version=self.version,
       +            please_update=_('You are most likely using an outdated version of Electrum. Please update.'))
       +
       +
       +def _hash_password(password: Union[bytes, str], *, version: int, salt: bytes) -> bytes:
       +    pw = to_bytes(password, 'utf8')
       +    if version == 1:
       +        return sha256d(pw)
       +    elif version == 2:
       +        if not isinstance(salt, bytes) or len(salt) < 16:
       +            raise Exception('too weak salt', salt)
       +        return hashlib.pbkdf2_hmac(hash_name='sha256',
       +                                   password=pw,
       +                                   salt=b'ELECTRUM_PW_HASH_V2'+salt,
       +                                   iterations=50_000)
       +    else:
       +        assert version not in KNOWN_PW_HASH_VERSIONS
       +        raise UnexpectedPasswordHashVersion(version)
       +
       +
       +def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
            if not password:
                return data
       -    secret = sha256d(password)
       -    return EncodeAES(secret, to_bytes(data, "utf8")).decode('utf8')
       +    if version not in KNOWN_PW_HASH_VERSIONS:
       +        raise UnexpectedPasswordHashVersion(version)
       +    # derive key from password
       +    if version == 1:
       +        salt = b''
       +    elif version == 2:
       +        salt = bytes(os.urandom(16))
       +    else:
       +        assert False, version
       +    secret = _hash_password(password, version=version, salt=salt)
       +    # encrypt given data
       +    e = EncodeAES_bytes(secret, to_bytes(data, "utf8"))
       +    # return base64(salt + encrypted data)
       +    ciphertext = salt + e
       +    ciphertext_b64 = base64.b64encode(ciphertext)
       +    return ciphertext_b64.decode('utf8')
        
        
       -def pw_decode(data: str, password: Union[bytes, str]) -> str:
       +def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
            if password is None:
                return data
       -    secret = sha256d(password)
       +    if version not in KNOWN_PW_HASH_VERSIONS:
       +        raise UnexpectedPasswordHashVersion(version)
       +    data_bytes = bytes(base64.b64decode(data))
       +    # derive key from password
       +    if version == 1:
       +        salt = b''
       +    elif version == 2:
       +        salt, data_bytes = data_bytes[:16], data_bytes[16:]
       +    else:
       +        assert False, version
       +    secret = _hash_password(password, version=version, salt=salt)
       +    # decrypt given data
            try:
       -        d = to_string(DecodeAES(secret, data), "utf8")
       -    except Exception:
       -        raise InvalidPassword()
       +        d = to_string(DecodeAES_bytes(secret, data_bytes), "utf8")
       +    except Exception as e:
       +        raise InvalidPassword() from e
            return d
        
        
   DIR diff --git a/electrum/keystore.py b/electrum/keystore.py
       t@@ -35,7 +35,7 @@ from .bip32 import (bip32_public_derivation, deserialize_xpub, CKD_pub,
                            bip32_private_key, bip32_derivation, BIP32_PRIME,
                            is_xpub, is_xprv)
        from .ecc import string_to_number, number_to_string
       -from .crypto import pw_decode, pw_encode, sha256d
       +from .crypto import (pw_decode, pw_encode, sha256d, PW_HASH_VERSION_LATEST)
        from .util import (PrintError, InvalidPassword, hfu, WalletFileException,
                           BitcoinException, bh2u, bfh, print_error, inv_dict)
        from .mnemonic import Mnemonic, load_wordlist
       t@@ -92,8 +92,9 @@ class KeyStore(PrintError):
        
        class Software_KeyStore(KeyStore):
        
       -    def __init__(self):
       +    def __init__(self, d):
                KeyStore.__init__(self)
       +        self.pw_hash_version = d.get('pw_hash_version', 1)
        
            def may_have_password(self):
                return not self.is_watching_only()
       t@@ -122,6 +123,12 @@ class Software_KeyStore(KeyStore):
                if keypairs:
                    tx.sign(keypairs)
        
       +    def update_password(self, old_password, new_password):
       +        raise NotImplementedError()  # implemented by subclasses
       +
       +    def check_password(self, password):
       +        raise NotImplementedError()  # implemented by subclasses
       +
        
        class Imported_KeyStore(Software_KeyStore):
            # keystore for imported private keys
       t@@ -129,7 +136,7 @@ class Imported_KeyStore(Software_KeyStore):
            type = 'imported'
        
            def __init__(self, d):
       -        Software_KeyStore.__init__(self)
       +        Software_KeyStore.__init__(self, d)
                self.keypairs = d.get('keypairs', {})
        
            def is_deterministic(self):
       t@@ -142,6 +149,7 @@ class Imported_KeyStore(Software_KeyStore):
                return {
                    'type': self.type,
                    'keypairs': self.keypairs,
       +            'pw_hash_version': self.pw_hash_version,
                }
        
            def can_import(self):
       t@@ -161,14 +169,14 @@ class Imported_KeyStore(Software_KeyStore):
                # there will only be one pubkey-privkey pair for it in self.keypairs,
                # and the privkey will encode a txin_type but that txin_type cannot be trusted.
                # Removing keys complicates this further.
       -        self.keypairs[pubkey] = pw_encode(serialized_privkey, password)
       +        self.keypairs[pubkey] = pw_encode(serialized_privkey, password, version=self.pw_hash_version)
                return txin_type, pubkey
        
            def delete_imported_key(self, key):
                self.keypairs.pop(key)
        
            def get_private_key(self, pubkey, password):
       -        sec = pw_decode(self.keypairs[pubkey], password)
       +        sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
                txin_type, privkey, compressed = deserialize_privkey(sec)
                # this checks the password
                if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
       t@@ -189,16 +197,17 @@ class Imported_KeyStore(Software_KeyStore):
                if new_password == '':
                    new_password = None
                for k, v in self.keypairs.items():
       -            b = pw_decode(v, old_password)
       -            c = pw_encode(b, new_password)
       +            b = pw_decode(v, old_password, version=self.pw_hash_version)
       +            c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
                    self.keypairs[k] = c
       +        self.pw_hash_version = PW_HASH_VERSION_LATEST
        
        
        
        class Deterministic_KeyStore(Software_KeyStore):
        
            def __init__(self, d):
       -        Software_KeyStore.__init__(self)
       +        Software_KeyStore.__init__(self, d)
                self.seed = d.get('seed', '')
                self.passphrase = d.get('passphrase', '')
        
       t@@ -206,12 +215,14 @@ class Deterministic_KeyStore(Software_KeyStore):
                return True
        
            def dump(self):
       -        d = {}
       +        d = {
       +            'type': self.type,
       +            'pw_hash_version': self.pw_hash_version,
       +        }
                if self.seed:
                    d['seed'] = self.seed
                if self.passphrase:
                    d['passphrase'] = self.passphrase
       -        d['type'] = self.type
                return d
        
            def has_seed(self):
       t@@ -226,10 +237,13 @@ class Deterministic_KeyStore(Software_KeyStore):
                self.seed = self.format_seed(seed)
        
            def get_seed(self, password):
       -        return pw_decode(self.seed, password)
       +        return pw_decode(self.seed, password, version=self.pw_hash_version)
        
            def get_passphrase(self, password):
       -        return pw_decode(self.passphrase, password) if self.passphrase else ''
       +        if self.passphrase:
       +            return pw_decode(self.passphrase, password, version=self.pw_hash_version)
       +        else:
       +            return ''
        
        
        class Xpub:
       t@@ -312,10 +326,10 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
                return d
        
            def get_master_private_key(self, password):
       -        return pw_decode(self.xprv, password)
       +        return pw_decode(self.xprv, password, version=self.pw_hash_version)
        
            def check_password(self, password):
       -        xprv = pw_decode(self.xprv, password)
       +        xprv = pw_decode(self.xprv, password, version=self.pw_hash_version)
                if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]:
                    raise InvalidPassword()
        
       t@@ -325,13 +339,14 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub):
                    new_password = None
                if self.has_seed():
                    decoded = self.get_seed(old_password)
       -            self.seed = pw_encode(decoded, new_password)
       +            self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
                if self.passphrase:
                    decoded = self.get_passphrase(old_password)
       -            self.passphrase = pw_encode(decoded, new_password)
       +            self.passphrase = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
                if self.xprv is not None:
       -            b = pw_decode(self.xprv, old_password)
       -            self.xprv = pw_encode(b, new_password)
       +            b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
       +            self.xprv = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
       +        self.pw_hash_version = PW_HASH_VERSION_LATEST
        
            def is_watching_only(self):
                return self.xprv is None
       t@@ -362,7 +377,7 @@ class Old_KeyStore(Deterministic_KeyStore):
                self.mpk = d.get('mpk')
        
            def get_hex_seed(self, password):
       -        return pw_decode(self.seed, password).encode('utf8')
       +        return pw_decode(self.seed, password, version=self.pw_hash_version).encode('utf8')
        
            def dump(self):
                d = Deterministic_KeyStore.dump(self)
       t@@ -484,8 +499,9 @@ class Old_KeyStore(Deterministic_KeyStore):
                if new_password == '':
                    new_password = None
                if self.has_seed():
       -            decoded = pw_decode(self.seed, old_password)
       -            self.seed = pw_encode(decoded, new_password)
       +            decoded = pw_decode(self.seed, old_password, version=self.pw_hash_version)
       +            self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
       +        self.pw_hash_version = PW_HASH_VERSION_LATEST
        
        
        
   DIR diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py
       t@@ -4,7 +4,7 @@
        #
        
        try:
       -    from electrum.crypto import sha256d, EncodeAES, DecodeAES
       +    from electrum.crypto import sha256d, EncodeAES_base64, DecodeAES_base64
            from electrum.bitcoin import (TYPE_ADDRESS, push_script, var_int, public_key_to_p2pkh,
                                          is_address)
            from electrum.bip32 import serialize_xpub, deserialize_xpub
       t@@ -396,10 +396,10 @@ class DigitalBitbox_Client():
                reply = ""
                try:
                    secret = sha256d(self.password)
       -            msg = EncodeAES(secret, msg)
       +            msg = EncodeAES_base64(secret, msg)
                    reply = self.hid_send_plain(msg)
                    if 'ciphertext' in reply:
       -                reply = DecodeAES(secret, ''.join(reply["ciphertext"]))
       +                reply = DecodeAES_base64(secret, ''.join(reply["ciphertext"]))
                        reply = to_string(reply, 'utf8')
                        reply = json.loads(reply)
                    if 'error' in reply:
       t@@ -716,7 +716,7 @@ class DigitalBitboxPlugin(HW_PluginBase):
                key_s = base64.b64decode(self.digitalbitbox_config['encryptionprivkey'])
                args = 'c=data&s=0&dt=0&uuid=%s&pl=%s' % (
                    self.digitalbitbox_config['comserverchannelid'],
       -            EncodeAES(key_s, json.dumps(payload).encode('ascii')).decode('ascii'),
       +            EncodeAES_base64(key_s, json.dumps(payload).encode('ascii')).decode('ascii'),
                )
                try:
                    requests.post(url, args)
   DIR diff --git a/electrum/tests/test_bitcoin.py b/electrum/tests/test_bitcoin.py
       t@@ -11,11 +11,11 @@ from electrum.bitcoin import (public_key_to_p2pkh, address_from_private_key,
        from electrum.bip32 import (bip32_root, bip32_public_derivation, bip32_private_derivation,
                                    xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation,
                                    is_xpub, convert_bip32_path_to_list_of_uint32)
       -from electrum.crypto import sha256d
       +from electrum.crypto import sha256d, KNOWN_PW_HASH_VERSIONS
        from electrum import ecc, crypto, constants
        from electrum.ecc import number_to_string, string_to_number
        from electrum.transaction import opcodes
       -from electrum.util import bfh, bh2u
       +from electrum.util import bfh, bh2u, InvalidPassword
        from electrum.storage import WalletStorage
        from electrum.keystore import xtype_from_derivation
        
       t@@ -219,23 +219,26 @@ class Test_bitcoin(SequentialTestCase):
                """Make sure AES is homomorphic."""
                payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
                password = u'secret'
       -        enc = crypto.pw_encode(payload, password)
       -        dec = crypto.pw_decode(enc, password)
       -        self.assertEqual(dec, payload)
       +        for version in KNOWN_PW_HASH_VERSIONS:
       +            enc = crypto.pw_encode(payload, password, version=version)
       +            dec = crypto.pw_decode(enc, password, version=version)
       +            self.assertEqual(dec, payload)
        
            @needs_test_with_all_aes_implementations
            def test_aes_encode_without_password(self):
                """When not passed a password, pw_encode is noop on the payload."""
                payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
       -        enc = crypto.pw_encode(payload, None)
       -        self.assertEqual(payload, enc)
       +        for version in KNOWN_PW_HASH_VERSIONS:
       +            enc = crypto.pw_encode(payload, None, version=version)
       +            self.assertEqual(payload, enc)
        
            @needs_test_with_all_aes_implementations
            def test_aes_deencode_without_password(self):
                """When not passed a password, pw_decode is noop on the payload."""
                payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
       -        enc = crypto.pw_decode(payload, None)
       -        self.assertEqual(payload, enc)
       +        for version in KNOWN_PW_HASH_VERSIONS:
       +            enc = crypto.pw_decode(payload, None, version=version)
       +            self.assertEqual(payload, enc)
        
            @needs_test_with_all_aes_implementations
            def test_aes_decode_with_invalid_password(self):
       t@@ -243,8 +246,10 @@ class Test_bitcoin(SequentialTestCase):
                payload = u"blah"
                password = u"uber secret"
                wrong_password = u"not the password"
       -        enc = crypto.pw_encode(payload, password)
       -        self.assertRaises(Exception, crypto.pw_decode, enc, wrong_password)
       +        for version in KNOWN_PW_HASH_VERSIONS:
       +            enc = crypto.pw_encode(payload, password, version=version)
       +            with self.assertRaises(InvalidPassword):
       +                crypto.pw_decode(enc, wrong_password, version=version)
        
            def test_sha256d(self):
                self.assertEqual(b'\x95MZI\xfdp\xd9\xb8\xbc\xdb5\xd2R&x)\x95\x7f~\xf7\xfalt\xf8\x84\x19\xbd\xc5\xe8"\t\xf4',