hal/ui: remove show_and_confirm_mnemonic
What changed, and why it matters
This commit is a code cleanup and test improvement. It removes a redundant user-interface method called show_and_confirm_mnemonic from the hardware abstraction layer and makes callers use the underlying workflow directly. It also adds more detailed test helpers so the device screens shown during backup verification can be checked more precisely. There is no indication this fixes a security bug or changes real device behavior in a risky way.
No security action required. Treat as a normal refactoring/test-coverage commit. Reviewers may verify that the moved call sites still propagate UserAbort correctly and that the new test helpers do not hide real UI behavior.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors the Rust HAL Ui trait by removing the default method show_and_confirm_mnemonic and its default implementation. Call sites in bip85.rs and show_mnemonic.rs now call crate::workflow::mnemonic::show_and_confirm_mnemonic directly. The testing mock UI gains explicit implementations for show_mnemonic and quiz_mnemonic_word, plus helper methods to queue quiz answers and assert the expected screen sequence. The production workflow logic itself is unchanged; only the abstraction layer and unit tests are affected.
Changed components
src/rust/bitbox02-rust/src/hal/ui.rssrc/rust/bitbox02-rust/src/hal/testing/ui.rssrc/rust/bitbox02-rust/src/hww/api/bip85.rssrc/rust/bitbox02-rust/src/hww/api/show_mnemonic.rssrc/rust/bitbox02-rust/src/workflow/mnemonic.rsInspect captured patch +246 / −81
diff --git a/src/rust/bitbox02-rust/src/hal/testing/ui.rs b/src/rust/bitbox02-rust/src/hal/testing/ui.rs
index 59fa3a1..5eb3a89 100644
--- a/src/rust/bitbox02-rust/src/hal/testing/ui.rs
+++ b/src/rust/bitbox02-rust/src/hal/testing/ui.rs
@@ -4,6 +4,7 @@ use crate::hal::Ui;
use crate::hal::ui::{CanCancel, ConfirmParams, EnterStringParams, TrinaryChoice, UserAbort};
use alloc::boxed::Box;
+use alloc::collections::VecDeque;
use alloc::string::String;
use alloc::vec::Vec;
@@ -27,8 +28,13 @@ pub enum Screen {
title: String,
success: bool,
},
- ShowAndConfirmMnemonic {
- mnemonic: String,
+ ShowMnemonic {
+ words: Vec<String>,
+ },
+ QuizMnemonicWord {
+ title: String,
+ choices: Vec<String>,
+ selected: u8,
},
More,
}
@@ -41,6 +47,7 @@ pub struct TestingUi<'a> {
_abort_nth: Option<usize>,
pub screens: Vec<Screen>,
_enter_string: Option<EnterStringCb<'a>>,
+ _quiz_choices: VecDeque<u8>,
}
impl Ui for TestingUi<'_> {
@@ -137,36 +144,47 @@ impl Ui for TestingUi<'_> {
todo!("not used in unit tests yet");
}
- async fn show_mnemonic(&mut self, _words: &[&str]) -> Result<(), UserAbort> {
- todo!("not used in unit tests yet");
+ async fn show_mnemonic(&mut self, words: &[&str]) -> Result<(), UserAbort> {
+ let words: Vec<String> = words.iter().map(|word| (*word).into()).collect();
+ self.screens.push(Screen::ShowMnemonic { words });
+ if self
+ ._abort_nth
+ .as_ref()
+ .is_some_and(|&n| self.screens.len() - 1 == n)
+ {
+ return Err(UserAbort);
+ }
+ Ok(())
}
- async fn quiz_mnemonic_word(
- &mut self,
- _choices: &[&str],
- _title: &str,
- ) -> Result<u8, UserAbort> {
- todo!("not used in unit tests yet");
- }
+ async fn quiz_mnemonic_word(&mut self, choices: &[&str], title: &str) -> Result<u8, UserAbort> {
+ let selected = self._quiz_choices.pop_front().unwrap_or_else(|| {
+ panic!("quiz_mnemonic_word called without queued choice; use push_quiz_choice")
+ });
- async fn show_and_confirm_mnemonic(
- &mut self,
- _random: &mut impl crate::hal::Random,
- words: &[&str],
- ) -> Result<(), UserAbort> {
- self.screens.push(Screen::ShowAndConfirmMnemonic {
- mnemonic: words.join(" "),
+ self.screens.push(Screen::QuizMnemonicWord {
+ title: title.into(),
+ choices: choices.iter().map(|choice| (*choice).into()).collect(),
+ selected,
});
- Ok(())
- }
- async fn get_mnemonic(&mut self) -> Result<zeroize::Zeroizing<String>, UserAbort>
- where
- Self: Sized,
- {
- let words = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide";
+ if self
+ ._abort_nth
+ .as_ref()
+ .is_some_and(|&n| self.screens.len() - 1 == n)
+ {
+ return Err(UserAbort);
+ }
- Ok(zeroize::Zeroizing::new(words.into()))
+ if selected as usize >= choices.len() {
+ panic!(
+ "quiz choice {} out of bounds for {} choices",
+ selected,
+ choices.len()
+ );
+ }
+
+ Ok(selected)
}
}
@@ -176,6 +194,7 @@ impl<'a> TestingUi<'a> {
screens: vec![],
_abort_nth: None,
_enter_string: None,
+ _quiz_choices: VecDeque::new(),
}
}
@@ -199,4 +218,143 @@ impl<'a> TestingUi<'a> {
pub fn remove_enter_string(&mut self) {
self._enter_string = None;
}
+
+ pub fn push_quiz_choice(&mut self, selected: u8) {
+ self._quiz_choices.push_back(selected);
+ }
+
+ pub fn push_quiz_choices(&mut self, selected: &[u8]) {
+ for choice in selected {
+ self.push_quiz_choice(*choice);
+ }
+ }
+
+ fn u16_to_rand(value: u16) -> [u8; 32] {
+ let mut out = [0u8; 32];
+ out[0] = (value >> 8) as u8;
+ out[1] = value as u8;
+ out
+ }
+
+ /// Push one mocked 16-bit random value in the format consumed by
+ /// `workflow::mnemonic::create_random_unique_words()` (its local `rand16`
+ /// helper reads the first two bytes big-endian).
+ pub fn mock_next_u16(random: &mut super::random::TestingRandom, value: u16) {
+ random.mock_next(Self::u16_to_rand(value));
+ }
+
+ /// Configure random values for one `create_random_unique_words()` call so that
+ /// the correct answer is placed at choice index 2 in a 5-entry list.
+ pub fn prepare_mnemonic_quiz_word_random(random: &mut super::random::TestingRandom) {
+ for value in [2u16, 0, 1, 2, 3] {
+ Self::mock_next_u16(random, value);
+ }
+ }
+
+ /// Configure deterministic random inputs and quiz responses for
+ /// `workflow::mnemonic::show_and_confirm_mnemonic`.
+ /// This prepares the quiz so the correct answer is always at choice index 2.
+ pub fn prepare_show_and_confirm_mnemonic(
+ &mut self,
+ random: &mut super::random::TestingRandom,
+ num_words: usize,
+ ) {
+ for _ in 0..num_words {
+ Self::prepare_mnemonic_quiz_word_random(random);
+ self.push_quiz_choice(2);
+ }
+ }
+
+ /// Assert screens emitted by `workflow::mnemonic::show_and_confirm_mnemonic()`.
+ pub fn assert_show_and_confirm_mnemonic_screens(screens: &[Screen], words: &[&str]) {
+ assert_eq!(
+ screens[0],
+ Screen::Confirm {
+ title: "".into(),
+ body: format!("{} words follow", words.len()),
+ longtouch: false
+ }
+ );
+ assert_eq!(
+ screens[1],
+ Screen::ShowMnemonic {
+ words: words.iter().map(|word| (*word).into()).collect()
+ }
+ );
+ assert_eq!(
+ screens[2],
+ Screen::Confirm {
+ title: "".into(),
+ body: "Please confirm\neach word".into(),
+ longtouch: false
+ }
+ );
+
+ for (word_idx, expected_word) in words.iter().enumerate() {
+ match &screens[3 + word_idx] {
+ Screen::QuizMnemonicWord {
+ title,
+ choices,
+ selected,
+ } => {
+ assert_eq!(*selected, 2);
+ assert_eq!(title, &format!("{:02}", word_idx + 1));
+ assert_eq!(choices[*selected as usize], *expected_word);
+ }
+ _ => panic!("unexpected screen"),
+ }
+ }
+ assert_eq!(screens.len(), words.len() + 3);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::hal::Ui;
+
+ use util::bb02_async::block_on;
+
+ #[test]
+ fn test_quiz_choices_queue() {
+ let mut ui = TestingUi::new();
+ ui.push_quiz_choice(1);
+ assert!(matches!(
+ block_on(ui.quiz_mnemonic_word(&["a", "b", "c"], "01")),
+ Ok(1)
+ ));
+ }
+
+ #[test]
+ fn test_quiz_choice_records_screen() {
+ let mut ui = TestingUi::new();
+ ui.push_quiz_choice(2);
+ assert!(matches!(
+ block_on(ui.quiz_mnemonic_word(&["x", "bar", "y"], "02")),
+ Ok(2)
+ ));
+ assert_eq!(
+ ui.screens,
+ vec![Screen::QuizMnemonicWord {
+ title: "02".into(),
+ choices: vec!["x".into(), "bar".into(), "y".into()],
+ selected: 2,
+ }]
+ );
+ }
+
+ #[test]
+ #[should_panic(expected = "quiz choice 9 out of bounds for 1 choices")]
+ fn test_quiz_choice_out_of_bounds_panics() {
+ let mut ui = TestingUi::new();
+ ui.push_quiz_choice(9);
+ let _ = block_on(ui.quiz_mnemonic_word(&["a"], "01"));
+ }
+
+ #[test]
+ #[should_panic(expected = "quiz_mnemonic_word called without queued choice")]
+ fn test_quiz_choice_without_state_panics() {
+ let mut ui = TestingUi::new();
+ let _ = block_on(ui.quiz_mnemonic_word(&["a"], "01"));
+ }
}
diff --git a/src/rust/bitbox02-rust/src/hal/ui.rs b/src/rust/bitbox02-rust/src/hal/ui.rs
index 061edae..e6620cb 100644
--- a/src/rust/bitbox02-rust/src/hal/ui.rs
+++ b/src/rust/bitbox02-rust/src/hal/ui.rs
@@ -108,24 +108,6 @@ pub trait Ui {
/// user backuped up the mnemonic correctly.
async fn quiz_mnemonic_word(&mut self, choices: &[&str], title: &str) -> Result<u8, UserAbort>;
- /// Display the mnemonic words and have the user confirm them in a multiple-choice quiz.
- ///
- /// The default implementation is implemented in terms of `self.show_mnemonic()`,
- /// `self.quiz_mnemonic_word()`, etc.
- ///
- /// This function is defined in the HAL so unit tests can easily mock it. Real implementations
- /// should leave the default implementation.
- async fn show_and_confirm_mnemonic(
- &mut self,
- random: &mut impl crate::hal::Random,
- words: &[&str],
- ) -> Result<(), UserAbort>
- where
- Self: Sized,
- {
- mnemonic::show_and_confirm_mnemonic(self, random, words).await
- }
-
/// Retrieve a BIP39 mnemonic sentence of 12 or 24 words from the user.
///
/// This function is defined in the HAL so unit tests can easily mock it. Real implementations
diff --git a/src/rust/bitbox02-rust/src/hww/api/bip85.rs b/src/rust/bitbox02-rust/src/hww/api/bip85.rs
index 65fbf69..7be2445 100644
--- a/src/rust/bitbox02-rust/src/hww/api/bip85.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/bip85.rs
@@ -112,7 +112,7 @@ async fn process_bip39(hal: &mut impl crate::hal::Hal) -> Result<(), Error> {
let words: Vec<&str> = mnemonic.split(' ').collect();
{
let crate::hal::HalSubsystems { ui, random, .. } = hal.subsystems();
- ui.show_and_confirm_mnemonic(random, &words).await?;
+ crate::workflow::mnemonic::show_and_confirm_mnemonic(ui, random, &words).await?;
}
hal.ui().status("Finished", true).await;
diff --git a/src/rust/bitbox02-rust/src/hww/api/show_mnemonic.rs b/src/rust/bitbox02-rust/src/hww/api/show_mnemonic.rs
index 8949099..4ae08d6 100644
--- a/src/rust/bitbox02-rust/src/hww/api/show_mnemonic.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/show_mnemonic.rs
@@ -48,7 +48,7 @@ pub async fn process(hal: &mut impl crate::hal::Hal) -> Result<Response, Error>
{
let crate::hal::HalSubsystems { ui, random, .. } = hal.subsystems();
- ui.show_and_confirm_mnemonic(random, &words).await?;
+ crate::workflow::mnemonic::show_and_confirm_mnemonic(ui, random, &words).await?;
}
hal.memory().set_initialized().or(Err(Error::Memory))?;
@@ -63,11 +63,13 @@ mod tests {
use alloc::boxed::Box;
- use crate::hal::testing::TestingHal;
use crate::hal::testing::ui::Screen;
+ use crate::hal::testing::{TestingHal, TestingUi};
use bitbox02::testing::mock_memory;
use util::bb02_async::block_on;
+ const MNEMONIC: &str = "shy parrot age monkey rhythm snake mystery burden topic hello mouse script gesture tattoo demand float verify shoe recycle cool network better aspect list";
+
/// When not yet initialized, we show the mnemonic without a password check. This happens during
/// wallet setup.
#[test]
@@ -87,6 +89,9 @@ mod tests {
mock_hal.ui.set_enter_string(Box::new(|_params| {
panic!("unexpected call to enter password")
}));
+ mock_hal
+ .ui
+ .prepare_show_and_confirm_mnemonic(&mut mock_hal.random, 24);
mock_hal.securechip.event_counter_reset();
assert_eq!(
@@ -96,28 +101,33 @@ mod tests {
// 1 operation for one copy_seed() to get the seed to display it.
assert_eq!(mock_hal.securechip.get_event_counter(), 1);
+ let words: Vec<&str> = MNEMONIC.split(' ').collect();
assert_eq!(
- mock_hal.ui.screens,
- vec![
+ mock_hal.ui.screens[..2],
+ [
Screen::Confirm {
title: "Warning".into(),
body: "DO NOT share your\nrecovery words with\nanyone!".into(),
- longtouch: false
+ longtouch: false,
},
Screen::Confirm {
title: "Recovery\nwords".into(),
body: "Please write down\nthe following words".into(),
- longtouch: false
- },
- Screen::ShowAndConfirmMnemonic {
- mnemonic: "shy parrot age monkey rhythm snake mystery burden topic hello mouse script gesture tattoo demand float verify shoe recycle cool network better aspect list".into(),
- },
- Screen::Status {
- title: "Backup created".into(),
- success: true
+ longtouch: false,
},
]
);
+ TestingUi::assert_show_and_confirm_mnemonic_screens(
+ &mock_hal.ui.screens[2..mock_hal.ui.screens.len() - 1],
+ &words,
+ );
+ assert_eq!(
+ mock_hal.ui.screens.last(),
+ Some(&Screen::Status {
+ title: "Backup created".into(),
+ success: true,
+ })
+ );
}
/// When initialized, a password check is prompted before displaying the mnemonic.
#[test]
@@ -141,6 +151,9 @@ mod tests {
password_entered = true;
Ok("password".into())
}));
+ mock_hal
+ .ui
+ .prepare_show_and_confirm_mnemonic(&mut mock_hal.random, 24);
mock_hal.securechip.event_counter_reset();
assert_eq!(
@@ -149,28 +162,33 @@ mod tests {
);
assert_eq!(mock_hal.securechip.get_event_counter(), 4);
+ let words: Vec<&str> = MNEMONIC.split(' ').collect();
assert_eq!(
- mock_hal.ui.screens,
- vec![
+ mock_hal.ui.screens[..2],
+ [
Screen::Confirm {
title: "Warning".into(),
body: "DO NOT share your\nrecovery words with\nanyone!".into(),
- longtouch: false
+ longtouch: false,
},
Screen::Confirm {
title: "Recovery\nwords".into(),
body: "Please write down\nthe following words".into(),
- longtouch: false
- },
- Screen::ShowAndConfirmMnemonic {
- mnemonic: "shy parrot age monkey rhythm snake mystery burden topic hello mouse script gesture tattoo demand float verify shoe recycle cool network better aspect list".into(),
- },
- Screen::Status {
- title: "Backup created".into(),
- success: true
+ longtouch: false,
},
]
);
+ TestingUi::assert_show_and_confirm_mnemonic_screens(
+ &mock_hal.ui.screens[2..mock_hal.ui.screens.len() - 1],
+ &words,
+ );
+ assert_eq!(
+ mock_hal.ui.screens.last(),
+ Some(&Screen::Status {
+ title: "Backup created".into(),
+ success: true,
+ })
+ );
drop(mock_hal); // to remove mutable borrow of `password_entered`
assert!(password_entered);
diff --git a/src/rust/bitbox02-rust/src/workflow/mnemonic.rs b/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
index 09fc028..f5860ae 100644
--- a/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
+++ b/src/rust/bitbox02-rust/src/workflow/mnemonic.rs
@@ -381,12 +381,8 @@ pub async fn get(
mod tests {
use super::*;
- fn u16_to_rand(value: u16) -> [u8; 32] {
- let mut out = [0u8; 32];
- out[0] = (value >> 8) as u8;
- out[1] = value as u8;
- out
- }
+ use crate::hal::testing::{TestingRandom, TestingUi};
+ use util::bb02_async::block_on;
fn bruteforce_lastword(mnemonic: &[&str]) -> Vec<zeroize::Zeroizing<String>> {
let mut result = Vec::new();
@@ -403,12 +399,9 @@ mod tests {
#[test]
fn test_create_random_unique_words() {
- let mut random = crate::hal::testing::TestingRandom::new();
- random.mock_next(u16_to_rand(2)); // place the target at index 2 in a 5-entry list.
- random.mock_next(u16_to_rand(0));
- random.mock_next(u16_to_rand(1));
- random.mock_next(u16_to_rand(2));
- random.mock_next(u16_to_rand(3));
+ let mut random = TestingRandom::new();
+ // Place the target at index 2 in a 5-entry list.
+ TestingUi::prepare_mnemonic_quiz_word_random(&mut random);
let (correct_idx, choices) =
create_random_unique_words(&mut random, "zoo", NUM_RANDOM_WORDS);
assert_eq!(correct_idx, 2);
@@ -423,6 +416,20 @@ mod tests {
assert_eq!(unique.len(), choices.len());
}
+ #[test]
+ fn test_show_and_confirm_mnemonic() {
+ 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 ui = TestingUi::new();
+ let mut random = TestingRandom::new();
+ ui.prepare_show_and_confirm_mnemonic(&mut random, words.len());
+
+ let result = block_on(show_and_confirm_mnemonic(&mut ui, &mut random, &words));
+ assert!(result.is_ok());
+ TestingUi::assert_show_and_confirm_mnemonic_screens(&ui.screens, &words);
+ }
+
#[test]
fn test_lastword_choices() {
// 23 words
Why this scored 12/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.