feat(core): allow disabling button on Eckhart `show_info()` layout
What changed, and why it matters
This commit is a routine user-interface feature change for Trezor hardware wallets. It lets the new 'Eckhart' screen layout optionally show an information screen with a disabled button (for example, a grayed-out 'Continue' that becomes active only after a timer or condition). Older layouts keep their previous behavior. There is no direct evidence in the commit that this fixes a security vulnerability; it reads as a product/usability feature.
No immediate security action required. Treat as a normal feature commit. If auditing, verify that downstream Eckhart screens using disabled buttons cannot be tricked into enabling the button prematurely through host-supplied data, since the enabled/disabled state is now part of the API surface.
Security signals we found
API signature change from `button: str` to `button: tuple[str, bool] | None`
Disabled button support added only for Eckhart layout
Bolt and Caesar/Delizia layouts explicitly reject or ignore disabled buttons
No input validation changes for untrusted data paths
No mention of vulnerability, CVE, or security fix in commit message or diff
Evidence from the diff
The change extends the show_info() firmware UI API so the button parameter becomes Option<(TString, bool)> instead of a plain TString. On the Eckhart layout, the boolean controls whether the action-bar button is initially enabled or disabled. Other layouts (Bolt, Caesar, Delizia) either reject disabled buttons or ignore the parameter. The Python call sites in reset flows are updated to pass (text, True) tuples. The commit also fixes a small related bug in Eckhart’s ActionBar::new_single() so it does not override the stylesheet of a disabled button.
Changed components
core/embed/rust/src/ui/api/firmware_micropython.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/firmware/action_bar.rscore/embed/rust/src/ui/layout_eckhart/ui_firmware.rscore/embed/rust/src/ui/ui_firmware.rscore/mocks/generated/trezorui_api.pyicore/src/trezor/ui/layouts/bolt/reset.pycore/src/trezor/ui/layouts/eckhart/reset.pyInspect captured patch +95 / −71
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 572b240b..74870643 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -1071,7 +1071,15 @@ extern "C" fn new_show_info(n_args: usize, args: *const Obj, kwargs: *mut Map) -
let block = move |_args: &[Obj], kwargs: &Map| {
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
let description: TString = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?;
- let button: TString = kwargs.get_or(Qstr::MP_QSTR_button, TString::empty())?;
+ let button = kwargs
+ .get(Qstr::MP_QSTR_button)
+ .unwrap_or_else(|_| Obj::const_none())
+ .try_into_option::<Obj>()?
+ .map(|obj| -> Result<(TString<'_>, bool), Error> {
+ let [text, enabled]: [Obj; 2] = util::iter_into_array(obj)?;
+ Ok((text.try_into()?, enabled.try_into()?))
+ })
+ .transpose()?;
let time_ms: u32 = kwargs.get_or(Qstr::MP_QSTR_time_ms, 0)?.try_into()?;
let obj = ModelUI::show_info(title, description, button, time_ms)?;
@@ -2012,7 +2020,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// *,
/// title: str,
/// description: str = "",
- /// button: str = "",
+ /// button: tuple[str, bool] | None = None,
/// time_ms: int = 0,
/// ) -> LayoutObj[UiResult]:
/// """Info screen."""
diff --git a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
index ca7cb298..2cd73aaf 100644
--- a/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -884,7 +884,7 @@ impl FirmwareUI for UIBolt {
title,
TString::empty(),
description,
- button,
+ (!button.is_empty()).then_some(button),
allow_cancel,
time_ms,
icon,
@@ -990,13 +990,17 @@ impl FirmwareUI for UIBolt {
fn show_info(
title: TString<'static>,
description: TString<'static>,
- button: TString<'static>,
+ button: Option<(TString<'static>, bool)>,
time_ms: u32,
) -> Result<Gc<LayoutObj>, Error> {
- assert!(
- !button.is_empty() || time_ms > 0,
- "either button or timeout must be set"
- );
+ let button_text = match (button, time_ms) {
+ // either button or timeout must be set
+ (None, 0) => return Err(Error::NotImplementedError),
+ (None, _) => None,
+ // disabled buttons are not supported on Bolt
+ (Some((_, false)), _) => return Err(Error::NotImplementedError),
+ (Some((text, true)), _) => Some(text),
+ };
let icon = BlendedImage::new(
theme::IMAGE_BG_CIRCLE,
@@ -1009,7 +1013,7 @@ impl FirmwareUI for UIBolt {
title,
TString::empty(),
description,
- button,
+ button_text,
false,
time_ms,
icon,
@@ -1247,7 +1251,7 @@ impl FirmwareUI for UIBolt {
title,
TString::empty(),
description,
- button,
+ (!button.is_empty()).then_some(button),
allow_cancel,
time_ms,
icon,
@@ -1280,7 +1284,7 @@ impl FirmwareUI for UIBolt {
title,
value,
description,
- button,
+ (!button.is_empty()).then_some(button),
allow_cancel,
0,
icon,
@@ -1302,62 +1306,70 @@ fn new_show_modal(
title: TString<'static>,
value: TString<'static>,
description: TString<'static>,
- button: TString<'static>,
+ button: Option<TString<'static>>,
allow_cancel: bool,
time_ms: u32,
icon: BlendedImage,
button_style: ButtonStyleSheet,
) -> Result<Gc<LayoutObj>, Error> {
- let no_buttons = button.is_empty();
- let obj = if no_buttons && time_ms == 0 {
- // No buttons and no timer, used when we only want to draw the dialog once and
- // then throw away the layout object.
- LayoutObj::new(
- IconDialog::new(icon, title, Empty)
- .with_value(value)
- .with_description(description),
- )?
- } else if no_buttons && time_ms > 0 {
- // Timeout, no buttons.
- LayoutObj::new(
- IconDialog::new(
- icon,
- title,
- Timeout::new(time_ms).map(|_| Some(CancelConfirmMsg::Confirmed)),
- )
- .with_value(value)
- .with_description(description),
- )?
- } else if allow_cancel {
- // Two buttons.
- LayoutObj::new(
- IconDialog::new(
- icon,
- title,
- Button::cancel_confirm(
- Button::with_icon(theme::ICON_CANCEL),
- Button::with_text(button).styled(button_style),
- false,
- ),
- )
- .with_value(value)
- .with_description(description),
- )?
- } else {
- // Single button.
- LayoutObj::new(
- IconDialog::new(
- icon,
- title,
- theme::button_bar(Button::with_text(button).styled(button_style).map(|msg| {
- (matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed)
- })),
- )
- .with_value(value)
- .with_description(description),
- )?
+ let obj = match button {
+ None => {
+ if time_ms == 0 {
+ // No buttons and no timer, used when we only want to draw the dialog once and
+ // then throw away the layout object.
+ LayoutObj::new(
+ IconDialog::new(icon, title, Empty)
+ .with_value(value)
+ .with_description(description),
+ )?
+ } else {
+ // Timeout, no buttons.
+ LayoutObj::new(
+ IconDialog::new(
+ icon,
+ title,
+ Timeout::new(time_ms).map(|_| Some(CancelConfirmMsg::Confirmed)),
+ )
+ .with_value(value)
+ .with_description(description),
+ )?
+ }
+ }
+ Some(button) => {
+ if allow_cancel {
+ // Two buttons.
+ LayoutObj::new(
+ IconDialog::new(
+ icon,
+ title,
+ Button::cancel_confirm(
+ Button::with_icon(theme::ICON_CANCEL),
+ Button::with_text(button).styled(button_style),
+ false,
+ ),
+ )
+ .with_value(value)
+ .with_description(description),
+ )?
+ } else {
+ // Single button.
+ LayoutObj::new(
+ IconDialog::new(
+ icon,
+ title,
+ theme::button_bar(Button::with_text(button).styled(button_style).map(
+ |msg| {
+ (matches!(msg, ButtonMsg::Clicked))
+ .then(|| CancelConfirmMsg::Confirmed)
+ },
+ )),
+ )
+ .with_value(value)
+ .with_description(description),
+ )?
+ }
+ }
};
-
Ok(obj)
}
diff --git a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
index ae5566c6..cca9e3cf 100644
--- a/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -1173,7 +1173,7 @@ impl FirmwareUI for UICaesar {
fn show_info(
title: TString<'static>,
description: TString<'static>,
- _button: TString<'static>,
+ _button: Option<(TString<'static>, bool)>,
time_ms: u32,
) -> Result<Gc<LayoutObj>, Error> {
let content = Frame::new(
diff --git a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
index 4f0bf0fd..9069f2b6 100644
--- a/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -971,7 +971,7 @@ impl FirmwareUI for UIDelizia {
fn show_info(
title: TString<'static>,
description: TString<'static>,
- _button: TString<'static>,
+ _button: Option<(TString<'static>, bool)>,
_time_ms: u32,
) -> Result<Gc<LayoutObj>, Error> {
let content = Paragraphs::new(Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, description));
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
index ac3935ed..524d8f60 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/action_bar.rs
@@ -79,7 +79,8 @@ impl ActionBar {
/// paginated content.
pub fn new_single(button: Button) -> Self {
let mut right_button = button.with_expanded_touch_area(Self::BUTTON_EXPAND_TOUCH);
- if right_button.stylesheet() == &theme::button_default() {
+ // If the button is disabled, don't override its stylesheet.
+ if right_button.is_enabled() && right_button.stylesheet() == &theme::button_default() {
right_button = right_button.styled(theme::firmware::button_actionbar_right_default());
};
if !right_button.has_gradient() {
diff --git a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
index a6486171..7e331736 100644
--- a/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -1233,15 +1233,18 @@ impl FirmwareUI for UIEckhart {
fn show_info(
title: TString<'static>,
description: TString<'static>,
- button: TString<'static>,
+ button: Option<(TString<'static>, bool)>,
_time_ms: u32,
) -> Result<Gc<LayoutObj>, Error> {
let content = Paragraphs::new(Paragraph::new(&theme::TEXT_REGULAR, description))
.with_placement(LinearPlacement::vertical());
+ let button = button.map_or_else(Button::empty, |(text, enabled)| {
+ Button::with_text(text).initially_enabled(enabled)
+ });
let screen = TextScreen::new(content)
.with_header(Header::new(title))
- .with_action_bar(ActionBar::new_single(Button::with_text(button)));
+ .with_action_bar(ActionBar::new_single(button));
let obj = LayoutObj::new(screen)?;
Ok(obj)
}
diff --git a/core/embed/rust/src/ui/ui_firmware.rs b/core/embed/rust/src/ui/ui_firmware.rs
index db6cd480..46ecb858 100644
--- a/core/embed/rust/src/ui/ui_firmware.rs
+++ b/core/embed/rust/src/ui/ui_firmware.rs
@@ -385,7 +385,7 @@ pub trait FirmwareUI {
fn show_info(
title: TString<'static>,
description: TString<'static>,
- button: TString<'static>,
+ button: Option<(TString<'static>, bool)>,
time_ms: u32,
) -> Result<Gc<LayoutObj>, Error>; // TODO: return LayoutMaybeTrace
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index 1c794394..8c1d4594 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -691,7 +691,7 @@ def show_info(
*,
title: str,
description: str = "",
- button: str = "",
+ button: tuple[str, bool] | None = None,
time_ms: int = 0,
) -> LayoutObj[UiResult]:
"""Info screen."""
diff --git a/core/src/trezor/ui/layouts/bolt/reset.py b/core/src/trezor/ui/layouts/bolt/reset.py
index b77997cf..5d47250e 100644
--- a/core/src/trezor/ui/layouts/bolt/reset.py
+++ b/core/src/trezor/ui/layouts/bolt/reset.py
@@ -289,7 +289,7 @@ def show_intro_backup(num_of_words: int | None) -> Awaitable[None]:
trezorui_api.show_info(
title="",
description=description,
- button=TR.buttons__continue,
+ button=(TR.buttons__continue, True),
),
"backup_intro",
ButtonRequestType.ResetDevice,
@@ -301,7 +301,7 @@ def show_warning_backup() -> Awaitable[trezorui_api.UiResult]:
trezorui_api.show_info(
title=TR.reset__never_make_digital_copy,
description="",
- button=TR.buttons__ok_i_understand,
+ button=(TR.buttons__ok_i_understand, True),
),
"backup_warning",
ButtonRequestType.ResetDevice,
diff --git a/core/src/trezor/ui/layouts/eckhart/reset.py b/core/src/trezor/ui/layouts/eckhart/reset.py
index 64d68392..a9468d67 100644
--- a/core/src/trezor/ui/layouts/eckhart/reset.py
+++ b/core/src/trezor/ui/layouts/eckhart/reset.py
@@ -316,7 +316,7 @@ async def show_intro_backup(num_of_words: int | None) -> None:
trezorui_api.show_info(
title=TR.reset__recovery_wallet_backup_title,
description=description,
- button=TR.buttons__continue,
+ button=(TR.buttons__continue, True),
),
"backup_intro",
ButtonRequestType.ResetDevice,
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.