Repository: spesmilo/electrum
Issue: Electrum is normalising BIP39 passphrases not according to spec

BIP39 seed words: `treat dwarf wealth gasp brass outside high rent blood crowd make initial`
passphrase:
```
a  a
```
(two spaces in between)

path to leaf node: `m/44'/0'/0'/0/0` (p2pkh script)

wallet.trezor.io and iancoleman.io is deriving:
13MLBrpsXoMYTfacYAbar3THN6FkAB5brt

Electrum is deriving:
1JE33ySdiFQBi2YKzJsvYMqw3Y4yv85Lzf

-----

This is because **Electrum is normalising the whitespaces such that there can be only one whitespace between words and no leading/trailing space is allowed**. This is not part of the BIP39 spec.
This is when using the Qt install wizard.

https://github.com/spesmilo/electrum/blob/e5661156f0d4667a91ea60cae5cd8189e0770a92/electrum/gui/qt/installwizard.py#L573
https://github.com/spesmilo/electrum/blob/e5661156f0d4667a91ea60cae5cd8189e0770a92/electrum/base_wizard.py#L361

Changed files:
electrum/tests/test_wallet_vertical.py, electrum/base_wizard.py, electrum/gui/qt/installwizard.py

diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py
index fd3c8cc39..d5a0f83a3 100644
--- a/electrum/base_wizard.py
+++ b/electrum/base_wizard.py
@@ -348,7 +348,7 @@ class BaseWizard(object):
         k = hardware_keystore(d)
         self.on_keystore(k)
 
-    def passphrase_dialog(self, run_next):
+    def passphrase_dialog(self, run_next, is_restoring=False):
         title = _('Seed extension')
         message = '\\n'.join([
             _('You may extend your seed with custom words.'),
@@ -358,7 +358,10 @@ class BaseWizard(object):
             _('Note that this is NOT your encryption password.'),
             _('If you do not know what this is, leave this field empty.'),
         ])
-        self.line_dialog(title=title, message=message, warning=warning, default='', test=lambda x:True, run_next=run_next)
+        warn_issue4566 = is_restoring and self.seed_type == 'bip39'
+        self.line_dialog(title=title, message=message, warning=warning,
+                         default='', test=lambda x:True, run_next=run_next,
+                         warn_issue4566=warn_issue4566)
 
     def restore_from_seed(self):
         self.opt_bip39 = True
@@ -371,10 +374,10 @@ class BaseWizard(object):
         self.seed_type = 'bip39' if is_bip39 else bitcoin.seed_type(seed)
         if self.seed_type == 'bip39':
             f = lambda passphrase: self.on_restore_bip39(seed, passphrase)
-            self.passphrase_dialog(run_next=f) if is_ext else f('')
+            self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('')
         elif self.seed_type in ['standard', 'segwit']:
             f = lambda passphrase: self.run('create_keystore', seed, passphrase)
-            self.passphrase_dialog(run_next=f) if is_ext else f('')
+            self.passphrase_dialog(run_next=f, is_restoring=True) if is_ext else f('')
         elif self.seed_type == 'old':
             self.run('create_keystore', seed, '')
         elif self.seed_type == '2fa':
diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py
index 79647235a..cd45e6f34 100644
--- a/electrum/gui/qt/installwizard.py
+++ b/electrum/gui/qt/installwizard.py
@@ -32,6 +32,12 @@ WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\\
                  'p2wpkh-p2sh:KxZcY47uGp9a... \\t-> 3NhNeZQXF...\\n' +
                  'p2wpkh:KxZcY47uGp9a...      \\t-> bc1q3fjfk...')
 # note: full key is KxZcY47uGp9aVQAb6VVvuBs8SwHKgkSR2DbZUzjDzXf2N2GPhG9n
+MSG_PASSPHRASE_WARN_ISSUE4566 = _("Warning") + ": "\\
+                              + _("You have multiple consecutive whitespaces or leading/trailing "
+                                  "whitespaces in your passphrase.") + " " \\
+                              + _("This is discouraged.") + " " \\
+                              + _("Due to a bug, old versions of Electrum will NOT be creating the "
+                                  "same wallet as newer versions or other software.")
 
 
 class CosignWidget(QWidget):
