What changed, and why it matters
This commit hardens how the BitBox02 firmware builds BIP39 recovery phrases so the memory buffer is pre-sized and never grows. The goal is to prevent leftover copies of sensitive mnemonic words from lingering in memory after a reallocation. The change is defensive: it does not fix an observed exploit, but reduces the attack surface for information leaks of wallet recovery words.
Treat as a hardening improvement rather than a confirmed vulnerability fix. Review whether the underlying allocator may still retain freed pages or copies in flash/secure-element storage. Consider whether additional secure-memory practices (e.g., locking pages, explicit allocator zeroing) are needed for the threat model. No urgent patch deployment is required solely on this diff.
Security signals we found
Use of zeroize crate to clear sensitive mnemonic data on drop
Pre-allocation of fixed-size buffer to avoid heap reallocations
Replacement of format! and join with manual byte writes into a single buffer
Added unit tests for maximum-length mnemonic capacity
Defensive memory hygiene for BIP39 seed material
Evidence from the diff
The patch modifies two Rust files. In bip39.rs, mnemonic_from_seed now creates a zeroize-wrapped String with a fixed capacity (24*8+23 bytes) and writes the mnemonic into it, avoiding reallocations that could leave unwiped copies. In workflow/mnemonic.rs, lastword_choices builds the candidate mnemonic in a pre-sized zeroize-wrapped Vec
Changed components
src/rust/bitbox02-rust/src/bip39.rssrc/rust/bitbox02-rust/src/workflow/mnemonic.rsInspect captured patch +49 / −7
### src/rust/bitbox02-rust/src/bip39.rs
@@ -2,6 +2,11 @@
use alloc::string::{String, ToString};
use alloc::vec::Vec;
+use core::fmt::Write;
+
+// English BIP39 words contain at most 8 ASCII bytes. Reserve space for 24 words and 23 separators
+// before writing any recovery words, avoiding reallocations that could leave unwiped copies.
+pub(crate) const MAX_MNEMONIC_BYTES: usize = 24 * 8 + 23;
/// `idx` must be smaller than BIP39_WORDLIST_LEN.
pub fn get_word(idx: u16) -> Result<zeroize::Zeroizing<String>, ()> {
@@ -17,7 +22,9 @@ pub fn get_word(idx: u16) -> Result<zeroize::Zeroizing<String>, ()> {
/// Encode a seed as a BIP39 mnemonic.
pub fn mnemonic_from_seed(seed: &[u8]) -> Result<zeroize::Zeroizing<String>, ()> {
let mnemonic = bip39::Mnemonic::from_entropy(seed).map_err(|_| ())?;
- Ok(zeroize::Zeroizing::new(mnemonic.to_string()))
+ let mut result = zeroize::Zeroizing::new(String::with_capacity(MAX_MNEMONIC_BYTES));
+ write!(&mut result, "{mnemonic}").unwrap();
+ Ok(result)
}
/// Decode a BIP39 mnemonic.
@@ -147,6 +154,19 @@ mod tests {
assert!(mnemonic_from_seed(b"foo").is_err());
}
+ #[test]
+ fn test_mnemonic_from_seed_max_length() {
+ // This entropy produces 24 eight-byte words, filling the entire reserved buffer.
+ let seed =
+ hex_lit::hex!("0200400801002004008010020040080100200400801002004008010020040081");
+ let mut expected_words = ["acoustic"; 24];
+ expected_words[23] = "decrease";
+ let mnemonic = mnemonic_from_seed(&seed).unwrap();
+ assert_eq!(mnemonic.as_str(), expected_words.join(" "));
+ assert_eq!(mnemonic.len(), MAX_MNEMONIC_BYTES);
+ assert_eq!(mnemonic.capacity(), MAX_MNEMONIC_BYTES);
+ }
+
#[test]
fn test_mnemonic_to_seed() {
assert!(mnemonic_to_seed("invalid").is_err());
### src/rust/bitbox02-rust/src/workflow/mnemonic.rs
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
+use crate::bip39::MAX_MNEMONIC_BYTES;
use crate::hal::ui::{ConfirmParams, TrinaryChoice, UserAbort, WordlistEntryAbort};
use alloc::string::String;
@@ -132,12 +133,22 @@ fn lastword_choices(entered_words: &[&str]) -> Vec<u16> {
let mut seed: zeroize::Zeroizing<Vec<u8>> = {
let mut i = 0;
loop {
- let mnemonic = zeroize::Zeroizing::new(format!(
- "{} {}",
- entered_words.join(" "),
- crate::bip39::get_word(i).unwrap().as_str(),
- ));
- if let Ok(seed) = crate::bip39::mnemonic_to_seed(&mnemonic) {
+ let mut mnemonic = zeroize::Zeroizing::new(Vec::with_capacity(MAX_MNEMONIC_BYTES));
+ for word in entered_words {
+ mnemonic.extend_from_slice(word.as_bytes());
+ mnemonic.push(b' ');
+ }
+ let last_word = crate::bip39::get_word(i).unwrap();
+ mnemonic.extend_from_slice(last_word.as_bytes());
+ #[cfg(test)]
+ assert_eq!(
+ mnemonic.capacity(),
+ MAX_MNEMONIC_BYTES,
+ "mnemonic buffer must not reallocate"
+ );
+ if let Ok(seed) =
+ crate::bip39::mnemonic_to_seed(core::str::from_utf8(mnemonic.as_slice()).unwrap())
+ {
break seed;
}
i += 1;
@@ -721,6 +732,17 @@ mod tests {
assert!(ui.contains_confirm("Restore", "Cancel restore?"));
}
+ #[test]
+ fn test_lastword_choices_max_length() {
+ // These eight-byte words fill all 215 bytes when "abstract" is tried as the last word,
+ // before reaching the first checksum-valid candidate, "banner". The capacity assertion in
+ // lastword_choices checks that the buffer does not grow while constructing these phrases.
+ let entered_words = ["abstract"; 23];
+ let expected = bruteforce_lastword(&entered_words);
+ assert_eq!(expected[0].as_str(), "banner");
+ assert_eq!(lastword_choices_strings(&entered_words), expected);
+ }
+
#[test]
fn test_lastword_choices() {
// 23 wordsWhy this scored 46/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.