Repository: spesmilo/electrum

Issue:
qml gui cannot open keystore-only-encrypted wallets
The qml gui errors when trying to open a wallet file that has a password but which is only keystore-encrypted (no storage encryption). This is a regression from 4.6.0, though strictly speaking I noticed some weirdness even on 4.5.8, where e.g. the seed could not be displayed in the wallet details.

To reproduce:
```
$ ./run_electrum --testnet -w /home/user/.electrum/testnet/wallets/heyheyhey -o restore "9dk" --password 1234 --encrypt_file=false
$ ./run_electrum --testnet -w /home/user/.electrum/testnet/wallets/heyheyhey -g qml
```

Note that while it has not been possible to create such wallet files on Android for a long time (maybe ever using the qml gui), the kivy gui used to create such wallets for several years, and existing installs quite possibly have them. (also see reports in linked issue)

> Good afternoon @ecdsa,
> 
> I just received another crash report related to this issue. The crash occured on Electrum 4.6.1-130-g23a82f328.
> I'm not sure which versions of Electrum include the fix but this is the first report from anything
> newer than 4.6.1 since you closed the issue.
> 
> Could you please check if this issue really is resolved? Here is the traceback that I just collected:
> 
> ```Python traceback
> Traceback (most recent call last):
>   File "/home/user/wspace/electrum/electrum/bitcoin.py", line 575, in base_decode
>     num = num * base + chars_inv[char]
> KeyError: 108
> 
> During handling of the above exception, another exception occurred:
> 
> Traceback (most recent call last):
>   File "/home/user/wspace/electrum/electrum/keystore.py", line 667, in check_password
>     bip32node = BIP32Node.from_xkey(xprv)
>   File "/home/user/wspace/electrum/electrum/bip32.py", line 137, in from_xkey
>     xkey = DecodeBase58Check(xkey)
>   File "/home/user/wspace/electrum/electrum/bitcoin.py", line 592, in DecodeBase58Check
>     vchRet = base_decode(psz, base=58)
>   File "/home/user/wspace/electrum/electrum/bitcoin.py", line 577, in base_decode
>     raise BaseDecodeError('Forbidden character {} for base {}'.format(char, base))
> electrum.bitcoin.BaseDecodeError: Forbidden character 108 for base 58
> 
> The above exception was the direct cause of the following exception:
> 
> Traceback (most recent call last):
>   File "/home/user/wspace/electrum/electrum/gui/qml/qedaemon.py", line 259, in _on_backend_wallet_loaded
>     wallet.unlock(password or None)  # not conditional on wallet.requires_unlock in qml, as
>   File "/home/user/wspace/electrum/electrum/wallet.py", line 3513, in unlock
>     self.check_password(password)
>   File "/home/user/wspace/electrum/electrum/wallet.py", line 3153, in check_password
>     self.keystore.check_password(password)
>   File "/home/user/wspace/electrum/electrum/keystore.py", line 75, in wrapper
>     return check_password_fn(self, password)
>   File "/home/user/wspace/electrum/electrum/keystore.py", line 669, in check_password
>     raise InvalidPassword() from e
> electrum.util.InvalidPassword: Incorrect password
> 
> InvalidPassword: Incorrect password
> ```
> 
> 
> ~ _With robotic wishes_
>  

 _Originally posted by @TARS-bot in [#6582](https://github.com/spesmilo/electrum/issues/6582#issuecomment-3217159975)_


Discussion:

Patch:
diff --git a/electrum/daemon.py b/electrum/daemon.py
index 1d50ceb04e4..7b21b12c336 100644
--- a/electrum/daemon.py
+++ b/electrum/daemon.py
@@ -476,14 +476,28 @@ def func_wrapper(self: 'Daemon', *args, **kwargs):
         return func_wrapper
 
     @with_wallet_lock
-    def load_wallet(self, path, password, *, upgrade=False) -> Optional[Abstract_Wallet]:
+    def load_wallet(
+        self,
+        path,
+        password: Optional[str],
+        *,
+        upgrade: bool = False,
+        force_check_password: bool = False,
+    ) -> Optional[Abstract_Wallet]:
+        """
+        force_check_password: if False, the password arg is only used if it needed to decrypt the storage.
+                              if True, the password arg is always validated.
+        """
         assert password != ''
         path = standardize_path(path)
         wallet_key = self._wallet_key_from_path(path)
         # wizard will be launched if we return
         if wallet := self._wallets.get(wallet_key):
+            if force_check_password:
+                wallet.check_password(password)
             return wallet
-        wallet = self._load_wallet(path, password, upgrade=upgrade, config=self.config)
+        wallet = self._load_wallet(
+            path, password, upgrade=upgrade, config=self.config, force_check_password=force_check_password)
         if self.network:
             wallet.start_network(self.network)
         elif wallet.lnworker:
@@ -501,10 +515,11 @@ def load_wallet(self, path, password, *, upgrade=False) -> Optional[Abstract_Wal
     @profiler
     def _load_wallet(
             path,
-            password,
+            password: Optional[str],
             *,
             upgrade: bool = False,
             config: SimpleConfig,
+            force_check_password: bool = False,  # if set, always validate password
     ) -> Optional[Abstract_Wallet]:
         path = standardize_path(path)
         storage = WalletStorage(path, allow_partial_writes=config.WALLET_PARTIAL_WRITES)
@@ -519,6 +534,8 @@ def _load_wallet(
         if db.get_action():
             raise WalletUnfinished(db)
         wallet = Wallet(db, config=config)
+        if force_check_password:
+            wallet.check_password(password)
         return wallet
 
     @with_wallet_lock
@@ -546,7 +563,7 @@ def delete_wallet(self, path: str) -> bool:
 
     def stop_wallet(self, path: str) -> bool:
         """Returns True iff a wallet was found."""
-        # note: this must not be called from the event loop. # TODO raise if so
+        assert util.get_running_loop() != util.get_asyncio_loop(), 'must not be called from asyncio thread'
         fut = asyncio.run_coroutine_threadsafe(self._stop_wallet(path), self.asyncio_loop)
         return fut.result()
 
diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py
index a4c135d9eb2..651c93fd1d7 100644
--- a/electrum/gui/qml/qedaemon.py
+++ b/electrum/gui/qml/qedaemon.py
@@ -201,7 +201,7 @@ def loadWallet(self, path=None, password=None):
 
         wallet_already_open = self.daemon.get_wallet(self._path)
         if wallet_already_open is not None:
-            wallet_already_open_password = QEWallet.getInstanceFor(wallet_already_open).password
+            password = QEWallet.getInstanceFor(wallet_already_open).password
 
         def load_wallet_task():
             success = False
@@ -209,7 +209,13 @@ def load_wallet_task():
                 local_password = password  # need this in local scope
                 wallet = None
                 try:
-                    wallet = self.daemon.load_wallet(self._path, local_password, upgrade=True)
+                    wallet = self.daemon.load_wallet(
+                        self._path,
+                        password=local_password,
+                        upgrade=True,
+                        # might have a keystore password, but unencrypted storage. we want to prompt for pw even then:
+                        force_check_password=True,
+                    )
                 except InvalidPassword:
                     self.walletRequiresPassword.emit(self._name, self._path)
                 except FileNotFoundError:
@@ -224,11 +230,6 @@ def load_wallet_task():
                 if wallet is None:
                     return
 
-                if wallet_already_open is not None:
-                    # wallet already open. daemon.load_wallet doesn't mind, but
-                    # we need the correct current wallet password below
-                    local_password = wallet_already_open_password
-
                 if self.daemon.config.WALLET_USE_SINGLE_PASSWORD:
                     self._use_single_password = self.daemon.update_password_for_directory(old_password=local_password, new_password=local_password)
                     self._password = local_password


Test patch:
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index 5cb6c619ff4..08f53cf4770 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -228,3 +228,79 @@ async def test_wp_command_with_diskfile_wallet_no_password(self):
         self.assertEqual(self.SEED, await cmds.getseed(wallet_path=wpath))
         self.assertEqual(self.SEED, await cmds.getseed(wallet_path=basename))
         self.assertEqual(self.SEED, await cmds.getseed(wallet=wallet))
+
+
+class TestLoadWallet(DaemonTestCase):
+
+    async def test_simple_load(self):
+        path1 = self._restore_wallet_from_text("9dk", password=None)
+        wallet1 = self.daemon.load_wallet(path1, password=None)
+        await self.daemon._stop_wallet(path1)
+
+    async def test_password_checks_for_no_password(self):
+        real_password = None
+        path1 = self._restore_wallet_from_text("9dk", password=real_password)
+        # load_wallet will not validate the password arg unless needed for storage.decrypt():
+        wallet1 = self.daemon.load_wallet(path1, password="garbage")
+        await self.daemon._stop_wallet(path1)
+        # unless force_check_password is set:
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        await self.daemon._stop_wallet(path1)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password, force_check_password=True)
+        await self.daemon._stop_wallet(path1)
+
+        # load_wallet will not validate the password arg if wallet is already loaded, unless force_check_password
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        wallet1 = self.daemon.load_wallet(path1, password="garbage")
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)
+
+
+    async def test_password_checks_for_ks_enc(self):
+        real_password = "1234"
+        path1 = self._restore_wallet_from_text("9dk", password=real_password, encrypt_file=False)
+        # load_wallet will not validate the password arg unless needed for storage.decrypt():
+        wallet1 = self.daemon.load_wallet(path1, password="garbage")
+        await self.daemon._stop_wallet(path1)
+        # unless force_check_password is set:
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        await self.daemon._stop_wallet(path1)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password, force_check_password=True)
+        await self.daemon._stop_wallet(path1)
+
+        # load_wallet will not validate the password arg if wallet is already loaded, unless force_check_password
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        wallet1 = self.daemon.load_wallet(path1, password="garbage")
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)
+
+
+    async def test_password_checks_for_sto_enc(self):
+        real_password = "1234"
+        path1 = self._restore_wallet_from_text("9dk", password=real_password, encrypt_file=True)
+
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage")
+
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        await self.daemon._stop_wallet(path1)
+
+        wallet1 = self.daemon.load_wallet(path1, password=real_password, force_check_password=True)
+        await self.daemon._stop_wallet(path1)
+
+        # load_wallet will not validate the password arg if wallet is already loaded, unless force_check_password
+        wallet1 = self.daemon.load_wallet(path1, password=real_password)
+        wallet1 = self.daemon.load_wallet(path1, password="garbage")
+        with self.assertRaises(util.InvalidPassword):
+            wallet1 = self.daemon.load_wallet(path1, password="garbage", force_check_password=True)