@@ -550,17 +556,24 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
 
     @wizard_dialog
     def line_dialog(self, run_next, title, message, default, test, warning='',
-                    presets=()):
+                    presets=(), warn_issue4566=False):
         vbox = QVBoxLayout()
         vbox.addWidget(WWLabel(message))
         line = QLineEdit()
         line.setText(default)
         def f(text):
             self.next_button.setEnabled(test(text))
+            if warn_issue4566:
+                text_whitespace_normalised = ' '.join(text.split())
+                warn_issue4566_label.setVisible(text != text_whitespace_normalised)
         line.textEdited.connect(f)
         vbox.addWidget(line)
         vbox.addWidget(WWLabel(warning))
 
+        warn_issue4566_label = WWLabel(MSG_PASSPHRASE_WARN_ISSUE4566)
+        warn_issue4566_label.setVisible(False)
+        vbox.addWidget(warn_issue4566_label)
+
         for preset in presets:
             button = QPushButton(preset[0])
             button.clicked.connect(lambda __, text=preset[1]: line.setText(text))
@@ -570,7 +583,7 @@ class InstallWizard(QDialog, MessageBoxMixin, BaseWizard):
             vbox.addLayout(hbox)
 
         self.exec_layout(vbox, title, next_enabled=test(default))
-        return ' '.join(line.text().split())
+        return line.text()
 
     @wizard_dialog
     def show_xpub_dialog(self, xpub, run_next):
diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py
index c95d98313..1b03983a4 100644
--- a/electrum/tests/test_wallet_vertical.py
+++ b/electrum/tests/test_wallet_vertical.py
@@ -18,6 +18,11 @@ from . import SequentialTestCase
 from .test_bitcoin import needs_test_with_all_ecc_implementations
 
 
+_UNICODE_HORROR_HEX = 'e282bf20f09f988020f09f98882020202020e3818620e38191e3819fe381be20e3828fe3828b2077cda2cda2cd9d68cda16fcda2cda120ccb8cda26bccb5cd9f6eccb4cd98c7ab77ccb8cc9b73cd9820cc80cc8177cd98cda2e1b8a9ccb561d289cca1cda27420cca7cc9568cc816fccb572cd8fccb5726f7273cca120ccb6cda1cda06cc4afccb665cd9fcd9f20ccb6cd9d696ecda220cd8f74cc9568ccb7cca1cd9f6520cd9fcd9f64cc9b61cd9c72cc95cda16bcca2cca820cda168ccb465cd8f61ccb7cca2cca17274cc81cd8f20ccb4ccb7cda0c3b2ccb5ccb666ccb82075cca7cd986ec3adcc9bcd9c63cda2cd8f6fccb7cd8f64ccb8cda265cca1cd9d3fcd9e'
+UNICODE_HORROR = bfh(_UNICODE_HORROR_HEX).decode('utf-8')
+# '₿ 😀 😈     う けたま わる w͢͢͝h͡o͢͡ ̸͢k̵͟n̴͘ǫw̸̛s͘ ̀́w͘͢ḩ̵a҉̡͢t ̧̕h́o̵r͏̵rors̡ ̶͡͠lį̶e͟͟ ̶͝in͢ ͏t̕h̷̡͟e ͟͟d̛a͜r̕͡k̢̨ ͡h̴e͏a̷̢̡rt́͏ ̴̷͠ò̵̶f̸ u̧͘ní̛͜c͢͏o̷͏d̸͢e̡͝?͞'
+
+
 class WalletIntegrityHelper:
 
     gap_limit = 1  # make tests run faster
@@ -68,7 +73,6 @@ class WalletIntegrityHelper:
         return w
 
 
-# TODO passphrase/seed_extension
 class TestWalletKeystoreAddressIntegrityForMainnet(SequentialTestCase):
 
     @needs_test_with_all_ecc_implementations
@@ -111,6 +115,26 @@ class TestWalletKeystoreAddressIntegrityForMainnet(SequentialTestCase):
         self.assertEqual(w.get_receiving_addresses()[0], 'bc1q3g5tmkmlvxryhh843v4dz026avatc0zzr6h3af')
         self.assertEqual(w.get_change_addresses()[0], 'bc1qdy94n2q5qcp0kg7v9yzwe6wvfkhnvyzje7nx2p')
 
