port bip39 functionality from libwally-core to rust-bip39
What changed, and why it matters
This commit replaces the BIP39 (seed phrase) implementation inside the BitBox02 hardware wallet from one library (libwally-core) to another (rust-bip39). The main user-visible reason is speed: unlocking the device is now faster, so the unlock animation was shortened. The change touches how seed phrases are converted to cryptographic seeds and how individual BIP39 words are looked up. There is no direct evidence in the commit that this fixes a known security bug, but any change to cryptographic code can introduce subtle risks, so it deserves careful review.
Treat this as a high-priority cryptographic change. Independently verify that rust-bip39 produces byte-for-byte identical BIP39 seed output for all supported seed lengths and passphrases compared to the previous libwally implementation. Audit the unsafe FFI functions rust_derive_bip39_seed and rust_get_bip39_word for null-pointer handling, buffer length checks, and correct null termination. Confirm that all intermediate mnemonic and seed buffers are zeroized and that no copies linger in Rust or C memory. Run the existing unit tests and add edge-case tests for empty passphrase, non-ASCII passphrase handling, maximum-length passphrases, and invalid seed lengths.
Security signals we found
Cryptographic library migration (libwally-core BIP39 → rust-bip39)
New unsafe FFI boundary for passphrase pointer and output buffer
Use of zeroize for sensitive derived seed material
Removal of C-side mnemonic string cleanup block; seed now passed directly to Rust
Change to unlock code path that derives the BIP39 seed used for wallet operations
Evidence from the diff
The patch ports BIP39 functionality from libwally-core to rust-bip39. In C, keystore_unlock_bip39 now calls rust_derive_bip39_seed instead of libwally’s bip39_mnemonic_from_bytes/bip39_mnemonic_to_seed, and keystore_get_bip39_word_stack calls rust_get_bip39_word. In Rust, bitbox02/src/keystore.rs now uses bip39::Mnemonic for mnemonic-to-seed, seed-to-mnemonic, and word-list lookup. The new C FFI functions in bitbox02-rust-c/src/bip39.rs use zeroize for the derived seed. The lock animation tick period was reduced from 100 ms to 40 ms because the Rust implementation is faster. No CVE, advisory, or vendor security disclosure is present in the supplied materials.
Changed components
src/keystore.csrc/keystore.hsrc/rust/bitbox02-rust-c/src/bip39.rssrc/rust/bitbox02/src/keystore.rssrc/ui/graphics/lock_animation.cInspect captured patch +186 / −129
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1d111c..1b67cd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
### [Unreleased]
- Change title when entering recovery words to `1 of 24`, `2 of 24`, etc.
+- Unlock is now faster after password/passphrase entry (shorter unlock animation)
### 9.23.1
- EVM: add HyperEVM (HYPE) and SONIC (S) to known networks
diff --git a/src/keystore.c b/src/keystore.c
index b170536..e51b5c1 100644
--- a/src/keystore.c
+++ b/src/keystore.c
@@ -308,11 +308,6 @@ keystore_error_t keystore_create_and_store_seed(
return keystore_encrypt_and_store_seed(seed, host_entropy_size, password);
}
-static void _free_string(char** str)
-{
- wally_free_string(*str);
-}
-
USE_RESULT static keystore_error_t _retain_seed(const uint8_t* seed, size_t seed_len)
{
#ifdef TESTING
@@ -463,24 +458,21 @@ bool keystore_unlock_bip39(const char* mnemonic_passphrase)
return false;
}
usb_processing_timeout_reset(LONG_TIMEOUT);
- char* mnemonic __attribute__((__cleanup__(_free_string))) = NULL;
- { // block so that `seed` is zeroed as soon as possible
- uint8_t seed[KEYSTORE_MAX_SEED_LENGTH] = {0};
- UTIL_CLEANUP_32(seed);
- size_t seed_length = 0;
- if (!keystore_copy_seed(seed, &seed_length)) {
- return false;
- }
- if (bip39_mnemonic_from_bytes(NULL, seed, seed_length, &mnemonic) != WALLY_OK) {
- return false;
- }
+
+ uint8_t seed[KEYSTORE_MAX_SEED_LENGTH] = {0};
+ UTIL_CLEANUP_32(seed);
+ size_t seed_length = 0;
+ if (!keystore_copy_seed(seed, &seed_length)) {
+ return false;
}
+
uint8_t bip39_seed[BIP39_SEED_LEN_512] = {0};
UTIL_CLEANUP_64(bip39_seed);
- if (bip39_mnemonic_to_seed(
- mnemonic, mnemonic_passphrase, bip39_seed, sizeof(bip39_seed), NULL) != WALLY_OK) {
- return false;
- }
+ rust_derive_bip39_seed(
+ rust_util_bytes(seed, seed_length),
+ mnemonic_passphrase,
+ rust_util_bytes_mut(bip39_seed, sizeof(bip39_seed)));
+
if (!_retain_bip39_seed(bip39_seed)) {
return false;
}
@@ -501,41 +493,9 @@ bool keystore_is_locked(void)
return !unlocked;
}
-bool keystore_bip39_mnemonic_from_seed(
- const uint8_t* seed,
- size_t seed_size,
- char* mnemonic_out,
- size_t mnemonic_out_size)
-{
- char* mnemonic = NULL;
- if (bip39_mnemonic_from_bytes(NULL, seed, seed_size, &mnemonic) != WALLY_OK) {
- return false;
- }
- int snprintf_result = snprintf(mnemonic_out, mnemonic_out_size, "%s", mnemonic);
- util_cleanup_str(&mnemonic);
- free(mnemonic);
- return snprintf_result >= 0 && snprintf_result < (int)mnemonic_out_size;
-}
-
-bool keystore_bip39_mnemonic_to_seed(const char* mnemonic, uint8_t* seed_out, size_t* seed_len_out)
-{
- return bip39_mnemonic_to_bytes(NULL, mnemonic, seed_out, 32, seed_len_out) == WALLY_OK;
-}
-
bool keystore_get_bip39_word_stack(uint16_t idx, char* word_out, size_t word_out_size)
{
- char* word_ptr;
- if (bip39_get_word(NULL, idx, &word_ptr) != WALLY_OK) {
- return false;
- }
- int snprintf_result = snprintf(word_out, word_out_size, "%s", word_ptr);
- wally_free_string(word_ptr);
- return snprintf_result >= 0 && snprintf_result < (int)word_out_size;
-}
-
-bool keystore_get_bip39_word(uint16_t idx, char** word_out)
-{
- return bip39_get_word(NULL, idx, word_out) == WALLY_OK;
+ return rust_get_bip39_word(idx, rust_util_bytes_mut((uint8_t*)word_out, word_out_size));
}
bool keystore_secp256k1_nonce_commit(
diff --git a/src/keystore.h b/src/keystore.h
index f699116..de35b36 100644
--- a/src/keystore.h
+++ b/src/keystore.h
@@ -124,35 +124,6 @@ void keystore_lock(void);
*/
USE_RESULT bool keystore_is_locked(void);
-/**
- * Converts a 16/24/32 byte seed into a BIP-39 mnemonic string.
- * Returns false if the seed size is invalid or the output string buffer is not large enough.
- */
-USE_RESULT bool keystore_bip39_mnemonic_from_seed(
- const uint8_t* seed,
- size_t seed_size,
- char* mnemonic_out,
- size_t mnemonic_out_size);
-
-/**
- * Turn a bip39 mnemonic into a seed. Make sure to use UTIL_CLEANUP_32 to destroy it.
- * Output can be fed into `keystore_encrypt_and_store_seed` to create a keystore from the mnemonic.
- * @param[in] mnemonic 12/18/24 word bip39 mnemonic
- * @param[out] seed_out must be 32 bytes
- * @param[out] seed_len_out will be the size of the seed
- */
-USE_RESULT bool keystore_bip39_mnemonic_to_seed(
- const char* mnemonic,
- uint8_t* seed_out,
- size_t* seed_len_out);
-
-/**
- * Returns the pointer to a word in the word list for the given index.
- * @param[in] idx The index into the word list. Must be smaller than BIP39_WORDLIST_LEN.
- * @param[out] word_out The pointer to the character array for the given index.
- */
-USE_RESULT bool keystore_get_bip39_word(uint16_t idx, char** word_out);
-
/**
* Retrieves the BIP39 word by index. `word_out` should be of at least 9 bytes long.
*/
diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock
index 0a9430e..3aa4373 100644
--- a/src/rust/Cargo.lock
+++ b/src/rust/Cargo.lock
@@ -102,6 +102,7 @@ dependencies = [
name = "bitbox02"
version = "0.1.0"
dependencies = [
+ "bip39",
"bitbox-aes",
"bitbox02-sys",
"bitcoin",
@@ -167,6 +168,7 @@ dependencies = [
"p256",
"sha2",
"util",
+ "zeroize",
]
[[package]]
diff --git a/src/rust/bitbox02-rust-c/Cargo.toml b/src/rust/bitbox02-rust-c/Cargo.toml
index f5e72de..1efa269 100644
--- a/src/rust/bitbox02-rust-c/Cargo.toml
+++ b/src/rust/bitbox02-rust-c/Cargo.toml
@@ -35,6 +35,7 @@ hex = { workspace = true }
sha2 = { workspace = true, optional = true }
bitcoin = { workspace = true, optional = true }
bip39 = { workspace = true }
+zeroize = { workspace = true }
[features]
# Only one of the "target-" should be activated, which in turn defines/activates the dependent features.
diff --git a/src/rust/bitbox02-rust-c/src/bip39.rs b/src/rust/bitbox02-rust-c/src/bip39.rs
new file mode 100644
index 0000000..6ea9fcd
--- /dev/null
+++ b/src/rust/bitbox02-rust-c/src/bip39.rs
@@ -0,0 +1,150 @@
+// Copyright 2025 Shift Crypto AG
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/// # Safety
+///
+/// The passphrase must be not NULL and null-terminated.
+///
+/// `seed` must be 16, 24 or 32 bytes long.
+/// `out` must be exactly 64 bytes long.
+#[no_mangle]
+pub unsafe extern "C" fn rust_derive_bip39_seed(
+ seed: crate::util::Bytes,
+ passphrase: *const core::ffi::c_char,
+ mut out: crate::util::BytesMut,
+) {
+ let mnemonic =
+ bip39::Mnemonic::from_entropy_in(bip39::Language::English, seed.as_ref()).unwrap();
+ let passphrase = core::ffi::CStr::from_ptr(passphrase);
+ let bip39_seed =
+ zeroize::Zeroizing::new(mnemonic.to_seed_normalized(passphrase.to_str().unwrap()));
+ out.as_mut().clone_from_slice(&bip39_seed[..]);
+}
+
+#[no_mangle]
+pub extern "C" fn rust_get_bip39_word(idx: u16, mut out: crate::util::BytesMut) -> bool {
+ let word = match bitbox02::keystore::get_bip39_word(idx) {
+ Err(()) => return false,
+ Ok(w) => w,
+ };
+ let bytes = word.as_bytes();
+ let out = out.as_mut();
+ if out.len() < bytes.len() + 1 {
+ return false;
+ }
+ out[..bytes.len()].clone_from_slice(bytes);
+ out[bytes.len()] = 0;
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_rust_derive_bip39_seed() {
+ struct Test {
+ seed: &'static str,
+ passphrase: &'static core::ffi::CStr,
+ expected_bip39_seed: &'static str,
+ }
+
+ let tests = &[
+ // 16 byte seed
+ Test {
+ seed: "fb5cf00d5ea61059fa066e25a6be9544",
+ passphrase: c"",
+ expected_bip39_seed: "f4577e463be595868060e5a763328153155b4167cd284998c8c6096d044742372020f5b052d0c41c1c5e6a6a7da2cb8a367aaaa074fab7773e8d5b2f684257ed",
+ },
+ Test {
+ seed: "fb5cf00d5ea61059fa066e25a6be9544",
+ passphrase: c"password",
+ expected_bip39_seed: "5922fb7630bc7cb871af102f733b6bdb8f05945147cd4646a89056fde0bdad5c3a4ff5be3f9e7af535f570e7053b5b22472555b331bc89cb797c306f7eb6a5a1",
+ },
+ // 24 byte seed
+ Test {
+ seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
+ passphrase: c"",
+ expected_bip39_seed: "4a2a016a6d90eb3a79b7931ca0a172df5c5bfee3e5b47f0fd84bc0791ea3bbc9476c3d5de71cdb12c37e93c2aa3d5c303257f1992aed400fc5bbfc7da787bfa7",
+ },
+ Test {
+ seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
+ passphrase: c"password",
+ expected_bip39_seed: "bc317ee0f88870254be32274d63ec2b0e962bf09f3ca04287912bfc843f2fab7c556f8657cadc924f99a217b0daa91898303a8414102031a125c50023e45a80b",
+ },
+ // 32 byte seed
+ Test {
+ seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
+ passphrase: c"",
+ expected_bip39_seed: "63f844e2c61ecfb20f9100de381a7a9ec875b085f5ac7735a2ba4d615a0f4147b87be402f65651969130683deeef752760c09e291604fe4b89d61ffee2630be8",
+ },
+ Test {
+ seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
+ passphrase: c"password",
+ expected_bip39_seed: "42e90dacd61f3373542d212f0fb9c291dcea84a6d85034272372dde7188638a98527280d65e41599f30d3434d8ee3d4747dbb84801ff1a851d2306c7d1648374",
+ },
+ ];
+
+ for test in tests {
+ let seed = hex::decode(test.seed).unwrap();
+ let mut bip39_seed = [0u8; 64];
+ unsafe {
+ rust_derive_bip39_seed(
+ crate::util::rust_util_bytes(seed.as_ptr(), seed.len()),
+ test.passphrase.as_ptr(),
+ crate::util::rust_util_bytes_mut(bip39_seed.as_mut_ptr(), bip39_seed.len()),
+ );
+ }
+ assert_eq!(hex::encode(bip39_seed).as_str(), test.expected_bip39_seed);
+ }
+ }
+
+ #[test]
+ fn test_rust_get_bip39_word() {
+ let mut word = [1u8; 10];
+ assert!(!rust_get_bip39_word(2048, unsafe {
+ crate::util::rust_util_bytes_mut(word.as_mut_ptr(), word.len())
+ }));
+
+ let mut word = [1u8; 10];
+ // 7 is too short, missing the null terminator.
+ assert!(!rust_get_bip39_word(0, unsafe {
+ crate::util::rust_util_bytes_mut(word.as_mut_ptr(), 7)
+ }));
+ // 8 is just enough.
+ assert!(rust_get_bip39_word(0, unsafe {
+ crate::util::rust_util_bytes_mut(word.as_mut_ptr(), 8)
+ }));
+ assert_eq!(
+ bitbox02::util::str_from_null_terminated(&word).unwrap(),
+ "abandon"
+ );
+ let mut word = [1u8; 10];
+ assert!(rust_get_bip39_word(2047, unsafe {
+ crate::util::rust_util_bytes_mut(word.as_mut_ptr(), word.len())
+ }));
+ assert_eq!(
+ bitbox02::util::str_from_null_terminated(&word).unwrap(),
+ "zoo"
+ );
+ let mut word = [1u8; 10];
+ assert!(rust_get_bip39_word(563, unsafe {
+ crate::util::rust_util_bytes_mut(word.as_mut_ptr(), word.len())
+ }));
+ assert_eq!(
+ bitbox02::util::str_from_null_terminated(&word).unwrap(),
+ "edit"
+ );
+ }
+}
diff --git a/src/rust/bitbox02-rust-c/src/lib.rs b/src/rust/bitbox02-rust-c/src/lib.rs
index c16c6d4..05f039c 100644
--- a/src/rust/bitbox02-rust-c/src/lib.rs
+++ b/src/rust/bitbox02-rust-c/src/lib.rs
@@ -25,6 +25,8 @@ mod util;
#[cfg(feature = "firmware")]
mod async_usb;
+#[cfg(feature = "firmware")]
+mod bip39;
#[cfg(feature = "bitbox02-noise")]
mod noise;
#[cfg(feature = "firmware")]
diff --git a/src/rust/bitbox02-sys/build.rs b/src/rust/bitbox02-sys/build.rs
index af03d28..c057568 100644
--- a/src/rust/bitbox02-sys/build.rs
+++ b/src/rust/bitbox02-sys/build.rs
@@ -83,7 +83,6 @@ const ALLOWLIST_FNS: &[&str] = &[
"keystore_secp256k1_sign",
"keystore_unlock",
"keystore_unlock_bip39",
- "keystore_bip39_mnemonic_from_seed",
"keystore_test_get_retained_seed_encrypted",
"keystore_test_get_retained_bip39_seed_encrypted",
"label_create",
diff --git a/src/rust/bitbox02/Cargo.toml b/src/rust/bitbox02/Cargo.toml
index 4e99ffe..806806f 100644
--- a/src/rust/bitbox02/Cargo.toml
+++ b/src/rust/bitbox02/Cargo.toml
@@ -26,6 +26,7 @@ bitbox02-sys = {path="../bitbox02-sys"}
util = {path = "../util"}
zeroize = { workspace = true }
bitcoin = { workspace = true }
+bip39 = { workspace = true }
hex = { workspace = true }
[dev-dependencies]
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index 434e002..1dcfaa0 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -13,7 +13,8 @@
// limitations under the License.
extern crate alloc;
-use alloc::string::String;
+use alloc::string::{String, ToString};
+
use alloc::vec;
use alloc::vec::Vec;
@@ -131,41 +132,19 @@ pub fn copy_bip39_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
}
pub fn bip39_mnemonic_from_seed(seed: &[u8]) -> Result<zeroize::Zeroizing<String>, ()> {
- let mut mnemonic = zeroize::Zeroizing::new([0u8; 256]);
- match unsafe {
- bitbox02_sys::keystore_bip39_mnemonic_from_seed(
- seed.as_ptr(),
- seed.len() as _,
- mnemonic.as_mut_ptr(),
- mnemonic.len() as _,
- )
- } {
- false => Err(()),
- true => Ok(zeroize::Zeroizing::new(
- crate::util::str_from_null_terminated(&mnemonic[..])
- .unwrap()
- .into(),
- )),
- }
+ let mnemonic = bip39::Mnemonic::from_entropy(seed).map_err(|_| ())?;
+ Ok(zeroize::Zeroizing::new(mnemonic.to_string()))
}
/// `idx` must be smaller than BIP39_WORDLIST_LEN.
pub fn get_bip39_word(idx: u16) -> Result<zeroize::Zeroizing<String>, ()> {
- let mut word_ptr: *mut u8 = core::ptr::null_mut();
- match unsafe { bitbox02_sys::keystore_get_bip39_word(idx, &mut word_ptr) } {
- false => Err(()),
- true => {
- let word = zeroize::Zeroizing::new(unsafe {
- crate::util::str_from_null_terminated_ptr(word_ptr)
- .unwrap()
- .into()
- });
- unsafe {
- bitbox02_sys::wally_free_string(word_ptr as _);
- }
- Ok(word)
- }
- }
+ Ok(zeroize::Zeroizing::new(
+ bip39::Language::English
+ .word_list()
+ .get(idx as usize)
+ .ok_or(())?
+ .to_string(),
+ ))
}
pub struct SignResult {
@@ -217,19 +196,10 @@ pub fn secp256k1_nonce_commit(
}
pub fn bip39_mnemonic_to_seed(mnemonic: &str) -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
- let mnemonic = zeroize::Zeroizing::new(crate::util::str_to_cstr_vec(mnemonic)?);
- let mut seed = zeroize::Zeroizing::new([0u8; MAX_SEED_LENGTH]);
- let mut seed_len: usize = 0;
- match unsafe {
- bitbox02_sys::keystore_bip39_mnemonic_to_seed(
- mnemonic.as_ptr(),
- seed.as_mut_ptr(),
- &mut seed_len,
- )
- } {
- true => Ok(zeroize::Zeroizing::new(seed[..seed_len].to_vec())),
- false => Err(()),
- }
+ let mnemonic =
+ bip39::Mnemonic::parse_in_normalized(bip39::Language::English, mnemonic).map_err(|_| ())?;
+ let (seed, seed_len) = mnemonic.to_entropy_array();
+ Ok(zeroize::Zeroizing::new(seed[..seed_len].to_vec()))
}
pub fn encrypt_and_store_seed(seed: &[u8], password: &str) -> Result<(), Error> {
diff --git a/src/ui/graphics/lock_animation.c b/src/ui/graphics/lock_animation.c
index e7a1b96..e3457bc 100644
--- a/src/ui/graphics/lock_animation.c
+++ b/src/ui/graphics/lock_animation.c
@@ -145,7 +145,7 @@ static const uint8_t* _get_frame(int frame_idx)
}
#endif
-#define TIMEOUT_TICK_PERIOD_MS 100
+#define TIMEOUT_TICK_PERIOD_MS 40
#ifndef TESTING
static struct timer_task _animation_timer_task = {0};
Why this scored 27/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.