Merge remote-tracking branch 'agent/benma-agent/passphrase-show-last-after-delete'
What changed, and why it matters
This commit changes how the BitBox02 hardware wallet shows the last character of a passphrase or PIN while the user is typing. Previously, deleting a character would immediately re-hide (mask) every character. After this change, deleting a character instead reveals the new last character in plain text until the next keystroke. This is a deliberate user-experience change, not a fix for a vulnerability. It slightly increases the chance that someone watching the device screen could see a character of the user's secret, but only while the user is actively editing it and only the final character.
Treat as a normal firmware change rather than a security patch. If your threat model includes shoulder-surfing during passphrase entry, be aware that deleting characters now briefly exposes the new last character. No immediate action is required for device security.
Security signals we found
UI behavior change for sensitive input masking
Last character of passphrase/PIN revealed after deletion
No changes to crypto, secure storage, or authentication
No vendor security advisory or CVE referenced in commit
Evidence from the diff
The patch updates two UI implementations of masked string entry. In enter_string.rs, the reveal condition changes from len > 0 && prev_len.get() < len to len > 0 && prev_len.get() != usize::MAX && prev_len.get() != len, so the last character is shown after both insertion and deletion, but not on the initial display. In trinary_input_string.c, _back() now sets data->show_last_character = true instead of false. The comment about width consistency when going backwards is also removed. Unit tests are updated to assert the new behavior. No cryptographic, memory-safety, or authentication logic is modified.
Changed components
BitBox02 firmware UI passphrase/PIN entry screensrc/rust/bitbox03/src/ui/enter_string.rssrc/ui/components/trinary_input_string.cInspect captured patch +43 / −9
### src/rust/bitbox03/src/ui/enter_string.rs
@@ -776,7 +776,7 @@ pub fn build_pin_screen(
/// Adds the masked entry display: a bare centred row of one filled circle per masked character —
/// drawn as LVGL objects, since the ASCII-only fonts have no bullet glyph — with the last
-/// entered character in plaintext until the next keystroke (deleting re-masks everything). The
+/// character in plaintext after typing or deleting, until the next keystroke. The
/// row has `flex_grow`, so it fills and centres within the space the screen's flex flow leaves
/// between the title and whatever follows.
///
@@ -814,7 +814,7 @@ fn add_masked_display(screen: &LvObj, preset: &str) -> Rc<LvTextarea> {
textarea.set_max_length(149);
let textarea = Rc::new(textarea);
- // The circle pool plus the plaintext label for the last entered character. The circle count
+ // The circle pool plus the plaintext label for the last character. The circle count
// saturates at what fits the row: the dots are identical, so a saturated display is
// indistinguishable from a scrolled one.
let mut dots = Vec::with_capacity(MASK_DOT_COUNT_MAX);
@@ -856,8 +856,8 @@ fn add_masked_display(screen: &LvObj, preset: &str) -> Rc<LvTextarea> {
let prev_len = core::cell::Cell::new(usize::MAX);
let refresh_display = Rc::new(move || {
let (len, last) = textarea_len_and_last(display_textarea.as_ref());
- // Only a just-entered character is readable; any other change (deletion) re-masks.
- let reveal = len > 0 && prev_len.get() < len;
+ // Reveal the last character after typing or deleting, but not on the initial refresh.
+ let reveal = len > 0 && prev_len.get() != usize::MAX && prev_len.get() != len;
prev_len.set(len);
let shown = core::cmp::min(if reveal { len - 1 } else { len }, MASK_DOT_COUNT_MAX);
for (i, dot) in dots.iter().enumerate() {
@@ -1632,12 +1632,18 @@ mod tests {
assert_eq!(harness.revealed_char().as_deref(), Some("1"));
assert_eq!(harness.text().as_str(), "qw1");
- // Deleting re-masks everything (the deleted character was the last one entered).
+ // Deleting reveals the last remaining character.
let backspace = harness.backspace();
harness.tap_button(&backspace);
+ assert_eq!(harness.shown_dots(), 1);
+ assert_eq!(harness.revealed_char().as_deref(), Some("w"));
+ assert_eq!(harness.text().as_str(), "qw");
+
+ // An update without a length change still re-masks everything.
+ harness.textarea().set_text("qw").unwrap();
+ pump_for(40);
assert_eq!(harness.shown_dots(), 2);
assert_eq!(harness.revealed_char(), None);
- assert_eq!(harness.text().as_str(), "qw");
// The circles must not shift vertically when the last-character label hides (the flex
// track shrinks to the tallest visible child; the track must stay centred).
@@ -1647,6 +1653,35 @@ mod tests {
(dot_after.y1, dot_after.y2),
"masking circles moved vertically"
);
+
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "q");
+ assert_eq!(harness.shown_dots(), 0);
+ assert_eq!(harness.revealed_char().as_deref(), Some("q"));
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "");
+ assert_eq!(harness.shown_dots(), 0);
+ assert_eq!(harness.revealed_char(), None);
+
+ harness.tap_char_key(false, false, 1, 0); // q
+ assert_eq!(harness.text().as_str(), "q");
+ assert_eq!(harness.shown_dots(), 0);
+ assert_eq!(harness.revealed_char().as_deref(), Some("q"));
+ }
+
+ #[test]
+ fn test_masking_preset_stays_hidden_until_edited() {
+ let _lock = lock_and_init();
+ let mut harness = Harness::with_params(&passphrase_params(), CanCancel::No, "qw1");
+
+ assert_eq!(harness.shown_dots(), 3);
+ assert_eq!(harness.revealed_char(), None);
+
+ let backspace = harness.backspace();
+ harness.tap_button(&backspace);
+ assert_eq!(harness.text().as_str(), "qw");
+ assert_eq!(harness.shown_dots(), 1);
+ assert_eq!(harness.revealed_char().as_deref(), Some("w"));
}
#[test]
### src/ui/components/trinary_input_string.c
@@ -111,8 +111,7 @@ static void _cleanup(component_t* component)
/**
* Computes width of inputted string, including trailing underscore. If hidden, the last letter is
* treated as masked as well, so that different widths of letters do not change the total width for
- * the purpose of scrolling (since going backwards would be a different width than going forward,
- * with the last letter never being shown when going backwards).
+ * the purpose of scrolling.
*
*/
static UG_S16 _constant_string_width(const component_t* component)
@@ -372,7 +371,7 @@ static void _back(void* user_data)
if (data->string_index > 0) {
data->string_index--;
data->string[data->string_index] = '\0';
- data->show_last_character = false;
+ data->show_last_character = true;
UG_S16 string_width = _constant_string_width(self);
if (data->target_x < STRING_POS_X_START &&
data->target_x + string_width < SCROLL_LEFT_PAD) {Why this scored 20/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.