Skip to content

Commit 48b0de7

Browse files
committed
keystore: stronger pbkdf for encryption
1 parent aceb022 commit 48b0de7

4 files changed

Lines changed: 138 additions & 50 deletions

File tree

electrum/crypto.py

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import pyaes
3333

3434
from .util import assert_bytes, InvalidPassword, to_bytes, to_string
35+
from .i18n import _
3536

3637

3738
try:
@@ -90,37 +91,103 @@ def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes:
9091
raise InvalidPassword()
9192

9293

93-
def EncodeAES(secret: bytes, msg: bytes) -> bytes:
94+
def EncodeAES_base64(secret: bytes, msg: bytes) -> bytes:
9495
"""Returns base64 encoded ciphertext."""
96+
e = EncodeAES_bytes(secret, msg)
97+
return base64.b64encode(e)
98+
99+
100+
def EncodeAES_bytes(secret: bytes, msg: bytes) -> bytes:
95101
assert_bytes(msg)
96102
iv = bytes(os.urandom(16))
97103
ct = aes_encrypt_with_iv(secret, iv, msg)
98-
e = iv + ct
99-
return base64.b64encode(e)
104+
return iv + ct
105+
100106

107+
def DecodeAES_base64(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
108+
ciphertext = bytes(base64.b64decode(ciphertext_b64))
109+
return DecodeAES_bytes(secret, ciphertext)
101110

102-
def DecodeAES(secret: bytes, ciphertext_b64: Union[bytes, str]) -> bytes:
103-
e = bytes(base64.b64decode(ciphertext_b64))
104-
iv, e = e[:16], e[16:]
111+
112+
def DecodeAES_bytes(secret: bytes, ciphertext: bytes) -> bytes:
113+
assert_bytes(ciphertext)
114+
iv, e = ciphertext[:16], ciphertext[16:]
105115
s = aes_decrypt_with_iv(secret, iv, e)
106116
return s
107117

108118

109-
def pw_encode(data: str, password: Union[bytes, str]) -> str:
119+
PW_HASH_VERSION_LATEST = 2
120+
KNOWN_PW_HASH_VERSIONS = (1, 2)
121+
assert PW_HASH_VERSION_LATEST in KNOWN_PW_HASH_VERSIONS
122+
123+
124+
class UnexpectedPasswordHashVersion(InvalidPassword):
125+
def __init__(self, version):
126+
self.version = version
127+
128+
def __str__(self):
129+
return "{unexpected}: {version}\n{please_update}".format(
130+
unexpected=_("Unexpected password hash version"),
131+
version=self.version,
132+
please_update=_('You are most likely using an outdated version of Electrum. Please update.'))
133+
134+
135+
def _hash_password(password: Union[bytes, str], *, version: int, salt: bytes) -> bytes:
136+
pw = to_bytes(password, 'utf8')
137+
if version == 1:
138+
return sha256d(pw)
139+
elif version == 2:
140+
if not isinstance(salt, bytes) or len(salt) < 16:
141+
raise Exception('too weak salt', salt)
142+
return hashlib.pbkdf2_hmac(hash_name='sha256',
143+
password=pw,
144+
salt=b'ELECTRUM_PW_HASH_V2'+salt,
145+
iterations=50_000)
146+
else:
147+
assert version not in KNOWN_PW_HASH_VERSIONS
148+
raise UnexpectedPasswordHashVersion(version)
149+
150+
151+
def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
110152
if not password:
111153
return data
112-
secret = sha256d(password)
113-
return EncodeAES(secret, to_bytes(data, "utf8")).decode('utf8')
154+
if version not in KNOWN_PW_HASH_VERSIONS:
155+
raise UnexpectedPasswordHashVersion(version)
156+
# derive key from password
157+
if version == 1:
158+
salt = b''
159+
elif version == 2:
160+
salt = bytes(os.urandom(16))
161+
else:
162+
assert False, version
163+
secret = _hash_password(password, version=version, salt=salt)
164+
# encrypt given data
165+
e = EncodeAES_bytes(secret, to_bytes(data, "utf8"))
166+
# return base64(salt + encrypted data)
167+
ciphertext = salt + e
168+
ciphertext_b64 = base64.b64encode(ciphertext)
169+
return ciphertext_b64.decode('utf8')
114170

115171

116-
def pw_decode(data: str, password: Union[bytes, str]) -> str:
172+
def pw_decode(data: str, password: Union[bytes, str, None], *, version: int) -> str:
117173
if password is None:
118174
return data
119-
secret = sha256d(password)
175+
if version not in KNOWN_PW_HASH_VERSIONS:
176+
raise UnexpectedPasswordHashVersion(version)
177+
data_bytes = bytes(base64.b64decode(data))
178+
# derive key from password
179+
if version == 1:
180+
salt = b''
181+
elif version == 2:
182+
salt, data_bytes = data_bytes[:16], data_bytes[16:]
183+
else:
184+
assert False, version
185+
secret = _hash_password(password, version=version, salt=salt)
186+
# decrypt given data
120187
try:
121-
d = to_string(DecodeAES(secret, data), "utf8")
122-
except Exception:
123-
raise InvalidPassword()
188+
d = to_string(DecodeAES_bytes(secret, data_bytes), "utf8")
189+
except Exception as e:
190+
raise InvalidPassword() from e
124191
return d
125192

126193

electrum/keystore.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
bip32_private_key, bip32_derivation, BIP32_PRIME,
3636
is_xpub, is_xprv)
3737
from .ecc import string_to_number, number_to_string
38-
from .crypto import pw_decode, pw_encode, sha256d
38+
from .crypto import (pw_decode, pw_encode, sha256d, PW_HASH_VERSION_LATEST)
3939
from .util import (PrintError, InvalidPassword, hfu, WalletFileException,
4040
BitcoinException, bh2u, bfh, print_error, inv_dict)
4141
from .mnemonic import Mnemonic, load_wordlist
@@ -92,8 +92,9 @@ def ready_to_sign(self):
9292

