diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 1fa064ac..3da014a0 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -1034,14 +1034,17 @@ class KeyboardScreen(BaseTopNavScreen): elif self.show_save_button and input == HardwareButtonsConstants.KEY3: # Save! + if len(self.user_input) == 0: + # Don't try to submit zero input + continue + # First show the save button reacting to the click self.save_button.is_selected = True self.save_button.render() self.renderer.show_image() # Then return the input to the View - if len(self.user_input) > 0: - return self.user_input.strip() + return self.user_input.strip() # Process normal input if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 5de5cc69..9d29ff83 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1482,3 +1482,17 @@ class MultisigWalletDescriptorScreen(ButtonListScreen): auto_line_break=True, allow_text_overflow=True, )) + +@dataclass +class SeedBIP85SelectChildIndexScreen(KeyboardScreen): + def __post_init__(self): + self.title = "BIP-85 Index" + self.user_input = "" + + # Specify the keys in the keyboard + self.rows = 3 + self.cols = 5 + self.keys_charset = "0123456789" + self.show_save_button = True + + super().__post_init__() \ No newline at end of file diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 276037a7..f050e1b8 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -8,6 +8,8 @@ from typing import List from seedsigner.models.settings import SettingsConstants from seedsigner.helpers import embit_utils +import hashlib +import hmac class InvalidSeedException(Exception): @@ -106,12 +108,40 @@ class Seed: 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') - + def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET): return embit_utils.get_xpub(seed_bytes=self.seed_bytes, derivation_path=wallet_path, embit_network=SettingsConstants.map_network_to_embit(network)) + # Derives a BIP85 mnemonic (seed word) from the master seed words using embit functions + def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, network: str = SettingsConstants.MAINNET): + + # Calculate the master bip32 root key from the parents bip39 seed_bytes (the mnemonic entropy) + root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"]) + + # TODO: Support other bip-39 wordlist languages! + # As per the BIP85 spec, 39 is the application number used for bip39 mnemonic, + # we use the selected word count and the index to form the full path. + path = "m/83696968'/39'/0'/{bip85_num_words}'/{bip85_index}'".format( + bip85_num_words=bip85_num_words, + bip85_index=bip85_index) + + # Derive the child xprv (HDKey) using the path format defined above + xprv = root.derive(path) + + # The xprv.secret plus the BIP85 key is hashed together using hmac sha512. + entropy = hmac.new(key=b'bip-entropy-from-k', msg=xprv.secret, digestmod=hashlib.sha512).digest() + + # Calculate number of bytes to retain for the entropy + # 24 words the width is 32bytes (256bits) + # 12 words the width is 16bytes (128bits) + width = round(bip85_num_words / 12 * 16) + + # Return the derived BIP85 child mnemonic using the truncated derived entropy + return bip39.mnemonic_from_bytes(entropy[:width]) + + ### override operators def __eq__(self, other): if isinstance(other, Seed): diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 83ebcddd..64936fdc 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -155,6 +155,7 @@ class SettingsConstants: SETTING__PASSPHRASE = "passphrase" SETTING__CAMERA_ROTATION = "camera_rotation" SETTING__COMPACT_SEEDQR = "compact_seedqr" + SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds" SETTING__PRIVACY_WARNINGS = "privacy_warnings" SETTING__DIRE_WARNINGS = "dire_warnings" SETTING__PARTNER_LOGOS = "partner_logos" @@ -439,6 +440,12 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS, + display_name="BIP-85 child seeds", + visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS, display_name="Show privacy warnings", diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index f03a5d9d..c935ee7b 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -351,6 +351,7 @@ class SeedOptionsView(View): EXPORT_XPUB = "Export Xpub" EXPLORER = "Address Explorer" BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) + BIP85_CHILD_SEED = "BIP-85 Child Seed" DISCARD = ("Discard Seed", None, None, "red") button_data = [] @@ -374,7 +375,7 @@ class SeedOptionsView(View): if self.controller.psbt: if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: - # Re-route us directly back to the start of the PSBT flow + # Re-route us directly back to the start of the PSBT flow self.controller.resume_main_flow = None self.controller.psbt_seed = self.seed return Destination(PSBTOverviewView, skip_current_view=True) @@ -391,6 +392,10 @@ class SeedOptionsView(View): button_data.append(EXPLORER) button_data.append(BACKUP) + + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(BIP85_CHILD_SEED) + button_data.append(DISCARD) selected_menu_num = seed_screens.SeedOptionsScreen( @@ -424,6 +429,9 @@ class SeedOptionsView(View): elif button_data[selected_menu_num] == BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) + elif button_data[selected_menu_num] == BIP85_CHILD_SEED: + return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) + elif button_data[selected_menu_num] == DISCARD: return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) @@ -776,15 +784,20 @@ class SeedExportXpubQRDisplayView(View): View Seed Words flow ****************************************************************************""" class SeedWordsWarningView(View): - def __init__(self, seed_num: int): + def __init__(self, seed_num: int, bip85_data: dict = None): super().__init__() self.seed_num = seed_num + self.bip85_data = bip85_data def run(self): destination = Destination( SeedWordsView, - view_args={"seed_num": self.seed_num, "page_index": 0}, + view_args=dict( + seed_num=self.seed_num, + page_index=0, + bip85_data=self.bip85_data + ), skip_current_view=True, # Prevent going BACK to WarningViews ) if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED: @@ -805,15 +818,15 @@ class SeedWordsWarningView(View): class SeedWordsView(View): - def __init__(self, seed_num: int, page_index: int = 0): + def __init__(self, seed_num: int, bip85_data: dict = None, page_index: int = 0): super().__init__() self.seed_num = seed_num if self.seed_num is None: self.seed = self.controller.storage.get_pending_seed() else: self.seed = self.controller.get_seed(self.seed_num) + self.bip85_data = bip85_data self.page_index = page_index - self.num_pages=int(len(self.seed.mnemonic_list)/4) def run(self): @@ -823,20 +836,26 @@ class SeedWordsView(View): # Slice the mnemonic to our current 4-word section words_per_page = 4 # TODO: eventually make this configurable for bigger screens? - mnemonic = self.seed.mnemonic_display_list + if self.bip85_data is not None: + mnemonic = self.seed.get_bip85_child_mnemonic(self.bip85_data["child_index"], self.bip85_data["num_words"]).split() + title = f"""Child #{self.bip85_data["child_index"]}""" + else: + mnemonic = self.seed.mnemonic_display_list + title = "Seed Words" words = mnemonic[self.page_index*words_per_page:(self.page_index + 1)*words_per_page] button_data = [] - if self.page_index < self.num_pages - 1 or self.seed_num is None: + num_pages = int(len(mnemonic)/words_per_page) + if self.page_index < num_pages - 1 or self.seed_num is None: button_data.append(NEXT) else: button_data.append(DONE) selected_menu_num = seed_screens.SeedWordsScreen( - title=f"Seed Words: {self.page_index+1}/{self.num_pages}", + title=f"{title}: {self.page_index+1}/{num_pages}", words=words, page_index=self.page_index, - num_pages=self.num_pages, + num_pages=num_pages, button_data=button_data, ).display() @@ -844,14 +863,93 @@ class SeedWordsView(View): return Destination(BackStackView) if button_data[selected_menu_num] == NEXT: - if self.seed_num is None and self.page_index == self.num_pages - 1: - return Destination(SeedWordsBackupTestPromptView, view_args=dict(seed_num=self.seed_num)) + if self.seed_num is None and self.page_index == num_pages - 1: + return Destination( + SeedWordsBackupTestPromptView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) else: - return Destination(SeedWordsView, view_args=dict(seed_num=self.seed_num, page_index=self.page_index + 1)) + return Destination( + SeedWordsView, + view_args=dict(seed_num=self.seed_num, page_index=self.page_index + 1, bip85_data=self.bip85_data) + ) elif button_data[selected_menu_num] == DONE: # Must clear history to avoid BACK button returning to private info - return Destination(SeedWordsBackupTestPromptView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestPromptView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) + + + +"""**************************************************************************** + BIP85 - Derive child mnemonic (seed) flow +****************************************************************************""" +class SeedBIP85ApplicationModeView(View): + """ + * Ask the user the application type as defined in the BIP0085 spec. + * Currently only Word mode of 12, 24 words (Application number: 39') + * Possible future additions are + * WIF (HDSEED) + * XPRV (BIP32) + """ + def __init__(self, seed_num: int): + super().__init__() + self.seed_num = seed_num + self.num_words = 0 + self.bip85_app_num = 39 # TODO: Support other Application numbers + + + def run(self): + # TODO: Future enhancement to display WIF (HD-SEED) and XPRV (Bip32)? + WORDS_12 = "12 Words" + WORDS_24 = "24 Words" + + button_data = [WORDS_12, WORDS_24] + + selected_menu_num = ButtonListScreen( + title="BIP-85 Num Words", + button_data=button_data + ).display() + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data[selected_menu_num] == WORDS_12: + self.num_words = 12 + elif button_data[selected_menu_num] == WORDS_24: + self.num_words = 24 + + return Destination( + SeedBIP85SelectChildIndexView, + view_args=dict(seed_num=self.seed_num, num_words=self.num_words) + ) + + + +# View to retrieve the derived seed index +class SeedBIP85SelectChildIndexView(View): + def __init__(self, seed_num: int, num_words: int): + super().__init__() + self.seed_num = seed_num + self.num_words = num_words + + + def run(self): + # Change this later to use the generic Screen input keyboard + ret = seed_screens.SeedBIP85SelectChildIndexScreen().display() + + if ret == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + return Destination( + SeedWordsWarningView, + view_args=dict( + seed_num=self.seed_num, + bip85_data=dict(child_index=int(ret), num_words=self.num_words), + ) + ) @@ -859,9 +957,10 @@ class SeedWordsView(View): Seed Words Backup Test ****************************************************************************""" class SeedWordsBackupTestPromptView(View): - def __init__(self, seed_num: int): + def __init__(self, seed_num: int, bip85_data: dict = None): self.seed_num = seed_num - + self.bip85_data = bip85_data + def run(self): VERIFY = "Verify" @@ -872,7 +971,10 @@ class SeedWordsBackupTestPromptView(View): ).display() if button_data[selected_menu_num] == VERIFY: - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) elif button_data[selected_menu_num] == SKIP: if self.seed_num is not None: @@ -883,19 +985,24 @@ class SeedWordsBackupTestPromptView(View): class SeedWordsBackupTestView(View): - def __init__(self, seed_num: int, confirmed_list: List[bool] = None, cur_index: int = None): + def __init__(self, seed_num: int, bip85_data: dict = None, confirmed_list: List[bool] = None, cur_index: int = None): super().__init__() self.seed_num = seed_num if self.seed_num is None: self.seed = self.controller.storage.get_pending_seed() else: self.seed = self.controller.get_seed(self.seed_num) + self.bip85_data = bip85_data + + if self.bip85_data is not None: + self.mnemonic_list = self.seed.get_bip85_child_mnemonic(self.bip85_data["child_index"], self.bip85_data["num_words"]).split() + else: + self.mnemonic_list = self.seed.mnemonic_display_list - self.mnemonic_list = self.seed.mnemonic_display_list self.confirmed_list = confirmed_list if not self.confirmed_list: self.confirmed_list = [] - + self.cur_index = cur_index @@ -904,7 +1011,7 @@ class SeedWordsBackupTestView(View): self.cur_index = int(random.random() * len(self.mnemonic_list)) while self.cur_index in self.confirmed_list: self.cur_index = int(random.random() * len(self.mnemonic_list)) - + real_word = self.mnemonic_list[self.cur_index] fake_word1 = bip39.WORDLIST[int(random.random() * 2047)] fake_word2 = bip39.WORDLIST[int(random.random() * 2047)] @@ -925,17 +1032,24 @@ class SeedWordsBackupTestView(View): self.confirmed_list.append(self.cur_index) if len(self.confirmed_list) == len(self.mnemonic_list): # Successfully confirmed the full mnemonic! - return Destination(SeedWordsBackupTestSuccessView, view_args=dict(seed_num=self.seed_num)) + return Destination( + SeedWordsBackupTestSuccessView, + view_args=dict(seed_num=self.seed_num), + ) else: # Continue testing the remaining words - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list)) - + return Destination( + SeedWordsBackupTestView, + view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list, bip85_data=self.bip85_data), + ) + else: # Picked the WRONG WORD! return Destination( SeedWordsBackupTestMistakeView, view_args=dict( seed_num=self.seed_num, + bip85_data=self.bip85_data, cur_index=self.cur_index, wrong_word=button_data[selected_menu_num], confirmed_list=self.confirmed_list, @@ -945,14 +1059,15 @@ class SeedWordsBackupTestView(View): class SeedWordsBackupTestMistakeView(View): - def __init__(self, seed_num: int, cur_index: int, wrong_word: str, confirmed_list: List[bool] = None): + def __init__(self, seed_num: int, bip85_data: dict = None, cur_index: int = None, wrong_word: str = None, confirmed_list: List[bool] = None): super().__init__() self.seed_num = seed_num + self.bip85_data = bip85_data self.cur_index = cur_index self.wrong_word = wrong_word self.confirmed_list = confirmed_list - + def run(self): REVIEW = "Review Seed Words" RETRY = "Try Again" @@ -967,17 +1082,28 @@ class SeedWordsBackupTestMistakeView(View): ).display() if button_data[selected_menu_num] == REVIEW: - return Destination(SeedWordsView, view_args=dict(seed_num=self.seed_num)) - + return Destination( + SeedWordsView, + view_args=dict(seed_num=self.seed_num, bip85_data=self.bip85_data), + ) + elif button_data[selected_menu_num] == RETRY: - return Destination(SeedWordsBackupTestView, view_args=dict(seed_num=self.seed_num, confirmed_list=self.confirmed_list, cur_index=self.cur_index)) - + return Destination( + SeedWordsBackupTestView, + view_args=dict( + seed_num=self.seed_num, + confirmed_list=self.confirmed_list, + cur_index=self.cur_index, + bip85_data=self.bip85_data, + ) + ) + class SeedWordsBackupTestSuccessView(View): def __init__(self, seed_num: int): self.seed_num = seed_num - + def run(self): LargeIconStatusScreen( title="Backup Verified", @@ -1618,7 +1744,7 @@ class LoadMultisigWalletDescriptorView(View): if button_data[selected_menu_num] == SCAN: return Destination(ScanView) - + elif button_data[selected_menu_num] == CANCEL: if self.controller.resume_main_flow == Controller.FLOW__PSBT: return Destination(BackStackView) @@ -1637,7 +1763,7 @@ class MultisigWalletDescriptorView(View): fingerprints.append(fingerprint) policy = descriptor.brief_policy.split("multisig")[0].strip() - + RETURN = "Return to PSBT" VERIFY = "Verify Addr" EXPLORER = "Address Explorer" diff --git a/tests/test_bip85.py b/tests/test_bip85.py new file mode 100644 index 00000000..c9f67a31 --- /dev/null +++ b/tests/test_bip85.py @@ -0,0 +1,23 @@ +import pytest +from mock import MagicMock +from seedsigner.models import Seed +from embit import bip39 + +from seedsigner.models.settings import SettingsConstants + + + +def test_derive_child_mnemonic(): + + expected = "unusual topic foot figure pulp target glimpse core electric spot neglect fame" + seed = Seed(mnemonic="resource timber firm banner horror pupil frozen main pear direct pioneer broken grid core insane begin sister pony end debate task silk empty curious".split()) + + actual = seed.get_bip85_child_mnemonic(0, 12) + assert actual == expected + + + expected = "imitate post very mandate retreat prevent tiny snow fetch canvas town shrug fix food summer library symptom occur slam style cruise wolf phone key" + seed = Seed(mnemonic="resource timber firm banner horror pupil frozen main pear direct pioneer broken grid core insane begin sister pony end debate task silk empty curious".split()) + + actual = seed.get_bip85_child_mnemonic(0, 24) + assert actual == expected