chore(core): make TextBox more UTF-8 compliant
What changed, and why it matters
This commit fixes a UI bug in how the Trezor hardware wallet counts and displays the last character of a passphrase when it contains multi-byte UTF-8 characters (such as emoji or accented letters). Previously, the code measured length in bytes and sliced the string by byte position, which could split a multi-byte character and produce a broken or incorrect last-character display. The change adds proper character-based counting and a safe way to retrieve the last character. It is a correctness/robustness fix rather than a clear security vulnerability, and there is no evidence it was disclosed or exploited as a security issue.
Treat as a routine robustness/correctness improvement. Review whether other UI components still use byte-based slicing on TextBox content. No urgent security response is indicated by the available evidence.
Security signals we found
String slicing by byte index on potentially multi-byte UTF-8 input removed
New helper uses char_indices().next_back() to safely obtain a valid &str for the last Unicode scalar value
UI display logic now uses character count instead of byte count for passphrase length
No explicit security claim, changelog entry, or advisory reference in commit
Evidence from the diff
The patch modifies TextBox in trezor-firmware to expose count() (character count via chars().count()) and last_char_str() (returns a valid &str slice for the final Unicode scalar value). The passphrase keyboard UI then uses count() for the visible length and last_char_str() when rendering the last character in partially-hidden passphrase modes. Before, it used len() (byte length) and sliced content[(pp_len - 1)..pp_len], which is incorrect for UTF-8 because a single character may span multiple bytes; this could slice inside a UTF-8 sequence, yielding invalid UTF-8 or a wrong glyph. The fix prevents malformed string slicing and improves display accuracy for non-ASCII passphrases.
Changed components
core/embed/rust/src/ui/component/text/common.rscore/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rsTrezor firmware UI text component (TextBox)Eckhart layout passphrase input keyboardInspect captured patch +37 / −22
diff --git a/core/embed/rust/src/ui/component/text/common.rs b/core/embed/rust/src/ui/component/text/common.rs
index 34d5015c..e93e00f0 100644
--- a/core/embed/rust/src/ui/component/text/common.rs
+++ b/core/embed/rust/src/ui/component/text/common.rs
@@ -36,14 +36,30 @@ impl TextBox {
&self.text
}
+ /// Length of the content in *bytes* (matches `std::String::len`). Use
+ /// `count()` for the number of characters.
pub fn len(&self) -> usize {
self.text.len()
}
+ /// Number of *characters* in the content. O(n) in the byte length.
+ pub fn count(&self) -> usize {
+ self.text.chars().count()
+ }
+
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
+ /// Returns the last character of the content as a string slice, if any.
+ /// Safe to use without knowing the byte width of the last UTF-8 sequence.
+ pub fn last_char_str(&self) -> Option<&str> {
+ self.text
+ .char_indices()
+ .next_back()
+ .map(|(i, _)| &self.text[i..])
+ }
+
/// Delete the last character of content, if any.
pub fn delete_last(&mut self, ctx: &mut EventCtx) {
let changed = self.text.pop().is_some();
diff --git a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
index 26d2ebe4..fb20b2be 100644
--- a/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
+++ b/core/embed/rust/src/ui/layout_eckhart/firmware/keyboard/passphrase.rs
@@ -106,7 +106,7 @@ impl PassphraseInput {
debug_assert_ne!(self.display_style, DisplayStyle::Shown);
let hidden_area: Rect = self.area.inset(KEYBOARD_INPUT_INSETS);
- let pp_len = self.content().len();
+ let pp_len = self.textbox.count();
let last_char = self.display_style != DisplayStyle::Hidden;
let mut cursor = hidden_area.left_center().ofs(Offset::x(12));
@@ -154,27 +154,26 @@ impl PassphraseInput {
}
if last_char {
- // This should not fail because pp_len > 0
- let last = &self.content()[(pp_len - 1)..pp_len];
-
- // Adapt x and y positions for the character
- cursor.y += Self::STYLE.text_font.visible_text_height("1") / 2;
-
- // Paint the last character
- Text::new(cursor, last, Self::STYLE.text_font)
- .with_align(Alignment::Start)
- .with_fg(Self::STYLE.text_color)
- .render(target);
-
- // Paint the pending marker.
- if self.display_style == DisplayStyle::LastWithMarker {
- render_pending_marker(
- target,
- cursor,
- last,
- Self::STYLE.text_font,
- Self::STYLE.text_color,
- );
+ if let Some(last) = self.textbox.last_char_str() {
+ // Adapt x and y positions for the character
+ cursor.y += Self::STYLE.text_font.visible_text_height("1") / 2;
+
+ // Paint the last character
+ Text::new(cursor, last, Self::STYLE.text_font)
+ .with_align(Alignment::Start)
+ .with_fg(Self::STYLE.text_color)
+ .render(target);
+
+ // Paint the pending marker.
+ if self.display_style == DisplayStyle::LastWithMarker {
+ render_pending_marker(
+ target,
+ cursor,
+ last,
+ Self::STYLE.text_font,
+ Self::STYLE.text_color,
+ );
+ }
}
}
}
Why this scored 15/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.