9393
class Software_KeyStore(KeyStore):
9494

95-
def __init__(self):
95+
def __init__(self, d):
9696
KeyStore.__init__(self)
97+
self.pw_hash_version = d.get('pw_hash_version', 1)
9798

9899
def may_have_password(self):
99100
return not self.is_watching_only()
@@ -122,14 +123,20 @@ def sign_transaction(self, tx, password):
122123
if keypairs:
123124
tx.sign(keypairs)
124125

126+
def update_password(self, old_password, new_password):
127+
raise NotImplementedError() # implemented by subclasses
128+
129+
def check_password(self, password):
130+
raise NotImplementedError() # implemented by subclasses
131+
125132

126133
class Imported_KeyStore(Software_KeyStore):
127134
# keystore for imported private keys
128135

129136
type = 'imported'
130137

131138
def __init__(self, d):
132-
Software_KeyStore.__init__(self)
139+
Software_KeyStore.__init__(self, d)
133140
self.keypairs = d.get('keypairs', {})
134141

135142
def is_deterministic(self):
@@ -142,6 +149,7 @@ def dump(self):
142149
return {
143150
'type': self.type,
144151
'keypairs': self.keypairs,
152+
'pw_hash_version': self.pw_hash_version,
145153
}
146154

147155
def can_import(self):
@@ -161,14 +169,14 @@ def import_privkey(self, sec, password):
161169
# there will only be one pubkey-privkey pair for it in self.keypairs,
162170
# and the privkey will encode a txin_type but that txin_type cannot be trusted.
163171
# Removing keys complicates this further.
164-
self.keypairs[pubkey] = pw_encode(serialized_privkey, password)
172+
self.keypairs[pubkey] = pw_encode(serialized_privkey, password, version=self.pw_hash_version)
165173
return txin_type, pubkey
166174

167175
def delete_imported_key(self, key):
168176
self.keypairs.pop(key)
169177

170178
def get_private_key(self, pubkey, password):
171-
sec = pw_decode(self.keypairs[pubkey], password)
179+
sec = pw_decode(self.keypairs[pubkey], password, version=self.pw_hash_version)
172180
txin_type, privkey, compressed = deserialize_privkey(sec)
173181
# this checks the password
174182
if pubkey != ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed):
@@ -189,29 +197,32 @@ def update_password(self, old_password, new_password):
189197
if new_password == '':
190198
new_password = None
191199
for k, v in self.keypairs.items():
192-
b = pw_decode(v, old_password)
193-
c = pw_encode(b, new_password)
200+
b = pw_decode(v, old_password, version=self.pw_hash_version)
201+
c = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
194202
self.keypairs[k] = c
203+
self.pw_hash_version = PW_HASH_VERSION_LATEST
195204

196205

197206

