feat(core/caesar): allow back in multishare setup
What changed, and why it matters
This commit adds a 'go back' navigation feature to the backup setup screens on Trezor's older 'Caesar' device layout. It lets users step backward through the multi-share backup checklist and number-selection carousels, matching behavior already available on newer layouts. There is no indication this fixes a security vulnerability; it is a user-experience improvement.
No security action required. Treat as a normal feature/UX commit. If reviewing for release notes, note improved back navigation during SLIP39 backup on Caesar layout.
Security signals we found
No memory-safety changes
No cryptographic changes
No input validation changes
No privilege or authorization changes
UI flow change only
Evidence from the diff
The change wires up the previously ignored back_button parameter in show_checklist, adds a BACK item to the leftmost position of the NumberInput carousel, and propagates a new BACK result through the Python reset flow so the SLIP39 setup can return to the previous step. Extensive test flows are added for the Caesar layout to exercise basic and advanced SLIP39 back navigation.
Changed components
core/embed/rust/src/ui/layout_caesar/component/input_methods/number_input.rscore/embed/rust/src/ui/layout_caesar/component/mod.rscore/embed/rust/src/ui/layout_caesar/component_msg_obj.rscore/embed/rust/src/ui/layout_caesar/ui_firmware.rscore/src/trezor/ui/layouts/caesar/reset.pytests/click_tests/test_repeated_backup.pytests/device_tests/test_msg_backup_device.pytests/input_flows.pyInspect captured patch +282 / −36
### core/embed/rust/src/ui/layout_caesar/component/input_methods/number_input.rs
@@ -1,10 +1,19 @@
-use super::super::{ButtonLayout, ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage};
+use super::super::{theme, ButtonLayout, ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage};
use crate::strutil::ShortString;
use crate::translations::TR;
use crate::ui::component::{Component, Event, EventCtx};
use crate::ui::geometry::Rect;
use crate::ui::shape::Renderer;
+/// Action of a single choice in the number input carousel: either a number
+/// from the offered range, or going back to the previous screen (the leftmost
+/// item).
+#[derive(Clone, Copy)]
+pub enum NumberInputAction {
+ Back,
+ Number(u32),
+}
+
struct ChoiceFactoryNumberInput {
min: u32,
max: u32,
@@ -17,45 +26,60 @@ impl ChoiceFactoryNumberInput {
}
impl ChoiceFactory for ChoiceFactoryNumberInput {
- type Action = u32;
+ type Action = NumberInputAction;
type Item = ChoiceItem;
fn count(&self) -> usize {
- (self.max - self.min + 1) as usize
+ // one extra item for going back
+ (self.max - self.min + 2) as usize
}
fn get(&self, choice_index: usize) -> (Self::Item, Self::Action) {
- let num = self.min + choice_index as u32;
- let text = unwrap!(ShortString::try_from(num));
- let mut choice_item = ChoiceItem::new(
- text,
- ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
- );
-
- // Disabling prev/next buttons for the first/last choice.
- // (could be done to the same button if there is only one)
if choice_index == 0 {
- choice_item.set_left_btn(None);
- }
- if choice_index == <ChoiceFactoryNumberInput as ChoiceFactory>::count(self) - 1 {
- choice_item.set_right_btn(None);
- }
+ // the leftmost item goes back to the previous screen when
+ // confirmed; there is no going further left from it
+ (
+ TR::inputs__back.map_translated(|t| {
+ let mut choice_item = ChoiceItem::new(
+ t,
+ ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
+ )
+ .with_icon(theme::ICON_ARROW_BACK_UP);
+ choice_item.set_left_btn(None);
+ choice_item
+ }),
+ NumberInputAction::Back,
+ )
+ } else {
+ let num = self.min + choice_index as u32 - 1;
+ let text = unwrap!(ShortString::try_from(num));
+ let mut choice_item = ChoiceItem::new(
+ text,
+ ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()),
+ );
+
+ // Disabling the next button for the last choice.
+ if choice_index == <ChoiceFactoryNumberInput as ChoiceFactory>::count(self) - 1 {
+ choice_item.set_right_btn(None);
+ }
- (choice_item, num)
+ (choice_item, NumberInputAction::Number(num))
+ }
}
}
/// Simple wrapper around `ChoicePage` that allows for
/// inputting a list of values and receiving the chosen one.
pub struct NumberInput {
- choice_page: ChoicePage<ChoiceFactoryNumberInput, u32>,
+ choice_page: ChoicePage<ChoiceFactoryNumberInput, NumberInputAction>,
min: u32,
}
impl NumberInput {
pub fn new(min: u32, max: u32, init_value: u32) -> Self {
let choices = ChoiceFactoryNumberInput::new(min, max);
- let initial_page = init_value - min;
+ // +1 to skip the leading "back" item
+ let initial_page = init_value - min + 1;
Self {
min,
choice_page: ChoicePage::new(choices).with_initial_page_counter(initial_page as usize),
@@ -64,7 +88,7 @@ impl NumberInput {
}
impl Component for NumberInput {
- type Msg = ChoiceMsg<u32>;
+ type Msg = ChoiceMsg<NumberInputAction>;
fn place(&mut self, bounds: Rect) -> Rect {
self.choice_page.place(bounds)
### core/embed/rust/src/ui/layout_caesar/component/mod.rs
@@ -54,7 +54,7 @@ pub use frame::{Frame, ScrollableFrame};
pub use homescreen::{check_homescreen_format, ConfirmHomescreen, Homescreen, Lockscreen};
#[cfg(feature = "translations")]
pub use input_methods::{
- number_input::NumberInput,
+ number_input::{NumberInput, NumberInputAction},
passphrase::PassphraseEntry,
pin::PinEntry,
simple_choice::{SimpleChoice, MAX_LENGTH as SIMPLE_CHOICE_MAX_LENGTH},
### core/embed/rust/src/ui/layout_caesar/component_msg_obj.rs
@@ -2,16 +2,18 @@ use core::convert::TryInto;
use super::component::{
AddressDetails, ButtonPage, CancelConfirmMsg, CancelInfoConfirmMsg, CoinJoinProgress,
- ConfirmHomescreen, Flow, Frame, Homescreen, Lockscreen, NumberInput, Page, PassphraseEntry,
- PinEntry, Progress, ScrollableFrame, ShowMore, WordlistEntry,
+ ConfirmHomescreen, Flow, Frame, Homescreen, Lockscreen, NumberInput, NumberInputAction, Page,
+ PassphraseEntry, PinEntry, Progress, ScrollableFrame, ShowMore, WordlistEntry,
};
use crate::micropython::{Error, Obj};
use crate::ui::component::base::Component;
use crate::ui::component::paginated::{PageMsg, Paginate};
-use crate::ui::component::text::paragraphs::{ParagraphSource, Paragraphs};
-use crate::ui::component::{Never, Timeout};
+use crate::ui::component::text::paragraphs::{
+ Checklist, ParagraphSource, ParagraphVecLong, Paragraphs,
+};
+use crate::ui::component::{FlowMsg, MsgMap, Never, Timeout};
use crate::ui::layout::obj::ComponentMsgObj;
-use crate::ui::layout::result::{CANCELLED, CONFIRMED, INFO};
+use crate::ui::layout::result::{BACK, CANCELLED, CONFIRMED, INFO};
impl From<CancelConfirmMsg> for Obj {
fn from(value: CancelConfirmMsg) -> Self {
@@ -111,11 +113,31 @@ impl ComponentMsgObj for NumberInput {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
Self::Msg::Cancel => (CANCELLED.as_obj(), 0).try_into(),
- Self::Msg::Choice { item, .. } => (CONFIRMED.as_obj(), item).try_into(),
+ Self::Msg::Choice {
+ item: NumberInputAction::Back,
+ ..
+ } => (BACK.as_obj(), 0).try_into(),
+ Self::Msg::Choice {
+ item: NumberInputAction::Number(n),
+ ..
+ } => (CONFIRMED.as_obj(), n).try_into(),
}
}
}
+/// Layout returned by `show_checklist`: the checklist of backup steps with an
+/// optional back button (up arrow) in place of the cancel button.
+pub type ChecklistScreen =
+ MsgMap<ButtonPage<Checklist<ParagraphVecLong<'static>>>, fn(PageMsg<Never>) -> 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()
+ }
+}
+
impl ComponentMsgObj for WordlistEntry {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
msg.try_into()
### core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -24,7 +24,8 @@ use crate::ui::component::text::paragraphs::{
};
use crate::ui::component::text::TextStyle;
use crate::ui::component::{
- Component, ComponentExt, Empty, FormattedText, Label, LineBreaking, Paginate, Timeout,
+ Component, ComponentExt, Empty, FlowMsg, FormattedText, Label, LineBreaking, Never, PageMsg,
+ Paginate, Timeout,
};
use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
@@ -1004,7 +1005,7 @@ impl FirmwareUI for UICaesar {
button: TString<'static>,
active: usize,
items: [TString<'static>; MAX_CHECKLIST_ITEMS],
- _back_button: bool,
+ back_button: bool,
) -> Result<impl LayoutMaybeTrace, Error> {
let mut paragraphs = ParagraphVecLong::new();
for (i, item) in items.into_iter().enumerate() {
@@ -1016,6 +1017,16 @@ impl FirmwareUI for UICaesar {
paragraphs.add(Paragraph::new(style, item));
}
let confirm_btn = Some(ButtonDetails::text(button));
+ // The left button (only present when `back_button` is set) emits
+ // `PageMsg::Cancelled`; interpret it as going back. Going back is the
+ // only way out of the checklist -- interrupting the flow is not
+ // possible.
+ let cancel_btn = back_button.then(ButtonDetails::up_arrow_icon);
+ let map_fn: fn(PageMsg<Never>) -> Option<FlowMsg> = |msg| match msg {
+ PageMsg::Cancelled => Some(FlowMsg::Back),
+ PageMsg::Confirmed => Some(FlowMsg::Confirmed),
+ _ => None,
+ };
let layout = RootComponent::new(
ButtonPage::new(
@@ -1031,7 +1042,9 @@ impl FirmwareUI for UICaesar {
.with_current_offset(theme::CHECKLIST_CURRENT_OFFSET),
theme::BG,
)
- .with_confirm_btn(confirm_btn),
+ .with_confirm_btn(confirm_btn)
+ .with_cancel_btn(cancel_btn)
+ .map(map_fn),
);
Ok(layout)
}
### core/src/trezor/ui/layouts/caesar/reset.py
@@ -3,6 +3,7 @@
import trezorui_api
from trezor import TR
from trezor.enums import ButtonRequestType
+from trezor.wire import ActionCancelled
from ..common import interact
from . import confirm_action, show_success, show_warning
@@ -125,6 +126,7 @@ async def slip39_show_checklist(
button=TR.buttons__continue,
active=step,
items=items,
+ back_button=back_button,
) as layout:
return await interact(layout, "slip39_checklist", ButtonRequestType.ResetDevice)
@@ -135,7 +137,7 @@ async def _prompt_number(
min_count: int,
max_count: int,
br_name: str,
-) -> int:
+) -> int | trezorui_api.UiResult:
with trezorui_api.request_number(
title=title,
count=count,
@@ -146,14 +148,21 @@ async def _prompt_number(
num_input,
br_name,
ButtonRequestType.ResetDevice,
+ raise_on_cancel=None,
)
+ if result is trezorui_api.CANCELLED:
+ # not reachable from the UI, only via debuglink
+ raise ActionCancelled
+
if __debug__:
if isinstance(result, str):
# debuglink for TR sends a string representing the number
result = CONFIRMED, int(result)
status, value = result
+ if status is trezorui_api.BACK:
+ return trezorui_api.BACK
assert status is CONFIRMED
assert isinstance(value, int)
return value
### tests/click_tests/test_repeated_backup.py
@@ -145,8 +145,8 @@ def test_repeated_backup_via_device(
reset.set_selection(debug, 3 - 5)
# confirm checklist
reset.confirm_read(debug)
- # threshold=2
- reset.set_selection(debug, 2 - 3)
+ # threshold=2 (the default for 3 shares)
+ reset.set_selection(debug, 0)
# confirm checklist
reset.confirm_read(debug)
# confirm backup warning
### tests/device_tests/test_msg_backup_device.py
@@ -190,7 +190,7 @@ def test_backup_slip39_advanced(
assert expected_ms == actual_ms
-@pytest.mark.models("delizia,eckhart") # going back is supported on these layouts
+@pytest.mark.models("safe3,delizia,eckhart") # going back is supported on these layouts
@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
@@ -216,7 +216,7 @@ def test_backup_slip39_basic_back_navigation(
assert expected_ms == actual_ms
-@pytest.mark.models("delizia,eckhart") # going back is supported on these layouts
+@pytest.mark.models("safe3,delizia,eckhart") # going back is supported on these layouts
@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
### tests/input_flows.py
@@ -2730,6 +2730,75 @@ def __init__(
self.mnemonics: list[str] = []
self.method = method
+ def input_flow_caesar(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
+
+ yield # 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 info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # number of shares carousel: navigate to the BACK item and select it
+ assert (yield).name == "slip39_shares"
+ for _ in range(5): # initial value 5 -> 5 items to the left
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # checklist: set number of shares
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # number of shares info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # number of shares carousel: change the default 5 to 3
+ assert (yield).name == "slip39_shares"
+ self.debug.press_left()
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # checklist: set threshold
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # threshold info
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # threshold carousel: change the default 2 to 3
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_right()
+ self.debug.press_middle()
+
+ # checklist: write down the shares -- go back via the up arrow
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_left()
+
+ # threshold info
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # threshold carousel: the previously entered 3 is preselected
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_middle()
+
+ # checklist: write down the shares
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ yield # Confirm show seeds
+ self.debug.press_yes()
+
+ # Mnemonic phrases
+ self.mnemonics = yield from load_N_shares(self.debug, 3, self.method)
+
def input_flow_delizia(self) -> BRGeneratorType:
assert self.method in (
messages.BackupMethod.Display,
@@ -2893,6 +2962,115 @@ def __init__(
self.mnemonics: list[list[str]] = []
self.method = method
+ def input_flow_caesar(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
+
+ yield # 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 carousel: change the default 5 to 2
+ assert (yield).name == "slip39_groups"
+ for _ in range(3): # 5 -> 2
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # checklist: set group threshold
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # group threshold carousel: keep the default 2
+ assert (yield).name == "slip39_group_threshold"
+ self.debug.press_middle()
+
+ # checklist: set sizes and thresholds of the groups
+ assert (yield).name == "slip39_checklist"
+ self.debug.press_yes()
+
+ # group 1 shares info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # group 1 shares carousel: change the default 5 to 3
+ assert (yield).name == "slip39_shares"
+ self.debug.press_left()
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # group 1 threshold info
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # group 1 threshold carousel: keep the default 2
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_middle()
+
+ # group 2 shares info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # group 2 shares carousel: navigate to the BACK item and select it
+ assert (yield).name == "slip39_shares"
+ for _ in range(5): # initial value 5 -> 5 items to the left
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # group 1 threshold info (again, after going back)
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # group 1 threshold carousel: the previously entered 2 is preselected
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_middle()
+
+ # group 2 shares info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # group 2 shares carousel: keep the default 5
+ assert (yield).name == "slip39_shares"
+ self.debug.press_middle()
+
+ # group 2 threshold info
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # group 2 threshold carousel: change the default 3 to 4, then navigate
+ # to the BACK item and select it
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_right() # 3 -> 4
+ for _ in range(3): # 4 is the third item -> 3 items to the left
+ self.debug.press_left()
+ self.debug.press_middle()
+
+ # group 2 shares info
+ assert (yield).name == "slip39_shares"
+ self.debug.press_yes()
+
+ # group 2 shares carousel: the previously entered 5 is preselected
+ assert (yield).name == "slip39_shares"
+ self.debug.press_middle()
+
+ # group 2 threshold info
+ assert (yield).name == "slip39_prompt_threshold"
+ self.debug.press_yes()
+
+ # group 2 threshold carousel: the default 3 is shown again -- keep it
+ assert (yield).name == "slip39_threshold"
+ self.debug.press_middle()
+
+ yield # Confirm show seeds
+ 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
+ )
+
def input_flow_delizia(self) -> BRGeneratorType:
assert self.method in (
messages.BackupMethod.Display,Why this scored 18/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.