diff --git a/core/Makefile b/core/Makefile index 97ef9ab572..f7a4939e5c 100644 --- a/core/Makefile +++ b/core/Makefile @@ -110,7 +110,8 @@ mypy: src/apps/bitcoin \ src/apps/cardano \ src/apps/misc \ - src/apps/webauthn + src/apps/webauthn \ + src/trezor/ui ## code generation: diff --git a/core/SConscript.firmware b/core/SConscript.firmware index ff8d21fb5b..648f60e807 100644 --- a/core/SConscript.firmware +++ b/core/SConscript.firmware @@ -523,7 +523,7 @@ if FROZEN: if TREZOR_MODEL == 'T': SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/tt/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/tt.py')) - SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tt.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tt/*.py')) elif TREZOR_MODEL == '1': SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/t1/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/t1.py')) diff --git a/core/SConscript.unix b/core/SConscript.unix index 24f1c07aac..533a445638 100644 --- a/core/SConscript.unix +++ b/core/SConscript.unix @@ -478,7 +478,7 @@ if FROZEN: if TREZOR_MODEL == 'T': SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/tt/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/tt.py')) - SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tt.py')) + SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/layouts/tt/*.py')) elif TREZOR_MODEL == '1': SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/components/t1/*.py')) SOURCE_PY.extend(Glob(SOURCE_PY_DIR + 'trezor/ui/constants/t1.py')) diff --git a/core/src/apps/management/reset_device/__init__.py b/core/src/apps/management/reset_device/__init__.py index 5241713cf0..547fd69274 100644 --- a/core/src/apps/management/reset_device/__init__.py +++ b/core/src/apps/management/reset_device/__init__.py @@ -88,7 +88,7 @@ async def reset_device(ctx: wire.Context, msg: ResetDevice) -> Success: secret, # for SLIP-39, this is the EMS msg.backup_type, needs_backup=not perform_backup, - no_backup=msg.no_backup, + no_backup=bool(msg.no_backup), ) # if we backed up the wallet, show success message @@ -109,12 +109,17 @@ async def backup_slip39_basic( await layout.slip39_show_checklist(ctx, 1, BackupType.Slip39_Basic) threshold = await layout.slip39_prompt_threshold(ctx, shares_count) + identifier = storage.device.get_slip39_identifier() + iteration_exponent = storage.device.get_slip39_iteration_exponent() + if identifier is None or iteration_exponent is None: + raise ValueError + # generate the mnemonics mnemonics = slip39.split_ems( 1, # Single Group threshold [(threshold, shares_count)], # Single Group threshold/count - storage.device.get_slip39_identifier(), - storage.device.get_slip39_iteration_exponent(), + identifier, + iteration_exponent, encrypted_master_secret, )[0] @@ -144,12 +149,17 @@ async def backup_slip39_advanced( share_threshold = await layout.slip39_prompt_threshold(ctx, share_count, i) groups.append((share_threshold, share_count)) + identifier = storage.device.get_slip39_identifier() + iteration_exponent = storage.device.get_slip39_iteration_exponent() + if identifier is None or iteration_exponent is None: + raise ValueError + # generate the mnemonics mnemonics = slip39.split_ems( group_threshold=group_threshold, groups=groups, - identifier=storage.device.get_slip39_identifier(), - iteration_exponent=storage.device.get_slip39_iteration_exponent(), + identifier=identifier, + iteration_exponent=iteration_exponent, encrypted_master_secret=encrypted_master_secret, ) @@ -193,7 +203,7 @@ def _compute_secret_from_entropy( async def backup_seed( ctx: wire.Context, backup_type: BackupType, mnemonic_secret: bytes -): +) -> None: if backup_type == BackupType.Slip39_Basic: await backup_slip39_basic(ctx, mnemonic_secret) elif backup_type == BackupType.Slip39_Advanced: diff --git a/core/src/apps/management/reset_device/layout.py b/core/src/apps/management/reset_device/layout.py index 9ce3981cca..a50691eeaa 100644 --- a/core/src/apps/management/reset_device/layout.py +++ b/core/src/apps/management/reset_device/layout.py @@ -1,24 +1,21 @@ -from trezor import ui, utils -from trezor.crypto import random -from trezor.enums import BackupType, ButtonRequestType -from trezor.ui.components.tt.button import Button, ButtonDefault -from trezor.ui.components.tt.checklist import Checklist -from trezor.ui.components.tt.info import InfoConfirm -from trezor.ui.components.tt.num_input import NumInput -from trezor.ui.components.tt.scroll import Paginated -from trezor.ui.components.tt.text import Text +from trezor import ui, utils, wire +from trezor.enums import ButtonRequestType from trezor.ui.layouts import confirm_action, confirm_blob, show_success, show_warning - -from apps.common.confirm import confirm, require_hold_to_confirm +from trezor.ui.layouts.tt.reset import ( # noqa: F401 + confirm_word, + show_share_words, + slip39_advanced_prompt_group_threshold, + slip39_advanced_prompt_number_of_groups, + slip39_prompt_number_of_shares, + slip39_prompt_threshold, + slip39_show_checklist, +) if False: - from trezor import loop - -if __debug__: - from apps import debug + from typing import Sequence -async def show_internal_entropy(ctx, entropy: bytes): +async def show_internal_entropy(ctx: wire.GenericContext, entropy: bytes) -> None: await confirm_blob( ctx, "entropy", @@ -30,90 +27,12 @@ async def show_internal_entropy(ctx, entropy: bytes): ) -async def _show_share_words(ctx, share_words, share_index=None, group_index=None): - first, chunks, last = _split_share_into_pages(share_words) - - if share_index is None: - header_title = "Recovery seed" - elif group_index is None: - header_title = "Recovery share #%s" % (share_index + 1) - else: - header_title = "Group %s - Share %s" % ((group_index + 1), (share_index + 1)) - header_icon = ui.ICON_RESET - pages = [] # ui page components - shares_words_check = [] # check we display correct data - - # first page - text = Text(header_title, header_icon) - text.bold("Write down these") - text.bold("%s words:" % len(share_words)) - text.br_half() - for index, word in first: - text.mono("%s. %s" % (index + 1, word)) - shares_words_check.append(word) - pages.append(text) - - # middle pages - for chunk in chunks: - text = Text(header_title, header_icon) - for index, word in chunk: - text.mono("%s. %s" % (index + 1, word)) - shares_words_check.append(word) - pages.append(text) - - # last page - text = Text(header_title, header_icon) - for index, word in last: - text.mono("%s. %s" % (index + 1, word)) - shares_words_check.append(word) - text.br_half() - text.bold("I wrote down all %s" % len(share_words)) - text.bold("words in order.") - pages.append(text) - - # pagination - paginated = Paginated(pages) - - if __debug__: - - word_pages = [first] + chunks + [last] - - def export_displayed_words(): - # export currently displayed mnemonic words into debuglink - words = [w for _, w in word_pages[paginated.page]] - debug.reset_current_words.publish(words) - - paginated.on_change = export_displayed_words - export_displayed_words() - - # make sure we display correct data - utils.ensure(share_words == shares_words_check) - - # confirm the share - await require_hold_to_confirm( - ctx, paginated, ButtonRequestType.ResetDevice, cancel=False - ) - - -def _split_share_into_pages(share_words): - share = list(enumerate(share_words)) # we need to keep track of the word indices - first = share[:2] # two words on the first page - length = len(share_words) - if length == 12 or length == 20 or length == 24: - middle = share[2:-2] - last = share[-2:] # two words on the last page - elif length == 33 or length == 18: - middle = share[2:] - last = [] # no words at the last page, because it does not add up - else: - # Invalid number of shares. SLIP-39 allows 20 or 33 words, BIP-39 12 or 24 - raise RuntimeError - - chunks = utils.chunks(middle, 4) # 4 words on the middle pages - return first, list(chunks), last - - -async def _confirm_share_words(ctx, share_index, share_words, group_index=None): +async def _confirm_share_words( + ctx: wire.GenericContext, + share_index: int | None, + share_words: Sequence[str], + group_index: int | None = None, +) -> bool: # divide list into thirds, rounding up, so that chunking by `third` always yields # three parts (the last one might be shorter) third = (len(share_words) + 2) // 3 @@ -121,41 +40,20 @@ async def _confirm_share_words(ctx, share_index, share_words, group_index=None): offset = 0 count = len(share_words) for part in utils.chunks(share_words, third): - if not await _confirm_word(ctx, share_index, part, offset, count, group_index): + if not await confirm_word(ctx, share_index, part, offset, count, group_index): return False offset += len(part) return True -async def _confirm_word(ctx, share_index, share_words, offset, count, group_index=None): - # remove duplicates - non_duplicates = list(set(share_words)) - # shuffle list - random.shuffle(non_duplicates) - # take top NUM_OF_CHOICES words - choices = non_duplicates[: MnemonicWordSelect.NUM_OF_CHOICES] - # select first of them - checked_word = choices[0] - # find its index - checked_index = share_words.index(checked_word) + offset - # shuffle again so the confirmed word is not always the first choice - random.shuffle(choices) - - if __debug__: - debug.reset_word_index.publish(checked_index) - - # let the user pick a word - select = MnemonicWordSelect(choices, share_index, checked_index, count, group_index) - selected_word = await ctx.wait(select) - # confirm it is the correct one - return selected_word == checked_word - - async def _show_confirmation_success( - ctx, share_index=None, num_of_shares=None, group_index=None -): - if share_index is None: # it is a BIP39 backup + ctx: wire.GenericContext, + share_index: int | None = None, + num_of_shares: int | None = None, + group_index: int | None = None, +) -> None: + if share_index is None or num_of_shares is None: # it is a BIP39 backup subheader = "You have finished\nverifying your\nrecovery seed." text = "" @@ -183,7 +81,9 @@ async def _show_confirmation_success( return await show_success(ctx, "success_recovery", text, subheader=subheader) -async def _show_confirmation_failure(ctx, share_index): +async def _show_confirmation_failure( + ctx: wire.GenericContext, share_index: int | None +) -> None: if share_index is None: header = "Recovery seed" else: @@ -199,7 +99,7 @@ async def _show_confirmation_failure(ctx, share_index): ) -async def show_backup_warning(ctx, slip39=False): +async def show_backup_warning(ctx: wire.GenericContext, slip39: bool = False) -> None: if slip39: description = "Never make a digital copy of your recovery shares and never upload them online!" else: @@ -216,7 +116,7 @@ async def show_backup_warning(ctx, slip39=False): ) -async def show_backup_success(ctx): +async def show_backup_success(ctx: wire.GenericContext) -> None: text = "Use your backup\nwhen you need to\nrecover your wallet." await show_success(ctx, "success_backup", text, subheader="Your backup is done.") @@ -225,7 +125,9 @@ async def show_backup_success(ctx): # === -async def bip39_show_and_confirm_mnemonic(ctx, mnemonic: str): +async def bip39_show_and_confirm_mnemonic( + ctx: wire.GenericContext, mnemonic: str +) -> None: # warn user about mnemonic safety await show_backup_warning(ctx) @@ -233,7 +135,7 @@ async def bip39_show_and_confirm_mnemonic(ctx, mnemonic: str): while True: # display paginated mnemonic on the screen - await _show_share_words(ctx, share_words=words) + await show_share_words(ctx, share_words=words) # make the user confirm some words from the mnemonic if await _confirm_share_words(ctx, None, words): @@ -247,119 +149,9 @@ async def bip39_show_and_confirm_mnemonic(ctx, mnemonic: str): # === -async def slip39_show_checklist(ctx, step: int, backup_type: BackupType) -> None: - checklist = Checklist("Backup checklist", ui.ICON_RESET) - if backup_type is BackupType.Slip39_Basic: - checklist.add("Set number of shares") - checklist.add("Set threshold") - checklist.add(("Write down and check", "all recovery shares")) - elif backup_type is BackupType.Slip39_Advanced: - checklist.add("Set number of groups") - checklist.add("Set group threshold") - checklist.add(("Set size and threshold", "for each group")) - checklist.select(step) - - return await confirm( - ctx, checklist, ButtonRequestType.ResetDevice, cancel=None, confirm="Continue" - ) - - -async def slip39_prompt_threshold(ctx, num_of_shares, group_id=None): - count = num_of_shares // 2 + 1 - # min value of share threshold is 2 unless the number of shares is 1 - # number of shares 1 is possible in advnaced slip39 - min_count = min(2, num_of_shares) - max_count = num_of_shares - - while True: - shares = Slip39NumInput( - Slip39NumInput.SET_THRESHOLD, count, min_count, max_count, group_id - ) - confirmed = await confirm( - ctx, - shares, - ButtonRequestType.ResetDevice, - cancel="Info", - confirm="Continue", - major_confirm=True, - cancel_style=ButtonDefault, - ) - count = shares.input.count - if confirmed: - break - - text = "The threshold sets the number of shares " - if group_id is None: - text += "needed to recover your wallet. " - text += "Set it to %s and you will need " % count - if num_of_shares == 1: - text += "1 share." - elif num_of_shares == count: - text += "all %s of your %s shares." % (count, num_of_shares) - else: - text += "any %s of your %s shares." % (count, num_of_shares) - else: - text += "needed to form a group. " - text += "Set it to %s and you will " % count - if num_of_shares == 1: - text += "need 1 share " - elif num_of_shares == count: - text += "need all %s of %s shares " % (count, num_of_shares) - else: - text += "need any %s of %s shares " % (count, num_of_shares) - text += "to form Group %s." % (group_id + 1) - info = InfoConfirm(text) - await info - - return count - - -async def slip39_prompt_number_of_shares(ctx, group_id=None): - count = 5 - min_count = 1 - max_count = 16 - - while True: - shares = Slip39NumInput( - Slip39NumInput.SET_SHARES, count, min_count, max_count, group_id - ) - confirmed = await confirm( - ctx, - shares, - ButtonRequestType.ResetDevice, - cancel="Info", - confirm="Continue", - major_confirm=True, - cancel_style=ButtonDefault, - ) - count = shares.input.count - if confirmed: - break - - if group_id is None: - info = InfoConfirm( - "Each recovery share is a " - "sequence of 20 words. " - "Next you will choose " - "how many shares you " - "need to recover your " - "wallet." - ) - else: - info = InfoConfirm( - "Each recovery share is a " - "sequence of 20 words. " - "Next you will choose " - "the threshold number of " - "shares needed to form " - "Group %s." % (group_id + 1) - ) - await info - - return count - - -async def slip39_basic_show_and_confirm_shares(ctx, shares): +async def slip39_basic_show_and_confirm_shares( + ctx: wire.GenericContext, shares: Sequence[str] +) -> None: # warn user about mnemonic safety await show_backup_warning(ctx, slip39=True) @@ -367,7 +159,7 @@ async def slip39_basic_show_and_confirm_shares(ctx, shares): share_words = share.split(" ") while True: # display paginated share on the screen - await _show_share_words(ctx, share_words, index) + await show_share_words(ctx, share_words, index) # make the user confirm words from the share if await _confirm_share_words(ctx, index, share_words): @@ -379,73 +171,9 @@ async def slip39_basic_show_and_confirm_shares(ctx, shares): await _show_confirmation_failure(ctx, index) -async def slip39_advanced_prompt_number_of_groups(ctx): - count = 5 - min_count = 2 - max_count = 16 - - while True: - shares = Slip39NumInput(Slip39NumInput.SET_GROUPS, count, min_count, max_count) - confirmed = await confirm( - ctx, - shares, - ButtonRequestType.ResetDevice, - cancel="Info", - confirm="Continue", - major_confirm=True, - cancel_style=ButtonDefault, - ) - count = shares.input.count - if confirmed: - break - - info = InfoConfirm( - "Each group has a set " - "number of shares and " - "its own threshold. In the " - "next steps you will set " - "the numbers of shares " - "and the thresholds." - ) - await info - - return count - - -async def slip39_advanced_prompt_group_threshold(ctx, num_of_groups): - count = num_of_groups // 2 + 1 - min_count = 1 - max_count = num_of_groups - - while True: - shares = Slip39NumInput( - Slip39NumInput.SET_GROUP_THRESHOLD, count, min_count, max_count - ) - confirmed = await confirm( - ctx, - shares, - ButtonRequestType.ResetDevice, - cancel="Info", - confirm="Continue", - major_confirm=True, - cancel_style=ButtonDefault, - ) - count = shares.input.count - if confirmed: - break - else: - info = InfoConfirm( - "The group threshold " - "specifies the number of " - "groups required to " - "recover your wallet. " - ) - await info - - return count - - -async def slip39_advanced_show_and_confirm_shares(ctx, shares): +async def slip39_advanced_show_and_confirm_shares( + ctx: wire.GenericContext, shares: Sequence[Sequence[str]] +) -> None: # warn user about mnemonic safety await show_backup_warning(ctx, slip39=True) @@ -454,7 +182,7 @@ async def slip39_advanced_show_and_confirm_shares(ctx, shares): share_words = share.split(" ") while True: # display paginated share on the screen - await _show_share_words(ctx, share_words, share_index, group_index) + await show_share_words(ctx, share_words, share_index, group_index) # make the user confirm words from the share if await _confirm_share_words( @@ -469,131 +197,3 @@ async def slip39_advanced_show_and_confirm_shares(ctx, shares): break # this share is confirmed, go to next one else: await _show_confirmation_failure(ctx, share_index) - - -class Slip39NumInput(ui.Component): - SET_SHARES = object() - SET_THRESHOLD = object() - SET_GROUPS = object() - SET_GROUP_THRESHOLD = object() - - def __init__(self, step, count, min_count, max_count, group_id=None): - super().__init__() - self.step = step - self.input = NumInput(count, min_count=min_count, max_count=max_count) - self.input.on_change = self.on_change - self.group_id = group_id - - def dispatch(self, event, x, y): - self.input.dispatch(event, x, y) - if event is ui.RENDER: - self.on_render() - - def on_render(self): - if self.repaint: - count = self.input.count - - # render the headline - if self.step is Slip39NumInput.SET_SHARES: - header = "Set num. of shares" - elif self.step is Slip39NumInput.SET_THRESHOLD: - header = "Set threshold" - elif self.step is Slip39NumInput.SET_GROUPS: - header = "Set num. of groups" - elif self.step is Slip39NumInput.SET_GROUP_THRESHOLD: - header = "Set group threshold" - ui.header(header, ui.ICON_RESET, ui.TITLE_GREY, ui.BG, ui.ORANGE_ICON) - - # render the counter - if self.step is Slip39NumInput.SET_SHARES: - if self.group_id is None: - if count == 1: - first_line_text = "Only one share will" - second_line_text = "be created." - else: - first_line_text = "%s people or locations" % count - second_line_text = "will each hold one share." - else: - first_line_text = "Set the total number of" - second_line_text = "shares in Group %s." % (self.group_id + 1) - ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) - ui.display.text(12, 130, first_line_text, ui.NORMAL, ui.FG, ui.BG) - ui.display.text(12, 156, second_line_text, ui.NORMAL, ui.FG, ui.BG) - elif self.step is Slip39NumInput.SET_THRESHOLD: - if self.group_id is None: - first_line_text = "For recovery you need" - if count == 1: - second_line_text = "1 share." - elif count == self.input.max_count: - second_line_text = "all %s of the shares." % count - else: - second_line_text = "any %s of the shares." % count - else: - first_line_text = "The required number of " - second_line_text = "shares to form Group %s." % (self.group_id + 1) - ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) - ui.display.text(12, 130, first_line_text, ui.NORMAL, ui.FG, ui.BG) - ui.display.text(12, 156, second_line_text, ui.NORMAL, ui.FG, ui.BG) - elif self.step is Slip39NumInput.SET_GROUPS: - ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) - ui.display.text( - 12, 130, "A group is made up of", ui.NORMAL, ui.FG, ui.BG - ) - ui.display.text(12, 156, "recovery shares.", ui.NORMAL, ui.FG, ui.BG) - elif self.step is Slip39NumInput.SET_GROUP_THRESHOLD: - ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) - ui.display.text( - 12, 130, "The required number of", ui.NORMAL, ui.FG, ui.BG - ) - ui.display.text( - 12, 156, "groups for recovery.", ui.NORMAL, ui.FG, ui.BG - ) - - self.repaint = False - - def on_change(self, count): - self.repaint = True - - -class MnemonicWordSelect(ui.Layout): - NUM_OF_CHOICES = 3 - - def __init__(self, words, share_index, word_index, count, group_index=None): - super().__init__() - self.words = words - self.share_index = share_index - self.word_index = word_index - self.buttons = [] - for i, word in enumerate(words): - area = ui.grid(i + 2, n_x=1) - btn = Button(area, word) - btn.on_click = self.select(word) - self.buttons.append(btn) - if share_index is None: - self.text = Text("Check seed") - elif group_index is None: - self.text = Text("Check share #%s" % (share_index + 1)) - else: - self.text = Text( - "Check G%s - Share %s" % ((group_index + 1), (share_index + 1)) - ) - self.text.normal("Select word %d of %d:" % (word_index + 1, count)) - - def dispatch(self, event, x, y): - for btn in self.buttons: - btn.dispatch(event, x, y) - self.text.dispatch(event, x, y) - - def select(self, word): - def fn(): - raise ui.Result(word) - - return fn - - if __debug__: - - def read_content(self) -> list[str]: - return self.text.read_content() + [b.text for b in self.buttons] - - def create_tasks(self) -> tuple[loop.Task, ...]: - return super().create_tasks() + (debug.input_signal(),) diff --git a/core/src/trezor/ui/components/common/text.py b/core/src/trezor/ui/components/common/text.py index 634891666a..c43490c214 100644 --- a/core/src/trezor/ui/components/common/text.py +++ b/core/src/trezor/ui/components/common/text.py @@ -15,7 +15,7 @@ LINE_WIDTH = ui.WIDTH - TEXT_MARGIN_LEFT LINE_WIDTH_PAGINATED = LINE_WIDTH - PAGINATION_MARGIN_RIGHT if False: - from typing import Any, Union + from typing import Any, Sequence, Union TextContent = Union[str, int] @@ -176,7 +176,7 @@ _WORKING_SPAN = Span() def render_text( - items: list[TextContent], + items: Sequence[TextContent], new_lines: bool, max_lines: int, font: int = ui.NORMAL, diff --git a/core/src/trezor/ui/components/tt/reset.py b/core/src/trezor/ui/components/tt/reset.py new file mode 100644 index 0000000000..bf61f7b66f --- /dev/null +++ b/core/src/trezor/ui/components/tt/reset.py @@ -0,0 +1,154 @@ +from trezor import ui + +from .button import Button +from .num_input import NumInput +from .text import Text + +if False: + from trezor import loop + from typing import Callable, NoReturn, Sequence + +if __debug__: + from apps import debug + + +class Slip39NumInput(ui.Component): + SET_SHARES = object() + SET_THRESHOLD = object() + SET_GROUPS = object() + SET_GROUP_THRESHOLD = object() + + def __init__( + self, + step: object, + count: int, + min_count: int, + max_count: int, + group_id: int | None = None, + ) -> None: + super().__init__() + self.step = step + self.input = NumInput(count, min_count=min_count, max_count=max_count) + self.input.on_change = self.on_change # type: ignore + self.group_id = group_id + + def dispatch(self, event: int, x: int, y: int) -> None: + self.input.dispatch(event, x, y) + if event is ui.RENDER: + self.on_render() + + def on_render(self) -> None: + if self.repaint: + count = self.input.count + + # render the headline + if self.step is Slip39NumInput.SET_SHARES: + header = "Set num. of shares" + elif self.step is Slip39NumInput.SET_THRESHOLD: + header = "Set threshold" + elif self.step is Slip39NumInput.SET_GROUPS: + header = "Set num. of groups" + elif self.step is Slip39NumInput.SET_GROUP_THRESHOLD: + header = "Set group threshold" + ui.header(header, ui.ICON_RESET, ui.TITLE_GREY, ui.BG, ui.ORANGE_ICON) + + # render the counter + if self.step is Slip39NumInput.SET_SHARES: + if self.group_id is None: + if count == 1: + first_line_text = "Only one share will" + second_line_text = "be created." + else: + first_line_text = "%s people or locations" % count + second_line_text = "will each hold one share." + else: + first_line_text = "Set the total number of" + second_line_text = "shares in Group %s." % (self.group_id + 1) + ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) + ui.display.text(12, 130, first_line_text, ui.NORMAL, ui.FG, ui.BG) + ui.display.text(12, 156, second_line_text, ui.NORMAL, ui.FG, ui.BG) + elif self.step is Slip39NumInput.SET_THRESHOLD: + if self.group_id is None: + first_line_text = "For recovery you need" + if count == 1: + second_line_text = "1 share." + elif count == self.input.max_count: + second_line_text = "all %s of the shares." % count + else: + second_line_text = "any %s of the shares." % count + else: + first_line_text = "The required number of " + second_line_text = "shares to form Group %s." % (self.group_id + 1) + ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) + ui.display.text(12, 130, first_line_text, ui.NORMAL, ui.FG, ui.BG) + ui.display.text(12, 156, second_line_text, ui.NORMAL, ui.FG, ui.BG) + elif self.step is Slip39NumInput.SET_GROUPS: + ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) + ui.display.text( + 12, 130, "A group is made up of", ui.NORMAL, ui.FG, ui.BG + ) + ui.display.text(12, 156, "recovery shares.", ui.NORMAL, ui.FG, ui.BG) + elif self.step is Slip39NumInput.SET_GROUP_THRESHOLD: + ui.display.bar(0, 110, ui.WIDTH, 52, ui.BG) + ui.display.text( + 12, 130, "The required number of", ui.NORMAL, ui.FG, ui.BG + ) + ui.display.text( + 12, 156, "groups for recovery.", ui.NORMAL, ui.FG, ui.BG + ) + + self.repaint = False + + def on_change(self, count: int) -> None: + self.repaint = True + + +class MnemonicWordSelect(ui.Layout): + NUM_OF_CHOICES = 3 + + def __init__( + self, + words: Sequence[str], + share_index: int | None, + word_index: int, + count: int, + group_index: int | None = None, + ) -> None: + super().__init__() + self.words = words + self.share_index = share_index + self.word_index = word_index + self.buttons = [] + for i, word in enumerate(words): + area = ui.grid(i + 2, n_x=1) + btn = Button(area, word) + btn.on_click = self.select(word) # type: ignore + self.buttons.append(btn) + if share_index is None: + self.text: ui.Component = Text("Check seed") + elif group_index is None: + self.text = Text("Check share #%s" % (share_index + 1)) + else: + self.text = Text( + "Check G%s - Share %s" % ((group_index + 1), (share_index + 1)) + ) + self.text.normal("Select word %d of %d:" % (word_index + 1, count)) + + def dispatch(self, event: int, x: int, y: int) -> None: + for btn in self.buttons: + btn.dispatch(event, x, y) + self.text.dispatch(event, x, y) + + def select(self, word: str) -> Callable: + def fn() -> NoReturn: + raise ui.Result(word) + + return fn + + if __debug__: + + def read_content(self) -> list[str]: + return self.text.read_content() + [b.text for b in self.buttons] + + def create_tasks(self) -> tuple[loop.Task, ...]: + return super().create_tasks() + (debug.input_signal(),) diff --git a/core/src/trezor/ui/layouts/tt.py b/core/src/trezor/ui/layouts/tt/__init__.py similarity index 98% rename from core/src/trezor/ui/layouts/tt.py rename to core/src/trezor/ui/layouts/tt/__init__.py index 7adf96bb49..6246839987 100644 --- a/core/src/trezor/ui/layouts/tt.py +++ b/core/src/trezor/ui/layouts/tt/__init__.py @@ -9,21 +9,21 @@ from trezor.ui.popup import Popup from trezor.ui.qr import Qr from trezor.utils import chunks, chunks_intersperse -from ..components.common import break_path_to_lines -from ..components.common.confirm import is_confirmed, raise_if_cancelled -from ..components.common.webauthn import ConfirmInfo -from ..components.tt import passphrase, pin -from ..components.tt.button import ButtonCancel, ButtonDefault -from ..components.tt.confirm import Confirm, ConfirmPageable, HoldToConfirm, Pageable -from ..components.tt.scroll import ( +from ...components.common import break_path_to_lines +from ...components.common.confirm import is_confirmed, raise_if_cancelled +from ...components.common.webauthn import ConfirmInfo +from ...components.tt import passphrase, pin +from ...components.tt.button import ButtonCancel, ButtonDefault +from ...components.tt.confirm import Confirm, ConfirmPageable, HoldToConfirm, Pageable +from ...components.tt.scroll import ( PAGEBREAK, Paginated, paginate_paragraphs, paginate_text, ) -from ..components.tt.text import LINE_WIDTH_PAGINATED, Span, Text -from ..components.tt.webauthn import ConfirmContent -from ..constants.tt import ( +from ...components.tt.text import LINE_WIDTH_PAGINATED, Span, Text +from ...components.tt.webauthn import ConfirmContent +from ...constants.tt import ( MONO_ADDR_PER_LINE, MONO_HEX_PER_LINE, QR_SIZE_THRESHOLD, @@ -31,7 +31,7 @@ from ..constants.tt import ( QR_Y, TEXT_MAX_LINES, ) -from .common import button_request, interact +from ..common import button_request, interact if False: from typing import ( diff --git a/core/src/trezor/ui/layouts/tt/reset.py b/core/src/trezor/ui/layouts/tt/reset.py new file mode 100644 index 0000000000..92d217c2ac --- /dev/null +++ b/core/src/trezor/ui/layouts/tt/reset.py @@ -0,0 +1,361 @@ +from trezor import ui, utils, wire +from trezor.crypto import random +from trezor.enums import BackupType, ButtonRequestType + +from ...components.common.confirm import is_confirmed, raise_if_cancelled +from ...components.tt.button import ButtonDefault +from ...components.tt.checklist import Checklist +from ...components.tt.confirm import Confirm, HoldToConfirm +from ...components.tt.info import InfoConfirm +from ...components.tt.reset import MnemonicWordSelect, Slip39NumInput +from ...components.tt.scroll import Paginated +from ...components.tt.text import Text +from ..common import interact + +if False: + from typing import Sequence + + NumberedWords = Sequence[tuple[int, str]] + +if __debug__: + from apps import debug + + +async def show_share_words( + ctx: wire.GenericContext, + share_words: Sequence[str], + share_index: int | None = None, + group_index: int | None = None, +) -> None: + first, chunks, last = _split_share_into_pages(share_words) + + if share_index is None: + header_title = "Recovery seed" + elif group_index is None: + header_title = "Recovery share #%s" % (share_index + 1) + else: + header_title = "Group %s - Share %s" % ((group_index + 1), (share_index + 1)) + header_icon = ui.ICON_RESET + pages: list[ui.Component] = [] # ui page components + shares_words_check = [] # check we display correct data + + # first page + text = Text(header_title, header_icon) + text.bold("Write down these") + text.bold("%s words:" % len(share_words)) + text.br_half() + for index, word in first: + text.mono("%s. %s" % (index + 1, word)) + shares_words_check.append(word) + pages.append(text) + + # middle pages + for chunk in chunks: + text = Text(header_title, header_icon) + for index, word in chunk: + text.mono("%s. %s" % (index + 1, word)) + shares_words_check.append(word) + pages.append(text) + + # last page + text = Text(header_title, header_icon) + for index, word in last: + text.mono("%s. %s" % (index + 1, word)) + shares_words_check.append(word) + text.br_half() + text.bold("I wrote down all %s" % len(share_words)) + text.bold("words in order.") + pages.append(text) + + pages[-1] = HoldToConfirm(pages[-1], cancel=False) + + # pagination + paginated = Paginated(pages) + + if __debug__: + + word_pages = [first] + chunks + [last] + + def export_displayed_words() -> None: + # export currently displayed mnemonic words into debuglink + words = [w for _, w in word_pages[paginated.page]] + debug.reset_current_words.publish(words) + + paginated.on_change = export_displayed_words # type: ignore + export_displayed_words() + + # make sure we display correct data + utils.ensure(share_words == shares_words_check) + + # confirm the share + await raise_if_cancelled( + interact( + ctx, + paginated, + "backup_words", + ButtonRequestType.ResetDevice, + ) + ) + + +async def confirm_word( + ctx: wire.GenericContext, + share_index: int | None, + share_words: Sequence[str], + offset: int, + count: int, + group_index: int | None = None, +) -> bool: + # remove duplicates + non_duplicates = list(set(share_words)) + # shuffle list + random.shuffle(non_duplicates) + # take top NUM_OF_CHOICES words + choices = non_duplicates[: MnemonicWordSelect.NUM_OF_CHOICES] + # select first of them + checked_word = choices[0] + # find its index + checked_index = share_words.index(checked_word) + offset + # shuffle again so the confirmed word is not always the first choice + random.shuffle(choices) + + if __debug__: + debug.reset_word_index.publish(checked_index) + + # let the user pick a word + select = MnemonicWordSelect(choices, share_index, checked_index, count, group_index) + selected_word: str = await ctx.wait(select) + # confirm it is the correct one + return selected_word == checked_word + + +def _split_share_into_pages( + share_words: Sequence[str], +) -> tuple[NumberedWords, list[NumberedWords], NumberedWords]: + share = list(enumerate(share_words)) # we need to keep track of the word indices + first = share[:2] # two words on the first page + length = len(share_words) + if length == 12 or length == 20 or length == 24: + middle = share[2:-2] + last = share[-2:] # two words on the last page + elif length == 33 or length == 18: + middle = share[2:] + last = [] # no words at the last page, because it does not add up + else: + # Invalid number of shares. SLIP-39 allows 20 or 33 words, BIP-39 12 or 24 + raise RuntimeError + + chunks = utils.chunks(middle, 4) # 4 words on the middle pages + return first, list(chunks), last + + +async def slip39_show_checklist( + ctx: wire.GenericContext, step: int, backup_type: BackupType +) -> None: + checklist = Checklist("Backup checklist", ui.ICON_RESET) + if backup_type is BackupType.Slip39_Basic: + checklist.add("Set number of shares") + checklist.add("Set threshold") + checklist.add(("Write down and check", "all recovery shares")) + elif backup_type is BackupType.Slip39_Advanced: + checklist.add("Set number of groups") + checklist.add("Set group threshold") + checklist.add(("Set size and threshold", "for each group")) + checklist.select(step) + + await raise_if_cancelled( + interact( + ctx, + Confirm(checklist, confirm="Continue", cancel=None), + "slip39_checklist", + ButtonRequestType.ResetDevice, + ) + ) + + +async def slip39_prompt_threshold( + ctx: wire.GenericContext, num_of_shares: int, group_id: int | None = None +) -> int: + count = num_of_shares // 2 + 1 + # min value of share threshold is 2 unless the number of shares is 1 + # number of shares 1 is possible in advnaced slip39 + min_count = min(2, num_of_shares) + max_count = num_of_shares + + while True: + shares = Slip39NumInput( + Slip39NumInput.SET_THRESHOLD, count, min_count, max_count, group_id + ) + confirmed = is_confirmed( + await interact( + ctx, + Confirm( + shares, + confirm="Continue", + cancel="Info", + major_confirm=True, + cancel_style=ButtonDefault, + ), + "slip39_threshold", + ButtonRequestType.ResetDevice, + ) + ) + + count = shares.input.count + if confirmed: + break + + text = "The threshold sets the number of shares " + if group_id is None: + text += "needed to recover your wallet. " + text += "Set it to %s and you will need " % count + if num_of_shares == 1: + text += "1 share." + elif num_of_shares == count: + text += "all %s of your %s shares." % (count, num_of_shares) + else: + text += "any %s of your %s shares." % (count, num_of_shares) + else: + text += "needed to form a group. " + text += "Set it to %s and you will " % count + if num_of_shares == 1: + text += "need 1 share " + elif num_of_shares == count: + text += "need all %s of %s shares " % (count, num_of_shares) + else: + text += "need any %s of %s shares " % (count, num_of_shares) + text += "to form Group %s." % (group_id + 1) + info = InfoConfirm(text) + await info + + return count + + +async def slip39_prompt_number_of_shares( + ctx: wire.GenericContext, group_id: int | None = None +) -> int: + count = 5 + min_count = 1 + max_count = 16 + + while True: + shares = Slip39NumInput( + Slip39NumInput.SET_SHARES, count, min_count, max_count, group_id + ) + confirmed = is_confirmed( + await interact( + ctx, + Confirm( + shares, + confirm="Continue", + cancel="Info", + major_confirm=True, + cancel_style=ButtonDefault, + ), + "slip39_shares", + ButtonRequestType.ResetDevice, + ) + ) + count = shares.input.count + if confirmed: + break + + if group_id is None: + info = InfoConfirm( + "Each recovery share is a " + "sequence of 20 words. " + "Next you will choose " + "how many shares you " + "need to recover your " + "wallet." + ) + else: + info = InfoConfirm( + "Each recovery share is a " + "sequence of 20 words. " + "Next you will choose " + "the threshold number of " + "shares needed to form " + "Group %s." % (group_id + 1) + ) + await info + + return count + + +async def slip39_advanced_prompt_number_of_groups(ctx: wire.GenericContext) -> int: + count = 5 + min_count = 2 + max_count = 16 + + while True: + shares = Slip39NumInput(Slip39NumInput.SET_GROUPS, count, min_count, max_count) + confirmed = is_confirmed( + await interact( + ctx, + Confirm( + shares, + confirm="Continue", + cancel="Info", + major_confirm=True, + cancel_style=ButtonDefault, + ), + "slip39_groups", + ButtonRequestType.ResetDevice, + ) + ) + count = shares.input.count + if confirmed: + break + + info = InfoConfirm( + "Each group has a set " + "number of shares and " + "its own threshold. In the " + "next steps you will set " + "the numbers of shares " + "and the thresholds." + ) + await info + + return count + + +async def slip39_advanced_prompt_group_threshold( + ctx: wire.GenericContext, num_of_groups: int +) -> int: + count = num_of_groups // 2 + 1 + min_count = 1 + max_count = num_of_groups + + while True: + shares = Slip39NumInput( + Slip39NumInput.SET_GROUP_THRESHOLD, count, min_count, max_count + ) + confirmed = is_confirmed( + await interact( + ctx, + Confirm( + shares, + confirm="Continue", + cancel="Info", + major_confirm=True, + cancel_style=ButtonDefault, + ), + "slip39_group_threshold", + ButtonRequestType.ResetDevice, + ) + ) + count = shares.input.count + if confirmed: + break + else: + info = InfoConfirm( + "The group threshold " + "specifies the number of " + "groups required to " + "recover your wallet. " + ) + await info + + return count