198207
class Deterministic_KeyStore(Software_KeyStore):
199208

200209
def __init__(self, d):
201-
Software_KeyStore.__init__(self)
210+
Software_KeyStore.__init__(self, d)
202211
self.seed = d.get('seed', '')
203212
self.passphrase = d.get('passphrase', '')
204213

205214
def is_deterministic(self):
206215
return True
207216

208217
def dump(self):
209-
d = {}
218+
d = {
219+
'type': self.type,
220+
'pw_hash_version': self.pw_hash_version,
221+
}
210222
if self.seed:
211223
d['seed'] = self.seed
212224
if self.passphrase:
213225
d['passphrase'] = self.passphrase
214-
d['type'] = self.type
215226
return d
216227

217228
def has_seed(self):
@@ -226,10 +237,13 @@ def add_seed(self, seed):
226237
self.seed = self.format_seed(seed)
227238

228239
def get_seed(self, password):
229-
return pw_decode(self.seed, password)
240+
return pw_decode(self.seed, password, version=self.pw_hash_version)
230241

231242
def get_passphrase(self, password):
232-
return pw_decode(self.passphrase, password) if self.passphrase else ''
243+
if self.passphrase:
244+
return pw_decode(self.passphrase, password, version=self.pw_hash_version)
245+
else:
246+
return ''
233247

234248

235249
class Xpub:
@@ -312,10 +326,10 @@ def dump(self):
312326
return d
313327

314328
def get_master_private_key(self, password):
315-
return pw_decode(self.xprv, password)
329+
return pw_decode(self.xprv, password, version=self.pw_hash_version)
316330

317331
def check_password(self, password):
318-
xprv = pw_decode(self.xprv, password)
332+
xprv = pw_decode(self.xprv, password, version=self.pw_hash_version)
319333
if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]:
320334
raise InvalidPassword()
321335

@@ -325,13 +339,14 @@ def update_password(self, old_password, new_password):
325339
new_password = None
326340
if self.has_seed():
327341
decoded = self.get_seed(old_password)
328-
self.seed = pw_encode(decoded, new_password)
342+
self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
329343
if self.passphrase:
330344
decoded = self.get_passphrase(old_password)
331-
self.passphrase = pw_encode(decoded, new_password)
345+
self.passphrase = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
332346
if self.xprv is not None:
333-
b = pw_decode(self.xprv, old_password)
334-
self.xprv = pw_encode(b, new_password)
347+
b = pw_decode(self.xprv, old_password, version=self.pw_hash_version)
348+
self.xprv = pw_encode(b, new_password, version=PW_HASH_VERSION_LATEST)
349+
self.pw_hash_version = PW_HASH_VERSION_LATEST
335350

336351
def is_watching_only(self):
337352
return self.xprv is None
@@ -362,7 +377,7 @@ def __init__(self, d):
362377
self.mpk = d.get('mpk')
363378

364379
def get_hex_seed(self, password):
365-
return pw_decode(self.seed, password).encode('utf8')
380+
return pw_decode(self.seed, password, version=self.pw_hash_version).encode('utf8')
366381

367382
def dump(self):
368383
d = Deterministic_KeyStore.dump(self)
@@ -484,8 +499,9 @@ def update_password(self, old_password, new_password):
484499
if new_password == '':
485500
new_password = None
486501
if self.has_seed():
487-
decoded = pw_decode(self.seed, old_password)
488-
self.seed = pw_encode(decoded, new_password)
502+
decoded = pw_decode(self.seed, old_password, version=self.pw_hash_version)
503+
self.seed = pw_encode(decoded, new_password, version=PW_HASH_VERSION_LATEST)
504+
self.pw_hash_version = PW_HASH_VERSION_LATEST
489505

490506

491507

electrum/plugins/digitalbitbox/digitalbitbox.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55

