Merge pull request #10982 from spesmilo/wizard_2fa_seed_redirect
What changed, and why it matters
This commit changes Electrum's wallet creation wizard so that if a user tries to restore a normal 'standard' wallet but pastes a seed phrase that actually belongs to a two-factor authentication (2FA) wallet, the wizard automatically redirects them into the correct 2FA wallet setup flow instead of rejecting the seed or creating a mismatched wallet. Previously, the wizard would treat 2FA seeds as invalid for standard wallets. The change also ensures the Qt GUI seed validator matches this new behavior. It is a usability fix that prevents user confusion and potential loss of access to funds, not a remote-exploitable vulnerability.
No urgent security action required. Treat as a normal quality/usability improvement. Reviewers may want to confirm that the mutated `wallet_type` is consistently respected downstream and that users are clearly informed they are being switched to a 2FA wallet.
Security signals we found
UX-level safety fix preventing wallet-type/seed mismatch
State mutation: wizard_data['wallet_type'] changed from 'standard' to '2fa' based on seed type
No cryptographic, network, or privilege changes
No input sanitization changes beyond seed-type routing
Test coverage added for both redirect and non-redirect paths
Evidence from the diff
The patch modifies electrum/wizard.py and electrum/gui/qt/wizard/wallet.py. It introduces supports_2fa() returning False in the base Wizard and True in NewWalletWizard, plus a wants_2fa() helper. validate_seed() now marks a 2FA seed as valid for a ‘standard’ wallet type only when the wizard supports 2FA redirection. on_have_seed and on_accept_have_seed route the wizard to the trustedcoin 2FA views (trustedcoin_keep_disable or trustedcoin_have_ext) and mutate wizard_data['wallet_type'] to '2fa'. The Qt is_seed() check is updated to allow 2FA seeds for standard wallets when 2FA is supported. Tests are added/updated to cover redirection and the KeystoreWizard case where redirection is unavailable (seed remains invalid).
Changed components
electrum/wizard.pyelectrum/gui/qt/wizard/wallet.pytests/test_wizard.pyInspect captured patch +102 / −13
### electrum/gui/qt/wizard/wallet.py
@@ -622,7 +622,7 @@ def is_seed(self, x):
# really only used for electrum seeds. bip39 and slip39 are validated in SeedWidget
t = mnemonic.calc_seed_type(x)
if self.wizard_data['wallet_type'] == 'standard':
- return mnemonic.is_seed(x) and not mnemonic.is_any_2fa_seed_type(t)
+ return mnemonic.is_seed(x) and (self.wizard.supports_2fa() or not mnemonic.is_any_2fa_seed_type(t))
elif self.wizard_data['wallet_type'] == '2fa':
return mnemonic.is_any_2fa_seed_type(t)
else:
### electrum/wizard.py
@@ -300,6 +300,9 @@ def is_multisig(self, wizard_data: dict) -> bool:
def is_hardware(self, wizard_data: dict) -> bool:
return wizard_data['keystore_type'] == 'hardware'
+ def supports_2fa(self) -> bool:
+ return False
+
def wallet_password_view(self, wizard_data: dict) -> str:
if self.is_hardware(wizard_data) and wizard_data['wallet_type'] == 'standard':
return 'wallet_password_hardware'
@@ -345,6 +348,9 @@ def validate_seed(self, seed: str, seed_variant: str, wallet_type: str) -> Tuple
# check if seed matches wallet type
if wallet_type == '2fa' and not is_any_2fa_seed_type(seed_type):
seed_valid = False
+ elif wallet_type == 'standard' and is_any_2fa_seed_type(seed_type):
+ # wizard will redirect
+ seed_valid = self.supports_2fa()
elif wallet_type == 'standard' and seed_type not in ['old', 'standard', 'segwit', 'bip39', 'slip39']:
seed_valid = False
elif wallet_type == 'multisig' and seed_type not in ['standard', 'segwit', 'bip39', 'slip39']:
@@ -433,10 +439,10 @@ def __init__(self, daemon: 'Daemon', plugins: 'Plugins'):
'last': lambda d: self.is_single_password() and not self.is_multisig(d)
},
'have_seed': {
- 'next': lambda d: 'have_ext' if self.wants_ext(d) else self.on_have_or_confirm_seed(d),
- 'accept': lambda d: None if self.wants_ext(d) else self.maybe_master_pubkey(d),
+ 'next': self.on_have_seed,
+ 'accept': self.on_accept_have_seed,
'last': lambda d: self.is_single_password() and not
- (self.needs_derivation_path(d) or self.is_multisig(d) or self.wants_ext(d)),
+ (self.needs_derivation_path(d) or self.is_multisig(d) or self.wants_ext(d) or self.wants_2fa(d)),
},
'have_ext': {
'next': self.on_have_or_confirm_seed,
@@ -530,6 +536,30 @@ def on_keystore_type(self, wizard_data: dict) -> str:
'hardware': 'choose_hardware_device'
}.get(t)
+ def supports_2fa(self) -> bool:
+ return True
+
+ def wants_2fa(self, wizard_data: dict) -> bool:
+ # True if the user entered a 2fa seed while restoring a wallet of type 'standard'
+ return (wizard_data['wallet_type'] == 'standard'
+ and is_any_2fa_seed_type(wizard_data.get('seed_type', ''))
+ and self.supports_2fa())
+
+ def on_have_seed(self, wizard_data: dict) -> str:
+ if wizard_data['wallet_type'] == '2fa': # redirected by on_accept_have_seed
+ return 'trustedcoin_have_ext' if self.wants_ext(wizard_data) else 'trustedcoin_keep_disable'
+ elif self.wants_ext(wizard_data):
+ return 'have_ext'
+ else:
+ return self.on_have_or_confirm_seed(wizard_data)
+
+ def on_accept_have_seed(self, wizard_data: dict) -> None:
+ if self.wants_2fa(wizard_data):
+ wizard_data['wallet_type'] = '2fa'
+ return
+ if not self.wants_ext(wizard_data):
+ self.maybe_master_pubkey(wizard_data)
+
def on_have_or_confirm_seed(self, wizard_data: dict) -> str:
if self.needs_derivation_path(wizard_data):
return 'script_and_derivation'
### tests/test_wizard.py
@@ -42,6 +42,12 @@ def __init__(self, config: SimpleConfig):
self.network = NetworkMock()
+class TNewWalletWizard(NewWalletWizard):
+ def is_single_password(self):
+ """impl abstract reqd"""
+ return True
+
+
class WizardTestCase(ElectrumTestCase):
def setUp(self):
@@ -133,11 +139,6 @@ def is_single_password(self):
"""impl abstract reqd"""
return True
- class TNewWalletWizard(NewWalletWizard):
- def is_single_password(self):
- """impl abstract reqd"""
- return True
-
def _wizard_for(self, *, wallet_type: str = 'standard', hww: bool = False) -> tuple[KeystoreWizard, WizardViewState]:
w = KeystoreWizardTestCase.TKeystoreWizard(self.plugins)
start_viewstate = WizardViewState('keystore_type', {'wallet_type': wallet_type}, {})
@@ -156,7 +157,7 @@ def _wizard_for(self, *, wallet_type: str = 'standard', hww: bool = False) -> tu
return w, v
def _create_xpub_keystore_wallet(self, *, wallet_type: str = 'standard', xpub):
- w = KeystoreWizardTestCase.TNewWalletWizard(DaemonMock(self.config), self.plugins)
+ w = TNewWalletWizard(DaemonMock(self.config), self.plugins)
wallet_path = self.wallet_path
d = {
'wallet_type': wallet_type,
@@ -262,6 +263,14 @@ async def test_haveseed_electrum__mismatching_seed(self):
wallet.enable_keystore(ks, ishww, None)
self.assertTrue("mismatching xpubs" in ctx.exception.args[0])
+ async def test_haveseed_electrum__2fa_seed(self):
+ """unlike NewWalletWizard, KeystoreWizard cannot redirect to the 2fa flow, so 2fa seeds are invalid"""
+ w, v = self._wizard_for()
+ seed_valid, seed_type, *_ = w.validate_seed(
+ 'oblige basket safe educate whale bacon celery demand novel slice various awkward', 'electrum', 'standard')
+ self.assertFalse(seed_valid)
+ self.assertEqual('2fa_segwit', seed_type)
+
async def test_haveseed_electrum_oldseed(self):
w, v = self._wizard_for()
d = v.wizard_data
@@ -439,9 +448,8 @@ def _wizard_for(
name: str = "mywallet",
wallet_type: str,
) -> NewWalletWizard:
- w = NewWalletWizard(DaemonMock(self.config), self.plugins)
- if wallet_type == '2fa':
- w.plugins.get_plugin('trustedcoin').extend_wizard(w)
+ w = TNewWalletWizard(DaemonMock(self.config), self.plugins)
+ w.plugins.get_plugin('trustedcoin').extend_wizard(w)
v_init = w.start()
self.assertEqual('wallet_name', v_init.view)
d = {'wallet_name': name}
@@ -895,6 +903,57 @@ async def test_2fa_haveseed_passphrase(self):
v = w.resolve_next(v.view, d)
self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud")
+ async def test_2fa_haveseed_redirected_from_standard(self):
+ w = self._wizard_for(wallet_type='standard')
+ v = w._current
+ d = v.wizard_data
+ self.assertEqual('keystore_type', v.view)
+
+ d.update({'keystore_type': 'haveseed'})
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('have_seed', v.view)
+ myseed = 'oblige basket safe educate whale bacon celery demand novel slice various awkward'
+ self.assertTrue(w.validate_seed(myseed, 'electrum', 'standard')[0])
+ d.update({
+ 'seed': myseed,
+ 'seed_type': '2fa_segwit', 'seed_extend': False, 'seed_variant': 'electrum',
+ })
+ self.assertFalse(w.is_last_view(v.view, d))
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_keep_disable', v.view)
+ self.assertEqual('2fa', v.wizard_data['wallet_type'])
+ d.update({'trustedcoin_keepordisable': 'keep'})
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_tos', v.view)
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_show_confirm_otp', v.view)
+ v = w.resolve_next(v.view, d)
+ wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
+ self.assertEqual('2fa', wallet.wallet_type)
+
+ async def test_2fa_haveseed_redirected_from_standard_passphrase(self):
+ w = self._wizard_for(wallet_type='standard')
+ v = w._current
+ d = v.wizard_data
+ self.assertEqual('keystore_type', v.view)
+
+ d.update({'keystore_type': 'haveseed'})
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('have_seed', v.view)
+ d.update({
+ 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward',
+ 'seed_type': '2fa_segwit', 'seed_extend': True, 'seed_variant': 'electrum',
+ })
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_have_ext', v.view)
+ d.update({'seed_extra_words': UNICODE_HORROR})
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_keep_disable', v.view)
+ d.update({'trustedcoin_keepordisable': 'disable'})
+ v = w.resolve_next(v.view, d)
+ wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud")
+ self.assertEqual('2fa', wallet.wallet_type)
+
async def test_create_standard_wallet_trezor(self):
# bip39 seed for trezor: "history six okay anchor sheriff flock atom tomorrow foster aerobic eternal foam"
w = self._wizard_for(wallet_type='standard')Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.