diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 6164ff84..3fdd3eb2 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: return None + def detect_version(self, derivation_path: str, network: str = SettingsConstants.MAINNET, wallet_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 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: embit_network = NETWORKS[SettingsConstants.map_network_to_embit(network)] return embit_network["zpub"] if SettingsConstants.SINGLE_SIG == wallet_type 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 c5334018..d67dbfaf 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -209,6 +209,8 @@ class SettingsConstants: LABEL__BIP39_PASSPHRASE = "BIP-39 Passphrase" LABEL__CUSTOM_EXTENSION = "Custom Extension" + + @dataclass class SettingsEntry: """ diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 069737ec..1fb412f4 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -620,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) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index e4fa201a..f2f5eb75 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -152,6 +152,7 @@ class TestSeedFlows(FlowTest): ] 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) @@ -164,10 +165,8 @@ class TestSeedFlows(FlowTest): 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()) - # Most BIP-39 seeds should fail; test seeds generated by bitcoiner.guide + # 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) - test_with_mnemonic("gentle combine cool hamster ghost harvest gossip lend dismiss slam any toast".split(), expects_electrum_seed_is_valid=False) - test_with_mnemonic("enough board blossom stamp fire buffalo digital solution sadness random number stone".split(), expects_electrum_seed_is_valid=False) def test_export_xpub_standard_flow(self): 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)