rust/keystore: wrap unlock_bip39() and copy_bip39_seed() in bitbox02-rust
What changed, and why it matters
This commit is a routine internal code reorganization in the BitBox02 firmware. It moves two existing keystore functions—used to unlock and copy the BIP39 seed—into a higher-level Rust wrapper module, and renames the original low-level functions with an underscore prefix so the compiler flags any remaining direct callers. The change does not alter the cryptographic behavior, add new user-facing features, or fix a known security bug. It is preparation for a future native Rust implementation.
No immediate action required. Treat as normal refactoring. If reviewing for defense in depth, verify that the wrapper functions preserve the same zeroization behavior and error handling as the originals, and that no new direct callers of the underscore-prefixed low-level functions remain outside test code.
Security signals we found
Refactoring of seed-handling code paths
Renaming of functions that access retained BIP39 seed material
Addition of test-only accessor for encrypted retained BIP39 seed
No change to encryption, decryption, or access-control semantics
Evidence from the diff
The patch wraps bitbox02::keystore::unlock_bip39() and bitbox02::keystore::copy_bip39_seed() inside bitbox02_rust::keystore, exposing them as unlock_bip39() and copy_bip39_seed(). The underlying C FFI functions are renamed to _unlock_bip39() and _copy_bip39_seed(). Call sites in restore, ed25519, workflow/unlock, and tests are updated to use the new wrapper. A test helper test_get_retained_bip39_seed_encrypted() is added, and the existing test_unlock_bip39 test is moved from the low-level crate to the higher-level crate with minor adjustments (e.g., removing the explicit Secp256k1 context argument). No cryptographic constants, memory handling, or access-control logic are changed.
Changed components
bitbox02-firmware Rust keystore wrapperBIP39 seed unlock/copy pathsRestore workflowDevice unlock workflowEd25519 key derivationInspect captured patch +92 / −98
diff --git a/src/rust/bitbox02-rust/src/hww/api/restore.rs b/src/rust/bitbox02-rust/src/hww/api/restore.rs
index 3903a43..2b98e6b 100644
--- a/src/rust/bitbox02-rust/src/hww/api/restore.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/restore.rs
@@ -212,7 +212,7 @@ mod tests {
"19f1bcfccf3e9d497cd245cf864ff0d42216625258d4f68d56b571aceb329257"
);
assert_eq!(
- hex::encode(keystore::copy_bip39_seed().unwrap()),
+ hex::encode(crate::keystore::copy_bip39_seed().unwrap()),
"257724bccc8858cfe565b456b01263a4a6a45184fab4531f5c199649207a74e74c399a01d4f957258c05cee818369b31404c884a4b7a29ff6886bae6700fb56a"
);
}
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index 8856e96..174cbdd 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -46,11 +46,28 @@ pub fn unlock(password: &str) -> Result<zeroize::Zeroizing<Vec<u8>>, Error> {
keystore::_unlock(password)
}
+/// 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 async fn unlock_bip39(
+ seed: &[u8],
+ mnemonic_passphrase: &str,
+ yield_now: impl AsyncFn(),
+) -> Result<(), Error> {
+ keystore::_unlock_bip39(SECP256K1, seed, mnemonic_passphrase, yield_now).await
+}
+
/// Returns a copy of the retained seed. Errors if the keystore is locked.
pub fn copy_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
keystore::_copy_seed()
}
+/// Returns a copy of the retained bip39 seed. Errors if the keystore is locked.
+pub fn copy_bip39_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
+ keystore::_copy_bip39_seed()
+}
+
/// Restores a seed. This also unlocks the keystore with this seed.
/// `password` is the password with which we encrypt the seed.
pub fn encrypt_and_store_seed(seed: &[u8], password: &str) -> Result<(), Error> {
@@ -63,7 +80,7 @@ pub fn get_bip39_mnemonic() -> Result<zeroize::Zeroizing<String>, ()> {
}
fn get_xprv(keypath: &[u32]) -> Result<bip32::Xprv, ()> {
- let bip39_seed = keystore::copy_bip39_seed()?;
+ let bip39_seed = copy_bip39_seed()?;
let xprv: bip32::Xprv =
bitcoin::bip32::Xpriv::new_master(bitcoin::NetworkKind::Main, &bip39_seed)
.map_err(|_| ())?
@@ -359,7 +376,7 @@ pub fn secp256k1_schnorr_sign(
/// Get the seed to be used for u2f
#[cfg(feature = "app-u2f")]
pub fn get_u2f_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
- let bip39_seed = keystore::copy_bip39_seed()?;
+ let bip39_seed = copy_bip39_seed()?;
let mut engine = HmacEngine::<bitcoin::hashes::sha256::Hash>::new(&bip39_seed);
// Null-terminator for backwards compatibility from the time when this was coded in C.
@@ -472,15 +489,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!(
- block_on(keystore::unlock_bip39(
- &secp256k1::Secp256k1::new(),
- &seed,
- "foo",
- async || {}
- ))
- .is_ok()
- );
+ assert!(block_on(unlock_bip39(&seed, "foo", async || {})).is_ok());
assert!(!is_locked());
lock();
assert!(is_locked());
@@ -552,6 +561,60 @@ mod tests {
assert!(matches!(unlock("password"), Err(Error::Unseeded)));
}
+ #[test]
+ fn test_unlock_bip39() {
+ mock_memory();
+ lock();
+
+ let seed = hex::decode("1111111111111111222222222222222233333333333333334444444444444444")
+ .unwrap();
+
+ let mock_salt_root =
+ hex::decode("3333333333333333444444444444444411111111111111112222222222222222")
+ .unwrap();
+ bitbox02::memory::set_salt_root(mock_salt_root.as_slice().try_into().unwrap()).unwrap();
+
+ assert!(root_fingerprint().is_err());
+ assert!(encrypt_and_store_seed(&seed, "password").is_ok());
+ assert!(root_fingerprint().is_err());
+ // Incorrect seed passed
+ assert!(
+ block_on(unlock_bip39(
+ b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "foo",
+ async || {}
+ ))
+ .is_err()
+ );
+ // Correct seed passed.
+ bitbox02::securechip::fake_event_counter_reset();
+ assert!(block_on(unlock_bip39(&seed, "foo", async || {})).is_ok());
+ assert_eq!(bitbox02::securechip::fake_event_counter(), 1);
+ assert_eq!(root_fingerprint(), Ok(vec![0xf1, 0xbc, 0x3c, 0x46]),);
+
+ let expected_bip39_seed = hex::decode("2b3c63de86f0f2b13cc6a36c1ba2314fbc1b40c77ab9cb64e96ba4d5c62fc204748ca6626a9f035e7d431bce8c9210ec0bdffc2e7db873dee56c8ac2153eee9a").unwrap();
+
+ assert_eq!(
+ copy_bip39_seed().unwrap().as_slice(),
+ expected_bip39_seed.as_slice()
+ );
+
+ // Check that the retained bip39 seed was encrypted with the expected encryption key.
+ let decrypted = {
+ let retained_bip39_seed_encrypted: &[u8] =
+ keystore::test_get_retained_bip39_seed_encrypted();
+ let expected_retained_bip39_seed_secret =
+ hex::decode("856d9a8c1ea42a69ae76324244ace674397ff1360a4ba4c85ffbd42cee8a7f29")
+ .unwrap();
+ bitbox_aes::decrypt_with_hmac(
+ &expected_retained_bip39_seed_secret,
+ retained_bip39_seed_encrypted,
+ )
+ .unwrap()
+ };
+ assert_eq!(decrypted.as_slice(), expected_bip39_seed.as_slice());
+ }
+
#[test]
fn test_secp256k1_get_private_key() {
lock();
@@ -921,15 +984,7 @@ mod tests {
lock();
let seed = &seed[..test.seed_len];
- assert!(
- block_on(keystore::unlock_bip39(
- SECP256K1,
- seed,
- test.mnemonic_passphrase,
- async || {}
- ))
- .is_err()
- );
+ assert!(block_on(unlock_bip39(seed, test.mnemonic_passphrase, async || {})).is_err());
bitbox02::securechip::fake_event_counter_reset();
assert!(encrypt_and_store_seed(seed, "foo").is_ok());
@@ -938,15 +993,7 @@ mod tests {
assert!(is_locked());
bitbox02::securechip::fake_event_counter_reset();
- assert!(
- block_on(keystore::unlock_bip39(
- SECP256K1,
- seed,
- test.mnemonic_passphrase,
- async || {}
- ))
- .is_ok()
- );
+ assert!(block_on(unlock_bip39(seed, test.mnemonic_passphrase, async || {})).is_ok());
assert_eq!(bitbox02::securechip::fake_event_counter(), 1);
assert!(!is_locked());
diff --git a/src/rust/bitbox02-rust/src/keystore/ed25519.rs b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
index 7cf23c3..f5532b3 100644
--- a/src/rust/bitbox02-rust/src/keystore/ed25519.rs
+++ b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
@@ -31,7 +31,7 @@ fn hmac_sha512(key: &[u8], msg: &[u8]) -> [u8; 64] {
/// https://github.com/LedgerHQ/orakolo/blob/0b2d5e669ec61df9a824df9fa1a363060116b490/src/python/orakolo/HDEd25519.py.
/// Returns 96 bytes. It will contain a 64 byte expanded ed25519 private key followed by a 32 byte chain code.
fn get_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
- let bip39_seed = bitbox02::keystore::copy_bip39_seed()?;
+ let bip39_seed = crate::keystore::copy_bip39_seed()?;
let mut seed_out = zeroize::Zeroizing::new(vec![0u8; 96]);
let first64: &mut [u8] = &mut seed_out.as_mut_slice()[..64];
first64.copy_from_slice(&bip39_seed);
diff --git a/src/rust/bitbox02-rust/src/workflow/unlock.rs b/src/rust/bitbox02-rust/src/workflow/unlock.rs
index 3d5f57d..f7b6886 100644
--- a/src/rust/bitbox02-rust/src/workflow/unlock.rs
+++ b/src/rust/bitbox02-rust/src/workflow/unlock.rs
@@ -15,7 +15,6 @@
use crate::general::abort;
use crate::hal::Ui;
use crate::workflow::{confirm, password};
-use bitbox02::keystore;
pub use password::CanCancel;
@@ -137,8 +136,7 @@ pub async fn unlock_bip39(hal: &mut impl crate::hal::Hal, seed: &[u8]) {
let ((), result) = futures_lite::future::zip(
super::unlock_animation::animate(),
- keystore::unlock_bip39(
- crate::secp256k1::SECP256K1,
+ crate::keystore::unlock_bip39(
seed,
&mnemonic_passphrase,
// for the simulator, we don't yield at all, otherwise unlock becomes very slow in the
@@ -224,7 +222,7 @@ mod tests {
assert!(!crate::keystore::is_locked());
assert_eq!(
- bitbox02::keystore::copy_bip39_seed().unwrap().as_slice(),
+ crate::keystore::copy_bip39_seed().unwrap().as_slice(),
hex::decode("cff4b263e5b0eb299e5fd35fcd09988f6b14e5b464f8d18fb84b152f889dd2a30550f4c2b346cae825ffedd4a87fc63fc12a9433de5125b6c7fdbc5eab0c590b")
.unwrap(),
);
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index a14f924..6f5a08d 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -148,11 +148,20 @@ pub fn test_get_retained_seed_encrypted() -> &'static [u8] {
}
}
+#[cfg(feature = "testing")]
+pub fn test_get_retained_bip39_seed_encrypted() -> &'static [u8] {
+ unsafe {
+ let mut len = 0usize;
+ let ptr = bitbox02_sys::keystore_test_get_retained_bip39_seed_encrypted(&mut len);
+ core::slice::from_raw_parts(ptr, len)
+ }
+}
+
/// 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 async fn unlock_bip39(
+pub async fn _unlock_bip39(
secp: &Secp256k1<All>,
seed: &[u8],
mnemonic_passphrase: &str,
@@ -214,7 +223,7 @@ pub fn _copy_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
}
}
-pub fn copy_bip39_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
+pub fn _copy_bip39_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
let mut bip39_seed = zeroize::Zeroizing::new(vec![0u8; 64]);
match unsafe { bitbox02_sys::keystore_copy_bip39_seed(bip39_seed.as_mut_ptr()) } {
true => Ok(bip39_seed),
@@ -438,66 +447,6 @@ mod tests {
}
}
- #[test]
- fn test_unlock_bip39() {
- mock_memory();
- _lock();
-
- let seed = hex::decode("1111111111111111222222222222222233333333333333334444444444444444")
- .unwrap();
-
- let mock_salt_root =
- hex::decode("3333333333333333444444444444444411111111111111112222222222222222")
- .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!(
- block_on(unlock_bip39(
- &secp,
- b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "foo",
- async || {}
- ))
- .is_err()
- );
- // Correct seed passed.
- crate::securechip::fake_event_counter_reset();
- assert!(block_on(unlock_bip39(&secp, &seed, "foo", async || {})).is_ok());
- assert_eq!(crate::securechip::fake_event_counter(), 1);
- assert_eq!(root_fingerprint(), Ok(vec![0xf1, 0xbc, 0x3c, 0x46]),);
-
- let expected_bip39_seed = hex::decode("2b3c63de86f0f2b13cc6a36c1ba2314fbc1b40c77ab9cb64e96ba4d5c62fc204748ca6626a9f035e7d431bce8c9210ec0bdffc2e7db873dee56c8ac2153eee9a").unwrap();
-
- assert_eq!(
- copy_bip39_seed().unwrap().as_slice(),
- expected_bip39_seed.as_slice()
- );
-
- // Check that the retained bip39 seed was encrypted with the expected encryption key.
- let decrypted = {
- let retained_bip39_seed_encrypted: &[u8] = unsafe {
- let mut len = 0usize;
- let ptr = bitbox02_sys::keystore_test_get_retained_bip39_seed_encrypted(&mut len);
- core::slice::from_raw_parts(ptr, len)
- };
- let expected_retained_bip39_seed_secret =
- hex::decode("856d9a8c1ea42a69ae76324244ace674397ff1360a4ba4c85ffbd42cee8a7f29")
- .unwrap();
- bitbox_aes::decrypt_with_hmac(
- &expected_retained_bip39_seed_secret,
- retained_bip39_seed_encrypted,
- )
- .unwrap()
- };
- assert_eq!(decrypted.as_slice(), expected_bip39_seed.as_slice());
- }
-
#[test]
fn test_create_and_store_seed() {
let mock_salt_root =
diff --git a/src/rust/bitbox02/src/testing.rs b/src/rust/bitbox02/src/testing.rs
index 87b968f..462c7bc 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())
}
- util::bb02_async::block_on(keystore::unlock_bip39(
+ util::bb02_async::block_on(keystore::_unlock_bip39(
&bitcoin::secp256k1::Secp256k1::new(),
&seed,
passphrase,
Why this scored 18/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.