What changed, and why it matters
This commit is a straightforward refactoring: it moves the BIP39 seed derivation and wallet-unlock logic from C into Rust, splitting one C function into a Rust wrapper plus two existing C helper functions. There is no change to the cryptographic behavior, no new user-facing feature, and no obvious security bug introduced. It appears to be a code-quality/architecture change to prepare for making the unlock flow asynchronous.
No security action required. Treat as normal code-review item; verify the moved tests still pass and that the new Rust function is covered by the same test vectors as the removed C implementation.
Security signals we found
Refactor only: equivalent BIP39 derivation and root-fingerprint computation moved from C to Rust.
No change to input validation, memory clearing, or error handling semantics.
Sensitive buffers continue to use `zeroize::Zeroizing`.
No new FFI surface or unsafe blocks beyond what already existed.
No vendor disclosure, CVE, or researcher attribution present.
Evidence from the diff
The change removes keystore_unlock_bip39() from src/keystore.c and the corresponding rust_derive_bip39_seed() C-callable Rust function from src/rust/bitbox02-rust/src/bip39.rs. It reimplements the same logic in src/rust/bitbox02/src/keystore.rs using the existing keystore_unlock_bip39_check() and keystore_unlock_bip39_finalize() C functions plus the same bip39 and bitcoin Rust crates. The root fingerprint is still stored in the same static ROOT_FINGERPRINT, and the same test vectors are preserved. The commit message explicitly states the goal is to make the function async in the future.
Changed components
src/keystore.csrc/keystore.hsrc/rust/bitbox02-rust/src/bip39.rssrc/rust/bitbox02-rust/src/keystore.rssrc/rust/bitbox02-rust/src/workflow/unlock.rssrc/rust/bitbox02-sys/build.rssrc/rust/bitbox02/src/keystore.rssrc/rust/bitbox02/src/testing.rsInspect captured patch +126 / −170
diff --git a/src/keystore.c b/src/keystore.c
index 5350e75..465f414 100644
--- a/src/keystore.c
+++ b/src/keystore.c
@@ -41,9 +41,9 @@ static size_t _retained_seed_encrypted_len = 0;
// plaintext.
static uint8_t _retained_seed_hash[32] = {0};
-// Change this ONLY via keystore_unlock_bip39().
+// Change this ONLY via keystore_unlock_bip39_finalize().
static bool _is_unlocked_bip39 = false;
-// Stores a random keyy after bip39-unlock which, after stretching, is used to encrypt the retained
+// Stores a random key after bip39-unlock which, after stretching, is used to encrypt the retained
// bip39 seed.
static uint8_t _unstretched_retained_bip39_seed_encryption_key[32] = {0};
// Must be defined if _is_unlocked is true. ONLY ACCESS THIS WITH keystore_copy_bip39_seed().
@@ -514,27 +514,6 @@ bool keystore_unlock_bip39_finalize(const uint8_t* bip39_seed)
return true;
}
-bool keystore_unlock_bip39(
- const uint8_t* seed,
- size_t seed_length,
- const char* mnemonic_passphrase,
- uint8_t* root_fingerprint_out)
-{
- if (!keystore_unlock_bip39_check(seed, seed_length)) {
- return false;
- }
-
- uint8_t bip39_seed[64] = {0};
- UTIL_CLEANUP_64(bip39_seed);
- rust_derive_bip39_seed(
- rust_util_bytes(seed, seed_length),
- mnemonic_passphrase,
- rust_util_bytes_mut(bip39_seed, sizeof(bip39_seed)),
- rust_util_bytes_mut(root_fingerprint_out, 4));
-
- return keystore_unlock_bip39_finalize(bip39_seed);
-}
-
void keystore_lock(void)
{
_is_unlocked_device = false;
diff --git a/src/keystore.h b/src/keystore.h
index 1a5ecfe..8efc92c 100644
--- a/src/keystore.h
+++ b/src/keystore.h
@@ -113,22 +113,6 @@ keystore_unlock(const char* password, uint8_t* remaining_attempts_out, int* secu
*/
USE_RESULT bool keystore_unlock_bip39_check(const uint8_t* seed, size_t seed_length);
-/** Unlocks the bip39 seed. The input seed must be the keystore seed (i.e. must match the output
- * of `keystore_copy_seed()`).
- * @param[in] seed the input seed to BIP39.
- * @param[in] seed_length the size of the seed
- * @param[in] mnemonic_passphrase bip39 passphrase used in the derivation. Use the
- * empty string if no passphrase is needed or provided.
- * @param[out] root_fingerprint_out must be 4 bytes long and will contain the root fingerprint of
- * the wallet.
- * @return returns false if there was a critital memory error, otherwise true.
- */
-USE_RESULT bool keystore_unlock_bip39(
- const uint8_t* seed,
- size_t seed_length,
- const char* mnemonic_passphrase,
- uint8_t* root_fingerprint_out);
-
/**
* Retains the given bip39 seed and marks the keystore as unlocked.
* @param[in] bip39_seed 64 byte bip39 seed.
diff --git a/src/rust/bitbox02-rust/src/bip39.rs b/src/rust/bitbox02-rust/src/bip39.rs
index e055fca..4b68542 100644
--- a/src/rust/bitbox02-rust/src/bip39.rs
+++ b/src/rust/bitbox02-rust/src/bip39.rs
@@ -27,37 +27,6 @@ pub fn get_word(idx: u16) -> Result<zeroize::Zeroizing<String>, ()> {
// C API
-/// # Safety
-///
-/// The passphrase must be not NULL and null-terminated.
-///
-/// `seed` must be 16, 24 or 32 bytes long.
-/// `bip39_seed_out` must be exactly 64 bytes long.
-/// `root_fingerprint_out` must be exactly 4 bytes long.
-#[unsafe(no_mangle)]
-pub unsafe extern "C" fn rust_derive_bip39_seed(
- seed: util::bytes::Bytes,
- passphrase: *const core::ffi::c_char,
- mut bip39_seed_out: util::bytes::BytesMut,
- mut root_fingerprint_out: util::bytes::BytesMut,
-) {
- let mnemonic =
- bip39::Mnemonic::from_entropy_in(bip39::Language::English, seed.as_ref()).unwrap();
- let passphrase = unsafe { core::ffi::CStr::from_ptr(passphrase) };
- let bip39_seed: zeroize::Zeroizing<[u8; 64]> =
- zeroize::Zeroizing::new(mnemonic.to_seed_normalized(passphrase.to_str().unwrap()));
- bip39_seed_out.as_mut().clone_from_slice(&bip39_seed[..]);
-
- let root_fingerprint: [u8; 4] =
- bitcoin::bip32::Xpriv::new_master(bitcoin::NetworkKind::Main, bip39_seed.as_ref())
- .unwrap()
- .fingerprint(crate::secp256k1::SECP256K1)
- .to_bytes();
- root_fingerprint_out
- .as_mut()
- .clone_from_slice(&root_fingerprint);
-}
-
#[unsafe(no_mangle)]
pub extern "C" fn rust_get_bip39_word(idx: u16, mut out: util::bytes::BytesMut) -> bool {
let word = match get_word(idx) {
@@ -78,80 +47,6 @@ pub extern "C" fn rust_get_bip39_word(idx: u16, mut out: util::bytes::BytesMut)
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,
- expected_root_fingerprint: &'static str,
- }
-
- let tests = &[
- // 16 byte seed
- Test {
- seed: "fb5cf00d5ea61059fa066e25a6be9544",
- passphrase: c"",
- expected_bip39_seed: "f4577e463be595868060e5a763328153155b4167cd284998c8c6096d044742372020f5b052d0c41c1c5e6a6a7da2cb8a367aaaa074fab7773e8d5b2f684257ed",
- expected_root_fingerprint: "0b2fa4e5",
- },
- Test {
- seed: "fb5cf00d5ea61059fa066e25a6be9544",
- passphrase: c"password",
- expected_bip39_seed: "5922fb7630bc7cb871af102f733b6bdb8f05945147cd4646a89056fde0bdad5c3a4ff5be3f9e7af535f570e7053b5b22472555b331bc89cb797c306f7eb6a5a1",
- expected_root_fingerprint: "c4062d44",
- },
- // 24 byte seed
- Test {
- seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
- passphrase: c"",
- expected_bip39_seed: "4a2a016a6d90eb3a79b7931ca0a172df5c5bfee3e5b47f0fd84bc0791ea3bbc9476c3d5de71cdb12c37e93c2aa3d5c303257f1992aed400fc5bbfc7da787bfa7",
- expected_root_fingerprint: "62fd19e0",
- },
- Test {
- seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
- passphrase: c"password",
- expected_bip39_seed: "bc317ee0f88870254be32274d63ec2b0e962bf09f3ca04287912bfc843f2fab7c556f8657cadc924f99a217b0daa91898303a8414102031a125c50023e45a80b",
- expected_root_fingerprint: "c745266d",
- },
- // 32 byte seed
- Test {
- seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
- passphrase: c"",
- expected_bip39_seed: "63f844e2c61ecfb20f9100de381a7a9ec875b085f5ac7735a2ba4d615a0f4147b87be402f65651969130683deeef752760c09e291604fe4b89d61ffee2630be8",
- expected_root_fingerprint: "93ba3a7b",
- },
- Test {
- seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
- passphrase: c"password",
- expected_bip39_seed: "42e90dacd61f3373542d212f0fb9c291dcea84a6d85034272372dde7188638a98527280d65e41599f30d3434d8ee3d4747dbb84801ff1a851d2306c7d1648374",
- expected_root_fingerprint: "b95c9318",
- },
- ];
-
- for test in tests {
- let seed = hex::decode(test.seed).unwrap();
- let mut bip39_seed = [0u8; 64];
- let mut root_fingerprint = [0u8; 4];
- unsafe {
- rust_derive_bip39_seed(
- util::bytes::rust_util_bytes(seed.as_ptr(), seed.len()),
- test.passphrase.as_ptr(),
- util::bytes::rust_util_bytes_mut(bip39_seed.as_mut_ptr(), bip39_seed.len()),
- util::bytes::rust_util_bytes_mut(
- root_fingerprint.as_mut_ptr(),
- root_fingerprint.len(),
- ),
- );
- }
- assert_eq!(hex::encode(bip39_seed).as_str(), test.expected_bip39_seed);
- assert_eq!(
- hex::encode(root_fingerprint).as_str(),
- test.expected_root_fingerprint
- );
- }
- }
-
#[test]
fn test_rust_get_bip39_word() {
let mut word = [1u8; 10];
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index 169d385..516e177 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -573,7 +573,7 @@ mod tests {
keystore::lock();
let seed = &seed[..test.seed_len];
- assert!(keystore::unlock_bip39(seed, test.mnemonic_passphrase).is_err());
+ assert!(keystore::unlock_bip39(SECP256K1, seed, test.mnemonic_passphrase).is_err());
bitbox02::securechip::fake_event_counter_reset();
assert!(keystore::encrypt_and_store_seed(seed, "foo").is_ok());
@@ -582,7 +582,7 @@ mod tests {
assert!(keystore::is_locked());
bitbox02::securechip::fake_event_counter_reset();
- assert!(keystore::unlock_bip39(seed, test.mnemonic_passphrase).is_ok());
+ assert!(keystore::unlock_bip39(SECP256K1, seed, test.mnemonic_passphrase).is_ok());
assert_eq!(bitbox02::securechip::fake_event_counter(), 1);
assert!(!keystore::is_locked());
diff --git a/src/rust/bitbox02-rust/src/workflow/unlock.rs b/src/rust/bitbox02-rust/src/workflow/unlock.rs
index 6cdccc2..476f1f8 100644
--- a/src/rust/bitbox02-rust/src/workflow/unlock.rs
+++ b/src/rust/bitbox02-rust/src/workflow/unlock.rs
@@ -133,8 +133,9 @@ pub async fn unlock_bip39(hal: &mut impl crate::hal::Hal, seed: &[u8]) {
}
}
- let result =
- bitbox02::ui::with_lock_animation(|| keystore::unlock_bip39(seed, &mnemonic_passphrase));
+ let result = bitbox02::ui::with_lock_animation(|| {
+ keystore::unlock_bip39(crate::secp256k1::SECP256K1, seed, &mnemonic_passphrase)
+ });
if result.is_err() {
abort("bip39 unlock failed");
}
diff --git a/src/rust/bitbox02-sys/build.rs b/src/rust/bitbox02-sys/build.rs
index 94fdb9a..a70d9e4 100644
--- a/src/rust/bitbox02-sys/build.rs
+++ b/src/rust/bitbox02-sys/build.rs
@@ -79,7 +79,8 @@ const ALLOWLIST_FNS: &[&str] = &[
"keystore_secp256k1_nonce_commit",
"keystore_secp256k1_sign",
"keystore_unlock",
- "keystore_unlock_bip39",
+ "keystore_unlock_bip39_check",
+ "keystore_unlock_bip39_finalize",
"keystore_test_get_retained_seed_encrypted",
"keystore_test_get_retained_bip39_seed_encrypted",
"label_create",
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index 0713645..e4193b1 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -95,30 +95,61 @@ pub fn lock() {
unsafe { ROOT_FINGERPRINT.write(None) }
}
-pub fn unlock_bip39(seed: &[u8], mnemonic_passphrase: &str) -> Result<(), Error> {
- let mut root_fingerprint = [0u8; 4];
- if unsafe {
- bitbox02_sys::keystore_unlock_bip39(
- seed.as_ptr(),
- seed.len(),
- crate::util::str_to_cstr_vec(mnemonic_passphrase)
- .unwrap()
- .as_ptr()
- .cast(),
- root_fingerprint.as_mut_ptr(),
- )
- } {
- // Store root fingerprint.
- unsafe {
- ROOT_FINGERPRINT.write(Some(root_fingerprint));
- }
+fn unlock_bip39_check(seed: &[u8]) -> Result<(), Error> {
+ if unsafe { bitbox02_sys::keystore_unlock_bip39_check(seed.as_ptr(), seed.len()) } {
+ Ok(())
+ } else {
+ Err(Error::CannotUnlockBIP39)
+ }
+}
+fn unlock_bip39_finalize(bip39_seed: &[u8; 64]) -> Result<(), Error> {
+ if unsafe { bitbox02_sys::keystore_unlock_bip39_finalize(bip39_seed.as_ptr()) } {
Ok(())
} else {
Err(Error::CannotUnlockBIP39)
}
}
+fn derive_bip39_seed(
+ secp: &Secp256k1<All>,
+ seed: &[u8],
+ mnemonic_passphrase: &str,
+) -> (zeroize::Zeroizing<[u8; 64]>, [u8; 4]) {
+ let mnemonic = bip39::Mnemonic::from_entropy_in(bip39::Language::English, seed).unwrap();
+ let bip39_seed: zeroize::Zeroizing<[u8; 64]> =
+ zeroize::Zeroizing::new(mnemonic.to_seed_normalized(mnemonic_passphrase));
+ let root_fingerprint: [u8; 4] =
+ bitcoin::bip32::Xpriv::new_master(bitcoin::NetworkKind::Main, bip39_seed.as_ref())
+ .unwrap()
+ .fingerprint(secp)
+ .to_bytes();
+
+ (bip39_seed, root_fingerprint)
+}
+
+/// Unlocks the bip39 seed. The input seed must be the keystore seed (i.e. must match the output
+/// of `keystore_copy_seed()`).
+/// `mnemonic_passphrase` is the bip39 passphrase used in the derivation. Use the empty string if no
+/// passphrase is needed or provided.
+pub fn unlock_bip39(
+ secp: &Secp256k1<All>,
+ seed: &[u8],
+ mnemonic_passphrase: &str,
+) -> Result<(), Error> {
+ unlock_bip39_check(seed)?;
+
+ let (bip39_seed, root_fingerprint) = derive_bip39_seed(secp, seed, mnemonic_passphrase);
+
+ unlock_bip39_finalize(bip39_seed.as_slice().try_into().unwrap())?;
+
+ // Store root fingerprint.
+ unsafe {
+ ROOT_FINGERPRINT.write(Some(root_fingerprint));
+ }
+ Ok(())
+}
+
pub fn root_fingerprint() -> Result<Vec<u8>, ()> {
if is_locked() {
return Err(());
@@ -411,7 +442,7 @@ mod tests {
.unwrap();
assert!(encrypt_and_store_seed(&seed, "password").is_ok());
assert!(is_locked()); // still locked, it is only unlocked after unlock_bip39.
- assert!(unlock_bip39(&seed, "foo").is_ok());
+ assert!(unlock_bip39(&secp256k1::Secp256k1::new(), &seed, "foo").is_ok());
assert!(!is_locked());
lock();
assert!(is_locked());
@@ -484,6 +515,69 @@ mod tests {
assert!(matches!(unlock("password"), Err(Error::Unseeded)));
}
+ #[test]
+ fn test_derive_bip39_seed() {
+ struct Test {
+ seed: &'static str,
+ passphrase: &'static str,
+ expected_bip39_seed: &'static str,
+ expected_root_fingerprint: &'static str,
+ }
+
+ let tests = &[
+ // 16 byte seed
+ Test {
+ seed: "fb5cf00d5ea61059fa066e25a6be9544",
+ passphrase: "",
+ expected_bip39_seed: "f4577e463be595868060e5a763328153155b4167cd284998c8c6096d044742372020f5b052d0c41c1c5e6a6a7da2cb8a367aaaa074fab7773e8d5b2f684257ed",
+ expected_root_fingerprint: "0b2fa4e5",
+ },
+ Test {
+ seed: "fb5cf00d5ea61059fa066e25a6be9544",
+ passphrase: "password",
+ expected_bip39_seed: "5922fb7630bc7cb871af102f733b6bdb8f05945147cd4646a89056fde0bdad5c3a4ff5be3f9e7af535f570e7053b5b22472555b331bc89cb797c306f7eb6a5a1",
+ expected_root_fingerprint: "c4062d44",
+ },
+ // 24 byte seed
+ Test {
+ seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
+ passphrase: "",
+ expected_bip39_seed: "4a2a016a6d90eb3a79b7931ca0a172df5c5bfee3e5b47f0fd84bc0791ea3bbc9476c3d5de71cdb12c37e93c2aa3d5c303257f1992aed400fc5bbfc7da787bfa7",
+ expected_root_fingerprint: "62fd19e0",
+ },
+ Test {
+ seed: "23705a91b177b49822f28b3f1a60072d113fcaff4f250191",
+ passphrase: "password",
+ expected_bip39_seed: "bc317ee0f88870254be32274d63ec2b0e962bf09f3ca04287912bfc843f2fab7c556f8657cadc924f99a217b0daa91898303a8414102031a125c50023e45a80b",
+ expected_root_fingerprint: "c745266d",
+ },
+ // 32 byte seed
+ Test {
+ seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
+ passphrase: "",
+ expected_bip39_seed: "63f844e2c61ecfb20f9100de381a7a9ec875b085f5ac7735a2ba4d615a0f4147b87be402f65651969130683deeef752760c09e291604fe4b89d61ffee2630be8",
+ expected_root_fingerprint: "93ba3a7b",
+ },
+ Test {
+ seed: "bd83a008b3b78c8cc56c678d1b7bfc651cc5be8242f44b5c0db96a34ee297833",
+ passphrase: "password",
+ expected_bip39_seed: "42e90dacd61f3373542d212f0fb9c291dcea84a6d85034272372dde7188638a98527280d65e41599f30d3434d8ee3d4747dbb84801ff1a851d2306c7d1648374",
+ expected_root_fingerprint: "b95c9318",
+ },
+ ];
+
+ let secp = secp256k1::Secp256k1::new();
+ for test in tests {
+ let seed = hex::decode(test.seed).unwrap();
+ let (bip39_seed, root_fingerprint) = derive_bip39_seed(&secp, &seed, test.passphrase);
+ assert_eq!(hex::encode(bip39_seed).as_str(), test.expected_bip39_seed);
+ assert_eq!(
+ hex::encode(root_fingerprint).as_str(),
+ test.expected_root_fingerprint
+ );
+ }
+ }
+
#[test]
fn test_unlock_bip39() {
mock_memory();
@@ -497,13 +591,15 @@ mod tests {
.unwrap();
crate::memory::set_salt_root(mock_salt_root.as_slice().try_into().unwrap()).unwrap();
+ let secp = secp256k1::Secp256k1::new();
+
assert!(root_fingerprint().is_err());
assert!(encrypt_and_store_seed(&seed, "password").is_ok());
assert!(root_fingerprint().is_err());
// Incorrect seed passed
- assert!(unlock_bip39(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "foo").is_err());
+ assert!(unlock_bip39(&secp, b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "foo").is_err());
// Correct seed passed.
- assert!(unlock_bip39(&seed, "foo").is_ok());
+ assert!(unlock_bip39(&secp, &seed, "foo").is_ok());
assert_eq!(root_fingerprint(), Ok(vec![0xf1, 0xbc, 0x3c, 0x46]),);
let expected_bip39_seed = hex::decode("2b3c63de86f0f2b13cc6a36c1ba2314fbc1b40c77ab9cb64e96ba4d5c62fc204748ca6626a9f035e7d431bce8c9210ec0bdffc2e7db873dee56c8ac2153eee9a").unwrap();
diff --git a/src/rust/bitbox02/src/testing.rs b/src/rust/bitbox02/src/testing.rs
index 9d1729f..5d41c6e 100644
--- a/src/rust/bitbox02/src/testing.rs
+++ b/src/rust/bitbox02/src/testing.rs
@@ -22,7 +22,7 @@ pub fn mock_unlocked_using_mnemonic(mnemonic: &str, passphrase: &str) {
unsafe {
bitbox02_sys::keystore_mock_unlocked(seed.as_ptr(), seed.len() as _, core::ptr::null())
}
- keystore::unlock_bip39(&seed, passphrase).unwrap();
+ keystore::unlock_bip39(&bitcoin::secp256k1::Secp256k1::new(), &seed, passphrase).unwrap();
}
pub const TEST_MNEMONIC: &str = "purity concert above invest pigeon category peace tuition hazard vivid latin since legal speak nation session onion library travel spell region blast estate stay";
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.