fix(core): re-implement `draw_simple()` to avoid scoping violations
What changed, and why it matters
This commit fixes a UI lifecycle bug in Trezor firmware. The old `draw_simple()` function started a layout but did not properly wait for it to finish or stop it, which could leave stale UI tasks running ('scoping violations'). The new `interact_simple()` runs the layout to completion and returns its result, ensuring the passphrase prompt is correctly stopped. The security relevance is implied by the commit title and comments, but no explicit security disclosure or CVE is present.
Treat as a defensive hardening fix. Review whether the old `draw_simple()` behavior could have caused UI state confusion, overlapping prompts, or information disclosure in passphrase entry flows. No immediate exploit code is evident, but firmware updates should include this fix to avoid UI concurrency issues.
Security signals we found
Fixes UI task lifecycle / scoping violation
Prevents stale layout tasks from continuing after prompt should have ended
Passphrase prompt flow affected
No explicit security disclosure in commit message
References prior PR #7282
Evidence from the diff
The patch replaces draw_simple() with interact_simple() across all device layout variants (bolt, caesar, delizia, eckhart). draw_simple() called ui.Layout(layout).start() without awaiting completion, leaving the layout referenced by trezor.ui.CURRENT_LAYOUT with running event-handling tasks. interact_simple() uses a LayoutContext as a context manager, starts the layout, asserts no button request handler is spawned, and awaits layout.get_result(). request_passphrase_on_host() is made async and now awaits interact_simple(). A related fix in passphrase.py adds await to request_passphrase_on_host() inside _delay_request_passphrase_on_host(). The Rust and mock type stubs update show_simple() return type from LayoutObj[UiResult] to LayoutContext[UiResult].
Changed components
core/embed/rust/src/ui/api/firmware_micropython.rscore/mocks/generated/trezorui_api.pyicore/src/apps/common/passphrase.pycore/src/trezor/ui/layouts/bolt/__init__.pycore/src/trezor/ui/layouts/caesar/__init__.pycore/src/trezor/ui/layouts/common.pycore/src/trezor/ui/layouts/delizia/__init__.pycore/src/trezor/ui/layouts/eckhart/__init__.pyInspect captured patch +36 / −20
diff --git a/core/embed/rust/src/ui/api/firmware_micropython.rs b/core/embed/rust/src/ui/api/firmware_micropython.rs
index 962cf331..8d5d3afd 100644
--- a/core/embed/rust/src/ui/api/firmware_micropython.rs
+++ b/core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -2117,7 +2117,7 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// text: str,
/// title: str | None = None,
/// button: str | None = None,
- /// ) -> LayoutObj[UiResult]:
+ /// ) -> LayoutContext[UiResult]:
/// """Simple dialog with text. TT: optional button."""
Qstr::MP_QSTR_show_simple => obj_fn_kw!(0, new_show_simple).as_obj(),
diff --git a/core/mocks/generated/trezorui_api.pyi b/core/mocks/generated/trezorui_api.pyi
index 43137d5b..e1d4d796 100644
--- a/core/mocks/generated/trezorui_api.pyi
+++ b/core/mocks/generated/trezorui_api.pyi
@@ -811,7 +811,7 @@ def show_simple(
text: str,
title: str | None = None,
button: str | None = None,
-) -> LayoutObj[UiResult]:
+) -> LayoutContext[UiResult]:
"""Simple dialog with text. TT: optional button."""
diff --git a/core/src/apps/common/passphrase.py b/core/src/apps/common/passphrase.py
index a20d5bc8..1b8eec03 100644
--- a/core/src/apps/common/passphrase.py
+++ b/core/src/apps/common/passphrase.py
@@ -101,7 +101,7 @@ if not utils.USE_THP:
async def _delay_request_passphrase_on_host() -> None:
await loop.sleep(100)
- return request_passphrase_on_host()
+ await request_passphrase_on_host()
on_host = workflow.spawn(_delay_request_passphrase_on_host())
try:
diff --git a/core/src/trezor/ui/layouts/bolt/__init__.py b/core/src/trezor/ui/layouts/bolt/__init__.py
index 6c2276bf..e08a1cab 100644
--- a/core/src/trezor/ui/layouts/bolt/__init__.py
+++ b/core/src/trezor/ui/layouts/bolt/__init__.py
@@ -6,7 +6,7 @@ from trezor import TR, ui, utils
from trezor.enums import ButtonRequestType, RecoveryType
from trezor.wire import ActionCancelled
-from ..common import draw_simple, interact, raise_if_not_confirmed, with_info
+from ..common import interact, interact_simple, raise_if_not_confirmed, with_info
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
@@ -2188,8 +2188,9 @@ def error_popup(
return layout
-def request_passphrase_on_host() -> None:
- draw_simple(trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter))
+async def request_passphrase_on_host() -> None:
+ ctx = trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter)
+ await interact_simple(ctx)
async def request_passphrase_on_device(max_len: int) -> str:
diff --git a/core/src/trezor/ui/layouts/caesar/__init__.py b/core/src/trezor/ui/layouts/caesar/__init__.py
index 20f7eb1a..9d52fe6f 100644
--- a/core/src/trezor/ui/layouts/caesar/__init__.py
+++ b/core/src/trezor/ui/layouts/caesar/__init__.py
@@ -5,7 +5,7 @@ from trezor import TR, ui, utils
from trezor.enums import ButtonRequestType, RecoveryType
from trezor.wire import ActionCancelled
-from ..common import draw_simple, interact, raise_if_not_confirmed
+from ..common import interact, interact_simple, raise_if_not_confirmed
if TYPE_CHECKING:
from buffer_types import AnyBytes, StrOrBytes
@@ -2226,8 +2226,9 @@ def error_popup(
)
-def request_passphrase_on_host() -> None:
- draw_simple(trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter))
+async def request_passphrase_on_host() -> None:
+ ctx = trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter)
+ await interact_simple(ctx)
async def request_passphrase_on_device(max_len: int) -> str:
diff --git a/core/src/trezor/ui/layouts/common.py b/core/src/trezor/ui/layouts/common.py
index 436eb30a..39b92922 100644
--- a/core/src/trezor/ui/layouts/common.py
+++ b/core/src/trezor/ui/layouts/common.py
@@ -152,8 +152,20 @@ async def confirm_linear_flow(
raise ActionCancelled
-def draw_simple(layout: trezorui_api.LayoutObj[Any]) -> None:
- # IMPORTANT: after this call, `layout` is referenced by `trezor.ui.CURRENT_LAYOUT`
- # and its event-handling tasks are still running. Therefore, it MUST NOT be dropped,
- # until a new layout is started.
- ui.Layout(layout).start()
+async def interact_simple(layout_ctx: trezorui_api.LayoutContext[T]) -> T:
+ """
+ Run a simple layout till completion, returning its result.
+
+ Neither closes other workflows, nor sends button requests, nor checks the result.
+ """
+
+ with layout_ctx as obj:
+ # Block until the user confirmation.
+ # Don't use `interact` to avoid cancelling current workflow.
+ layout = ui.Layout(obj)
+ layout.start()
+ # This task may have no access to I/O context.
+ # Therefore, the new layout won't start its own ButtonRequest handler,
+ # avoiding interference with the existing layout.
+ assert layout.button_request_handler is None
+ return await layout.get_result()
diff --git a/core/src/trezor/ui/layouts/delizia/__init__.py b/core/src/trezor/ui/layouts/delizia/__init__.py
index 1bcdf046..a8f63f4a 100644
--- a/core/src/trezor/ui/layouts/delizia/__init__.py
+++ b/core/src/trezor/ui/layouts/delizia/__init__.py
@@ -7,8 +7,8 @@ from trezor.wire import ActionCancelled
from ..common import (
confirm_linear_flow,
- draw_simple,
interact,
+ interact_simple,
raise_if_not_confirmed,
with_info,
)
@@ -2183,8 +2183,9 @@ def error_popup(
)
-def request_passphrase_on_host() -> None:
- draw_simple(trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter))
+async def request_passphrase_on_host() -> None:
+ ctx = trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter)
+ await interact_simple(ctx)
async def request_passphrase_on_device(max_len: int) -> str:
diff --git a/core/src/trezor/ui/layouts/eckhart/__init__.py b/core/src/trezor/ui/layouts/eckhart/__init__.py
index 79062288..5a148e16 100644
--- a/core/src/trezor/ui/layouts/eckhart/__init__.py
+++ b/core/src/trezor/ui/layouts/eckhart/__init__.py
@@ -7,8 +7,8 @@ from trezor.wire import ActionCancelled
from ..common import (
confirm_linear_flow,
- draw_simple,
interact,
+ interact_simple,
raise_if_not_confirmed,
with_info,
)
@@ -2305,8 +2305,9 @@ def error_popup(
)
-def request_passphrase_on_host() -> None:
- draw_simple(trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter))
+async def request_passphrase_on_host() -> None:
+ ctx = trezorui_api.show_simple(title=None, text=TR.passphrase__please_enter)
+ await interact_simple(ctx)
async def request_passphrase_on_device(max_len: int) -> str:
Why this scored 32/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.