66
try:
7-
from electrum.crypto import sha256d, EncodeAES, DecodeAES
7+
from electrum.crypto import sha256d, EncodeAES_base64, DecodeAES_base64
88
from electrum.bitcoin import (TYPE_ADDRESS, push_script, var_int, public_key_to_p2pkh,
99
is_address)
1010
from electrum.bip32 import serialize_xpub, deserialize_xpub
@@ -396,10 +396,10 @@ def hid_send_encrypt(self, msg):
396396
reply = ""
397397
try:
398398
secret = sha256d(self.password)
399-
msg = EncodeAES(secret, msg)
399+
msg = EncodeAES_base64(secret, msg)
400400
reply = self.hid_send_plain(msg)
401401
if 'ciphertext' in reply:
402-
reply = DecodeAES(secret, ''.join(reply["ciphertext"]))
402+
reply = DecodeAES_base64(secret, ''.join(reply["ciphertext"]))
403403
reply = to_string(reply, 'utf8')
404404
reply = json.loads(reply)
405405
if 'error' in reply:
@@ -716,7 +716,7 @@ def comserver_post_notification(self, payload):
716716
key_s = base64.b64decode(self.digitalbitbox_config['encryptionprivkey'])
717717
args = 'c=data&s=0&dt=0&uuid=%s&pl=%s' % (
718718
self.digitalbitbox_config['comserverchannelid'],
719-
EncodeAES(key_s, json.dumps(payload).encode('ascii')).decode('ascii'),
719+
EncodeAES_base64(key_s, json.dumps(payload).encode('ascii')).decode('ascii'),
720720
)
721721
try:
722722
requests.post(url, args)

electrum/tests/test_bitcoin.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
from electrum.bip32 import (bip32_root, bip32_public_derivation, bip32_private_derivation,
1212
xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation,
1313
is_xpub, convert_bip32_path_to_list_of_uint32)
14-
from electrum.crypto import sha256d
14+
from electrum.crypto import sha256d, KNOWN_PW_HASH_VERSIONS
1515
from electrum import ecc, crypto, constants
1616
from electrum.ecc import number_to_string, string_to_number
1717
from electrum.transaction import opcodes
18-
from electrum.util import bfh, bh2u
18+
from electrum.util import bfh, bh2u, InvalidPassword
1919
from electrum.storage import WalletStorage
2020
from electrum.keystore import xtype_from_derivation
2121

@@ -219,32 +219,37 @@ def test_aes_homomorphic(self):
219219
"""Make sure AES is homomorphic."""
220220
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
221221
password = u'secret'
222-
enc = crypto.pw_encode(payload, password)
223-
dec = crypto.pw_decode(enc, password)
224-
self.assertEqual(dec, payload)
222+
for version in KNOWN_PW_HASH_VERSIONS:
223+
enc = crypto.pw_encode(payload, password, version=version)
224+
dec = crypto.pw_decode(enc, password, version=version)
225+
self.assertEqual(dec, payload)
225226

226227
@needs_test_with_all_aes_implementations
227228
def test_aes_encode_without_password(self):
228229
"""When not passed a password, pw_encode is noop on the payload."""
229230
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
230-
enc = crypto.pw_encode(payload, None)
231-
self.assertEqual(payload, enc)
231+
for version in KNOWN_PW_HASH_VERSIONS:
232+
enc = crypto.pw_encode(payload, None, version=version)
233+
self.assertEqual(payload, enc)
232234

233235
@needs_test_with_all_aes_implementations
234236
def test_aes_deencode_without_password(self):
235237
"""When not passed a password, pw_decode is noop on the payload."""
236238
payload = u'\u66f4\u7a33\u5b9a\u7684\u4ea4\u6613\u5e73\u53f0'
237-
enc = crypto.pw_decode(payload, None)
238-
self.assertEqual(payload, enc)
239+
for version in KNOWN_PW_HASH_VERSIONS:
240+
enc = crypto.pw_decode(payload, None, version=version)
241+
self.assertEqual(payload, enc)
239242

240243
@needs_test_with_all_aes_implementations
241244
def test_aes_decode_with_invalid_password(self):
242245
"""pw_decode raises an Exception when supplied an invalid password."""
243246
payload = u"blah"
244247
password = u"uber secret"
245248
wrong_password = u"not the password"
246-
enc = crypto.pw_encode(payload, password)
247-
self.assertRaises(Exception, crypto.pw_decode, enc, wrong_password)
249+
for version in KNOWN_PW_HASH_VERSIONS:
250+
enc = crypto.pw_encode(payload, password, version=version)
251+
with self.assertRaises(InvalidPassword):
252+
crypto.pw_decode(enc, wrong_password, version=version)
248253

249254
def test_sha256d(self):
250255
self.assertEqual(b'\x95MZI\xfdp\xd9\xb8\xbc\xdb5\xd2R&x)\x95\x7f~\xf7\xfalt\xf8\x84\x19\xbd\xc5\xe8"\t\xf4',

0 commit comments

Comments
 (0)