bb03: placeholder UI for restore from mnemonic
What changed, and why it matters
This commit adds placeholder user-interface screens for restoring a wallet from a recovery phrase on the BitBox03 hardware wallet. It replaces some unfinished 'todo!' stubs with basic screens and adds a retry loop so users must enter a valid BIP39 word. There is no clear security bug in the change, but it is a new, incomplete UI implementation that could contain latent issues.
Treat as routine development; no immediate security action required. Continue normal review and testing of the new BitBox03 UI, especially around recovery-phrase entry, cancellation flows, and edge cases in menu navigation.
Security signals we found
New UI code for sensitive recovery-phrase workflow
Added input validation loop requiring entered mnemonic word to exist in wordlist
Replaced panicking 'todo!' placeholders with functional UI implementations
No explicit security claim or CVE reference in commit
Evidence from the diff
The patch implements BitBox03 UI modules for trinary choices, menus, and mnemonic display/quiz, and refactors the mnemonic entry workflow to use a new helper enter_word_from_wordlist that validates the entered word against the allowed wordlist and retries on mismatch. It also adds unit tests for invalid-word retry behavior. The changes are largely UI scaffolding and input validation, not a fix for a known vulnerability.
Changed components
src/rust/bitbox02-rust/src/workflow/mnemonic.rssrc/rust/bitbox03/src/ui.rssrc/rust/bitbox03/src/ui/choice.rssrc/rust/bitbox03/src/ui/menu.rsInspect captured patch +504 / −35
diff --git a/src/rust/bitbox02-rust/src/workflow/mnemonic.rs b/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
index d4821ed..779f16a 100644
--- a/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
+++ b/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
@@ -233,17 +233,7 @@ async fn get_12th_18th_word(
// these.
loop {
let choices = lastword_choices(entered_words);
- let word = hal_ui
- .enter_string(
- &crate::hal::ui::EnterStringParams {
- title,
- wordlist: Some(&choices),
- ..Default::default()
- },
- CanCancel::Yes,
- "",
- )
- .await?;
+ let word = enter_word_from_wordlist(hal_ui, title, &choices, "").await?;
// Confirm word picked again, as a typo here would be extremely annoying. Double checking
// is also safer, as the user might not even realize they made a typo.
@@ -260,6 +250,41 @@ async fn get_12th_18th_word(
}
}
+fn wordlist_contains(wordlist: &[u16], word: &str) -> bool {
+ wordlist
+ .iter()
+ .any(|word_idx| match crate::bip39::get_word(*word_idx) {
+ Ok(candidate) => candidate.as_str() == word,
+ Err(()) => false,
+ })
+}
+
+async fn enter_word_from_wordlist(
+ hal_ui: &mut impl crate::hal::Ui,
+ title: &str,
+ wordlist: &[u16],
+ preset: &str,
+) -> Result<zeroize::Zeroizing<String>, UserAbort> {
+ loop {
+ let word = hal_ui
+ .enter_string(
+ &crate::hal::ui::EnterStringParams {
+ title,
+ wordlist: Some(wordlist),
+ ..Default::default()
+ },
+ CanCancel::Yes,
+ preset,
+ )
+ .await?;
+
+ if wordlist_contains(wordlist, &word) {
+ return Ok(word);
+ }
+ hal_ui.status("Invalid word\nTry again", false).await;
+ }
+}
+
/// Retrieve a BIP39 mnemonic sentence of 12 or 24 words from the user.
pub async fn get(
hal_ui: &mut impl crate::hal::Ui,
@@ -307,17 +332,7 @@ pub async fn get(
get_12th_18th_word(hal_ui, &title, &as_str_vec(&entered_words[..word_idx])).await
}
} else {
- hal_ui
- .enter_string(
- &crate::hal::ui::EnterStringParams {
- title: &title,
- wordlist: Some(&bip39_wordlist),
- ..Default::default()
- },
- CanCancel::Yes,
- preset,
- )
- .await
+ enter_word_from_wordlist(hal_ui, &title, &bip39_wordlist, preset).await
};
match user_entry {
@@ -382,6 +397,9 @@ mod tests {
use super::*;
use crate::hal::testing::{TestingRandom, TestingUi};
+ use alloc::boxed::Box;
+ use alloc::collections::VecDeque;
+ use alloc::string::String;
fn bruteforce_lastword(mnemonic: &[&str]) -> Vec<zeroize::Zeroizing<String>> {
let mut result = Vec::new();
@@ -446,6 +464,58 @@ mod tests {
assert_eq!(mnemonic.as_str(), words.join(" "));
}
+ #[async_test::test]
+ async fn test_get_retries_invalid_word() {
+ let words: Vec<&str> = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide"
+ .split(' ')
+ .collect();
+ let mut entries: VecDeque<String> = ["notaword"]
+ .into_iter()
+ .chain(words[..23].iter().copied())
+ .map(String::from)
+ .collect();
+ let last_word = words[23];
+ let mut ui = TestingUi::new();
+
+ ui.set_trinary_choice(Box::new(
+ |message, label_left, label_middle, label_right| {
+ assert_eq!(message, "How many words?");
+ assert_eq!(label_left, Some("12"));
+ assert_eq!(label_middle, None);
+ assert_eq!(label_right, Some("24"));
+ TrinaryChoice::Right
+ },
+ ));
+ ui.set_menu(Box::new(move |menu_words, title| {
+ assert_eq!(title, Some("24 of 24"));
+ Ok(menu_words
+ .iter()
+ .position(|word| *word == last_word)
+ .unwrap()
+ .try_into()
+ .unwrap())
+ }));
+ ui.set_enter_string(Box::new(move |params| {
+ assert!(params.wordlist.is_some());
+ assert!(params.title.ends_with(" of 24"));
+ Ok(entries.pop_front().unwrap())
+ }));
+
+ let result = get(&mut ui).await;
+ assert!(result.is_ok());
+ let mnemonic = match result {
+ Ok(mnemonic) => mnemonic,
+ Err(_) => panic!("unexpected user abort"),
+ };
+ assert_eq!(mnemonic.as_str(), words.join(" "));
+ assert!(ui.screens.iter().any(
+ |screen| matches!(screen, crate::hal::testing::Screen::Status {
+ title,
+ success: false,
+ } if title == "Invalid word\nTry again")
+ ));
+ }
+
#[test]
fn test_lastword_choices() {
// 23 words
diff --git a/src/rust/bitbox03/src/ui.rs b/src/rust/bitbox03/src/ui.rs
index fcf842b..33439a0 100644
--- a/src/rust/bitbox03/src/ui.rs
+++ b/src/rust/bitbox03/src/ui.rs
@@ -11,8 +11,10 @@ use core::marker::PhantomData;
use tracing::info;
use util::futures::completion;
+mod choice;
mod confirm;
mod enter_string;
+mod menu;
mod status;
const LOGO: &[u8] = include_bytes!("../splash.png");
@@ -145,32 +147,74 @@ impl<Timer: bitbox_hal::timer::Timer> hal::ui::Ui for BitBox03Ui<Timer> {
async fn menu(
&mut self,
- _words: &[&str],
- _title: Option<&str>,
+ words: &[&str],
+ title: Option<&str>,
) -> Result<u8, bitbox_hal::ui::UserAbort> {
- todo!()
+ match self.menu_impl(words, title, true, false, 0).await {
+ menu::MenuResult::Selected(choice_idx) => Ok(choice_idx),
+ menu::MenuResult::Cancel(_) => Err(bitbox_hal::ui::UserAbort),
+ menu::MenuResult::Continue => panic!("unexpected menu continue"),
+ }
}
async fn trinary_choice(
&mut self,
- _message: &str,
- _label_left: Option<&str>,
- _label_middle: Option<&str>,
- _label_right: Option<&str>,
+ message: &str,
+ label_left: Option<&str>,
+ label_middle: Option<&str>,
+ label_right: Option<&str>,
) -> bitbox_hal::ui::TrinaryChoice {
- todo!()
+ self.with_result_screen(|responder| {
+ choice::build_trinary_choice_screen(
+ message,
+ label_left,
+ label_middle,
+ label_right,
+ responder,
+ )
+ })
+ .await
}
- async fn show_mnemonic(&mut self, _words: &[&str]) -> Result<(), bitbox_hal::ui::UserAbort> {
- todo!()
+ async fn show_mnemonic(&mut self, words: &[&str]) -> Result<(), bitbox_hal::ui::UserAbort> {
+ let mut index = 0usize;
+ loop {
+ match self.menu_impl(words, None, false, true, index).await {
+ menu::MenuResult::Continue => return Ok(()),
+ menu::MenuResult::Cancel(cancelled_index) => {
+ index = cancelled_index;
+ match menu::confirm_recovery_words_cancel(self).await {
+ Ok(()) => return Err(bitbox_hal::ui::UserAbort),
+ Err(bitbox_hal::ui::UserAbort) => {}
+ }
+ }
+ menu::MenuResult::Selected(_) => panic!("unexpected mnemonic word selection"),
+ }
+ }
}
async fn quiz_mnemonic_word(
&mut self,
- _choices: &[&str],
- _title: &str,
+ choices: &[&str],
+ title: &str,
) -> Result<u8, bitbox_hal::ui::UserAbort> {
- todo!()
+ let mut index = 0usize;
+ loop {
+ match self
+ .menu_impl(choices, Some(title), true, false, index)
+ .await
+ {
+ menu::MenuResult::Selected(choice_idx) => return Ok(choice_idx),
+ menu::MenuResult::Cancel(cancelled_index) => {
+ index = cancelled_index;
+ match menu::confirm_recovery_words_cancel(self).await {
+ Ok(()) => return Err(bitbox_hal::ui::UserAbort),
+ Err(bitbox_hal::ui::UserAbort) => {}
+ }
+ }
+ menu::MenuResult::Continue => panic!("unexpected mnemonic quiz continue"),
+ }
+ }
}
}
@@ -298,3 +342,46 @@ impl<Timer> BitBox03Ui<Timer> {
}
}
}
+
+impl<Timer: bitbox_hal::timer::Timer> BitBox03Ui<Timer> {
+ async fn menu_impl(
+ &mut self,
+ words: &[&str],
+ title: Option<&str>,
+ select_word: bool,
+ continue_on_last: bool,
+ start_index: usize,
+ ) -> menu::MenuResult {
+ assert!(!words.is_empty(), "menu requires at least one word");
+ let mut index = start_index.min(words.len() - 1);
+ loop {
+ let action = self
+ .with_result_screen(|responder| {
+ menu::build_menu_screen(
+ words,
+ title,
+ index,
+ select_word,
+ continue_on_last,
+ responder,
+ )
+ })
+ .await;
+ match action {
+ menu::MenuAction::Previous => index = index.saturating_sub(1),
+ menu::MenuAction::Next => {
+ if index + 1 < words.len() {
+ index += 1;
+ }
+ }
+ menu::MenuAction::Select => {
+ return menu::MenuResult::Selected(
+ index.try_into().expect("menu supports at most 256 items"),
+ );
+ }
+ menu::MenuAction::Continue => return menu::MenuResult::Continue,
+ menu::MenuAction::Cancel => return menu::MenuResult::Cancel(index),
+ }
+ }
+ }
+}
diff --git a/src/rust/bitbox03/src/ui/choice.rs b/src/rust/bitbox03/src/ui/choice.rs
new file mode 100644
index 0000000..fbdb4d1
--- /dev/null
+++ b/src/rust/bitbox03/src/ui/choice.rs
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use alloc::vec::Vec;
+
+use bitbox_hal::ui::TrinaryChoice;
+use bitbox_lvgl::{
+ self as lvgl, LabelExt, LvAlign, LvButton, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
+ ObjExt,
+};
+use util::futures::completion::Responder;
+
+fn add_button(
+ parent: &LvObj,
+ width: i32,
+ label: &str,
+ choice: TrinaryChoice,
+ responder: Responder<TrinaryChoice>,
+) {
+ let button = LvButton::new(parent).unwrap();
+ button.set_size(width, 72);
+ button.set_style_bg_color(lvgl::color::white(), 0);
+ button.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ button.set_style_border_width(2, 0);
+ button.set_style_border_color(lvgl::color::black(), 0);
+ button
+ .add_click_cb(move || responder.resolve(choice))
+ .expect("failed to register choice callback");
+
+ let button_label = LvLabel::new(&button).unwrap();
+ button_label.set_text(label).unwrap();
+ button_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ button_label.set_style_text_color(lvgl::color::black(), 0);
+ button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+}
+
+pub(super) fn build_trinary_choice_screen(
+ message: &str,
+ label_left: Option<&str>,
+ label_middle: Option<&str>,
+ label_right: Option<&str>,
+ responder: Responder<TrinaryChoice>,
+) -> LvObj {
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(50, 0);
+ screen.set_style_pad_bottom(40, 0);
+ screen.set_style_pad_left(50, 0);
+ screen.set_style_pad_row(24, 0);
+
+ let title = LvLabel::new(&screen).unwrap();
+ title.set_width(380);
+ title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ title.set_text(message).unwrap();
+ title.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ title.set_style_flex_grow(1, 0);
+
+ let actions = LvObj::with_parent(&screen).unwrap();
+ actions.set_width(380);
+ actions.set_height(72);
+ actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ actions.set_style_pad_top(0, 0);
+ actions.set_style_pad_bottom(0, 0);
+ actions.set_style_pad_left(0, 0);
+ actions.set_style_pad_right(0, 0);
+ actions.set_style_pad_column(20, 0);
+ actions.set_style_border_width(0, 0);
+ actions.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ let choices: Vec<(&str, TrinaryChoice)> = [
+ label_left.map(|label| (label, TrinaryChoice::Left)),
+ label_middle.map(|label| (label, TrinaryChoice::Middle)),
+ label_right.map(|label| (label, TrinaryChoice::Right)),
+ ]
+ .into_iter()
+ .flatten()
+ .collect();
+ assert!(!choices.is_empty(), "trinary choice requires a button");
+ let choice_count = choices.len();
+ let width = match choice_count {
+ 1 => 380,
+ 2 => 180,
+ 3 => 113,
+ _ => unreachable!("only three choices exist"),
+ };
+
+ for (label, choice) in choices {
+ add_button(&actions, width, label, choice, responder.clone());
+ }
+
+ screen
+}
diff --git a/src/rust/bitbox03/src/ui/menu.rs b/src/rust/bitbox03/src/ui/menu.rs
new file mode 100644
index 0000000..90b851a
--- /dev/null
+++ b/src/rust/bitbox03/src/ui/menu.rs
@@ -0,0 +1,210 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use alloc::format;
+
+use bitbox_hal::ui::UserAbort;
+use bitbox_lvgl::{
+ self as lvgl, LabelExt, LvAlign, LvButton, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
+ ObjExt,
+};
+use util::futures::completion::Responder;
+
+#[derive(Clone, Copy)]
+pub(super) enum MenuAction {
+ Previous,
+ Next,
+ Select,
+ Continue,
+ Cancel,
+}
+
+pub(super) enum MenuResult {
+ Selected(u8),
+ Continue,
+ Cancel(usize),
+}
+
+fn add_button<F>(parent: &LvObj, width: i32, height: i32, label: &str, primary: bool, cb: F)
+where
+ F: FnMut() + 'static,
+{
+ let button = LvButton::new(parent).unwrap();
+ button.set_size(width, height);
+ button.set_style_bg_color(
+ if primary {
+ lvgl::color::white()
+ } else {
+ lvgl::color::hex(0x30333a)
+ },
+ 0,
+ );
+ button.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ button.set_style_border_width(2, 0);
+ button.set_style_border_color(
+ if primary {
+ lvgl::color::black()
+ } else {
+ lvgl::color::white()
+ },
+ 0,
+ );
+ button
+ .add_click_cb(cb)
+ .expect("failed to register menu callback");
+
+ let button_label = LvLabel::new(&button).unwrap();
+ button_label.set_text(label).unwrap();
+ button_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ button_label.set_style_text_color(
+ if primary {
+ lvgl::color::black()
+ } else {
+ lvgl::color::white()
+ },
+ 0,
+ );
+ button_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+}
+
+fn transparent_row(parent: &LvObj, height: i32) -> LvObj {
+ let row = LvObj::with_parent(parent).unwrap();
+ row.set_width(380);
+ row.set_height(height);
+ row.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ row.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ row.set_style_pad_top(0, 0);
+ row.set_style_pad_bottom(0, 0);
+ row.set_style_pad_left(0, 0);
+ row.set_style_pad_right(0, 0);
+ row.set_style_pad_column(20, 0);
+ row.set_style_border_width(0, 0);
+ row.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+ row
+}
+
+pub(super) fn build_menu_screen(
+ words: &[&str],
+ title: Option<&str>,
+ index: usize,
+ select_word: bool,
+ continue_on_last: bool,
+ responder: Responder<MenuAction>,
+) -> LvObj {
+ assert!(!words.is_empty(), "menu requires at least one word");
+ assert!(index < words.len(), "menu index out of bounds");
+
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(50, 0);
+ screen.set_style_pad_bottom(40, 0);
+ screen.set_style_pad_left(50, 0);
+ screen.set_style_pad_row(24, 0);
+
+ let title_text = title
+ .map(alloc::string::ToString::to_string)
+ .unwrap_or_else(|| format!("{:02}", index + 1));
+ let title_label = LvLabel::new(&screen).unwrap();
+ title_label.set_width(380);
+ title_label.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ title_label.set_text(&title_text).unwrap();
+ title_label.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
+ title_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ let word_label = LvLabel::new(&screen).unwrap();
+ word_label.set_width(380);
+ word_label.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ word_label.set_text(words[index]).unwrap();
+ word_label.set_style_text_align(lvgl::LvTextAlign::LV_TEXT_ALIGN_CENTER, 0);
+ word_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ word_label.set_style_flex_grow(1, 0);
+
+ let can_go_previous = index > 0;
+ let can_go_next = index + 1 < words.len();
+ if can_go_previous || can_go_next {
+ let navigation = transparent_row(&screen, 64);
+ let navigation_button_width = if can_go_previous && can_go_next {
+ 180
+ } else {
+ 380
+ };
+ if can_go_previous {
+ let previous_responder = responder.clone();
+ add_button(
+ &navigation,
+ navigation_button_width,
+ 64,
+ "Back",
+ false,
+ move || {
+ previous_responder.resolve(MenuAction::Previous);
+ },
+ );
+ }
+ if can_go_next {
+ let next_responder = responder.clone();
+ add_button(
+ &navigation,
+ navigation_button_width,
+ 64,
+ "Next",
+ false,
+ move || {
+ next_responder.resolve(MenuAction::Next);
+ },
+ );
+ }
+ }
+
+ let actions = transparent_row(&screen, 72);
+ let show_continue = continue_on_last && index + 1 == words.len();
+ let show_primary = select_word || show_continue;
+ let action_button_width = if show_primary { 180 } else { 380 };
+
+ let cancel_responder = responder.clone();
+ add_button(
+ &actions,
+ action_button_width,
+ 72,
+ "Cancel",
+ false,
+ move || {
+ cancel_responder.resolve(MenuAction::Cancel);
+ },
+ );
+
+ if select_word {
+ add_button(&actions, 180, 72, "Select", true, move || {
+ responder.resolve(MenuAction::Select);
+ });
+ } else if show_continue {
+ add_button(&actions, 180, 72, "Continue", true, move || {
+ responder.resolve(MenuAction::Continue);
+ });
+ }
+
+ screen
+}
+
+pub(super) async fn confirm_recovery_words_cancel(
+ ui: &mut impl bitbox_hal::ui::Ui,
+) -> Result<(), UserAbort> {
+ ui.confirm(&bitbox_hal::ui::ConfirmParams {
+ title: "Recovery\nwords",
+ body: "Do you really\nwant to cancel?",
+ ..Default::default()
+ })
+ .await
+}
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.