diff --git a/electrum/crypto.py b/electrum/crypto.py index 7dbb57b31ffc..6618c1f485e2 100644 --- a/electrum/crypto.py +++ b/electrum/crypto.py @@ -189,23 +189,19 @@ 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) -> 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: 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 +212,46 @@ 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_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 + mac) + return ciphertext_b64.decode('utf8') + + +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) + 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: """plaintext str -> base64 ciphertext""" if not password: diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index d0e8ebf60c38..6dd364c67d50 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -415,7 +415,7 @@ def on_qr(self, data): 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 20cfaf25ee01..78b81cc79a9d 100644 --- a/electrum/gui/kivy/uix/dialogs/lightning_channels.py +++ b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -379,7 +379,7 @@ def export_backup(self): _("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 743650073372..4a224e3c1a1c 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -132,7 +132,7 @@ def export_channel_backup(self, channel_id): _("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 587d1f54aa63..219a479082db 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2606,7 +2606,7 @@ def read_tx_from_qrcode(self): 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/lnutil.py b/electrum/lnutil.py index cca35ae7e4e6..1bafac5b664b 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 @@ def channel_id(self): 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,9 @@ def to_bytes(self): def from_bytes(s): vds = BCDataStream() vds.write(s) + version = vds.read_int16() + 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(), diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 02cd671ceaf0..321e67ad91a3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -66,7 +66,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_with_version_and_mac, pw_decode_with_version_and_mac from .lnutil import ChannelBackupStorage from .lnchannel import ChannelBackup from .channel_db import UpdateStatus @@ -1380,9 +1380,9 @@ def export_channel_backup(self, channel_id): 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_with_version_and_mac(backup_bytes, xpub) + assert backup_bytes == pw_decode_with_version_and_mac(encrypted, xpub), "encrypt failed" + return 'channel_backup:' + encrypted class LNBackups(Logger): @@ -1433,9 +1433,11 @@ def stop(self): 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_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")