keystore: pass seed to unlock_bip39() to reduce secure chip events
What changed, and why it matters
This commit is a performance and reliability improvement, not a security fix. It changes how the BitBox02 hardware wallet restores a wallet so that the device does not repeatedly ask its secure chip for the same seed during restore. Instead, the seed is passed directly to the BIP39 unlock step and verified against a stored hash. The goal is to avoid hitting the secure chip's throttling limit (133 events) when users repeatedly reset or restore the device. The change does not remove any security check; it only avoids an unnecessary secure-chip read by comparing a hash of the seed instead.
No security response required. Treat as a normal firmware improvement. Reviewers may want to confirm that the new _hash_seed() function uses a fresh salt per device/session and that the stored hash is zeroized on lock, which the diff shows is done.
Security signals we found
Reduces secure chip event count to mitigate Optiga throttling after 133 events
Adds salted HMAC-SHA256 seed hash for comparison without storing plaintext seed
Retains existing device-unlock and seed-retention checks
No new trust boundary or privilege change
Evidence from the diff
The patch refactors keystore_unlock_bip39() to accept the seed as an argument rather than calling keystore_copy_seed() internally. A new salted HMAC-SHA256 hash of the retained seed is stored when the seed is retained, and unlock_bip39() now hashes the supplied seed and compares it to the stored hash before proceeding. This eliminates one secure-chip event per restore/reset cycle. All existing callers are updated: restore passes the seed it already has, set_password passes copy_seed(), and normal unlock still calls copy_seed(). The change is defensive and does not weaken access controls.
Changed components
src/keystore.csrc/keystore.hsrc/rust/bitbox02-rust/src/hww/api/restore.rssrc/rust/bitbox02-rust/src/hww/api/set_password.rssrc/rust/bitbox02-rust/src/keystore.rssrc/rust/bitbox02-rust/src/workflow/unlock.rssrc/rust/bitbox02/src/keystore.rssrc/rust/bitbox02/src/testing.rsInspect captured patch +57 / −22
diff --git a/src/keystore.c b/src/keystore.c
index 6333507..9d38bf4 100644
--- a/src/keystore.c
+++ b/src/keystore.c
@@ -37,6 +37,9 @@ static uint8_t _unstretched_retained_seed_encryption_key[32] = {0};
// Stores the encrypted seed after unlock.
static uint8_t _retained_seed_encrypted[KEYSTORE_MAX_SEED_LENGTH + 64] = {0};
static size_t _retained_seed_encrypted_len = 0;
+// A hash of the unencrypted retained seed, used for comparing seeds without knowing their
+// plaintext.
+static uint8_t _retained_seed_hash[32] = {0};
// Change this ONLY via keystore_unlock_bip39().
static bool _is_unlocked_bip39 = false;
@@ -224,6 +227,17 @@ static bool _verify_seed(
return true;
}
+static keystore_error_t _hash_seed(const uint8_t* seed, size_t seed_len, uint8_t* out)
+{
+ uint8_t salted_key[32] = {0};
+ if (!salt_hash_data(NULL, 0, "keystore_retain_seed_hash", salted_key)) {
+ return KEYSTORE_ERR_SALT;
+ }
+
+ rust_hmac_sha256(salted_key, sizeof(salted_key), seed, seed_len, out);
+ return KEYSTORE_OK;
+}
+
USE_RESULT static keystore_error_t _retain_seed(const uint8_t* seed, size_t seed_len)
{
#ifdef TESTING
@@ -253,7 +267,8 @@ USE_RESULT static keystore_error_t _retain_seed(const uint8_t* seed, size_t seed
return KEYSTORE_ERR_ENCRYPT;
}
_retained_seed_encrypted_len = len;
- return KEYSTORE_OK;
+
+ return _hash_seed(seed, seed_len, _retained_seed_hash);
}
USE_RESULT static bool _retain_bip39_seed(const uint8_t* bip39_seed)
@@ -298,6 +313,8 @@ static void _delete_retained_seeds(void)
sizeof(_unstretched_retained_seed_encryption_key));
util_zero(_retained_seed_encrypted, sizeof(_retained_seed_encrypted));
_retained_seed_encrypted_len = 0;
+ util_zero(_retained_seed_hash, sizeof(_retained_seed_hash));
+
util_zero(
_unstretched_retained_bip39_seed_encryption_key,
sizeof(_unstretched_retained_seed_encryption_key));
@@ -455,17 +472,23 @@ keystore_error_t keystore_unlock(
return result;
}
-bool keystore_unlock_bip39(const char* mnemonic_passphrase, uint8_t* root_fingerprint_out)
+bool keystore_unlock_bip39(
+ const uint8_t* seed,
+ size_t seed_length,
+ const char* mnemonic_passphrase,
+ uint8_t* root_fingerprint_out)
{
if (!_is_unlocked_device) {
return false;
}
usb_processing_timeout_reset(LONG_TIMEOUT);
- uint8_t seed[KEYSTORE_MAX_SEED_LENGTH] = {0};
- UTIL_CLEANUP_32(seed);
- size_t seed_length = 0;
- if (!keystore_copy_seed(seed, &seed_length)) {
+ uint8_t seed_hashed[32] = {0};
+ UTIL_CLEANUP_32(seed_hashed);
+ if (_hash_seed(seed, seed_length, seed_hashed) != KEYSTORE_OK) {
+ return false;
+ }
+ if (!MEMEQ(seed_hashed, _retained_seed_hash, sizeof(_retained_seed_hash))) {
return false;
}
diff --git a/src/keystore.h b/src/keystore.h
index 68dcb86..848ea83 100644
--- a/src/keystore.h
+++ b/src/keystore.h
@@ -104,7 +104,10 @@ USE_RESULT keystore_error_t keystore_create_and_store_seed(
USE_RESULT keystore_error_t
keystore_unlock(const char* password, uint8_t* remaining_attempts_out, int* securechip_result_out);
-/** Unlocks the bip39 seed.
+/** 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
@@ -112,6 +115,8 @@ keystore_unlock(const char* password, uint8_t* remaining_attempts_out, int* secu
* @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);
diff --git a/src/rust/bitbox02-rust/src/hww/api/restore.rs b/src/rust/bitbox02-rust/src/hww/api/restore.rs
index e64b7dd..bc8d180 100644
--- a/src/rust/bitbox02-rust/src/hww/api/restore.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/restore.rs
@@ -65,7 +65,8 @@ pub async fn from_file(
}
let password = password::enter_twice(hal).await?;
- if let Err(err) = bitbox02::keystore::encrypt_and_store_seed(data.get_seed(), &password) {
+ let seed = data.get_seed();
+ if let Err(err) = bitbox02::keystore::encrypt_and_store_seed(seed, &password) {
hal.ui()
.status(&format!("Could not\nrestore backup\n{:?}", err), false)
.await;
@@ -87,7 +88,7 @@ pub async fn from_file(
// Ignore non-critical error.
let _ = bitbox02::memory::set_device_name(&metadata.name);
- unlock::unlock_bip39(hal).await;
+ unlock::unlock_bip39(hal, seed).await;
Ok(Response::Success(pb::Success {}))
}
@@ -157,7 +158,7 @@ pub async fn from_mnemonic(
bitbox02::memory::set_initialized().or(Err(Error::Memory))?;
- unlock::unlock_bip39(hal).await;
+ unlock::unlock_bip39(hal, &seed).await;
Ok(Response::Success(pb::Success {}))
}
@@ -199,7 +200,7 @@ mod tests {
)),
Ok(Response::Success(pb::Success {}))
);
- assert_eq!(bitbox02::securechip::fake_event_counter(), 14);
+ assert_eq!(bitbox02::securechip::fake_event_counter(), 13);
drop(mock_hal); // to remove mutable borrow of counter
assert_eq!(counter, 2);
assert!(!keystore::is_locked());
diff --git a/src/rust/bitbox02-rust/src/hww/api/set_password.rs b/src/rust/bitbox02-rust/src/hww/api/set_password.rs
index 5f67c2d..c36ac3a 100644
--- a/src/rust/bitbox02-rust/src/hww/api/set_password.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/set_password.rs
@@ -40,7 +40,7 @@ pub async fn process(
hal.ui().status(&format!("Error\n{:?}", err), false).await;
return Err(Error::Generic);
}
- unlock::unlock_bip39(hal).await;
+ unlock::unlock_bip39(hal, &keystore::copy_seed()?).await;
Ok(Response::Success(pb::Success {}))
}
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index fa2b094..2e74b80 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(test.mnemonic_passphrase).is_err());
+ assert!(keystore::unlock_bip39(seed, test.mnemonic_passphrase).is_err());
bitbox02::securechip::fake_event_counter_reset();
assert!(keystore::encrypt_and_store_seed(seed, "foo").is_ok());
@@ -582,8 +582,8 @@ mod tests {
assert!(keystore::is_locked());
bitbox02::securechip::fake_event_counter_reset();
- assert!(keystore::unlock_bip39(test.mnemonic_passphrase).is_ok());
- assert_eq!(bitbox02::securechip::fake_event_counter(), 2);
+ assert!(keystore::unlock_bip39(seed, test.mnemonic_passphrase).is_ok());
+ assert_eq!(bitbox02::securechip::fake_event_counter(), 1);
assert!(!keystore::is_locked());
assert_eq!(
diff --git a/src/rust/bitbox02-rust/src/workflow/unlock.rs b/src/rust/bitbox02-rust/src/workflow/unlock.rs
index 5c9784b..6cdccc2 100644
--- a/src/rust/bitbox02-rust/src/workflow/unlock.rs
+++ b/src/rust/bitbox02-rust/src/workflow/unlock.rs
@@ -108,7 +108,7 @@ pub async fn unlock_keystore(
/// Performs the BIP39 keystore unlock, including unlock animation. If the optional passphrase
/// feature is enabled, the user will be asked for the passphrase.
-pub async fn unlock_bip39(hal: &mut impl crate::hal::Hal) {
+pub async fn unlock_bip39(hal: &mut impl crate::hal::Hal, seed: &[u8]) {
// Empty passphrase by default.
let mut mnemonic_passphrase = zeroize::Zeroizing::new("".into());
@@ -133,7 +133,8 @@ pub async fn unlock_bip39(hal: &mut impl crate::hal::Hal) {
}
}
- let result = bitbox02::ui::with_lock_animation(|| keystore::unlock_bip39(&mnemonic_passphrase));
+ let result =
+ bitbox02::ui::with_lock_animation(|| keystore::unlock_bip39(seed, &mnemonic_passphrase));
if result.is_err() {
abort("bip39 unlock failed");
}
@@ -160,6 +161,6 @@ pub async fn unlock(hal: &mut impl crate::hal::Hal) -> Result<(), ()> {
.is_err()
{}
- unlock_bip39(hal).await;
+ unlock_bip39(hal, &bitbox02::keystore::copy_seed()?).await;
Ok(())
}
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index 8ab249b..0713645 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -95,10 +95,12 @@ pub fn lock() {
unsafe { ROOT_FINGERPRINT.write(None) }
}
-pub fn unlock_bip39(mnemonic_passphrase: &str) -> Result<(), Error> {
+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()
@@ -409,7 +411,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("foo").is_ok());
+ assert!(unlock_bip39(&seed, "foo").is_ok());
assert!(!is_locked());
lock();
assert!(is_locked());
@@ -498,7 +500,10 @@ mod tests {
assert!(root_fingerprint().is_err());
assert!(encrypt_and_store_seed(&seed, "password").is_ok());
assert!(root_fingerprint().is_err());
- assert!(unlock_bip39("foo").is_ok());
+ // Incorrect seed passed
+ assert!(unlock_bip39(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "foo").is_err());
+ // Correct seed passed.
+ assert!(unlock_bip39(&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 7fbbb97..9d1729f 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(passphrase).unwrap();
+ keystore::unlock_bip39(&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 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.