What changed, and why it matters
This commit fixes a security hygiene issue in the BitBox02 hardware wallet's handling of BIP39 recovery phrases (mnemonics). When converting a mnemonic to text, the previous code could create temporary copies of the secret phrase in memory due to how Rust's string buffer grows. The new code pre-allocates a fixed-size, self-erasing buffer and writes the mnemonic directly into it, reducing the chance that leftover copies of the recovery words remain in device memory.
Treat this as a security-hardening fix for a potential information-disclosure weakness. Review whether any other secret-to-string conversions in the firmware use `to_string()` or similar growable buffers without pre-allocation, and apply the same zeroizing pre-allocation pattern. Verify that `Zeroizing` reliably wipes the underlying allocation on drop in the embedded target.
Security signals we found
Sensitive data (BIP39 mnemonic) may have been left in memory due to string reallocation/growth
Use of `zeroize::Zeroizing` to attempt secure erasure of secret material
Pre-allocation of fixed capacity to avoid heap reallocations during secret serialization
Commit title and message explicitly describe unwiped mnemonic copies as the problem
Evidence from the diff
The patch changes mnemonic_from_seed in src/rust/bitbox02-rust/src/bip39.rs to avoid Mnemonic::to_string(), which may reallocate and leave unwiped copies of the mnemonic in heap memory as the String grows. Instead, it creates a zeroize::Zeroizing<String> with a precomputed maximum capacity (MAX_MNEMONIC_BYTES = 24*8 + 23) and uses core::fmt::write! to format the mnemonic into that buffer. The constant is moved from workflow/mnemonic.rs to bip39.rs and shared. A unit test verifies that the longest valid English mnemonic fills the buffer exactly without reallocation.
Changed components
src/rust/bitbox02-rust/src/bip39.rssrc/rust/bitbox02-rust/src/workflow/mnemonic.rsBIP39 mnemonic generation/serialization pathInspect captured patch +22 / −5
### 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;
@@ -12,10 +13,6 @@ const NUM_RANDOM_WORDS: u8 = 5;
/// Number of words in the BIP-39 wordlist.
const BIP39_WORDLIST_LEN: u16 = 2048;
-// 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.
-const MAX_MNEMONIC_BYTES: usize = 24 * 8 + 23;
-
fn as_str_vec(v: &[zeroize::Zeroizing<String>]) -> Vec<&str> {
v.iter().map(|s| s.as_str()).collect()
}Why this scored 59/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.