+    @needs_test_with_all_ecc_implementations
+    @mock.patch.object(storage.WalletStorage, '_write')
+    def test_electrum_seed_segwit_passphrase(self, mock_write):
+        seed_words = 'bitter grass shiver impose acquire brush forget axis eager alone wine silver'
+        self.assertEqual(bitcoin.seed_type(seed_words), 'segwit')
+
+        ks = keystore.from_seed(seed_words, UNICODE_HORROR, False)
+
+        WalletIntegrityHelper.check_seeded_keystore_sanity(self, ks)
+        self.assertTrue(isinstance(ks, keystore.BIP32_KeyStore))
+
+        self.assertEqual(ks.xprv, 'zprvAZDmEQiCLUcZXPfrBXoksCD2R6RMAzAre7SUyBotibisy9c7vGhLYvHaP3d9rYU12DKAWdZfscPNA7qEPgTkCDqX5sE93ryAJAQvkDbfLxU')
+        self.assertEqual(ks.xpub, 'zpub6nD7dvF6ArArjskKHZLmEL9ky8FqaSti1LN5maDWGwFrqwwGTp1b6ic4EHwciFNaYDmCXcQYxXSiF9BjcLCMPcaYkVN2nQD6QjYQ8vpSR3Z')
+
+        w = WalletIntegrityHelper.create_standard_wallet(ks)
+        self.assertEqual(w.txin_type, 'p2wpkh')
+
+        self.assertEqual(w.get_receiving_addresses()[0], 'bc1qx94dutas7ysn2my645cyttujrms5d9p57f6aam')
+        self.assertEqual(w.get_change_addresses()[0], 'bc1qcywwsy87sdp8vz5rfjh3sxdv6rt95kujdqq38g')
+
     @needs_test_with_all_ecc_implementations
     @mock.patch.object(storage.WalletStorage, '_write')
     def test_electrum_seed_old(self, mock_write):
@@ -183,6 +207,25 @@ class TestWalletKeystoreAddressIntegrityForMainnet(SequentialTestCase):
         self.assertEqual(w.get_receiving_addresses()[0], '16j7Dqk3Z9DdTdBtHcCVLaNQy9MTgywUUo')
         self.assertEqual(w.get_change_addresses()[0], '1GG5bVeWgAp5XW7JLCphse14QaC4qiHyWn')
 
+    @needs_test_with_all_ecc_implementations
+    @mock.patch.object(storage.WalletStorage, '_write')
+    def test_bip39_seed_bip44_standard_passphrase(self, mock_write):
+        seed_words = 'treat dwarf wealth gasp brass outside high rent blood crowd make initial'
+        self.assertEqual(keystore.bip39_is_checksum_valid(seed_words), (True, True))
+
+        ks = keystore.from_bip39_seed(seed_words, UNICODE_HORROR, "m/44'/0'/0'")
+
+        self.assertTrue(isinstance(ks, keystore.BIP32_KeyStore))
+
+        self.assertEqual(ks.xprv, 'xprv9z8izheguGnLopSqkY7GcGFrP2Gu6rzBvvHo6uB9B8DWJhsows6WDZAsbBTaP3ncP2AVbTQphyEQkahrB9s1L7ihZtfz5WGQPMbXwsUtSik')
+        self.assertEqual(ks.xpub, 'xpub6D85QDBajeLe2JXJrZeGyQCaw47PWKi3J9DPuHakjTkVBWCxVQQkmMVMSSfnw39tj9FntbozpRtb1AJ8ubjeVSBhyK4M5mzdvsXZzKPwodT')
+
+        w = WalletIntegrityHelper.create_standard_wallet(ks)
+        self.assertEqual(w.txin_type, 'p2pkh')
+
+        self.assertEqual(w.get_receiving_addresses()[0], '1F88g2naBMhDB7pYFttPWGQgryba3hPevM')
+        self.assertEqual(w.get_change_addresses()[0], '1H4QD1rg2zQJ4UjuAVJr5eW1fEM8WMqyxh')
+
     @needs_test_with_all_ecc_implementations
     @mock.patch.object(storage.WalletStorage, '_write')
     def test_bip39_seed_bip49_p2sh_segwit(self, mock_write):