From 34d73a0f0a8cea15a154e262d019fb42df3f36b0 Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Sun, 8 Dec 2024 17:32:04 +0100 Subject: [PATCH 01/10] Update seed.py to support SSS --- src/seedsigner/models/seed.py | 59 ++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 2218ae6c..da378376 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -4,7 +4,7 @@ import hmac from binascii import hexlify -from embit import bip39, bip32, bip85 +from embit import bip39, bip32, bip85, slip39 from embit.networks import NETWORKS from typing import List @@ -35,6 +35,8 @@ def __init__(self, self.seed_bytes: bytes = None self._generate_seed() + self._shamir_share_sets: List[(int, int, List[str], str)] = [] + @staticmethod def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]: @@ -159,6 +161,60 @@ def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, netwo return bip85.derive_mnemonic(root, bip85_num_words, bip85_index) + ### Shamir's Secret Sharing + + def generate_shares(self, k, n, slip39_passphrase: str = ""): + share_set = slip39.ShareSet.generate_shares(self.mnemonic_str, k, n, slip39_passphrase.encode('utf-8')) + self._shamir_share_sets.append((k, n, share_set, slip39_passphrase)) + return share_set + + + @classmethod + def recover_from_shares(cls, shares: list[str], slip39_passphrase: str = ""): + mnemonic = slip39.ShareSet.recover_mnemonic(shares, slip39_passphrase.encode('utf-8')) + return cls(mnemonic.split(), wordlist_language_code = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + + + @staticmethod + def get_slip39_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]: + if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH: + return slip39.SLIP39_WORDS + else: + raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}") + + + @property + def slip39_wordlist(self) -> List[str]: + return Seed.get_slip39_wordlist(self.wordlist_language_code) + + + @property + def slip39_passphrase_label(self) -> str: + #return SettingsConstants.LABEL__BIP39_PASSPHRASE + return "SLIP-39 Passphrase" + + + @property + def shamir_share_set_count(self) -> int: + #return SettingsConstants.LABEL__BIP39_PASSPHRASE + return len(self._shamir_share_sets) + + + def get_shamir_share_data(self, idx: int) -> dict: + return {"k": self._shamir_share_sets[idx][0], + "n": self._shamir_share_sets[idx][1], + "share_list": self._shamir_share_sets[idx][2], + "passphrase": self._shamir_share_sets[idx][3]} + + + def get_share_slip39_display_list(self, share_set_idx, share_idx) -> List[str]: + return unicodedata.normalize("NFC", self._shamir_share_sets[share_set_idx][2][share_idx]).split() + + + def get_share_slip39_list(self, share_set_idx, share_idx) -> List[str]: + return self._shamir_share_sets[share_set_idx][2][share_idx].split() + + ### override operators def __eq__(self, other): if isinstance(other, Seed): @@ -234,3 +290,4 @@ def seedqr_supported(self) -> bool: @property def bip85_supported(self) -> bool: return False + From ca719e04be0b6beab3fb89b22c30e235fae27e08 Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Sat, 14 Dec 2024 12:37:24 +0100 Subject: [PATCH 02/10] Shamir shares minimal mnemonic import support --- src/seedsigner/models/seed_storage.py | 46 +++ src/seedsigner/views/seed_views.py | 387 ++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 85feb49c..967902b1 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -10,6 +10,7 @@ def __init__(self) -> None: self.pending_seed: Seed = None self._pending_mnemonic: List[str] = [] self._pending_is_electrum : bool = False + self._pending_shamir_share_set: List[str] = [] def set_pending_seed(self, seed: Seed): @@ -103,3 +104,48 @@ def convert_pending_mnemonic_to_pending_seed(self): def discard_pending_mnemonic(self): self._pending_mnemonic = [] self._pending_is_electrum = False + + + # Shamir shares + + def init_pending_shamir_share_set(self, num_words: int = 20, num_shares: int = 2, is_electrum: bool = False): + self._pending_mnemonic = [None] * num_words + self._pending_shamir_share_set = [None] * num_shares + self._pending_is_electrum = is_electrum + + + def update_pending_shamir_share_set(self, index: int): + """ + Replaces the nth share in the pending shamir share. + + * may specify a negative `index` (e.g. -1 is the last word). + """ + if index >= len(self._pending_shamir_share_set): + raise Exception(f"index {index} is too high") + self._pending_shamir_share_set[index] = self._pending_mnemonic + if index < len(self._pending_shamir_share_set) - 1: + self.discard_pending_mnemonic() + self.init_pending_mnemonic(len(self._pending_shamir_share_set[index])) + + + def get_pending_shamir_share_set_share(self, index: int) -> List[str]: + if index < len(self._pending_shamir_share_set): + return self._pending_shamir_share_set[index] + return None + + + @property + def pending_shamir_share_set_length(self) -> int: + return len(self._pending_shamir_share_set) + + + def discard_pending_shamir_share_set(self): + self._pending_shamir_share_set = [] + + + def convert_pending_shamir_share_set_to_pending_seed(self, passphrase: str = ''): + share_set_formatted = [" ".join(share) for share in self._pending_shamir_share_set] + self.pending_seed = Seed.recover_from_shares(share_set_formatted, passphrase) + self.discard_pending_mnemonic() + self.discard_pending_shamir_share_set() + \ No newline at end of file diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 9483c111..6ea966c5 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -171,6 +171,8 @@ class LoadSeedView(View): SEED_QR = (" Scan a SeedQR", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_SSS_12WORD = ("Recover 12-word Shamir Share", FontAwesomeIconConstants.KEYBOARD) + TYPE_SSS_24WORD = ("Recover 24-word Shamir Share", FontAwesomeIconConstants.KEYBOARD) TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) CREATE = (" Create a seed", SeedSignerIconConstants.PLUS) @@ -181,6 +183,9 @@ def run(self): self.TYPE_24WORD, ] + button_data.append(self.TYPE_SSS_12WORD) + button_data.append(self.TYPE_SSS_24WORD) + if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.TYPE_ELECTRUM) @@ -207,6 +212,14 @@ def run(self): elif button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) + + elif button_data[selected_menu_num] == self.TYPE_SSS_12WORD: + self.controller.storage.init_pending_shamir_share_set(num_words=20, num_shares=2) + return Destination(SeedShamirShareMnemonicEntryView) + + elif button_data[selected_menu_num] == self.TYPE_SSS_24WORD: + self.controller.storage.init_pending_shamir_share_set(num_words=33, num_shares=2) + return Destination(SeedShamirShareMnemonicEntryView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: return Destination(SeedElectrumMnemonicStartView) @@ -2185,3 +2198,377 @@ def run(self): # Exiting/Canceling the QR display screen always returns Home return Destination(MainMenuView, skip_current_view=True) + + +"""**************************************************************************** + Shamir's Secret Sharing Views +****************************************************************************""" + +class SeedEntryShamirThresholdView(View): + def __init__(self, seed_num: int): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + + + def run(self): + title="SLIP-39 Threshold" + ret_dict = self.run_screen(seed_screens.SeedEntryShamirThresholdScreen, entered_number="", title=title) + + # The new passphrase will be the return value; it might be empty. + #self.seed.set_passphrase(ret_dict["passphrase"]) + print(ret_dict["entered_number"]) + + if "is_back_button" in ret_dict: + return Destination(BackStackView) + + elif ret_dict["entered_number"] != "": + return Destination(SeedEntryShamirShareCountView, view_args={"seed_num": self.seed_num, "k": int(ret_dict["entered_number"])}) + + else: + return Destination(BackStackView) + + +class SeedEntryShamirShareCountView(View): + def __init__(self, seed_num: int, k: int): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.k = k + + + def run(self): + title="SLIP-39 Share Count" + ret_dict = self.run_screen(seed_screens.SeedEntryShamirShareCountScreen, entered_number="", title=title) + + # The new passphrase will be the return value; it might be empty. + #self.seed.set_passphrase(ret_dict["passphrase"]) + print(ret_dict["entered_number"]) + + if "is_back_button" in ret_dict: + return Destination(BackStackView) + + elif ret_dict["entered_number"] != "": + #print(self.seed.generate_shares(self.k, int(ret_dict["entered_number"]))) + #return Destination(MainMenuView, view_args={"seed_num": self.seed_num, "k": ret_dict["entered_number"]}) + return Destination(SeedShamirShareFinalizeView, view_args={"seed_num": self.seed_num, "k": self.k, "n": int(ret_dict["entered_number"])}) + + else: + return Destination(BackStackView) + + + +class SeedShamirShareFinalizeView(View): + FINALIZE = "Done" + + def __init__(self, seed_num: int, k: int, n: int): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.k = k + self.n = n + self.shamir_set_index = self.seed.shamir_share_set_count + + + def run(self): + button_data = [self.FINALIZE] + passphrase_button = self.seed.slip39_passphrase_label + #if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: + # button_data.append(passphrase_button) + button_data.append(passphrase_button) + + selected_menu_num = self.run_screen( + seed_screens.ShamirFinalizeScreen, + shamir_set_index=self.shamir_set_index, + button_data=button_data, + ) + + if button_data[selected_menu_num] == self.FINALIZE: + self.seed.generate_shares(self.k, self.n) + return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) + + elif button_data[selected_menu_num] == passphrase_button: + return Destination(SeedAddSlip39PassphraseView, view_args={"seed_num": self.seed_num, "k": self.k, "n": self.n}) + + + +class SeedAddSlip39PassphraseView(View): + def __init__(self, seed_num: int, k: int, n: int): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.k = k + self.n = n + self.shamir_set_index = self.seed.shamir_share_set_count + + + def run(self): + passphrase_title=self.seed.slip39_passphrase_label + ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase="", title=passphrase_title) + + # The new passphrase will be the return value; it might be empty. + #self.seed.set_passphrase(ret_dict["passphrase"]) + print(ret_dict["passphrase"]) + + if "is_back_button" in ret_dict: + return Destination(BackStackView) + + else: + self.seed.generate_shares(self.k, self.n, ret_dict["passphrase"]) + return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) + + + +class SeedShamirShareListView(View): + + def __init__(self, seed_num): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + + + def run(self): + button_data = [f"Shamir Share Set #{i}" for i in range(self.seed.shamir_share_set_count)] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Shamir Shares", + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + # Force BACK to always return to the SeedOptionsView + return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True) + + elif selected_menu_num < self.seed.shamir_share_set_count: + return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": selected_menu_num}) + + + +class SeedShamirShareOptionsView(View): + VIEW_WORDS = "View Shares Words" + EXPORT_SEEDQR = "Export Shares as SeedQR" + + def __init__(self, seed_num, shamir_set_index): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.shamir_set_index = shamir_set_index + + + def run(self): + button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title=f"Backup SSS #{self.shamir_set_index}", + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + # Force BACK to always return to the SeedShamirShareListView + return Destination(SeedShamirShareListView, view_args={"seed_num": self.seed_num}) + + elif button_data[selected_menu_num] == self.VIEW_WORDS: + #return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) + return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode":0}) + + elif button_data[selected_menu_num] == self.EXPORT_SEEDQR: + #return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) + return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode":1}) + + + +class SeedShamirShareSelectView(View): + VIEW_WORDS = "View Shares Words" + EXPORT_SEEDQR = "Export Shares as SeedQR" + def __init__(self, seed_num, shamir_set_index, mode): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.shamir_set_index = shamir_set_index + self.share_set = self.seed.get_shamir_share_data(shamir_set_index) + self.mode = mode + + + def run(self): + button_data = [f"Share #{i}" for i in range(len(self.share_set["share_list"]))] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Select Share", + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) + + elif selected_menu_num < len(self.share_set["share_list"]) and self.mode == 0: + return Destination(SeedShamirShareWordsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "share_index": selected_menu_num}) + + elif selected_menu_num < len(self.share_set["share_list"]) and self.mode == 1: + # QR Export for shamir share not yet implemented + return Destination(NotYetImplementedView) + + +class SeedShamirShareWordsView(View): + def __init__(self, seed_num, shamir_set_index, share_index, page_index: int = 0): + super().__init__() + self.seed_num = seed_num + self.seed = self.controller.get_seed(self.seed_num) + self.shamir_set_index = shamir_set_index + self.share_set = self.seed.get_shamir_share_data(shamir_set_index) + self.share_index = share_index + self.page_index = page_index + + def run(self): + NEXT = "Next" + DONE = "Done" + + # Slice the mnemonic to our current 4-word section + words_per_page = 4 # TODO: eventually make this configurable for bigger screens? + + + mnemonic = self.seed.get_share_slip39_display_list(self.shamir_set_index, self.share_index) + title = f"Share #{self.share_index}" + words = mnemonic[self.page_index*words_per_page:(self.page_index + 1)*words_per_page] + + button_data = [] + # Modified calculation so all words are shown when having 33 words + num_pages = (len(mnemonic) + words_per_page - 1) // 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"{title}: {self.page_index+1}/{num_pages}", + words=words, + page_index=self.page_index, + num_pages=num_pages, + button_data=button_data, + ).display() + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data[selected_menu_num] == NEXT: + if self.seed_num is None and self.page_index == num_pages - 1: + """ + return Destination( + SeedWordsBackupTestPromptView, + view_args=dict(seed_num=self.seed_num), + ) + """ + return Destination(MainMenuView) + else: + return Destination( + SeedShamirShareWordsView, + view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "share_index": self.share_index, "page_index": self.page_index + 1} + ) + + elif button_data[selected_menu_num] == DONE: + # Must clear history to avoid BACK button returning to private info + return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode": 0}) + + + +class SeedShamirShareMnemonicEntryView(View): + def __init__(self, cur_word_index: int = 0, is_calc_final_word: bool=False, cur_set_index: int = 0): + super().__init__() + self.cur_word_index = cur_word_index + self.cur_word = self.controller.storage.get_pending_mnemonic_word(cur_word_index) + self.is_calc_final_word = is_calc_final_word + self.cur_set_index = cur_set_index + + + def run(self): + ret = self.run_screen( + seed_screens.SeedMnemonicEntryScreen, + title=f"Share #{self.cur_set_index + 1} Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! + initial_letters=list(self.cur_word) if self.cur_word else ["a"], + wordlist=Seed.get_slip39_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), + ) + + if ret == RET_CODE__BACK_BUTTON: + if self.cur_word_index > 0: + return Destination(BackStackView) + else: + self.controller.storage.discard_pending_mnemonic() + return Destination(MainMenuView) + + # ret will be our new mnemonic word + self.controller.storage.update_pending_mnemonic(ret, self.cur_word_index) + + """" + if self.is_calc_final_word and self.cur_word_index == self.controller.storage.pending_mnemonic_length - 2: + # Time to calculate the last word. User must decide how they want to specify + # the last bits of entropy for the final word. + from seedsigner.views.tools_views import ToolsCalcFinalWordFinalizePromptView + return Destination(ToolsCalcFinalWordFinalizePromptView) + + if self.is_calc_final_word and self.cur_word_index == self.controller.storage.pending_mnemonic_length - 1: + # Time to calculate the last word. User must either select a final word to + # contribute entropy to the checksum word OR we assume 0 ("abandon"). + from seedsigner.views.tools_views import ToolsCalcFinalWordShowFinalWordView + return Destination(ToolsCalcFinalWordShowFinalWordView) + """ + + if self.cur_word_index < self.controller.storage.pending_mnemonic_length - 1: + return Destination( + SeedShamirShareMnemonicEntryView, + view_args={ + "cur_word_index": self.cur_word_index + 1, + "is_calc_final_word": self.is_calc_final_word, + "cur_set_index": self.cur_set_index + } + ) + + else: + self.controller.storage.update_pending_shamir_share_set(self.cur_set_index) + + if self.cur_set_index == self.controller.storage.pending_shamir_share_set_length - 1: + # Attempt to finalize the shamir share set + try: + self.controller.storage.convert_pending_shamir_share_set_to_pending_seed() + except ValueError: + return Destination(SeedShamirShareInvalidView) + + if self.cur_set_index < self.controller.storage.pending_shamir_share_set_length - 1: + return Destination( + SeedShamirShareMnemonicEntryView, + view_args={ + "cur_set_index": self.cur_set_index + 1 + } + ) + + return Destination(SeedFinalizeView) + + + +class SeedShamirShareInvalidView(View): + EDIT = "Review & Edit" + DISCARD = ("Discard", None, None, "red") + + def __init__(self): + super().__init__() + self.mnemonic: List[str] = self.controller.storage.pending_mnemonic + + + def run(self): + button_data = [self.EDIT, self.DISCARD] + selected_menu_num = self.run_screen( + WarningScreen, + title="Invalid Shamir Share!", + status_headline=None, + text=f"Checksum failure; not a valid Shamir share.", + show_back_button=False, + button_data=button_data, + ) + + if button_data[selected_menu_num] == self.EDIT: + return Destination(SeedShamirShareMnemonicEntryView, view_args={"cur_word_index": 0}) + + elif button_data[selected_menu_num] == self.DISCARD: + self.controller.storage.discard_pending_mnemonic() + return Destination(MainMenuView) \ No newline at end of file From 7b0f393d47b6ed64b8999d93da0a37a1eb60ecae Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Sat, 14 Dec 2024 12:54:28 +0100 Subject: [PATCH 03/10] New screens for SSS --- src/seedsigner/gui/screens/seed_screens.py | 187 +++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 64fb7ea9..e2ef1582 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1576,3 +1576,190 @@ def __post_init__(self): screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING, ) self.components.append(address_display) + + + +@dataclass +class NumericEntryScreen(BaseTopNavScreen): + title: str = "" + entered_number: str = "" + + def __post_init__(self): + super().__post_init__() + + keys_number = "0123456789" + + # Set up the keyboard params + self.right_panel_buttons_width = 56 + + text_entry_display_y = self.top_nav.height + text_entry_display_height = 30 + + keyboard_start_y = text_entry_display_y + text_entry_display_height + GUIConstants.COMPONENT_PADDING + + self.keyboard_digits = Keyboard( + draw=self.renderer.draw, + charset=keys_number, + rows=3, + cols=5, + rect=( + GUIConstants.COMPONENT_PADDING, + keyboard_start_y, + self.canvas_width - GUIConstants.COMPONENT_PADDING - self.right_panel_buttons_width, + self.canvas_height - GUIConstants.EDGE_PADDING + ), + additional_keys=[ + Keyboard.KEY_CURSOR_LEFT, + Keyboard.KEY_CURSOR_RIGHT, + Keyboard.KEY_BACKSPACE + ], + auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT] + ) + + self.text_entry_display = TextEntryDisplay( + canvas=self.renderer.canvas, + rect=( + GUIConstants.EDGE_PADDING, + text_entry_display_y, + self.canvas_width - self.right_panel_buttons_width, + text_entry_display_y + text_entry_display_height + ), + cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR, + is_centered=False, + cur_text=''.join(self.entered_number) + ) + + # Nudge the buttons off the right edge w/padding + hw_button_x = self.canvas_width - self.right_panel_buttons_width + GUIConstants.COMPONENT_PADDING + + # Calc center button position first + hw_button_y = int((self.canvas_height - GUIConstants.BUTTON_HEIGHT)/2) + + self.hw_button3 = IconButton( + icon_name=SeedSignerIconConstants.CHECK, + icon_color=GUIConstants.SUCCESS_COLOR, + width=self.right_panel_buttons_width, + screen_x=hw_button_x, + screen_y=hw_button_y + 3*GUIConstants.COMPONENT_PADDING + GUIConstants.BUTTON_HEIGHT, + ) + + + def _render(self): + super()._render() + + self.text_entry_display.render() + self.hw_button3.render() + self.keyboard_digits.render_keys() + + self.renderer.show_image() + + def _run(self): + cursor_position = len(self.entered_number) + + cur_keyboard = self.keyboard_digits + + # Start the interactive update loop + while True: + input = self.hw_inputs.wait_for( + HardwareButtonsConstants.ALL_KEYS, + check_release=True, + release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY1, HardwareButtonsConstants.KEY2, HardwareButtonsConstants.KEY3] + ) + + keyboard_swap = False + + # Check our two possible exit conditions + # TODO: note the unusual return value, consider refactoring to a Response object in the future + if input == HardwareButtonsConstants.KEY3: + # Save! + # First light up key3 + self.hw_button3.is_selected = True + self.hw_button3.render() + self.renderer.show_image() + return dict(entered_number=self.entered_number) + + elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected: + # Back button clicked + return dict(entered_number=self.entered_number, is_back_button=True) + + + + + # Process normal input + if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected: + # We're navigating off the previous button + self.top_nav.is_selected = False + self.top_nav.render_buttons() + + # Override the actual input w/an ENTER signal for the Keyboard + if input == HardwareButtonsConstants.KEY_DOWN: + input = Keyboard.ENTER_TOP + else: + input = Keyboard.ENTER_BOTTOM + elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected: + # ignore + continue + + ret_val = cur_keyboard.update_from_input(input) + + # Now process the result from the keyboard + if ret_val in Keyboard.EXIT_DIRECTIONS: + self.top_nav.is_selected = True + self.top_nav.render_buttons() + + elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS: + if ret_val == Keyboard.KEY_BACKSPACE["code"]: + if cursor_position == 0: + pass + elif cursor_position == len(self.entered_number): + self.entered_number = self.entered_number[:-1] + else: + self.entered_number = self.entered_number[:cursor_position - 1] + self.entered_number[cursor_position:] + + cursor_position -= 1 + + elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]: + cursor_position -= 1 + if cursor_position < 0: + cursor_position = 0 + + elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]: + cursor_position += 1 + if cursor_position > len(self.entered_number): + cursor_position = len(self.entered_number) + + elif ret_val == Keyboard.KEY_SPACE["code"]: + if cursor_position == len(self.entered_number): + self.entered_number += " " + else: + self.entered_number = self.entered_number[:cursor_position] + " " + self.entered_number[cursor_position:] + cursor_position += 1 + + # Update the text entry display and cursor + self.text_entry_display.render(self.entered_number, cursor_position) + + elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS: + # User has locked in the current letter + if cursor_position == len(self.entered_number): + self.entered_number += ret_val + else: + self.entered_number = self.entered_number[:cursor_position] + ret_val + self.entered_number[cursor_position:] + cursor_position += 1 + + # Update the text entry display and cursor + self.text_entry_display.render(self.entered_number, cursor_position) + + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN or keyboard_swap: + # Live joystick movement; haven't locked this new letter in yet. + # Leave current spot blank for now. Only update the active keyboard keys + # when a selection has been locked in (KEY_PRESS) or removed ("del"). + pass + + + self.renderer.show_image() + +class SeedEntryShamirThresholdScreen(NumericEntryScreen): + title: str = "SLIP-39 Threshold" + +class SeedEntryShamirShareCountScreen(NumericEntryScreen): + title: str = "SLIP-39 Share Count" From 2ae57387e82bbd2e71b3306eee42b376431b3a4e Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:38:11 +0100 Subject: [PATCH 04/10] Shamir share import flow, screens and settings --- src/seedsigner/gui/screens/seed_screens.py | 30 ++ src/seedsigner/models/seed_storage.py | 7 +- src/seedsigner/models/settings_definition.py | 9 + src/seedsigner/views/seed_views.py | 276 ++++++------------- 4 files changed, 123 insertions(+), 199 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index e2ef1582..93216b8f 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1758,8 +1758,38 @@ def _run(self): self.renderer.show_image() + + class SeedEntryShamirThresholdScreen(NumericEntryScreen): title: str = "SLIP-39 Threshold" + + class SeedEntryShamirShareCountScreen(NumericEntryScreen): title: str = "SLIP-39 Share Count" + + + +@dataclass +class ShamirFinalizeScreen(ButtonListScreen): + value_text: str = None + title: str = "Finalize Shamir Share" + is_bottom_list: bool = True + button_data: list = None + + def __post_init__(self): + self.show_back_button = False + + super().__post_init__() + + self.fingerprint_icontl = IconTextLine( + icon_name=SeedSignerIconConstants.FINGERPRINT, + icon_color=GUIConstants.INFO_COLOR, + icon_size=GUIConstants.ICON_FONT_SIZE + 12, + label_text="SLIP-39", + value_text=self.value_text, + font_size=GUIConstants.BODY_FONT_SIZE + 2, + is_text_centered=True, + screen_y=self.top_nav.height + int((self.buttons[0].screen_y - self.top_nav.height) / 2) - 30 + ) + self.components.append(self.fingerprint_icontl) \ No newline at end of file diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index 967902b1..295e90cc 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -143,9 +143,10 @@ def discard_pending_shamir_share_set(self): self._pending_shamir_share_set = [] - def convert_pending_shamir_share_set_to_pending_seed(self, passphrase: str = ''): + def convert_pending_shamir_share_set_to_pending_seed(self, passphrase: str = '', clean: bool = True): share_set_formatted = [" ".join(share) for share in self._pending_shamir_share_set] self.pending_seed = Seed.recover_from_shares(share_set_formatted, passphrase) - self.discard_pending_mnemonic() - self.discard_pending_shamir_share_set() + if clean: + self.discard_pending_mnemonic() + self.discard_pending_shamir_share_set() \ No newline at end of file diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 089a94b2..0cf746c6 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -163,6 +163,7 @@ def map_network_to_embit(cls, network) -> str: SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds" SETTING__ELECTRUM_SEEDS = "electrum_seeds" SETTING__MESSAGE_SIGNING = "message_signing" + SETTING__SSS = "sss_seeds" SETTING__PRIVACY_WARNINGS = "privacy_warnings" SETTING__DIRE_WARNINGS = "dire_warnings" SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips" @@ -485,6 +486,14 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__SSS, + abbreviated_name="sss", + display_name="Shamir's Secret Sharing", + help_text="Shares import only", + visibility=SettingsConstants.VISIBILITY__ADVANCED, + default_value=SettingsConstants.OPTION__DISABLED), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS, abbreviated_name="priv_warn", diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 6ea966c5..4a308061 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -171,8 +171,7 @@ class LoadSeedView(View): SEED_QR = (" Scan a SeedQR", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_SSS_12WORD = ("Recover 12-word Shamir Share", FontAwesomeIconConstants.KEYBOARD) - TYPE_SSS_24WORD = ("Recover 24-word Shamir Share", FontAwesomeIconConstants.KEYBOARD) + TYPE_SSS = ("Recover from SSS", FontAwesomeIconConstants.KEYBOARD) TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD) CREATE = (" Create a seed", SeedSignerIconConstants.PLUS) @@ -183,11 +182,11 @@ def run(self): self.TYPE_24WORD, ] - button_data.append(self.TYPE_SSS_12WORD) - button_data.append(self.TYPE_SSS_24WORD) - if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED: button_data.append(self.TYPE_ELECTRUM) + + if self.settings.get_value(SettingsConstants.SETTING__SSS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.TYPE_SSS) button_data.append(self.CREATE) @@ -213,13 +212,8 @@ def run(self): self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == self.TYPE_SSS_12WORD: - self.controller.storage.init_pending_shamir_share_set(num_words=20, num_shares=2) - return Destination(SeedShamirShareMnemonicEntryView) - - elif button_data[selected_menu_num] == self.TYPE_SSS_24WORD: - self.controller.storage.init_pending_shamir_share_set(num_words=33, num_shares=2) - return Destination(SeedShamirShareMnemonicEntryView) + elif button_data[selected_menu_num] == self.TYPE_SSS: + return Destination(SeedEntryShamirThresholdView) elif button_data[selected_menu_num] == self.TYPE_ELECTRUM: return Destination(SeedElectrumMnemonicStartView) @@ -2200,37 +2194,42 @@ def run(self): return Destination(MainMenuView, skip_current_view=True) + """**************************************************************************** Shamir's Secret Sharing Views ****************************************************************************""" - class SeedEntryShamirThresholdView(View): - def __init__(self, seed_num: int): + def __init__(self, mode: int = 0, seed_num: int = -1): super().__init__() self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) + if mode == 1: + # Export mode (TODO) + self.seed = self.controller.get_seed(self.seed_num) + self.mode = mode def run(self): title="SLIP-39 Threshold" ret_dict = self.run_screen(seed_screens.SeedEntryShamirThresholdScreen, entered_number="", title=title) - # The new passphrase will be the return value; it might be empty. - #self.seed.set_passphrase(ret_dict["passphrase"]) - print(ret_dict["entered_number"]) + #print(ret_dict["entered_number"]) if "is_back_button" in ret_dict: return Destination(BackStackView) elif ret_dict["entered_number"] != "": - return Destination(SeedEntryShamirShareCountView, view_args={"seed_num": self.seed_num, "k": int(ret_dict["entered_number"])}) - + if self.mode == 1: + return Destination(SeedEntryShamirShareCountView, view_args={"k": int(ret_dict["entered_number"]), "seed_num": self.seed_num}) + else: + return Destination(SeedShamirShareImportSelectWordCount, view_args={"k": int(ret_dict["entered_number"])}) + else: return Destination(BackStackView) + class SeedEntryShamirShareCountView(View): - def __init__(self, seed_num: int, k: int): + def __init__(self, k: int, seed_num: int): super().__init__() self.seed_num = seed_num self.seed = self.controller.get_seed(self.seed_num) @@ -2249,10 +2248,10 @@ def run(self): return Destination(BackStackView) elif ret_dict["entered_number"] != "": - #print(self.seed.generate_shares(self.k, int(ret_dict["entered_number"]))) - #return Destination(MainMenuView, view_args={"seed_num": self.seed_num, "k": ret_dict["entered_number"]}) - return Destination(SeedShamirShareFinalizeView, view_args={"seed_num": self.seed_num, "k": self.k, "n": int(ret_dict["entered_number"])}) - + # What if == 0? or == 1? TODO + # Should take to SeedShamirShareFinalizeView + return Destination(NotYetImplementedView) + else: return Destination(BackStackView) @@ -2260,217 +2259,102 @@ def run(self): class SeedShamirShareFinalizeView(View): FINALIZE = "Done" + PASSPHRASE = "SLIP-39 Passphrase" - def __init__(self, seed_num: int, k: int, n: int): + def __init__(self, k: int, n: int = 0, mode: int = 0, seed_num: int = -1): super().__init__() + self.mode = mode self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) + if mode == 1: + self.seed = self.controller.get_seed(self.seed_num) + self.shamir_set_index = self.seed.shamir_share_set_count self.k = k self.n = n - self.shamir_set_index = self.seed.shamir_share_set_count def run(self): - button_data = [self.FINALIZE] - passphrase_button = self.seed.slip39_passphrase_label - #if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - # button_data.append(passphrase_button) - button_data.append(passphrase_button) + button_data = [self.FINALIZE, self.PASSPHRASE] selected_menu_num = self.run_screen( seed_screens.ShamirFinalizeScreen, - shamir_set_index=self.shamir_set_index, + value_text=self.PASSPHRASE if self.mode == 0 else f"Shamir Share #{self.shamir_set_index}", button_data=button_data, ) - if button_data[selected_menu_num] == self.FINALIZE: + if self.mode == 1 and button_data[selected_menu_num] == self.FINALIZE: self.seed.generate_shares(self.k, self.n) - return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) - - elif button_data[selected_menu_num] == passphrase_button: - return Destination(SeedAddSlip39PassphraseView, view_args={"seed_num": self.seed_num, "k": self.k, "n": self.n}) - - - -class SeedAddSlip39PassphraseView(View): - def __init__(self, seed_num: int, k: int, n: int): - super().__init__() - self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) - self.k = k - self.n = n - self.shamir_set_index = self.seed.shamir_share_set_count - - - def run(self): - passphrase_title=self.seed.slip39_passphrase_label - ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase="", title=passphrase_title) + #return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) + return Destination(NotYetImplementedView) - # The new passphrase will be the return value; it might be empty. - #self.seed.set_passphrase(ret_dict["passphrase"]) - print(ret_dict["passphrase"]) + elif self.mode == 1 and button_data[selected_menu_num] == self.PASSPHRASE: + return Destination(SeedAddSlip39PassphraseView, view_args={"seed_num": self.seed_num, "k": self.k, "n": self.n, "mode": self.mode}) - if "is_back_button" in ret_dict: - return Destination(BackStackView) - - else: - self.seed.generate_shares(self.k, self.n, ret_dict["passphrase"]) - return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) - + if self.mode == 0 and button_data[selected_menu_num] == self.FINALIZE: + self.controller.storage.convert_pending_shamir_share_set_to_pending_seed() + return Destination(SeedFinalizeView) + elif self.mode == 0 and button_data[selected_menu_num] == self.PASSPHRASE: + return Destination(SeedAddSlip39PassphraseView, view_args={"k": self.k}) -class SeedShamirShareListView(View): +class SeedShamirShareImportSelectWordCount(View): + TYPE_12WORD = "12 words" + TYPE_24WORD = "24 words" - def __init__(self, seed_num): + def __init__(self, k: int): super().__init__() - self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) - - - def run(self): - button_data = [f"Shamir Share Set #{i}" for i in range(self.seed.shamir_share_set_count)] - - selected_menu_num = self.run_screen( - ButtonListScreen, - title="Shamir Shares", - button_data=button_data, - ) - - if selected_menu_num == RET_CODE__BACK_BUTTON: - # Force BACK to always return to the SeedOptionsView - return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True) - - elif selected_menu_num < self.seed.shamir_share_set_count: - return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": selected_menu_num}) - - + self.k = k -class SeedShamirShareOptionsView(View): - VIEW_WORDS = "View Shares Words" - EXPORT_SEEDQR = "Export Shares as SeedQR" - - def __init__(self, seed_num, shamir_set_index): - super().__init__() - self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) - self.shamir_set_index = shamir_set_index - def run(self): - button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] + button_data = [self.TYPE_12WORD, self.TYPE_24WORD] selected_menu_num = self.run_screen( ButtonListScreen, - title=f"Backup SSS #{self.shamir_set_index}", + title="Select Seed Word Count", button_data=button_data, ) - if selected_menu_num == RET_CODE__BACK_BUTTON: - # Force BACK to always return to the SeedShamirShareListView - return Destination(SeedShamirShareListView, view_args={"seed_num": self.seed_num}) - - elif button_data[selected_menu_num] == self.VIEW_WORDS: - #return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode":0}) - - elif button_data[selected_menu_num] == self.EXPORT_SEEDQR: - #return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) - return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode":1}) + if button_data[selected_menu_num] == self.TYPE_12WORD: + self.controller.storage.init_pending_shamir_share_set(num_words=20, num_shares=self.k) + return Destination(SeedShamirShareMnemonicEntryView) + + elif button_data[selected_menu_num] == self.TYPE_24WORD: + self.controller.storage.init_pending_shamir_share_set(num_words=33, num_shares=self.k) + return Destination(SeedShamirShareMnemonicEntryView) -class SeedShamirShareSelectView(View): - VIEW_WORDS = "View Shares Words" - EXPORT_SEEDQR = "Export Shares as SeedQR" - def __init__(self, seed_num, shamir_set_index, mode): +class SeedAddSlip39PassphraseView(View): + def __init__(self, k: int, n: int = 0, mode: int = 0, seed_num: int = -1): super().__init__() - self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) - self.shamir_set_index = shamir_set_index - self.share_set = self.seed.get_shamir_share_data(shamir_set_index) self.mode = mode - - - def run(self): - button_data = [f"Share #{i}" for i in range(len(self.share_set["share_list"]))] - - selected_menu_num = self.run_screen( - ButtonListScreen, - title="Select Share", - button_data=button_data, - ) - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) - - elif selected_menu_num < len(self.share_set["share_list"]) and self.mode == 0: - return Destination(SeedShamirShareWordsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "share_index": selected_menu_num}) - - elif selected_menu_num < len(self.share_set["share_list"]) and self.mode == 1: - # QR Export for shamir share not yet implemented - return Destination(NotYetImplementedView) - - -class SeedShamirShareWordsView(View): - def __init__(self, seed_num, shamir_set_index, share_index, page_index: int = 0): - super().__init__() self.seed_num = seed_num - self.seed = self.controller.get_seed(self.seed_num) - self.shamir_set_index = shamir_set_index - self.share_set = self.seed.get_shamir_share_data(shamir_set_index) - self.share_index = share_index - self.page_index = page_index - - def run(self): - NEXT = "Next" - DONE = "Done" - - # Slice the mnemonic to our current 4-word section - words_per_page = 4 # TODO: eventually make this configurable for bigger screens? + if mode == 1: + self.seed = self.controller.get_seed(self.seed_num) + self.shamir_set_index = self.seed.shamir_share_set_count + self.k = k + self.n = n - - mnemonic = self.seed.get_share_slip39_display_list(self.shamir_set_index, self.share_index) - title = f"Share #{self.share_index}" - words = mnemonic[self.page_index*words_per_page:(self.page_index + 1)*words_per_page] - button_data = [] - # Modified calculation so all words are shown when having 33 words - num_pages = (len(mnemonic) + words_per_page - 1) // 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) + def run(self): + passphrase_title=self.seed.slip39_passphrase_label + ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase="", title=passphrase_title) - selected_menu_num = seed_screens.SeedWordsScreen( - title=f"{title}: {self.page_index+1}/{num_pages}", - words=words, - page_index=self.page_index, - num_pages=num_pages, - button_data=button_data, - ).display() + # The new passphrase will be the return value; it might be empty. + #self.seed.set_passphrase(ret_dict["passphrase"]) + print(ret_dict["passphrase"]) - if selected_menu_num == RET_CODE__BACK_BUTTON: + if "is_back_button" in ret_dict: return Destination(BackStackView) - - if button_data[selected_menu_num] == NEXT: - if self.seed_num is None and self.page_index == num_pages - 1: - """ - return Destination( - SeedWordsBackupTestPromptView, - view_args=dict(seed_num=self.seed_num), - ) - """ - return Destination(MainMenuView) + + else: + if self.mode == 1: + self.seed.generate_shares(self.k, self.n, ret_dict["passphrase"]) + #return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) + return Destination(NotYetImplementedView) else: - return Destination( - SeedShamirShareWordsView, - view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "share_index": self.share_index, "page_index": self.page_index + 1} - ) - - elif button_data[selected_menu_num] == DONE: - # Must clear history to avoid BACK button returning to private info - return Destination(SeedShamirShareSelectView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index, "mode": 0}) - + self.controller.storage.convert_pending_shamir_share_set_to_pending_seed(passphrase=ret_dict["passphrase"]) + return Destination(SeedFinalizeView) class SeedShamirShareMnemonicEntryView(View): @@ -2530,7 +2414,7 @@ def run(self): if self.cur_set_index == self.controller.storage.pending_shamir_share_set_length - 1: # Attempt to finalize the shamir share set try: - self.controller.storage.convert_pending_shamir_share_set_to_pending_seed() + self.controller.storage.convert_pending_shamir_share_set_to_pending_seed(clean=False) except ValueError: return Destination(SeedShamirShareInvalidView) @@ -2542,7 +2426,7 @@ def run(self): } ) - return Destination(SeedFinalizeView) + return Destination(SeedShamirShareFinalizeView, view_args={"k": self.controller.storage.pending_shamir_share_set_length}) From a66d7765fd3c38b363451530a09df49575ba15fd Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:38:34 +0100 Subject: [PATCH 05/10] pytest update for Shamir shares --- tests/test_flows_seed.py | 89 ++++++++++++++++++++++++++++++++++++++++ tests/test_seed.py | 11 +++++ 2 files changed, 100 insertions(+) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index f6ef501d..8820377b 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -664,3 +664,92 @@ def expect_unsupported_derivation(load_message: Callable): self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET) expect_unsupported_derivation(self.load_custom_derivation_into_decoder) + +class TestShamirShareImportFlows(FlowTest): + def test_mnemonic_entry_flow(self): + """ + Manually importing a mnemonic from a Shamir shared secret should land at + the Finalize Seed flow and end at the SeedOptionsView. + """ + + def test_with_mnemonic(mnemonic): + # Ensure SSS is enabled + self.settings.set_value(SettingsConstants.SETTING__SSS, SettingsConstants.OPTION__ENABLED) + + if len(mnemonic) % 20 == 0: + threshold_k = int(len(mnemonic) / 20) + + elif len(mnemonic) % 33 == 0: + threshold_k = int(len(mnemonic) / 33) + + Settings.HOSTNAME = "not seedsigner-os" + 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_SSS), + FlowStep(seed_views.SeedEntryShamirThresholdView, screen_return_value=dict(entered_number=str(threshold_k))), + FlowStep(seed_views.SeedShamirShareImportSelectWordCount, button_data_selection=seed_views.SeedShamirShareImportSelectWordCount.TYPE_12WORD if len(mnemonic) % 20 == 0 else seed_views.SeedShamirShareImportSelectWordCount.TYPE_24WORD), + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep(seed_views.SeedShamirShareMnemonicEntryView, screen_return_value=word) + ) + + # With the mnemonic completely entered, we land on the SeedFinalizeView + sequence += [ + FlowStep(seed_views.SeedShamirShareFinalizeView, button_data_selection=seed_views.SeedShamirShareFinalizeView.FINALIZE), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView), + ] + + self.run_sequence(sequence) + + # Test data from iancoleman.io; 12- and 24-word mnemonic + test_with_mnemonic("yield upgrade acrobat leader briefing capacity again epidemic minister frozen impulse math guilt lily install market modify envelope index become yield upgrade beard leader ceramic total morning critical brother slap lungs medical dilemma expect olympic jacket ruin airline promise literary".split()) + + BaseTest.reset_controller() + + + def test_invalid_mnemonic(self): + # Ensure SSS is enabled + self.settings.set_value(SettingsConstants.SETTING__SSS, SettingsConstants.OPTION__ENABLED) + + # Should be able to go back and edit or discard an invalid mnemonic + # Test data from iancoleman.io + mnemonic = "yield upgrade acrobat leader briefing capacity again epidemic minister frozen impulse math guilt lily install market modify envelope index become yield upgrade beard leader ceramic total morning critical brother slap lungs medical dilemma expect olympic jacket ruin airline promise literary".split() + + if len(mnemonic) % 20 == 0: + threshold_k = int(len(mnemonic) / 20) + + elif len(mnemonic) % 33 == 0: + threshold_k = int(len(mnemonic) / 33) + + 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_SSS), + FlowStep(seed_views.SeedEntryShamirThresholdView, screen_return_value=dict(entered_number=str(threshold_k))), + FlowStep(seed_views.SeedShamirShareImportSelectWordCount, button_data_selection=seed_views.SeedShamirShareImportSelectWordCount.TYPE_12WORD if len(mnemonic) % 20 == 0 else seed_views.SeedShamirShareImportSelectWordCount.TYPE_24WORD), + ] + for word in mnemonic[:-1]: + sequence.append(FlowStep(seed_views.SeedShamirShareMnemonicEntryView, screen_return_value=word)) + + sequence += [ + FlowStep(seed_views.SeedShamirShareMnemonicEntryView, screen_return_value="gravity"), # But finish with an INVALID checksum word + FlowStep(seed_views.SeedShamirShareInvalidView, button_data_selection=seed_views.SeedShamirShareInvalidView.EDIT), + ] + + # Restarts from first word + for word in mnemonic[:-1]: + sequence.append(FlowStep(seed_views.SeedShamirShareMnemonicEntryView, screen_return_value=word)) + + sequence += [ + FlowStep(seed_views.SeedShamirShareMnemonicEntryView, screen_return_value="goat"), # provide yet another invalid checksum word + FlowStep(seed_views.SeedShamirShareInvalidView, button_data_selection=seed_views.SeedShamirShareInvalidView.DISCARD), + FlowStep(MainMenuView), + ] + + self.run_sequence(sequence) + \ No newline at end of file diff --git a/tests/test_seed.py b/tests/test_seed.py index 4dd8af93..9bf5e4cb 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -83,3 +83,14 @@ def test_electrum_seed_rejects_most_bip39_mnemonics(): mnemonic = "only gain spot output unknown craft simple cram absorb suggest ridge famous".split() Seed(mnemonic) ElectrumSeed(mnemonic) + + +def test_shamir_share_seed(): + share_set_formatted = [ "yield upgrade acrobat leader briefing capacity again epidemic minister frozen impulse math guilt lily install market modify envelope index become", + "yield upgrade beard leader ceramic total morning critical brother slap lungs medical dilemma expect olympic jacket ruin airline promise literary"] + + seed_sss = Seed.recover_from_shares(share_set_formatted, slip39_passphrase="") + + seed_regular = Seed(mnemonic="once diamond onion six visa social chair long solve draw stool witness".split()) + + assert seed_sss.seed_bytes == seed_regular.seed_bytes \ No newline at end of file From ced477a3042019aab9929de2b53859474163ffcd Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Thu, 19 Dec 2024 18:05:14 +0100 Subject: [PATCH 06/10] Cleanup --- src/seedsigner/models/seed.py | 29 +---------------------------- src/seedsigner/views/seed_views.py | 4 ++-- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index da378376..49dd5aa5 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -161,13 +161,7 @@ def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, netwo return bip85.derive_mnemonic(root, bip85_num_words, bip85_index) - ### Shamir's Secret Sharing - - def generate_shares(self, k, n, slip39_passphrase: str = ""): - share_set = slip39.ShareSet.generate_shares(self.mnemonic_str, k, n, slip39_passphrase.encode('utf-8')) - self._shamir_share_sets.append((k, n, share_set, slip39_passphrase)) - return share_set - + # Shamir's Secret Sharing @classmethod def recover_from_shares(cls, shares: list[str], slip39_passphrase: str = ""): @@ -192,29 +186,8 @@ def slip39_wordlist(self) -> List[str]: def slip39_passphrase_label(self) -> str: #return SettingsConstants.LABEL__BIP39_PASSPHRASE return "SLIP-39 Passphrase" - - - @property - def shamir_share_set_count(self) -> int: - #return SettingsConstants.LABEL__BIP39_PASSPHRASE - return len(self._shamir_share_sets) - - - def get_shamir_share_data(self, idx: int) -> dict: - return {"k": self._shamir_share_sets[idx][0], - "n": self._shamir_share_sets[idx][1], - "share_list": self._shamir_share_sets[idx][2], - "passphrase": self._shamir_share_sets[idx][3]} - - - def get_share_slip39_display_list(self, share_set_idx, share_idx) -> List[str]: - return unicodedata.normalize("NFC", self._shamir_share_sets[share_set_idx][2][share_idx]).split() - def get_share_slip39_list(self, share_set_idx, share_idx) -> List[str]: - return self._shamir_share_sets[share_set_idx][2][share_idx].split() - - ### override operators def __eq__(self, other): if isinstance(other, Seed): diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 4a308061..1e400144 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -2203,7 +2203,7 @@ def __init__(self, mode: int = 0, seed_num: int = -1): super().__init__() self.seed_num = seed_num if mode == 1: - # Export mode (TODO) + # TODO: Export mode self.seed = self.controller.get_seed(self.seed_num) self.mode = mode @@ -2248,7 +2248,7 @@ def run(self): return Destination(BackStackView) elif ret_dict["entered_number"] != "": - # What if == 0? or == 1? TODO + # What if == 0? TODO # Should take to SeedShamirShareFinalizeView return Destination(NotYetImplementedView) From 0e1b4954702f63ca131e35eab34bcb8fc02a90d2 Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Thu, 19 Dec 2024 18:05:32 +0100 Subject: [PATCH 07/10] Test update and screenshot generator --- tests/screenshot_generator/generator.py | 7 +++++ tests/test_seed.py | 40 ++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 4c0c092a..5090893c 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -227,6 +227,13 @@ def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes): seed_views.SeedSignMessageConfirmAddressView, seed_views.SeedElectrumMnemonicStartView, + + seed_views.SeedEntryShamirThresholdView, + (seed_views.SeedShamirShareImportSelectWordCount, dict(k=2)), + seed_views.SeedShamirShareMnemonicEntryView, + (seed_views.SeedShamirShareFinalizeView, dict(k=2)), + seed_views.SeedShamirShareInvalidView, + ], "PSBT Views": [ psbt_views.PSBTSelectSeedView, # this will fail, be rerun below diff --git a/tests/test_seed.py b/tests/test_seed.py index 9bf5e4cb..84086e26 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -85,7 +85,8 @@ def test_electrum_seed_rejects_most_bip39_mnemonics(): ElectrumSeed(mnemonic) -def test_shamir_share_seed(): +def test_shamir_share_import_seed(): + # 12-word mnemonic, no passphrase share_set_formatted = [ "yield upgrade acrobat leader briefing capacity again epidemic minister frozen impulse math guilt lily install market modify envelope index become", "yield upgrade beard leader ceramic total morning critical brother slap lungs medical dilemma expect olympic jacket ruin airline promise literary"] @@ -93,4 +94,41 @@ def test_shamir_share_seed(): seed_regular = Seed(mnemonic="once diamond onion six visa social chair long solve draw stool witness".split()) + assert seed_sss.seed_bytes == seed_regular.seed_bytes + + # 12-word mnemonic, with passphrase + share_set_formatted = [ "window lunch ceramic leader cover satisfy emerald obesity impact purple gravity plains gasoline example cluster deadline license golden window teaspoon", + "window lunch beard leader civil burden that extend husband oven forget husband identify arena furl diploma focus unwrap belong artwork"] + + seed_sss = Seed.recover_from_shares(share_set_formatted, slip39_passphrase="mupassphrase") + + seed_regular = Seed(mnemonic="cliff space wide equip pretty museum busy goddess camp embark matrix soldier".split()) + + assert seed_sss.seed_bytes == seed_regular.seed_bytes + + # 24-word mnemonic, no passphrase + share_set_formatted = ['slush flea agency academic angel lobe flea library writing clogs cards liberty river fiction therapy peasant uncover lend extend herald vampire seafood smug method syndrome grin moisture aunt aviation expand orange western froth', + 'slush flea birthday academic angry hazard vampire tendency violence club vexed ocean energy material station mixture thumb submit process strategy lungs numerous unhappy location grasp both presence member grasp picture owner darkness saver', + 'slush flea cleanup academic armed coal scroll fangs capture fused adorn argue military cylinder dismiss general forbid pleasure glimpse wavy award trouble belong spider fiber wisdom image fatigue surface favorite wolf spelling distance', + 'slush flea desert academic avoid nuclear sympathy scene remind shaft budget taste window engage easel ranked fragment scout retailer express browser music friendly pharmacy husky explain lawsuit chew smear camera unfair belong modern', + 'slush flea email academic arcade emission forward short adequate location disease fitness paper syndrome coding knit random order railroad emerald canyon thorn adjust ceiling knife false kidney gums mountain disease software flame famous'] + + + seed_sss = Seed.recover_from_shares(share_set_formatted, slip39_passphrase="") + + seed_regular = Seed(mnemonic="volume lock pulse large evoke body diet borrow food promote divide track wall february virtual brick hood diary work delay torch upon truth spray".split()) + + assert seed_sss.seed_bytes == seed_regular.seed_bytes + + # 24-word mnemonic, with passphrase + share_set_formatted = ['blessing leader agency academic alien valid husky inherit duckling favorite angel skin hazard response peanut process spew treat breathe boring sweater either valid dismiss herd program increase typical chest pumps legal tension acrobat', + 'blessing leader birthday academic amount escape physics leaf dining furl being domain editor glasses finance chest argue garden satoshi wolf marathon elite salt salary clock military review ting security length trust apart system', + 'blessing leader cleanup academic angry depend costume work leaf believe general museum alto spew greatest favorite cowboy slow endorse beam patrol intimate source canyon actress body ceramic silent kitchen camera genre deadline iris', + 'blessing leader desert academic aluminum fake iris permit sympathy genre security flavor species upgrade pajamas shaft kidney sister clogs mobile thorn gross marathon penalty dismiss guilt modern provide fatal shelter railroad require permit', + 'blessing leader email academic anxiety blessing diet slim military listen smith carbon artwork bike salt purchase unhappy observe burden rebuild imply dismiss slow penalty award receiver industry peanut squeeze husband armed evaluate drink'] + + seed_sss = Seed.recover_from_shares(share_set_formatted, slip39_passphrase="mupassphrase") + + seed_regular = Seed(mnemonic="butter hole monkey orphan strong split predict song desk oak spirit myth bomb wise same build already vacant damp gallery found praise bread melody".split()) + assert seed_sss.seed_bytes == seed_regular.seed_bytes \ No newline at end of file From d93d225be6af17f30eeefc21517af8b54557e3cb Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Thu, 19 Dec 2024 18:48:33 +0100 Subject: [PATCH 08/10] solves passphrase bug --- src/seedsigner/views/seed_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 1e400144..75942f35 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -2337,7 +2337,7 @@ def __init__(self, k: int, n: int = 0, mode: int = 0, seed_num: int = -1): def run(self): - passphrase_title=self.seed.slip39_passphrase_label + passphrase_title = "SLIP-39 Passphrase" ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase="", title=passphrase_title) # The new passphrase will be the return value; it might be empty. @@ -2349,7 +2349,8 @@ def run(self): else: if self.mode == 1: - self.seed.generate_shares(self.k, self.n, ret_dict["passphrase"]) + # TODO: export shares + #self.seed.generate_shares(self.k, self.n, ret_dict["passphrase"]) #return Destination(SeedShamirShareOptionsView, view_args={"seed_num": self.seed_num, "shamir_set_index": self.shamir_set_index}) return Destination(NotYetImplementedView) else: From b9594a9441bdfb7b992be0dd94526531c364b060 Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Sat, 28 Dec 2024 15:32:08 +0100 Subject: [PATCH 09/10] New text format in SSS related views --- src/seedsigner/views/seed_views.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 210c711c..1d358786 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -2292,7 +2292,7 @@ def __init__(self, mode: int = 0, seed_num: int = -1): def run(self): - title = "SLIP-39 Threshold" + title = _("SLIP-39 Threshold") ret_dict = self.run_screen(seed_screens.SeedEntryShamirThresholdScreen, entered_number="", title=title) #print(ret_dict["entered_number"]) @@ -2320,7 +2320,7 @@ def __init__(self, k: int, seed_num: int): def run(self): - title = "SLIP-39 Share Count" + title = _("SLIP-39 Share Count") ret_dict = self.run_screen(seed_screens.SeedEntryShamirShareCountScreen, entered_number="", title=title) # The new passphrase will be the return value; it might be empty. @@ -2360,7 +2360,7 @@ def run(self): selected_menu_num = self.run_screen( seed_screens.ShamirFinalizeScreen, - value_text=self.PASSPHRASE if self.mode == 0 else f"Shamir Share #{self.shamir_set_index}", + value_text=self.PASSPHRASE if self.mode == 0 else _("Shamir Share #{}").format(self.shamir_set_index), button_data=button_data, ) @@ -2393,7 +2393,7 @@ def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title="Select Seed Word Count", + title=_("Select Seed Word Count"), button_data=button_data, ) @@ -2420,7 +2420,7 @@ def __init__(self, k: int, n: int = 0, mode: int = 0, seed_num: int = -1): def run(self): - passphrase_title = "SLIP-39 Passphrase" + passphrase_title = _("SLIP-39 Passphrase") ret_dict = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase="", title=passphrase_title) # The new passphrase will be the return value; it might be empty. @@ -2453,7 +2453,7 @@ def __init__(self, cur_word_index: int = 0, is_calc_final_word: bool=False, cur_ def run(self): ret = self.run_screen( seed_screens.SeedMnemonicEntryScreen, - title=f"Share #{self.cur_set_index + 1} Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! + title=_("Share #{} Word #{}").format(self.cur_set_index+1, self.cur_word_index+1), # Human-readable 1-indexing! initial_letters=list(self.cur_word) if self.cur_word else ["a"], wordlist=Seed.get_slip39_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), ) @@ -2516,7 +2516,7 @@ def run(self): class SeedShamirShareInvalidView(View): EDIT = ButtonOption("Review & Edit") - DISCARD = ButtonOption("Discard", None, None, "red") + DISCARD = ButtonOption("Discard", button_label_color="red") def __init__(self): super().__init__() @@ -2527,9 +2527,9 @@ def run(self): button_data = [self.EDIT, self.DISCARD] selected_menu_num = self.run_screen( WarningScreen, - title="Invalid Shamir Share!", + title=_("Invalid Shamir Share!"), status_headline=None, - text=f"Checksum failure; not a valid Shamir share.", + text=_("Checksum failure; not a valid Shamir share."), show_back_button=False, button_data=button_data, ) From 0832a55cf17776fcf7a543700592fc46ee3ebecf Mon Sep 17 00:00:00 2001 From: alvroble <50918598+alvroble@users.noreply.github.com> Date: Mon, 30 Dec 2024 18:21:45 +0100 Subject: [PATCH 10/10] Solves screenshot generator error --- src/seedsigner/gui/screens/seed_screens.py | 9 +++++---- src/seedsigner/views/seed_views.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index fd62f2f9..139b6b5a 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -1815,11 +1815,13 @@ def _run(self): +@dataclass class SeedEntryShamirThresholdScreen(NumericEntryScreen): title: str = "SLIP-39 Threshold" +@dataclass class SeedEntryShamirShareCountScreen(NumericEntryScreen): title: str = "SLIP-39 Share Count" @@ -1828,22 +1830,21 @@ class SeedEntryShamirShareCountScreen(NumericEntryScreen): @dataclass class ShamirFinalizeScreen(ButtonListScreen): value_text: str = None - title: str = "Finalize Shamir Share" is_bottom_list: bool = True button_data: list = None def __post_init__(self): self.show_back_button = False - + self.title = _("Finalize Shamir Share") super().__post_init__() self.fingerprint_icontl = IconTextLine( icon_name=SeedSignerIconConstants.FINGERPRINT, icon_color=GUIConstants.INFO_COLOR, icon_size=GUIConstants.ICON_FONT_SIZE + 12, - label_text="SLIP-39", + label_text=_("SLIP-39"), value_text=self.value_text, - font_size=GUIConstants.BODY_FONT_SIZE + 2, + font_size=GUIConstants.get_body_font_size() + 2, is_text_centered=True, screen_y=self.top_nav.height + int((self.buttons[0].screen_y - self.top_nav.height) / 2) - 30 ) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 1d358786..f9450d6c 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -2360,7 +2360,7 @@ def run(self): selected_menu_num = self.run_screen( seed_screens.ShamirFinalizeScreen, - value_text=self.PASSPHRASE if self.mode == 0 else _("Shamir Share #{}").format(self.shamir_set_index), + value_text=_("SLIP-39 Passphrase") if self.mode == 0 else _("Shamir Share #{}").format(self.shamir_set_index), button_data=button_data, )