feat(core): allow back in multishare setup
What changed, and why it matters
This commit changes the on-device backup setup flow for newer Trezor devices so users can go back and revise choices such as the number of shares or the threshold. It is a user-experience improvement, not a security fix. The code removes the ability to cancel out of certain setup screens and instead adds a back button. There is no indication in the commit that this addresses a security vulnerability.
No security action required. Treat as a normal feature/UX commit. Reviewers may want to verify that the new state machine cannot loop indefinitely and that the removal of cancellation does not trap users on non-Eckhart layouts, but the tests cover the intended behavior.
Security signals we found
No security-relevant signal: change is a UX flow refactor for backup setup.
Removal of cancel/ActionCancelled path on some screens in favor of Back navigation.
New state machine assertions guard that share_count, group_threshold, and groups are populated before use.
Tests confirm the revised flow still produces valid SLIP39 mnemonics.
Evidence from the diff
The patch refactors the SLIP39 backup setup state machine in core/src/apps/management/reset_device/init.py to allow stepping backward and forward while preserving already-entered values. It adds a back_button parameter to the show_checklist Rust/Micropython API and wires it up only for the Eckhart layout; other layouts accept but ignore the parameter. Number-input prompts now return a BACK result on Eckhart and preselect previous values when revisited. The change is accompanied by new device tests that exercise the back navigation on Eckhart.
Changed components
core/src/apps/management/reset_device/__init__.pycore/src/trezor/ui/layouts/{bolt,caesar,delizia,eckhart}/reset.pycore/embed/rust/src/ui/{api/firmware_micropython.rs,ui_firmware.rs,layout_*/ui_firmware.rs,layout_eckhart/component_msg_obj.rs}tests/device_tests/test_msg_backup_device.pytests/input_flows.pyInspect captured patch +632 / −96
### core/.changelog.d/7823.changed
@@ -0,0 +1 @@
+Allow going back and changing previous selections during multishare backup creation, and remove the option to interrupt the flow from the checklist and number input screens.
### core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -828,10 +828,11 @@ extern "C" fn new_show_checklist(n_args: usize, args: *const Obj, kwargs: *mut M
let button: TString = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?;
let active: usize = kwargs.get(Qstr::MP_QSTR_active)?.try_into()?;
let items: Obj = kwargs.get(Qstr::MP_QSTR_items)?;
+ let back_button: bool = kwargs.get_or(Qstr::MP_QSTR_back_button, false)?;
let items: [TString<'static>; MAX_CHECKLIST_ITEMS] = util::iter_into_array(items)?;
- let layout = ModelUI::show_checklist(title, button, active, items)?;
+ let layout = ModelUI::show_checklist(title, button, active, items, back_button)?;
Ok(LayoutObj::new_root(layout)?.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
@@ -1874,6 +1875,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// items: Iterable[str],
/// active: int,
/// button: str,
+ /// back_button: bool = False,
/// ) -> LayoutContext[UiResult]:
/// """Checklist of backup steps. Active index is highlighted, previous items have check
/// mark next to them. Limited to 3 items."""
### core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -819,6 +819,7 @@ impl FirmwareUI for UIBolt {
button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
+ _back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut paragraphs = ParagraphVecLong::new();
for (i, item) in items.into_iter().enumerate() {
### core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -1004,6 +1004,7 @@ impl FirmwareUI for UICaesar {
button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
+ _back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut paragraphs = ParagraphVecLong::new();
for (i, item) in items.into_iter().enumerate() {
### core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -804,6 +804,7 @@ impl FirmwareUI for UIDelizia {
_button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
+ _back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut paragraphs = ParagraphVecLong::new();
for (i, item) in items.into_iter().enumerate() {
### core/embed/rust/src/ui/layout_eckhart/component_msg_obj.rs
@@ -8,15 +8,15 @@ use super::firmware::{
TextScreen, TextScreenMsg, ValueInput, ValueInputScreen, ValueInputScreenMsg,
};
use crate::micropython::{Error, Obj};
-use crate::ui::component::MsgMap;
+use crate::ui::component::text::paragraphs::{Checklist, ParagraphVecShort};
#[cfg(not(feature = "clippy"))]
use crate::ui::component::{
text::paragraphs::{ParagraphSource, Paragraphs},
Component, Timeout,
};
-use crate::ui::flow::FlowMsg;
+use crate::ui::component::{FlowMsg, MsgMap};
use crate::ui::layout::obj::ComponentMsgObj;
-use crate::ui::layout::result::{CANCELLED, CONFIRMED, INFO};
+use crate::ui::layout::result::{BACK, CANCELLED, CONFIRMED, INFO};
impl ComponentMsgObj for PinKeyboard<'_> {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
@@ -136,6 +136,19 @@ impl<T: ValueInput> ComponentMsgObj for ValueInputScreen<T> {
}
}
+/// Layout returned by `show_checklist`: the checklist of backup steps with an
+/// optional back button (chevron-up) in place of the cancel button.
+pub type ChecklistScreen =
+ MsgMap<TextScreen<Checklist<ParagraphVecShort<'static>>>, fn(TextScreenMsg) -> Option<FlowMsg>>;
+
+impl ComponentMsgObj for ChecklistScreen {
+ fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
+ // `TryFrom<FlowMsg> for Obj` covers all the emitted variants,
+ // including `FlowMsg::Back`.
+ msg.try_into()
+ }
+}
+
/// Layout returned by `request_number`: the number input screen with the
/// "more info" menu button in the header.
pub type RequestNumberScreen =
@@ -148,6 +161,7 @@ impl ComponentMsgObj for RequestNumberScreen {
let value: u32 = self.inner().value();
match msg {
FlowMsg::Confirmed => Ok((CONFIRMED.as_obj(), value).try_into()?),
+ FlowMsg::Back => Ok((BACK.as_obj(), value).try_into()?),
FlowMsg::Info => Ok((INFO.as_obj(), value).try_into()?),
msg => msg.try_into(),
}
### core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -800,8 +800,12 @@ impl FirmwareUI for UIEckhart {
// the header emits `FlowMsg::Info` and the layout returns the
// currently displayed number along with the result (see
// `ComponentMsgObj for RequestNumberScreen`).
+ // The left action bar button (chevron-up) emits
+ // `ValueInputScreenMsg::Cancelled`; interpret it as going back. Going
+ // back is the only way out of the prompt -- interrupting the flow is
+ // not possible.
let map_fn: fn(ValueInputScreenMsg) -> Option<FlowMsg> = |msg| match msg {
- ValueInputScreenMsg::Cancelled => Some(FlowMsg::Cancelled),
+ ValueInputScreenMsg::Cancelled => Some(FlowMsg::Back),
ValueInputScreenMsg::Confirmed(_) => Some(FlowMsg::Confirmed),
ValueInputScreenMsg::Menu => Some(FlowMsg::Info),
ValueInputScreenMsg::Changed(_) => None,
@@ -812,9 +816,10 @@ impl FirmwareUI for UIEckhart {
description.unwrap_or(TString::empty()),
)
.with_header(Header::new(title).with_menu_button())
- .with_action_bar(ActionBar::new_single(Button::with_text(
- TR::buttons__confirm.into(),
- )))
+ .with_action_bar(ActionBar::new_double(
+ Button::with_icon(theme::ICON_CHEVRON_UP),
+ Button::with_text(TR::buttons__confirm.into()),
+ ))
.map(map_fn),
);
Ok(layout)
@@ -979,6 +984,7 @@ impl FirmwareUI for UIEckhart {
button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
+ back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut paragraphs = ParagraphVecShort::new();
for (i, item) in items.into_iter().enumerate() {
@@ -1003,13 +1009,30 @@ impl FirmwareUI for UIEckhart {
.with_done_offset(theme::CHECKLIST_DONE_OFFSET)
.with_current_offset(theme::CHECKLIST_CURRENT_OFFSET);
+ let action_bar = if back_button {
+ ActionBar::new_double(
+ Button::with_icon(theme::ICON_CHEVRON_UP),
+ Button::with_text(button),
+ )
+ } else {
+ ActionBar::new_single(Button::with_text(button))
+ };
+
+ // The left action bar button (only present when `back_button` is set)
+ // emits `TextScreenMsg::Cancelled`; interpret it as going back. Going
+ // back is the only way out of the checklist -- interrupting the flow
+ // is not possible.
+ let map_fn: fn(TextScreenMsg) -> Option<FlowMsg> = |msg| match msg {
+ TextScreenMsg::Cancelled => Some(FlowMsg::Back),
+ TextScreenMsg::Menu => Some(FlowMsg::Info),
+ TextScreenMsg::Confirmed => Some(FlowMsg::Confirmed),
+ };
+
let layout = RootComponent::new(
TextScreen::new(checklist_content)
.with_header(Header::new(title))
- .with_action_bar(ActionBar::new_double(
- Button::with_icon(theme::ICON_CROSS),
- Button::with_text(button),
- )),
+ .with_action_bar(action_bar)
+ .map(map_fn),
);
Ok(layout)
### core/embed/rust/src/ui/ui_firmware.rs
@@ -420,6 +420,7 @@ pub trait FirmwareUI {
button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
+ back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error>;
fn show_danger(
### core/mocks/generated/trezorui_api.pyi
@@ -594,6 +594,7 @@ def show_checklist(
items: Iterable[str],
active: int,
button: str,
+ back_button: bool = False,
) -> LayoutContext[UiResult]:
"""Checklist of backup steps. Active index is highlighted, previous items have check
mark next to them. Limited to 3 items."""
### core/src/apps/management/reset_device/__init__.py
@@ -3,6 +3,7 @@
import storage
import storage.device as storage_device
+import trezorui_api
from trezor import TR
from trezor.crypto import hmac, slip39
from trezor.enums import BackupType, MessageType
@@ -224,14 +225,65 @@ async def _backup_slip39_basic(
await handler.intro()
- # get number of shares
- await layout.slip39_show_checklist(0, advanced=False)
- share_count = await layout.slip39_prompt_number_of_shares(num_of_words)
+ share_count: int | None = None
+ share_threshold: int | None = None
- # get threshold
- await layout.slip39_show_checklist(1, advanced=False, count=share_count)
- share_threshold = await layout.slip39_prompt_threshold(share_count)
+ # Let the user go back and forth between the steps, keeping the already
+ # entered values. `BACK` results can only occur on layouts that support
+ # going back; other layouts only ever confirm and march forward.
+ step = 0
+ while True:
+ if step == 0:
+ # checklist: set number of shares
+ # (no going back from the first step)
+ await layout.slip39_show_checklist(0, advanced=False)
+ step = 1
+ elif step == 1:
+ # get number of shares
+ result = await layout.slip39_prompt_number_of_shares(
+ num_of_words, init_value=share_count
+ )
+ if result is trezorui_api.BACK:
+ step = 0
+ else:
+ assert isinstance(result, int)
+ share_count = result
+ step = 2
+ elif step == 2:
+ # checklist: set threshold
+ assert share_count is not None
+ result = await layout.slip39_show_checklist(
+ 1, advanced=False, count=share_count, back_button=True
+ )
+ step = 1 if result is trezorui_api.BACK else 3
+ elif step == 3:
+ # get threshold
+ assert share_count is not None
+ result = await layout.slip39_prompt_threshold(
+ share_count, init_value=share_threshold
+ )
+ if result is trezorui_api.BACK:
+ step = 2
+ else:
+ assert isinstance(result, int)
+ share_threshold = result
+ step = 4
+ else:
+ # checklist: write down and check the shares
+ assert share_count is not None and share_threshold is not None
+ result = await layout.slip39_show_checklist(
+ 2,
+ advanced=False,
+ count=share_count,
+ threshold=share_threshold,
+ back_button=True,
+ )
+ if result is trezorui_api.BACK:
+ step = 3
+ else:
+ break
+ assert share_count is not None and share_threshold is not None
mnemonics = _get_slip39_mnemonics(
encrypted_master_secret,
group_threshold,
@@ -240,12 +292,77 @@ async def _backup_slip39_basic(
)
# show and confirm individual shares
- await layout.slip39_show_checklist(
- 2, advanced=False, count=share_count, threshold=share_threshold
- )
await layout.slip39_basic_show_and_confirm_shares(handler, mnemonics[0])
+async def _prompt_slip39_groups(
+ num_of_words: int,
+ groups_count: int,
+ groups: list[tuple[int, int]],
+) -> bool:
+ """Prompt for the share count and threshold of each group, letting the
+ user go back and forth between the groups. The entered (threshold, share
+ count) pairs are stored in `groups`, preselecting the previously entered
+ values.
+
+ Returns False if the user wants to go back from the first group,
+ otherwise True.
+ """
+ group_index = 0
+ # share count of the group currently being configured
+ pending_share_count: int | None = None
+ # whether to prompt for the group's share count or go straight to its
+ # threshold (used when going back to a previous group)
+ prompt_shares = True
+ while group_index < groups_count:
+ if prompt_shares:
+ # get number of shares of the current group
+ result = await layout.slip39_prompt_number_of_shares(
+ num_of_words,
+ group_index,
+ init_value=(
+ pending_share_count
+ if pending_share_count is not None
+ else groups[group_index][1]
+ if group_index < len(groups)
+ else None
+ ),
+ )
+ if result is trezorui_api.BACK:
+ if group_index == 0:
+ return False # back to the checklist
+ # back to the previous group's threshold
+ group_index -= 1
+ pending_share_count = groups[group_index][1]
+ prompt_shares = False
+ continue
+ assert isinstance(result, int)
+ pending_share_count = result
+
+ # get threshold of the current group
+ assert pending_share_count is not None
+ result = await layout.slip39_prompt_threshold(
+ pending_share_count,
+ group_index,
+ init_value=(groups[group_index][0] if group_index < len(groups) else None),
+ )
+ if result is trezorui_api.BACK:
+ # back to the current group's number of shares
+ prompt_shares = True
+ continue
+ assert isinstance(result, int)
+ group = (result, pending_share_count)
+ if group_index < len(groups):
+ groups[group_index] = group
+ else:
+ assert group_index == len(groups)
+ groups.append(group)
+ pending_share_count = None
+ group_index += 1
+ prompt_shares = True
+ return True
+
+
async def _backup_slip39_advanced(
handler: layout.BackupHandler,
encrypted_master_secret: bytes,
@@ -254,24 +371,71 @@ async def _backup_slip39_advanced(
) -> None:
await handler.intro()
- # get number of groups
- await layout.slip39_show_checklist(0, advanced=True)
- groups_count = await layout.slip39_advanced_prompt_number_of_groups()
+ groups_count: int | None = None
+ group_threshold: int | None = None
+ groups: list[tuple[int, int]] = []
- # get group threshold
- await layout.slip39_show_checklist(1, advanced=True, count=groups_count)
- group_threshold = await layout.slip39_advanced_prompt_group_threshold(groups_count)
-
- # get shares and thresholds
- await layout.slip39_show_checklist(
- 2, advanced=True, count=groups_count, threshold=group_threshold
- )
- groups = []
- for i in range(groups_count):
- share_count = await layout.slip39_prompt_number_of_shares(num_of_words, i)
- share_threshold = await layout.slip39_prompt_threshold(share_count, i)
- groups.append((share_threshold, share_count))
+ # Let the user go back and forth between the steps, keeping the already
+ # entered values. `BACK` results can only occur on layouts that support
+ # going back; other layouts only ever confirm and march forward.
+ step = 0
+ while True:
+ if step == 0:
+ # checklist: set number of groups
+ # (no going back from the first step)
+ await layout.slip39_show_checklist(0, advanced=True)
+ step = 1
+ elif step == 1:
+ # get number of groups
+ result = await layout.slip39_advanced_prompt_number_of_groups(
+ init_value=groups_count
+ )
+ if result is trezorui_api.BACK:
+ step = 0
+ else:
+ assert isinstance(result, int)
+ groups_count = result
+ # keep the already entered groups that still fit
+ del groups[groups_count:]
+ step = 2
+ elif step == 2:
+ # checklist: set group threshold
+ assert groups_count is not None
+ result = await layout.slip39_show_checklist(
+ 1, advanced=True, count=groups_count, back_button=True
+ )
+ step = 1 if result is trezorui_api.BACK else 3
+ elif step == 3:
+ # get group threshold
+ assert groups_count is not None
+ result = await layout.slip39_advanced_prompt_group_threshold(
+ groups_count, init_value=group_threshold
+ )
+ if result is trezorui_api.BACK:
+ step = 2
+ else:
+ assert isinstance(result, int)
+ group_threshold = result
+ step = 4
+ elif step == 4:
+ # checklist: set sizes and thresholds of the individual groups
+ assert groups_count is not None and group_threshold is not None
+ result = await layout.slip39_show_checklist(
+ 2,
+ advanced=True,
+ count=groups_count,
+ threshold=group_threshold,
+ back_button=True,
+ )
+ step = 3 if result is trezorui_api.BACK else 5
+ else:
+ # get sizes and thresholds of the individual groups
+ assert step == 5 and groups_count is not None
+ if await _prompt_slip39_groups(num_of_words, groups_count, groups):
+ break
+ step = 4 # back to the checklist
+ assert group_threshold is not None
mnemonics = _get_slip39_mnemonics(
encrypted_master_secret, group_threshold, groups, extendable
)
### core/src/trezor/ui/layouts/bolt/reset.py
@@ -77,7 +77,8 @@ async def slip39_show_checklist(
advanced: bool,
count: int | None = None,
threshold: int | None = None,
-) -> None:
+ back_button: bool = False,
+) -> trezorui_api.UiResult:
items = (
(
TR.reset__slip39_checklist_set_num_shares,
@@ -98,9 +99,7 @@ async def slip39_show_checklist(
active=step,
items=items,
) as layout:
- return await raise_if_not_confirmed(
- layout, "slip39_checklist", ButtonRequestType.ResetDevice
- )
+ return await interact(layout, "slip39_checklist", ButtonRequestType.ResetDevice)
async def _prompt_number(
@@ -147,13 +146,17 @@ async def _prompt_number(
def slip39_prompt_threshold(
- num_of_shares: int, group_id: int | None = None
-) -> Awaitable[int]:
- count = num_of_shares // 2 + 1
+ num_of_shares: int, group_id: int | None = None, init_value: int | None = None
+) -> Awaitable[int | trezorui_api.UiResult]:
# min value of share threshold is 2 unless the number of shares is 1
# number of shares 1 is possible in advanced slip39
min_count = min(2, num_of_shares)
max_count = num_of_shares
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_shares // 2 + 1
def description(count: int) -> str:
if group_id is None:
@@ -203,11 +206,15 @@ def info(count: int) -> str:
def slip39_prompt_number_of_shares(
- num_words: int, group_id: int | None = None
-) -> Awaitable[int]:
- count = 5
+ num_words: int, group_id: int | None = None, init_value: int | None = None
+) -> Awaitable[int | trezorui_api.UiResult]:
min_count = 1
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
def description(i: int) -> str:
if group_id is None:
@@ -238,10 +245,16 @@ def description(i: int) -> str:
)
-def slip39_advanced_prompt_number_of_groups() -> Awaitable[int]:
- count = 5
+def slip39_advanced_prompt_number_of_groups(
+ init_value: int | None = None,
+) -> Awaitable[int | trezorui_api.UiResult]:
min_count = 2
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
description = TR.reset__group_description
info = TR.reset__group_info
@@ -256,10 +269,16 @@ def slip39_advanced_prompt_number_of_groups() -> Awaitable[int]:
)
-def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> Awaitable[int]:
- count = num_of_groups // 2 + 1
+def slip39_advanced_prompt_group_threshold(
+ num_of_groups: int, init_value: int | None = None
+) -> Awaitable[int | trezorui_api.UiResult]:
min_count = 1
max_count = num_of_groups
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_groups // 2 + 1
description = TR.reset__required_number_of_groups
info = TR.reset__advanced_group_threshold_info
### core/src/trezor/ui/layouts/caesar/reset.py
@@ -4,7 +4,7 @@
from trezor import TR
from trezor.enums import ButtonRequestType
-from ..common import interact, raise_if_not_confirmed
+from ..common import interact
from . import confirm_action, show_success, show_warning
CONFIRMED = trezorui_api.CONFIRMED # global_import_cache
@@ -104,7 +104,8 @@ async def slip39_show_checklist(
advanced: bool,
count: int | None = None,
threshold: int | None = None,
-) -> None:
+ back_button: bool = False,
+) -> trezorui_api.UiResult:
items = (
(
TR.reset__slip39_checklist_num_shares,
@@ -125,9 +126,7 @@ async def slip39_show_checklist(
active=step,
items=items,
) as layout:
- return await raise_if_not_confirmed(
- layout, "slip39_checklist", ButtonRequestType.ResetDevice
- )
+ return await interact(layout, "slip39_checklist", ButtonRequestType.ResetDevice)
async def _prompt_number(
@@ -161,8 +160,8 @@ async def _prompt_number(
async def slip39_prompt_threshold(
- num_of_shares: int, group_id: int | None = None
-) -> int:
+ num_of_shares: int, group_id: int | None = None, init_value: int | None = None
+) -> int | trezorui_api.UiResult:
await confirm_action(
"slip39_prompt_threshold",
TR.words__title_threshold,
@@ -171,11 +170,15 @@ async def slip39_prompt_threshold(
verb_cancel=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 advanced slip39
min_count = min(2, num_of_shares)
max_count = num_of_shares
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_shares // 2 + 1
if group_id is not None:
title = f"{TR.words__title_threshold} - {TR.words__title_group} {group_id + 1}"
@@ -192,8 +195,8 @@ async def slip39_prompt_threshold(
async def slip39_prompt_number_of_shares(
- _num_words: int, group_id: int | None = None
-) -> int:
+ _num_words: int, group_id: int | None = None, init_value: int | None = None
+) -> int | trezorui_api.UiResult:
await confirm_action(
"slip39_shares",
TR.reset__title_number_of_shares,
@@ -202,9 +205,13 @@ async def slip39_prompt_number_of_shares(
verb_cancel=None,
)
- count = 5
min_count = 1
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
if group_id is not None:
title = f"# {TR.words__title_shares} - {TR.words__title_group} {group_id + 1}"
@@ -220,10 +227,16 @@ async def slip39_prompt_number_of_shares(
)
-def slip39_advanced_prompt_number_of_groups() -> Awaitable[int]:
- count = 5
+def slip39_advanced_prompt_number_of_groups(
+ init_value: int | None = None,
+) -> Awaitable[int | trezorui_api.UiResult]:
min_count = 2
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
return _prompt_number(
TR.reset__title_number_of_groups,
@@ -234,10 +247,16 @@ def slip39_advanced_prompt_number_of_groups() -> Awaitable[int]:
)
-def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> Awaitable[int]:
- count = num_of_groups // 2 + 1
+def slip39_advanced_prompt_group_threshold(
+ num_of_groups: int, init_value: int | None = None
+) -> Awaitable[int | trezorui_api.UiResult]:
min_count = 1
max_count = num_of_groups
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_groups // 2 + 1
return _prompt_number(
TR.reset__title_group_threshold,
### core/src/trezor/ui/layouts/delizia/reset.py
@@ -94,7 +94,8 @@ async def slip39_show_checklist(
advanced: bool,
count: int | None = None,
threshold: int | None = None,
-) -> None:
+ back_button: bool = False,
+) -> ui.UiResult:
items = _slip_39_checklist_items(step, advanced, count, threshold)
with trezorui_api.show_checklist(
title=TR.reset__title_shamir_backup,
@@ -107,6 +108,7 @@ async def slip39_show_checklist(
)
if result != CONFIRMED:
raise ActionCancelled
+ return result
def _slip_39_checklist_items(
@@ -199,13 +201,17 @@ async def _prompt_number(
def slip39_prompt_threshold(
- num_of_shares: int, group_id: int | None = None
-) -> Awaitable[int]:
- count = num_of_shares // 2 + 1
+ num_of_shares: int, group_id: int | None = None, init_value: int | None = None
+) -> Awaitable[int | ui.UiResult]:
# min value of share threshold is 2 unless the number of shares is 1
# number of shares 1 is possible in advanced slip39
min_count = min(2, num_of_shares)
max_count = num_of_shares
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_shares // 2 + 1
description = (
TR.reset__select_threshold
@@ -234,11 +240,15 @@ def info(count: int) -> str:
async def slip39_prompt_number_of_shares(
- num_words: int, group_id: int | None = None
-) -> int:
- count = 5
+ num_words: int, group_id: int | None = None, init_value: int | None = None
+) -> int | ui.UiResult:
min_count = 1
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
description = (
TR.reset__num_of_shares_how_many
@@ -264,10 +274,16 @@ async def slip39_prompt_number_of_shares(
)
-async def slip39_advanced_prompt_number_of_groups() -> int:
- count = 5
+async def slip39_advanced_prompt_number_of_groups(
+ init_value: int | None = None,
+) -> int | ui.UiResult:
min_count = 2
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
description = TR.reset__group_description
info = TR.reset__group_info
@@ -282,10 +298,16 @@ async def slip39_advanced_prompt_number_of_groups() -> int:
)
-async def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> int:
- count = num_of_groups // 2 + 1
+async def slip39_advanced_prompt_group_threshold(
+ num_of_groups: int, init_value: int | None = None
+) -> int | ui.UiResult:
min_count = 1
max_count = num_of_groups
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_groups // 2 + 1
description = TR.reset__required_number_of_groups
info = TR.reset__advanced_group_threshold_info
### core/src/trezor/ui/layouts/eckhart/reset.py
@@ -6,13 +6,11 @@
from trezor.wire import ActionCancelled
from ..common import interact
-from . import raise_if_not_confirmed, show_success
+from . import BACK, CANCELLED, CONFIRMED, raise_if_not_confirmed, show_success
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Sequence
-CONFIRMED = trezorui_api.CONFIRMED # global_import_cache
-
async def show_share_words(
share_words: Sequence[str],
@@ -118,19 +116,26 @@ async def slip39_show_checklist(
advanced: bool,
count: int | None = None,
threshold: int | None = None,
-) -> None:
+ back_button: bool = False,
+) -> ui.UiResult:
items = _slip_39_checklist_items(step, advanced, count, threshold)
with trezorui_api.show_checklist(
title=TR.reset__title_shamir_backup,
button=TR.buttons__continue,
active=step,
items=items,
+ back_button=back_button,
) as layout:
result = await interact(
- layout, "slip39_checklist", ButtonRequestType.ResetDevice
+ layout,
+ "slip39_checklist",
+ ButtonRequestType.ResetDevice,
+ raise_on_cancel=None,
)
- if result != CONFIRMED:
+ if result is CANCELLED:
+ # not reachable from the UI, only via debuglink
raise ActionCancelled
+ return result
def _slip_39_checklist_items(
@@ -175,7 +180,7 @@ async def _prompt_number(
min_count: int,
max_count: int,
br_name: str,
-) -> int:
+) -> int | ui.UiResult:
from trezor.ui.layouts.menu import Menu, leaf_from_layout, show_menu
with trezorui_api.request_number(
@@ -195,8 +200,9 @@ async def _prompt_number(
)
br_name_once = None # ButtonRequest should be sent only once
- if result is trezorui_api.CANCELLED:
- raise ActionCancelled # user cancelled request number prompt
+ if result is CANCELLED:
+ # not reachable from the UI, only via debuglink
+ raise ActionCancelled
if __debug__ and not isinstance(result, tuple):
# sent by debuglink. debuglink does not change the number of
@@ -208,6 +214,9 @@ async def _prompt_number(
assert isinstance(value, int)
return value
+ if result is BACK:
+ return BACK
+
if result is trezorui_api.INFO:
# shows the menu with the "more info" screen
leaf = leaf_from_layout(
@@ -223,13 +232,17 @@ async def _prompt_number(
def slip39_prompt_threshold(
- num_of_shares: int, group_id: int | None = None
-) -> Awaitable[int]:
- count = num_of_shares // 2 + 1
+ num_of_shares: int, group_id: int | None = None, init_value: int | None = None
+) -> Awaitable[int | ui.UiResult]:
# min value of share threshold is 2 unless the number of shares is 1
# number of shares 1 is possible in advanced slip39
min_count = min(2, num_of_shares)
max_count = num_of_shares
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_shares // 2 + 1
description = (
TR.reset__select_threshold
@@ -258,11 +271,15 @@ def info(count: int) -> str:
async def slip39_prompt_number_of_shares(
- num_words: int, group_id: int | None = None
-) -> int:
- count = 5
+ num_words: int, group_id: int | None = None, init_value: int | None = None
+) -> int | ui.UiResult:
min_count = 1
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
description = (
TR.reset__num_of_shares_how_many
@@ -288,10 +305,16 @@ async def slip39_prompt_number_of_shares(
)
-async def slip39_advanced_prompt_number_of_groups() -> int:
- count = 5
+async def slip39_advanced_prompt_number_of_groups(
+ init_value: int | None = None,
+) -> int | ui.UiResult:
min_count = 2
max_count = 16
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = 5
description = TR.reset__group_description
info = TR.reset__group_info
@@ -306,10 +329,16 @@ async def slip39_advanced_prompt_number_of_groups() -> int:
)
-async def slip39_advanced_prompt_group_threshold(num_of_groups: int) -> int:
- count = num_of_groups // 2 + 1
+async def slip39_advanced_prompt_group_threshold(
+ num_of_groups: int, init_value: int | None = None
+) -> int | ui.UiResult:
min_count = 1
max_count = num_of_groups
+ if init_value is not None:
+ # returning to the prompt with a previously entered value
+ count = max(min_count, min(init_value, max_count))
+ else:
+ count = num_of_groups // 2 + 1
description = TR.reset__required_number_of_groups
info = TR.reset__advanced_group_threshold_info
### tests/device_tests/test_msg_backup_device.py
@@ -36,8 +36,10 @@
FlowAdapter,
InputFlowBip39Backup,
InputFlowSlip39AdvancedBackup,
+ InputFlowSlip39AdvancedBackupBackNavigation,
InputFlowSlip39AdvancedCustomBackup,
InputFlowSlip39BasicBackup,
+ InputFlowSlip39BasicBackupBackNavigation,
InputFlowSlip39CustomBackup,
normal,
try_to_cancel,
@@ -188,6 +190,61 @@ def test_backup_slip39_advanced(
assert expected_ms == actual_ms
+@pytest.mark.models("eckhart") # going back is supported only on Eckhart
+@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_BASIC_20_3of6)
+def test_backup_slip39_basic_back_navigation(
+ session: Session, backup_method: messages.BackupMethod
+):
+ with session.test_ctx as client:
+ IF = InputFlowSlip39BasicBackupBackNavigation(session, method=backup_method)
+ client.set_input_flow(IF.get())
+ device.backup(session, backup_method=backup_method)
+
+ session.refresh_features()
+ assert session.features.initialized is True
+ assert (
+ session.features.backup_availability == messages.BackupAvailability.NotAvailable
+ )
+ assert session.features.unfinished_backup is False
+ assert session.features.no_backup is False
+ assert session.features.backup_type is messages.BackupType.Slip39_Basic
+
+ # the flow went back and forth and settled on 3-of-3 shares
+ assert len(IF.mnemonics) == 3
+ expected_ms = shamir.combine_mnemonics(MNEMONIC_SLIP39_BASIC_20_3of6)
+ actual_ms = shamir.combine_mnemonics(IF.mnemonics)
+ assert expected_ms == actual_ms
+
+
+@pytest.mark.models("eckhart") # going back is supported only on Eckhart
+@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_ADVANCED_20)
+def test_backup_slip39_advanced_back_navigation(
+ session: Session, backup_method: messages.BackupMethod
+):
+ with session.test_ctx as client:
+ IF = InputFlowSlip39AdvancedBackupBackNavigation(
+ session, method=backup_method
+ )
+ client.set_input_flow(IF.get())
+ device.backup(session, backup_method=backup_method)
+
+ session.refresh_features()
+ assert session.features.initialized is True
+ assert (
+ session.features.backup_availability == messages.BackupAvailability.NotAvailable
+ )
+ assert session.features.unfinished_backup is False
+ assert session.features.no_backup is False
+ assert session.features.backup_type is messages.BackupType.Slip39_Advanced
+
+ # the flow went back and forth and settled on groups 2-of-3 and 3-of-5
+ # with group threshold 2
+ assert [len(group) for group in IF.mnemonics] == [3, 5]
+ expected_ms = shamir.combine_mnemonics(MNEMONIC_SLIP39_ADVANCED_20)
+ actual_ms = shamir.combine_mnemonics(IF.mnemonics[0][:2] + IF.mnemonics[1][:3])
+ assert expected_ms == actual_ms
+
+
SLIP39_CUSTOM_PARAMS = [
(threshold, count, adapt_flow)
for threshold, count in ((1, 1), (2, 2), (3, 5))
### tests/input_flows.py
@@ -2717,6 +2717,187 @@ def input_flow_eckhart(self) -> BRGeneratorType:
self.debug.press_yes()
+class InputFlowSlip39BasicBackupBackNavigation(InputFlowBase):
+ """Going back and forth in the SLIP39 basic backup creation, changing the
+ number of shares and the threshold."""
+
+ def __init__(
+ self,
+ client: Client | DebugSession,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
+ ):
+ super().__init__(client)
+ self.mnemonics: list[str] = []
+ self.method = method
+
+ def input_flow_eckhart(self) -> BRGeneratorType:
+ assert self.method in (
+ messages.BackupMethod.Display,
+ messages.BackupMethod.N1W1,
+ )
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_intro"
+ self.debug.press_yes()
+
+ # checklist: set number of shares (no going back from the first step)
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # number of shares prompt: change the default 5 to 3
+ assert (yield).name == "slip39_shares"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 5
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: set threshold -- go back from here
+ assert (yield).name == "slip39_checklist"
+ self.debug.click(self.debug.screen_buttons.cancel())
+
+ # number of shares prompt: the previously entered value is preselected
+ assert (yield).name == "slip39_shares"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 3
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: set threshold
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # threshold prompt: increase the default 2 to 3
+ assert (yield).name == "slip39_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 2
+ self.debug.click(self.debug.screen_buttons.number_input_plus())
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: write down the shares -- go back from here
+ assert (yield).name == "slip39_checklist"
+ self.debug.click(self.debug.screen_buttons.cancel())
+
+ # threshold prompt: the previously entered value is preselected
+ assert (yield).name == "slip39_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 3
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: write down the shares
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_warning"
+ self.debug.press_yes()
+
+ # Mnemonic phrases
+ self.mnemonics = yield from load_N_shares(self.debug, 3, self.method)
+
+
+class InputFlowSlip39AdvancedBackupBackNavigation(InputFlowBase):
+ """Going back and forth in the SLIP39 advanced backup creation, changing
+ the group parameters. Only for Eckhart, which supports going back in this
+ flow."""
+
+ def __init__(
+ self,
+ client: Client | DebugSession,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
+ ):
+ super().__init__(client)
+ self.mnemonics: list[list[str]] = []
+ self.method = method
+
+ def input_flow_eckhart(self) -> BRGeneratorType:
+ assert self.method in (
+ messages.BackupMethod.Display,
+ messages.BackupMethod.N1W1,
+ )
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_intro"
+ self.debug.press_yes()
+
+ # checklist: set number of groups (no going back from the first step)
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # number of groups prompt: change the default 5 to 2
+ assert (yield).name == "slip39_groups"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 5
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: set group threshold
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # group threshold prompt: keep the default 2
+ assert (yield).name == "slip39_group_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 2
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # checklist: set sizes and thresholds of the groups
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # group 1 shares prompt: change the default 5 to 3
+ assert (yield).name == "slip39_shares"
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.number_input_minus())
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # group 1 threshold prompt: keep the default 2
+ assert (yield).name == "slip39_threshold"
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # group 2 shares prompt: keep the default 5 -- go back from here
+ assert (yield).name == "slip39_shares"
+ self.debug.click(self.debug.screen_buttons.cancel())
+
+ # group 1 threshold prompt: the previously entered value is preselected
+ assert (yield).name == "slip39_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 2
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # group 2 shares prompt: keep the default 5
+ assert (yield).name == "slip39_shares"
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # group 2 threshold prompt: change the default 3 to 4 -- go back
+ assert (yield).name == "slip39_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 3
+ self.debug.click(self.debug.screen_buttons.number_input_plus())
+ self.debug.click(self.debug.screen_buttons.cancel())
+
+ # group 2 shares prompt: the previously entered value is preselected
+ assert (yield).name == "slip39_shares"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 5
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ # group 2 threshold prompt: the default 3 is shown again -- keep it
+ assert (yield).name == "slip39_threshold"
+ layout = self.debug.read_layout()
+ assert layout.find_unique_value_by_key("value", None, only_type=int) == 3
+ self.debug.click(self.debug.screen_buttons.ok())
+
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_warning"
+ self.debug.press_yes()
+
+ # Mnemonic phrases - show & confirm shares for all groups
+ # 2 groups: 2-of-3 and 3-of-5
+ self.mnemonics = yield from load_N_groups(
+ self.debug, [(2, 3), (3, 5)], self.method
+ )
+
+
class InputFlowSlip39AdvancedResetRecovery(InputFlowBase):
def __init__(
self,Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.