From 26ae6d68a3df8eae26b2d66c6dc41ea8ed25b637 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 16 Jun 2020 10:42:47 +0200 Subject: [PATCH 1/4] add encryption version to channel backups --- electrum/crypto.py | 44 ++++++++++++++++--- electrum/gui/kivy/main_window.py | 2 +- .../kivy/uix/dialogs/lightning_channels.py | 2 +- electrum/gui/qt/channels_list.py | 2 +- electrum/gui/qt/main_window.py | 2 +- electrum/lnworker.py | 13 +++--- 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/electrum/crypto.py b/electrum/crypto.py index fd09f67e3..70192eaf0 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -189,23 +189,21 @@ def _hash_password(password: Union[bytes, str], *, version: int) -> bytes: raise UnexpectedPasswordHashVersion(version) -def pw_encode_bytes(data: bytes, password: Union[bytes, str], *, version: int) -> str: - """plaintext bytes -> base64 ciphertext""" +def pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> str: + """bytes -> bytes""" if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) # derive key from password secret = _hash_password(password, version=version) # encrypt given data ciphertext = EncodeAES_bytes(secret, data) - ciphertext_b64 = base64.b64encode(ciphertext) - return ciphertext_b64.decode('utf8') + return ciphertext -def pw_decode_bytes(data: str, password: Union[bytes, str], *, version: int) -> bytes: - """base64 ciphertext -> plaintext bytes""" +def pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: int) -> bytes: + """bytes -> bytes""" if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) - data_bytes = bytes(base64.b64decode(data)) # derive key from password secret = _hash_password(password, version=version) # decrypt given data @@ -216,6 +214,38 @@ def pw_decode_bytes(data: str, password: Union[bytes, str], *, version: int) -> return d +def pw_encode_bytes(data: bytes, password: Union[bytes, str], *, version: int) -> str: + """plaintext bytes -> base64 ciphertext""" + ciphertext = pw_encode_raw(data, password, version=version) + ciphertext_b64 = base64.b64encode(ciphertext) + return ciphertext_b64.decode('utf8') + + +def pw_decode_bytes(data: str, password: Union[bytes, str], *, version:int) -> bytes: + """base64 ciphertext -> plaintext bytes""" + if version not in KNOWN_PW_HASH_VERSIONS: + raise UnexpectedPasswordHashVersion(version) + data_bytes = bytes(base64.b64decode(data)) + return pw_decode_raw(data_bytes, password, version=version) + + +def pw_encode_b64_with_version(data: bytes, password: Union[bytes, str]) -> str: + """plaintext bytes -> base64 ciphertext""" + version = PW_HASH_VERSION_LATEST + ciphertext = pw_encode_raw(data, password, version=version) + ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext) + return ciphertext_b64.decode('utf8') + + +def pw_decode_b64_with_version(data: str, password: Union[bytes, str]) -> bytes: + """base64 ciphertext -> plaintext bytes""" + data_bytes = bytes(base64.b64decode(data)) + version = int(data_bytes[0]) + if version not in KNOWN_PW_HASH_VERSIONS: + raise UnexpectedPasswordHashVersion(version) + return pw_decode_raw(data_bytes[1:], password, version=version) + + def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str: """plaintext str -> base64 ciphertext""" if not password: diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index d0e8ebf60..6dd364c67 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -415,7 +415,7 @@ class ElectrumWindow(App): self.set_URI(data) return if data.startswith('channel_backup:'): - self.import_channel_backup(data[15:]) + self.import_channel_backup(data) return bolt11_invoice = maybe_extract_bolt11_invoice(data) if bolt11_invoice is not None: diff --git a/electrum/gui/kivy/uix/dialogs/lightning_channels.py b/electrum/gui/kivy/uix/dialogs/lightning_channels.py index 20cfaf25e..78b81cc79 100644 --- a/electrum/gui/kivy/uix/dialogs/lightning_channels.py +++ b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -379,7 +379,7 @@ class ChannelDetailsPopup(Popup): _("Please note that channel backups cannot be used to restore your channels."), _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), ]) - self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), 'channel_backup:'+text, help_text=help_text) + self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), text, help_text=help_text) def force_close(self): Question(_('Force-close channel?'), self._force_close).open() diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index c7306e29d..e5450736d 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -132,7 +132,7 @@ class ChannelsList(MyTreeView): _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), ]) data = self.lnworker.export_channel_backup(channel_id) - self.main_window.show_qrcode('channel_backup:' + data, 'channel backup', help_text=msg) + self.main_window.show_qrcode(data, 'channel backup', help_text=msg) def request_force_close(self, channel_id): def task(): diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index ed735eecb..be348aae9 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2617,7 +2617,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.pay_to_URI(data) return if data.startswith('channel_backup:'): - self.import_channel_backup(data[15:]) + self.import_channel_backup(data) return # else if the user scanned an offline signed tx tx = self.tx_from_text(data) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 0381d3dfb..99f01f21a 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -67,6 +67,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL from . import lnsweep from .lnwatcher import LNWalletWatcher from .crypto import pw_encode_bytes, pw_decode_bytes, PW_HASH_VERSION_LATEST +from .crypto import pw_encode_b64_with_version, pw_decode_b64_with_version from .lnutil import ChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus @@ -1396,9 +1397,9 @@ class LNWallet(LNWorker): xpub = self.wallet.get_fingerprint() backup_bytes = self.create_channel_backup(channel_id).to_bytes() assert backup_bytes == ChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed" - encrypted = pw_encode_bytes(backup_bytes, xpub, version=PW_HASH_VERSION_LATEST) - assert backup_bytes == pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST), "encrypt failed" - return encrypted + encrypted = pw_encode_b64_with_version(backup_bytes, xpub) + assert backup_bytes == pw_decode_b64_with_version(encrypted, xpub), "encrypt failed" + return 'channel_backup:' + encrypted class LNBackups(Logger): @@ -1449,9 +1450,11 @@ class LNBackups(Logger): self.lnwatcher.stop() self.lnwatcher = None - def import_channel_backup(self, encrypted): + def import_channel_backup(self, data): + assert data.startswith('channel_backup:') + encrypted = data[15:] xpub = self.wallet.get_fingerprint() - decrypted = pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST) + decrypted = pw_decode_b64_with_version(encrypted, xpub) cb_storage = ChannelBackupStorage.from_bytes(decrypted) channel_id = cb_storage.channel_id().hex() d = self.db.get_dict("channel_backups") From 6922d81a1e92a98a89972178ca8b957002fa7aa2 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 16 Jun 2020 11:08:52 +0200 Subject: [PATCH 2/4] channel backups: add another version number, for the backup itself --- electrum/lnutil.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index cca35ae7e..2225ad85c 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -154,6 +154,8 @@ class ChannelConstraints(StoredObject): is_initiator = attr.ib(type=bool) # note: sometimes also called "funder" funding_txn_minimum_depth = attr.ib(type=int) + +CHANNEL_BACKUP_VERSION = 0 @attr.s class ChannelBackupStorage(StoredObject): node_id = attr.ib(type=bytes, converter=hex_to_bytes) @@ -179,6 +181,7 @@ class ChannelBackupStorage(StoredObject): def to_bytes(self): vds = BCDataStream() + vds.write_int16(CHANNEL_BACKUP_VERSION) vds.write_boolean(self.is_initiator) vds.write_bytes(self.privkey, 32) vds.write_bytes(self.channel_seed, 32) @@ -198,6 +201,8 @@ class ChannelBackupStorage(StoredObject): def from_bytes(s): vds = BCDataStream() vds.write(s) + version = vds.read_int16() + assert version == CHANNEL_BACKUP_VERSION return ChannelBackupStorage( is_initiator = bool(vds.read_bytes(1)), privkey = vds.read_bytes(32).hex(), From f9788a5d90a9b263e72844dcd06d3f84ceba289c Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 16 Jun 2020 11:52:08 +0200 Subject: [PATCH 3/4] channel backups: add MAC --- electrum/crypto.py | 16 ++++++++++++---- electrum/lnworker.py | 9 ++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/electrum/crypto.py b/electrum/crypto.py index 70192eaf0..645d0561f 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -229,21 +229,29 @@ def pw_decode_bytes(data: str, password: Union[bytes, str], *, version:int) -> b return pw_decode_raw(data_bytes, password, version=version) -def pw_encode_b64_with_version(data: bytes, password: Union[bytes, str]) -> str: +def pw_encode_with_version_and_mac(data: bytes, password: Union[bytes, str]) -> str: """plaintext bytes -> base64 ciphertext""" + # https://crypto.stackexchange.com/questions/202/should-we-mac-then-encrypt-or-encrypt-then-mac + # Encrypt-and-MAC. The MAC will be used to detect invalid passwords version = PW_HASH_VERSION_LATEST + mac = sha256(data)[0:4] ciphertext = pw_encode_raw(data, password, version=version) - ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext) + ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext + mac) return ciphertext_b64.decode('utf8') -def pw_decode_b64_with_version(data: str, password: Union[bytes, str]) -> bytes: +def pw_decode_with_version_and_mac(data: str, password: Union[bytes, str]) -> bytes: """base64 ciphertext -> plaintext bytes""" data_bytes = bytes(base64.b64decode(data)) version = int(data_bytes[0]) + encrypted = data_bytes[1:-4] + mac = data_bytes[-4:] if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) - return pw_decode_raw(data_bytes[1:], password, version=version) + decrypted = pw_decode_raw(encrypted, password, version=version) + if sha256(decrypted)[0:4] != mac: + raise InvalidPassword() + return decrypted def pw_encode(data: str, password: Union[bytes, str, None], *, version: int) -> str: diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 99f01f21a..4bc956674 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -66,8 +66,7 @@ from .lnrouter import (RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_sane_t from .address_synchronizer import TX_HEIGHT_LOCAL from . import lnsweep from .lnwatcher import LNWalletWatcher -from .crypto import pw_encode_bytes, pw_decode_bytes, PW_HASH_VERSION_LATEST -from .crypto import pw_encode_b64_with_version, pw_decode_b64_with_version +from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac from .lnutil import ChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus @@ -1397,8 +1396,8 @@ class LNWallet(LNWorker): xpub = self.wallet.get_fingerprint() backup_bytes = self.create_channel_backup(channel_id).to_bytes() assert backup_bytes == ChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed" - encrypted = pw_encode_b64_with_version(backup_bytes, xpub) - assert backup_bytes == pw_decode_b64_with_version(encrypted, xpub), "encrypt failed" + encrypted = pw_encode_with_version_and_mac(backup_bytes, xpub) + assert backup_bytes == pw_decode_with_version_and_mac(encrypted, xpub), "encrypt failed" return 'channel_backup:' + encrypted @@ -1454,7 +1453,7 @@ class LNBackups(Logger): assert data.startswith('channel_backup:') encrypted = data[15:] xpub = self.wallet.get_fingerprint() - decrypted = pw_decode_b64_with_version(encrypted, xpub) + decrypted = pw_decode_with_version_and_mac(encrypted, xpub) cb_storage = ChannelBackupStorage.from_bytes(decrypted) channel_id = cb_storage.channel_id().hex() d = self.db.get_dict("channel_backups") From e1a2299f0c8b0676610622079a4e2c0440cfe68d Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 17 Jun 2020 17:17:05 +0200 Subject: [PATCH 4/4] channel backup versions: trivial clean-up --- electrum/crypto.py | 14 ++++++-------- electrum/lnutil.py | 3 ++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/electrum/crypto.py b/electrum/crypto.py index 645d0561f..7fda99aaa 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -189,8 +189,7 @@ def _hash_password(password: Union[bytes, str], *, version: int) -> bytes: raise UnexpectedPasswordHashVersion(version) -def pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> str: - """bytes -> bytes""" +def _pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> bytes: if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) # derive key from password @@ -200,8 +199,7 @@ def pw_encode_raw(data: bytes, password: Union[bytes, str], *, version: int) -> return ciphertext -def pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: int) -> bytes: - """bytes -> bytes""" +def _pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: int) -> bytes: if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) # derive key from password @@ -216,7 +214,7 @@ def pw_decode_raw(data_bytes: bytes, password: Union[bytes, str], *, version: in def pw_encode_bytes(data: bytes, password: Union[bytes, str], *, version: int) -> str: """plaintext bytes -> base64 ciphertext""" - ciphertext = pw_encode_raw(data, password, version=version) + ciphertext = _pw_encode_raw(data, password, version=version) ciphertext_b64 = base64.b64encode(ciphertext) return ciphertext_b64.decode('utf8') @@ -226,7 +224,7 @@ def pw_decode_bytes(data: str, password: Union[bytes, str], *, version:int) -> b if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) data_bytes = bytes(base64.b64decode(data)) - return pw_decode_raw(data_bytes, password, version=version) + return _pw_decode_raw(data_bytes, password, version=version) def pw_encode_with_version_and_mac(data: bytes, password: Union[bytes, str]) -> str: @@ -235,7 +233,7 @@ def pw_encode_with_version_and_mac(data: bytes, password: Union[bytes, str]) -> # Encrypt-and-MAC. The MAC will be used to detect invalid passwords version = PW_HASH_VERSION_LATEST mac = sha256(data)[0:4] - ciphertext = pw_encode_raw(data, password, version=version) + ciphertext = _pw_encode_raw(data, password, version=version) ciphertext_b64 = base64.b64encode(bytes([version]) + ciphertext + mac) return ciphertext_b64.decode('utf8') @@ -248,7 +246,7 @@ def pw_decode_with_version_and_mac(data: str, password: Union[bytes, str]) -> by mac = data_bytes[-4:] if version not in KNOWN_PW_HASH_VERSIONS: raise UnexpectedPasswordHashVersion(version) - decrypted = pw_decode_raw(encrypted, password, version=version) + decrypted = _pw_decode_raw(encrypted, password, version=version) if sha256(decrypted)[0:4] != mac: raise InvalidPassword() return decrypted diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 2225ad85c..1bafac5b6 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -202,7 +202,8 @@ class ChannelBackupStorage(StoredObject): vds = BCDataStream() vds.write(s) version = vds.read_int16() - assert version == CHANNEL_BACKUP_VERSION + if version != CHANNEL_BACKUP_VERSION: + raise Exception(f"unknown version for channel backup: {version}") return ChannelBackupStorage( is_initiator = bool(vds.read_bytes(1)), privkey = vds.read_bytes(32).hex(),