fix(core): cleanup menu handling irregularities
What changed, and why it matters
This commit is a code cleanup in the Trezor firmware's user-interface layer. It moves a small 'menu item intent' type to a better location, fixes a minor error-handling inconsistency, and reorders some on-screen menu entries so the cancel option appears first. There is no direct evidence this fixes an exploitable security vulnerability, but the reordering could be a defensive usability improvement to prevent accidental destructive actions.
Treat as a routine defensive-cleanup commit. Reviewers may want to verify that moving the cancel/danger entry to the top of menus does not introduce unintended behavior on delizia/eckhart, and that removing explicit cancel entries on caesar is consistent with the hardware-button cancellation flow. No urgent security action is indicated by the diff alone.
Security signals we found
Reordering destructive/cancel menu entries to the top of menus (defensive UX)
Removal of explicit cancel entries where hardware button provides cancellation
Refactoring of menu intent type without functional change to intent semantics
Minor error-handling normalization (value_error -> OutOfRange)
Evidence from the diff
The patch refactors MenuItemIntent (Standard/Danger) out of ui_firmware.rs into a new menu_item_intent.rs module and updates imports across model-specific UI implementations (bolt, caesar, delizia, eckhart). It also changes the error returned when too many menu items are supplied from a value_error! to Error::OutOfRange. In Python layouts for delizia and eckhart, multiple menus are changed from menu_items + [cancel_leaf(...)] to [cancel_leaf(...)] + menu_items, placing the cancel/danger action at the top. For caesar, explicit cancel entries are removed because that model uses a hardware button to cancel. The commit title calls these ‘irregularities’ and includes ‘[no changelog]’.
Changed components
core/embed/rust/src/ui/api/firmware_micropython.rscore/embed/rust/src/ui/layout/menu_item_intent.rscore/embed/rust/src/ui/layout/mod.rscore/embed/rust/src/ui/layout_bolt/component/select_menu.rscore/embed/rust/src/ui/layout_bolt/ui_firmware.rscore/embed/rust/src/ui/layout_caesar/ui_firmware.rscore/embed/rust/src/ui/layout_delizia/ui_firmware.rscore/embed/rust/src/ui/layout_eckhart/ui_firmware.rscore/embed/rust/src/ui/ui_firmware.rscore/src/trezor/ui/layouts/caesar/__init__.pycore/src/trezor/ui/layouts/delizia/__init__.pycore/src/trezor/ui/layouts/eckhart/__init__.pyInspect captured patch +98 / −89
### core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -1,6 +1,6 @@
use heapless::Vec;
-use crate::error::{value_error, Error};
+use crate::error::Error;
use crate::io::BinaryData;
use crate::micropython::buffer::StrBuffer;
use crate::micropython::gc::Gc;
@@ -20,13 +20,14 @@ use crate::ui::component::Empty;
use crate::ui::display::{fade_backlight_duration, get_backlight, set_backlight};
use crate::ui::layout::base::LAYOUT_STATE;
use crate::ui::layout::device_menu_result::DEVICE_MENU_RESULT;
+use crate::ui::layout::menu_item_intent::{MenuItemIntent, MENU_ITEM_INTENT_OBJ};
use crate::ui::layout::obj::{ComponentMsgObj, LayoutObj, ATTACH_TYPE_OBJ};
use crate::ui::layout::result::{BACK, CANCELLED, CONFIRMED, INFO};
use crate::ui::layout::util::{upy_disable_animation, RecoveryType};
use crate::ui::notification::{Notification, NotificationLevel, NOTIFICATION_LEVEL_OBJ};
use crate::ui::ui_firmware::{
- FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS, MENU_ITEM_INTENT_OBJ,
+ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
@@ -745,7 +746,7 @@ extern "C" fn new_select_menu(n_args: usize, args: *const Obj, kwargs: *mut Map)
let [text, intent]: [Obj; 2] = util::iter_into_array(item)?;
items
.push((text.try_into()?, intent.try_into()?))
- .map_err(|_| value_error!(c"Too many menu items"))?;
+ .map_err(|_| Error::OutOfRange)?;
}
let current = kwargs.get(Qstr::MP_QSTR_current)?.try_into()?;
### core/embed/rust/src/ui/layout/menu_item_intent.rs
@@ -0,0 +1,51 @@
+use crate::error::Error;
+#[cfg(feature = "micropython")]
+use crate::micropython::{
+ macros::{obj_dict, obj_map, obj_type},
+ obj::Obj,
+ qstr::Qstr,
+ simple_type::SimpleTypeObj,
+ typ::FullType,
+};
+
+/// What a menu entry means, which each model renders in its own way.
+#[repr(u8)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum MenuItemIntent {
+ /// An ordinary entry.
+ Standard = 0,
+ /// An entry with destructive consequences, e.g. cancelling a signature.
+ Danger = 1,
+}
+
+impl TryFrom<u8> for MenuItemIntent {
+ type Error = Error;
+ fn try_from(value: u8) -> Result<Self, Self::Error> {
+ match value {
+ 0 => Ok(MenuItemIntent::Standard),
+ 1 => Ok(MenuItemIntent::Danger),
+ _ => Err(Error::OutOfRange),
+ }
+ }
+}
+
+#[cfg(feature = "micropython")]
+impl TryFrom<Obj> for MenuItemIntent {
+ type Error = Error;
+
+ fn try_from(obj: Obj) -> Result<Self, Self::Error> {
+ Self::try_from(u8::try_from(obj)?)
+ }
+}
+
+#[cfg(feature = "micropython")]
+static MENU_ITEM_INTENT_TYPE: FullType = obj_type! {
+ name: Qstr::MP_QSTR_MenuItemIntent,
+ locals: &obj_dict!(obj_map! {
+ Qstr::MP_QSTR_STANDARD => Obj::small_int(MenuItemIntent::Standard as u16),
+ Qstr::MP_QSTR_DANGER => Obj::small_int(MenuItemIntent::Danger as u16),
+ }),
+};
+
+#[cfg(feature = "micropython")]
+pub static MENU_ITEM_INTENT_OBJ: SimpleTypeObj = SimpleTypeObj::new(&MENU_ITEM_INTENT_TYPE);
### core/embed/rust/src/ui/layout/mod.rs
@@ -5,6 +5,7 @@ pub mod obj;
#[cfg(feature = "micropython")]
pub mod device_menu_result;
+pub mod menu_item_intent;
#[cfg(feature = "micropython")]
pub mod result;
### core/embed/rust/src/ui/layout_bolt/component/select_menu.rs
@@ -5,8 +5,9 @@ use crate::error::Error;
use crate::strutil::TString;
use crate::ui::component::{Component, Event, EventCtx};
use crate::ui::geometry::{Insets, Rect};
+use crate::ui::layout::menu_item_intent::MenuItemIntent;
use crate::ui::shape::Renderer;
-use crate::ui::ui_firmware::{MenuItemIntent, MAX_MENU_ITEMS};
+use crate::ui::ui_firmware::MAX_MENU_ITEMS;
/// Maximum number of buttons shown on the screen at once.
/// TODO: pagination for menus with more items.
### core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -28,12 +28,13 @@ use crate::ui::component::text::TextStyle;
use crate::ui::component::{
Border, ComponentExt, Empty, FormattedText, Jpeg, Label, Never, Timeout,
};
+use crate::ui::layout::menu_item_intent::MenuItemIntent;
use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
### core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -28,12 +28,13 @@ use crate::ui::component::text::TextStyle;
use crate::ui::component::{
Component, ComponentExt, Empty, FormattedText, Label, LineBreaking, Paginate, Timeout,
};
+use crate::ui::layout::menu_item_intent::MenuItemIntent;
use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
@@ -916,10 +917,8 @@ impl FirmwareUI for UICaesar {
current: usize,
) -> Result<impl LayoutMaybeTrace, Error> {
// the entry's intent is not rendered on this model
- let mut labels = heapless::Vec::<TString<'static>, MAX_MENU_ITEMS>::new();
- for (text, _intent) in items {
- unwrap!(labels.push(text));
- }
+ let labels: heapless::Vec<TString<'static>, MAX_MENU_ITEMS> =
+ items.into_iter().map(|(text, _intent)| text).collect();
// Returning the index of the selected menu item
let layout = RootComponent::new(
SimpleChoice::new(
### core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -35,12 +35,13 @@ use crate::ui::component::{
};
use crate::ui::flow::FlowMsg;
use crate::ui::geometry::{self, Direction, Offset};
+use crate::ui::layout::menu_item_intent::MenuItemIntent;
use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ContentType, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
### core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -35,14 +35,15 @@ use crate::ui::component::{BLEHandler, BLEHandlerMode};
use crate::ui::component::{ComponentExt as _, Empty, FormattedText, Timeout};
use crate::ui::flow::FlowMsg;
use crate::ui::geometry::{Alignment, LinearPlacement, Offset};
+use crate::ui::layout::menu_item_intent::MenuItemIntent;
use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{
ConfirmValueParams, ContentType, PropsList, RecoveryType, StrOrBytes,
};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, MenuItemIntent, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ FirmwareUI, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS, MAX_PAIRED_DEVICES,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
use crate::util::interpolate;
### core/embed/rust/src/ui/ui_firmware.rs
@@ -1,5 +1,6 @@
use heapless::Vec;
+use super::layout::menu_item_intent::MenuItemIntent;
use super::layout::obj::{LayoutMaybeTrace, LayoutObj};
use super::layout::util::RecoveryType;
use crate::error::Error;
@@ -8,13 +9,6 @@ use crate::micropython::buffer::StrBuffer;
use crate::micropython::gc::Gc;
use crate::micropython::list::List;
use crate::micropython::obj::Obj;
-#[cfg(feature = "micropython")]
-use crate::micropython::{
- macros::{obj_dict, obj_map, obj_type},
- qstr::Qstr,
- simple_type::SimpleTypeObj,
- typ::FullType,
-};
use crate::strutil::TString;
use crate::ui::notification::Notification;
@@ -25,48 +19,6 @@ pub const MAX_MENU_ITEMS: usize = 5;
pub const MAX_PAIRED_DEVICES: usize = 8; // Maximum number of paired devices in the device menu
-/// What a menu entry means, which each model renders in its own way.
-#[repr(u8)]
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum MenuItemIntent {
- /// An ordinary entry.
- Standard = 0,
- /// An entry with destructive consequences, e.g. cancelling a signature.
- Danger = 1,
-}
-
-impl TryFrom<u8> for MenuItemIntent {
- type Error = Error;
- fn try_from(value: u8) -> Result<Self, Self::Error> {
- match value {
- 0 => Ok(MenuItemIntent::Standard),
- 1 => Ok(MenuItemIntent::Danger),
- _ => Err(Error::OutOfRange),
- }
- }
-}
-
-#[cfg(feature = "micropython")]
-impl TryFrom<Obj> for MenuItemIntent {
- type Error = Error;
-
- fn try_from(obj: Obj) -> Result<Self, Self::Error> {
- Self::try_from(u8::try_from(obj)?)
- }
-}
-
-#[cfg(feature = "micropython")]
-static MENU_ITEM_INTENT_TYPE: FullType = obj_type! {
- name: Qstr::MP_QSTR_MenuItemIntent,
- locals: &obj_dict!(obj_map! {
- Qstr::MP_QSTR_STANDARD => Obj::small_int(MenuItemIntent::Standard as u16),
- Qstr::MP_QSTR_DANGER => Obj::small_int(MenuItemIntent::Danger as u16),
- }),
-};
-
-#[cfg(feature = "micropython")]
-pub static MENU_ITEM_INTENT_OBJ: SimpleTypeObj = SimpleTypeObj::new(&MENU_ITEM_INTENT_TYPE);
-
pub trait FirmwareUI {
#[allow(clippy::too_many_arguments)]
fn confirm_action(
### core/src/trezor/ui/layouts/caesar/__init__.py
@@ -1481,7 +1481,7 @@ async def confirm_ethereum_eip7702_auth(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, confirm_with_menu
with trezorui_api.show_warning(
title=TR.words__warning,
@@ -1524,7 +1524,8 @@ async def confirm_ethereum_eip7702_auth(
]
await confirm_with_menu(
layout,
- Menu(children + [cancel_leaf(TR.buttons__cancel)]),
+ # no cancel entry: this model cancels with the hardware button
+ Menu(children),
"ethereum/auth7702/details",
ButtonRequestType.SignTx,
)
@@ -1535,20 +1536,20 @@ async def confirm_ethereum_eip7702_revoke(
account_path: str,
nonce: int,
) -> None:
- from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, confirm_with_menu
account_info = with_colon(
(
(TR.words__account, account, False),
(TR.address_details__derivation_path, account_path, False),
)
)
+ # no cancel entry: this model cancels with the hardware button
menu = Menu(
children=[
create_info_menu_leaf(TR.address_details__account_info, account_info),
# TODO: switch to non-Cardano specific string
create_info_menu_leaf(TR.cardano__nonce, str(nonce)),
- cancel_leaf(TR.buttons__cancel),
],
)
### core/src/trezor/ui/layouts/delizia/__init__.py
@@ -531,7 +531,7 @@ async def confirm_payment_request(
refund_account_items,
)
)
- menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
+ menu = Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items)
with main_ctx as main_layout:
await confirm_with_menu(main_layout, menu, "confirm_payment_request")
@@ -857,13 +857,13 @@ async def confirm_value(
for name, p, page_title in info_items or []:
menu_items.append(create_info_menu_leaf(name, p, page_title))
menu = Menu(
- menu_items
- + [
+ [
cancel_leaf(
cancel_text or TR.buttons__cancel,
confirm=trezorui_api.confirm_cancel,
)
]
+ + menu_items
)
with main_ctx as main_layout:
return await interact_with_menu(main_layout, menu, br_name, br_code)
@@ -989,7 +989,7 @@ async def confirm_trade(
]
for k, v in extra_menu_items:
menu_items.append(create_info_menu_leaf(k, v))
- menu = Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)])
+ menu = Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items)
with trade_ctx as trade_layout:
await confirm_with_menu(trade_layout, menu, "confirm_trade")
@@ -1371,7 +1371,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/intro",
ButtonRequestType.SignTx,
)
@@ -1386,7 +1386,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/vault_name",
)
@@ -1402,7 +1402,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/amount",
br_code,
)
@@ -1420,7 +1420,7 @@ async def _step4() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/extra_data",
br_code,
)
@@ -1440,7 +1440,7 @@ async def _step5() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/summary",
br_code,
)
@@ -1484,7 +1484,7 @@ async def _step1() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/intro",
br_code,
)
@@ -1500,7 +1500,7 @@ async def _step2() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/tokens",
br_code,
)
@@ -1518,7 +1518,7 @@ async def _step3() -> trezorui_api.UiResult:
) as layout:
return await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
f"{br_name}/summary",
br_code,
)
@@ -1568,7 +1568,7 @@ async def confirm_ethereum_eip7702_auth(
]
await confirm_with_menu(
layout,
- Menu(children + [cancel_leaf(TR.buttons__cancel)]),
+ Menu([cancel_leaf(TR.buttons__cancel)] + children),
"ethereum/auth7702/details",
ButtonRequestType.SignTx,
)
@@ -1587,9 +1587,9 @@ async def confirm_ethereum_eip7702_revoke(
]
menu = Menu(
children=[
+ cancel_leaf(TR.buttons__cancel),
create_info_menu_leaf(TR.address_details__account_info, account_info),
create_info_menu_leaf(TR.cardano__nonce, str(nonce)),
- cancel_leaf(TR.buttons__cancel),
],
)
@@ -1879,7 +1879,7 @@ async def confirm_tron_claim(
) as layout:
await interact_with_menu(
layout,
- Menu(menu_items + [cancel_leaf(TR.buttons__cancel_sign)]),
+ Menu([cancel_leaf(TR.buttons__cancel_sign)] + menu_items),
br_name,
br_code,
)
@@ -2248,15 +2248,15 @@ async def confirm_signverify(
)
menu = Menu(
- items
- + [
+ [
cancel_leaf(
TR.buttons__cancel,
confirm=lambda: trezorui_api.show_mismatch(
title=TR.addr_mismatch__mismatch
),
)
]
+ + items
)
with address_ctx as address_layout:
### core/src/trezor/ui/layouts/eckhart/__init__.py
@@ -884,9 +884,9 @@ async def confirm_properties(
br_code: ButtonRequestType = ButtonRequestType.ConfirmOutput,
verb: str | None = None,
) -> None:
- from trezor.ui.layouts.menu import Menu, confirm_with_menu
+ from trezor.ui.layouts.menu import Menu, cancel_leaf, confirm_with_menu
- menu = Menu.root(cancel=TR.buttons__cancel)
+ menu = Menu([cancel_leaf(TR.buttons__cancel)])
with trezorui_api.confirm_properties(
title=title,Why this scored 17/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.