diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index b0ba9fd1..a2b8c850 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -19,6 +19,7 @@ from seedsigner.models.settings_definition import SettingsConstants # TODO: PR these directly into `embit`? Or replace with new/existing methods already in `embit`? +# TODO: Refactor `wallet_type` to conform to our `sig_type` naming convention def get_standard_derivation_path(network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG, script_type: str = SettingsConstants.NATIVE_SEGWIT) -> str: if network == SettingsConstants.MAINNET: network_path = "0'" diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index 269a8f89..2bc33806 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -85,15 +85,17 @@ class BaseStaticQrEncoder(BaseQrEncoder): @dataclass class SeedQrEncoder(BaseStaticQrEncoder): - seed : Seed = None + mnemonic: List[str] = None + wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + def __post_init__(self): - self.wordlist = Seed.get_wordlist(self.seed.wordlist_language_code) + self.wordlist = Seed.get_wordlist(self.wordlist_language_code) super().__post_init__() self.data = "" # Output as Numeric data format - for word in self.seed.mnemonic_list: + for word in self.mnemonic: index = self.wordlist.index(word) self.data += str("%04d" % index) @@ -108,18 +110,18 @@ class CompactSeedQrEncoder(SeedQrEncoder): def next_part(self): # Output as binary data format binary_str = "" - for word in self.seed.mnemonic_list: + for word in self.mnemonic: index = self.wordlist.index(word) # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits binary_str += bin(index).split('b')[1].zfill(11) # We can exclude the checksum bits at the end - if len(self.seed.mnemonic_list) == 24: + if len(self.mnemonic) == 24: # 8 checksum bits in a 24-word seed binary_str = binary_str[:-8] - elif len(self.seed.mnemonic_list) == 12: + elif len(self.mnemonic) == 12: # 4 checksum bits in a 12-word seed binary_str = binary_str[:-4] diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 6164ff84..a93f6366 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -42,7 +42,7 @@ class Seed: raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}") - def _generate_seed(self) -> bool: + def _generate_seed(self): try: self.seed_bytes = bip39.mnemonic_to_seed(self.mnemonic_str, password=self._passphrase, wordlist=self.wordlist) except Exception as e: @@ -64,6 +64,7 @@ class Seed: def wordlist_language_code(self) -> str: return self._wordlist_language_code + @property def mnemonic_display_str(self) -> str: return unicodedata.normalize("NFC", " ".join(self._mnemonic)) @@ -106,29 +107,36 @@ class Seed: # TODO: Support other BIP-39 wordlist languages! raise Exception("Not yet implemented!") + @property - def script_override(self) -> list: - return None - - def derivation_override(self, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + def script_override(self) -> str: return None - def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + + def derivation_override(self, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: + return None + + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] return bip32.detect_version(derivation_path, default="xpub", network=embit_network) + @property def passphrase_label(self) -> str: return SettingsConstants.LABEL__BIP39_PASSPHRASE + @property def seedqr_supported(self) -> bool: return True + @property def bip85_supported(self) -> bool: return True + def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str: root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"]) return hexlify(root.child(0).fingerprint).decode('utf-8') @@ -158,19 +166,20 @@ class Seed: class ElectrumSeed(Seed): - - def _generate_seed(self) -> bool: + def _generate_seed(self): if len(self._mnemonic) != 12: - return False + raise InvalidSeedException(f"Unsupported Electrum seed length: {len(self._mnemonic)}") + s = hmac.digest(b"Seed version", self.mnemonic_str.encode('utf8'), hashlib.sha512).hex() prefix = s[0:3] + # only support Electrum Segwit version for now if SettingsConstants.ELECTRUM_SEED_SEGWIT == prefix: self.seed_bytes=hashlib.pbkdf2_hmac('sha512', self.mnemonic_str.encode('utf-8'), b'electrum' + self._passphrase.encode('utf-8'), iterations = SettingsConstants.ELECTRUM_PBKDF2_ROUNDS) - return True + else: - raise InvalidSeedException("Unsupported electrum seed input") - return False + raise InvalidSeedException(f"Unsupported Electrum seed format: {prefix}") + def set_passphrase(self, passphrase: str, regenerate_seed: bool = True): if passphrase: @@ -184,6 +193,7 @@ class ElectrumSeed(Seed): # Regenerate the internal seed since passphrase changes the result self._generate_seed() + @staticmethod def normalize_electrum_passphrase(passphrase : str) -> str: passphrase = unicodedata.normalize('NFKD', passphrase) @@ -193,25 +203,31 @@ class ElectrumSeed(Seed): passphrase = u' '.join(passphrase.split()) return passphrase + @property - def script_override(self) -> list: - return [SettingsConstants.NATIVE_SEGWIT] + def script_override(self) -> str: + return SettingsConstants.NATIVE_SEGWIT - def derivation_override(self, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: - return "m/0h" if SettingsConstants.SINGLE_SIG == wallet_type else "m/1h" - def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, wallet_type: str = SettingsConstants.SINGLE_SIG) -> str: + def derivation_override(self, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: + return "m/0h" if sig_type == SettingsConstants.SINGLE_SIG else "m/1h" + + + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, sig_type: str = SettingsConstants.SINGLE_SIG) -> str: embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] - return embit_network["zpub"] if SettingsConstants.SINGLE_SIG == wallet_type else embit_network["Zpub"] + return embit_network["zpub"] if sig_type == SettingsConstants.SINGLE_SIG else embit_network["Zpub"] + @property def passphrase_label(self) -> str: return SettingsConstants.LABEL__CUSTOM_EXTENSION + @property def seedqr_supported(self) -> bool: return False + @property def bip85_supported(self) -> bool: return False diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 8f33ee6b..c96bd68b 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -207,7 +207,9 @@ class SettingsConstants: # Label strings LABEL__BIP39_PASSPHRASE = "BIP-39 Passphrase" - LABEL__CUSTOM_EXTENSION = "Custom Extension" + LABEL__CUSTOM_EXTENSION = "Custom Extension" # Terminology used by Electrum seeds + + @dataclass class SettingsEntry: @@ -470,8 +472,9 @@ class SettingsDefinition: SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__ELECTRUM_SEEDS, - abbreviated_name="Electrum", - display_name="Electrum seed support (Native Segwit only)", + abbreviated_name="electrum", + display_name="Electrum seeds", + help_text="Native Segwit only", visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b4019e33..9eaa6ae0 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -79,16 +79,8 @@ class PSBTSelectSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - from seedsigner.views.seed_views import SeedMnemonicEntryView - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + from seedsigner.views.seed_views import SeedElectrumMnemonicStartView + return Destination(SeedElectrumMnemonicStartView) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index c4fbc4ed..9cc336c7 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -119,6 +119,7 @@ class SeedSelectSeedView(View): button_data.append(self.SCAN_SEED) button_data.append(self.TYPE_12WORD) button_data.append(self.TYPE_24WORD) + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.TYPE_ELECTRUM) @@ -158,17 +159,7 @@ class SeedSelectSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - - from seedsigner.views.seed_views import SeedMnemonicEntryView - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + return Destination(SeedElectrumMnemonicStartView) @@ -187,10 +178,12 @@ class LoadSeedView(View): self.SEED_QR, self.TYPE_12WORD, self.TYPE_24WORD, - self.CREATE, ] + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: - button_data.insert(len(button_data)-1, self.TYPE_ELECTRUM) + button_data.append(self.TYPE_ELECTRUM) + + button_data.append(self.CREATE) selected_menu_num = self.run_screen( ButtonListScreen, @@ -215,15 +208,7 @@ class LoadSeedView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + return Destination(SeedElectrumMnemonicStartView) elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView @@ -489,6 +474,27 @@ class SeedDiscardView(View): +class SeedElectrumMnemonicStartView(View): + """ + Currently just a warning display before entering an Electrum seed. + + Could be expanded with a follow-up View to specify Electrum seed type. + """ + def run(self): + self.run_screen( + WarningScreen, + title="Electrum warning", + status_headline=None, + text=f"Some features are disabled for Electrum seeds.", + show_back_button=False, + ) + + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + + return Destination(SeedMnemonicEntryView) + + + """**************************************************************************** Views for actions on individual seeds: ****************************************************************************""" @@ -614,6 +620,7 @@ class SeedBackupView(View): def run(self): button_data = [self.VIEW_WORDS] + if self.seed.seedqr_supported: button_data.append(self.EXPORT_SEEDQR) @@ -681,8 +688,16 @@ class SeedExportXpubScriptTypeView(View): def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView args = {"seed_num": self.seed_num, "sig_type": self.sig_type} + + script_types = self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) + seed = self.controller.storage.seeds[self.seed_num] - script_types = seed.script_override if seed.script_override else self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) + if seed.script_override: + # This seed only allows one script type + # TODO: Does it matter if the Settings don't have the override script type + # enabled? + script_types = [seed.script_override] + if len(script_types) == 1: # Nothing to select; skip this screen args["script_type"] = script_types[0] @@ -1456,7 +1471,8 @@ class SeedTranscribeSeedQRWholeQRView(View): def run(self): - encoder_args = dict(seed=self.seed) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: @@ -1492,7 +1508,8 @@ class SeedTranscribeSeedQRZoomedInView(View): def run(self): - encoder_args = dict(seed=self.seed) + encoder_args = dict(mnemonic=self.seed.mnemonic_list, + wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)) if self.seedqr_format == QRType.SEED__SEEDQR: e = SeedQrEncoder(**encoder_args) elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: @@ -1720,7 +1737,7 @@ class SeedAddressVerificationView(View): raise Exception("Can't validate a single sig addr without specifying a seed") self.seed_num = seed_num self.seed = self.controller.get_seed(seed_num) - self.seed_derivation_override = self.seed.derivation_override(wallet_type=SettingsConstants.SINGLE_SIG) + self.seed_derivation_override = self.seed.derivation_override(sig_type=SettingsConstants.SINGLE_SIG) else: self.seed = None self.address = self.controller.unverified_address["address"] diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index ad2940be..f6d29114 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -496,16 +496,8 @@ class ToolsAddressExplorerSelectSourceView(View): return Destination(SeedMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: - self.run_screen( - WarningScreen, - title="Electrum warning", - status_headline=None, - text=f"Some features disabled for Electrum seeds", - show_back_button=False, - ) - from seedsigner.views.seed_views import SeedMnemonicEntryView - self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) - return Destination(SeedMnemonicEntryView) + from seedsigner.views.seed_views import SeedElectrumMnemonicStartView + return Destination(SeedElectrumMnemonicStartView) @@ -540,7 +532,7 @@ class ToolsAddressExplorerAddressTypeView(View): if self.seed_num is not None: self.seed = self.controller.storage.seeds[seed_num] data["seed_num"] = self.seed - seed_derivation_override = self.seed.derivation_override(wallet_type=SettingsConstants.SINGLE_SIG) + seed_derivation_override = self.seed.derivation_override(sig_type=SettingsConstants.SINGLE_SIG) if self.script_type == SettingsConstants.CUSTOM_DERIVATION: derivation_path = self.custom_derivation diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 810e5d6f..4c0c092a 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -134,6 +134,16 @@ def test_generate_screenshots(target_locale): # Automatically populate all Settings options Views settings_views_list = [] settings_views_list.append(settings_views.SettingsMenuView) + settings_views_list.append(( + settings_views.SettingsMenuView, + dict( + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS, + initial_scroll=240, # Just guessing how many pixels to scroll down + ), + "SettingsMenuView__Advanced" + )) + # so we get a choice for transcribe seed qr format controller.settings.set_value( attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, @@ -215,6 +225,8 @@ def test_generate_screenshots(target_locale): seed_views.SeedSignMessageConfirmMessageView, seed_views.SeedSignMessageConfirmAddressView, + + seed_views.SeedElectrumMnemonicStartView, ], "PSBT Views": [ psbt_views.PSBTSelectSeedView, # this will fail, be rerun below diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 34f28631..14feb1e0 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -25,19 +25,18 @@ def test_ur_psbt_qr_encode(): def test_seedsigner_qr(): # test vector 1 from the SeedQR docs mnemonic = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() - e = SeedQrEncoder(seed=Seed(mnemonic)) + e = SeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643" - e = CompactSeedQrEncoder(seed=Seed(mnemonic)) + e = CompactSeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == b'\x0et\xb6A\x07\xf9L\xc0\xcc\xfa\xe6\xa1=\xcb\xec6b\x15O\xecg\xe0\xe0\t\x99\xc0x\x92Y}\x19\n' # test vector 4 from the SeedQR docs mnemonic="forum undo fragile fade shy sign arrest garment culture tube off merit".split() - seed = Seed(mnemonic) - e = SeedQrEncoder(seed=seed) + e = SeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == "073318950739065415961602009907670428187212261116" - e = CompactSeedQrEncoder(seed=seed) + e = CompactSeedQrEncoder(mnemonic=mnemonic) assert e.next_part() == b'[\xbd\x9dq\xa8\xecy\x90\x83\x1a\xff5\x9dBeE' diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index c328fe24..964b0d67 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,5 +1,6 @@ from base import FlowTest, FlowStep +from seedsigner.controller import Controller from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views from seedsigner.models.settings import SettingsConstants @@ -57,7 +58,40 @@ class TestPSBTFlows(FlowTest): FlowStep(MainMenuView) ]) - + + def test_scan_psbt_first_then_load_electrum_seed(self): + """ + Should be able to load an Electrum mnemonic after first loading in a psbt. + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + # Single sig psbt for the below Electrum mnemonic + view.decoder.add_data("cHNidP8BAHECAAAAAX9/d6VyI7nvVTyhLBfqu05za2AJ2Z0dKMC0cUX+S2U7AQAAAAD9////AgeHAAAAAAAAFgAUOnNPuZMD1sQudt3+7LvHBUvGhyd//gAAAAAAABYAFGO9QLvu4V9/hz6ZjbIGMrqsEiIYAjQTAAABAR+ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAQDBAQAAAAABAYeHL9UQlz/jEKUuNNY3LTeQRjudjBinsP2L0ppvgRt0AAAAAAD/////AnbP3rsPAAAAIlEgtgmCioGjfKwp6f8rOoI4OPb+ZV8db581J9IizZPskl2ghgEAAAAAABYAFKawrgcT62jmIVQwyHPCV0thmJWbAUDCBlMh9VjZN2NdU9Wabi0o3Ct1q9YHTsJRLAkLfUuIHB+BE+ucR4bdGAJG5nBhCWOmCXbpRwKP1INRYvkuQ2fHAAAAACIGA2+PEYHyVy6nhYwAx5SJKBIWXjsWgjhhf/2FEWqXgxnoEKNOC3gAAACAAAAAAAAAAAAAACICA0SBeeHxfHdny6rUnQJuteAnQ7shSydexjJCkSJarn3mEKNOC3gAAACAAQAAAAEAAAAA") + + self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), + ] + + # Load the associated Electrum mnemonic during the flow + for word in "apple drip silly junior language resource unaware whale snake copy gravity tank".split(): + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word), + ] + + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + ] + + self.run_sequence(sequence) + + def test_scan_multisig_psbt_seed_already_signed_flow(self): def load_psbt_into_decoder(view: scan_views.ScanView): diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 63230319..7171ef87 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -3,11 +3,11 @@ import pytest # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from base import FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException +from base import FlowTestInvalidButtonDataSelectionException from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON from seedsigner.models.settings import Settings, SettingsConstants -from seedsigner.models.seed import Seed +from seedsigner.models.seed import ElectrumSeed, Seed from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, RemoveMicroSDWarningView, View, NetworkMismatchErrorView, NotYetImplementedView from seedsigner.views import seed_views, scan_views, settings_views, tools_views @@ -119,6 +119,66 @@ class TestSeedFlows(FlowTest): self.run_sequence(sequence) + def test_electrum_mnemonic_entry_flow(self): + """ + Manually entering an Electrum mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + + Most BIP-39 mnemonics should generate an error if entered as Electrum seeds. + """ + def test_with_mnemonic(mnemonic: list[str], custom_extension: str = None, expects_electrum_seed_is_valid: bool = True): + Settings.HOSTNAME = "not seedsigner-os" + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), # Warning screen; no relevant button data selection. + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) + ) + + if expects_electrum_seed_is_valid: + # With the mnemonic completely entered, we land on the SeedFinalizeView + if custom_extension: + sequence += [ + FlowStep(seed_views.SeedFinalizeView, screen_return_value=1), # The passphrase / custom extension button is dynamic so there's no constant to refer to here + FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase=custom_extension)), # This is a one-off oddity where the Screen returns dict instead of int | str + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), + FlowStep(seed_views.SeedOptionsView), + ] + else: + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ] + + else: + # Or we bomb out if the mnemonic is invalid for Electrum + sequence.append(FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD)) + + self.run_sequence(sequence) + + BaseTest.reset_controller() + + + # Test seeds generated by Electrum v4.5.5 + test_with_mnemonic("bomb congress scorpion mutual word stamp tongue valid permit salmon yellow spy".split()) + test_with_mnemonic("morning pretty hobby click extend color wait joke define sausage boost salmon".split()) + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split()) + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split(), custom_extension="test") + test_with_mnemonic("basket print toy noodle betray weird filter ticket insect copy force machine".split(), custom_extension="monkey fling orange coin good") + + # Most BIP-39 seeds should fail; test seed generated by bitcoiner.guide + test_with_mnemonic("pioneer divide volcano art victory family grow novel mandate bicycle senior adjust".split(), expects_electrum_seed_is_valid=False) + + def test_export_xpub_standard_flow(self): """ Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView @@ -193,15 +253,15 @@ class TestSeedFlows(FlowTest): self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) - # test that multisig is not an option via exception raised when redirected to next step instead of having a choice - with pytest.raises(FlowTestRunScreenNotExecutedException) as e: - self.run_sequence( - initial_destination_view_args=dict(seed_num=0), - sequence=[ - FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig), - ] - ) + # If multisig isn't an option, then the sig type selection is skipped altogether + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) # test that taproot is not an option via exception raised when choice is taproot with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: @@ -295,6 +355,37 @@ class TestSeedFlows(FlowTest): ) + def test_export_xpub_electrum_seed_flow(self): + """ + Electrum seeds should skip script type selection + """ + # Load a finalized Seed into the Controller + self.controller.storage.init_pending_mnemonic(num_words=12, is_electrum=True) + self.controller.storage.set_pending_seed(ElectrumSeed("regular reject rare profit once math fringe chase until ketchup century escape".split())) + self.controller.storage.finalize_pending_seed() + + # Make sure all options are enabled + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in SettingsConstants.ALL_SIG_TYPES]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in SettingsConstants.ALL_SCRIPT_TYPES]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in SettingsConstants.ALL_COORDINATORS]) + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + + # Skips past the script type options via redirect + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0]), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + def test_discard_seed_flow(self): """ Selecting "Discard Seed" from the SeedOptionsView should enter the Discard Seed flow and diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 38d8325c..588859be 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -39,8 +39,6 @@ class TestToolsFlows(FlowTest): Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ - controller = Controller.get_instance() - def load_seed_into_decoder(view: scan_views.ScanView): view.decoder.add_data("0000" * 11 + "0003") @@ -55,11 +53,11 @@ class TestToolsFlows(FlowTest): FlowStep(seed_views.SeedExportXpubScriptTypeView), ]) - assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER + assert self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) + self.controller.storage.seeds.clear() + self.controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) # Finalize the new seed w/passphrase self.run_sequence( @@ -73,6 +71,39 @@ class TestToolsFlows(FlowTest): ) + def test__address_explorer__load_electrum_seed__sideflow(self): + """ + Loading an Electrum seed during the Address Explorer flow should return to + the Address Explorer flow upon completion, skip the script type selection, + and successfully generate receive or change addresses. + """ + self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) + + sequence = [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.TYPE_ELECTRUM), + FlowStep(seed_views.SeedElectrumMnemonicStartView), + ] + + # Load an Electrum mnemonic during the flow (same one used in test_seed.py) + for word in "regular reject rare profit once math fringe chase until ketchup century escape".split(): + sequence += [ + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word), + ] + + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView), + ] + + self.run_sequence(sequence) + + + def test__address_explorer__scan_wrong_qrtype__flow(self): """ Scanning the wrong type of QR code when a SeedQR is expected should route to ErrorView diff --git a/tests/test_seed.py b/tests/test_seed.py index 04291f65..4dd8af93 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1,8 +1,10 @@ -from seedsigner.models.seed import Seed, ElectrumSeed +import pytest +from seedsigner.models.seed import InvalidSeedException, Seed, ElectrumSeed from seedsigner.models.settings import SettingsConstants +# TODO: Change TAB indents to SPACE def test_seed(): seed = Seed(mnemonic="obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash".split()) @@ -37,9 +39,47 @@ def test_seed(): def test_electrum_seed(): + """ + ElectrumSeed should correctly parse a modern Electrum mnemonic. + """ seed = ElectrumSeed(mnemonic="regular reject rare profit once math fringe chase until ketchup century escape".split()) intended_seed = b'\xcan|\xf8\x8a\x8d\xf78=Pq\xc4_\xe6\x02\x91\xfcs\xb2[\xed*\xdc\xc7%\xb6[_-(~D\xe5\x1e\x85%N\x9c\x03\x9dh\xafX}\x16\xb1\x99,\xbe\xc4\x11\xfaW\x0f\xb0\x89yD\xf4\x0f\xd5?\x8eA' assert seed.seed_bytes == intended_seed + +def test_electrum_mnemonic_format(): + """ + ElectrumSeed should reject mnemonics that are not 12 words long. + """ + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 11) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 13) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic=["regular"] * 24) + + +def test_electrum_seed_rejects_most_bip39_mnemonics(): + """ + ElectrumSeed should throw an exception for most BIP-39 mnemonics. + + There are 1/16 odds that a seed will be valid for both formats. + """ + # Most BIP-39 seeds should fail; test seeds generated by bitcoiner.guide + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="pioneer divide volcano art victory family grow novel mandate bicycle senior adjust".split()) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="gentle combine cool hamster ghost harvest gossip lend dismiss slam any toast".split()) + + with pytest.raises(InvalidSeedException): + ElectrumSeed(mnemonic="enough board blossom stamp fire buffalo digital solution sadness random number stone".split()) + + # This one is valid for both formats + mnemonic = "only gain spot output unknown craft simple cram absorb suggest ridge famous".split() + Seed(mnemonic) + ElectrumSeed(mnemonic) diff --git a/tests/test_seedqr.py b/tests/test_seedqr.py index 2202af0d..d792683c 100644 --- a/tests/test_seedqr.py +++ b/tests/test_seedqr.py @@ -14,12 +14,11 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): assert len(mnemonic) == mnemonic_length if qr_type == QRType.SEED__SEEDQR: - e = SeedQrEncoder(seed=Seed(mnemonic)) + e = SeedQrEncoder(mnemonic=mnemonic) elif qr_type == QRType.SEED__COMPACTSEEDQR: - e = CompactSeedQrEncoder(seed=Seed(mnemonic)) + e = CompactSeedQrEncoder(mnemonic=mnemonic) data = e.next_part() - print(data) qr = QR() image = qr.qrimage(