keystore: reduce secure chip operations by precomputing fingerprint
What changed, and why it matters
This commit is a performance and reliability improvement, not a security fix. It precomputes a wallet's 'root fingerprint' during device unlock and stores it in memory, so the BitBoxApp can read it later without repeatedly asking the secure chip. The secure chip was being throttled by too many rapid requests, which could slow down or temporarily block the device. The change removes that throttling risk and slightly reduces secure-chip wear, but it does not patch an exploitable vulnerability.
Treat as a normal reliability/performance improvement. Review the new `SyncUnsafeCell` usage for soundness in a single-threaded embedded context, ensure `ROOT_FINGERPRINT` is always cleared before any path that returns to a locked state, and verify that the 4-byte output buffer cannot be passed as null from C callers. No urgent security patch is indicated.
Security signals we found
Avoids secure-chip throttling by eliminating two securechip operations per root_fingerprint() call
Adds a new global mutable static for cached root fingerprint, cleared on lock
Introduces a custom SyncUnsafeCell abstraction with unsafe read/write APIs
No input validation changes, no buffer size changes beyond adding a 4-byte out parameter
No cryptographic algorithm changes; fingerprint derivation uses the same BIP32 master xpub method
Evidence from the diff
The change moves root-fingerprint computation from bitbox02_rust::keystore::root_fingerprint() (which previously derived an xpub via the secure chip twice per call) to the BIP39 unlock path. rust_derive_bip39_seed() now also derives the BIP32 root fingerprint from the newly computed BIP39 seed using bitcoin::bip32::Xpriv::new_master(...).fingerprint(...). The 4-byte fingerprint is stored in a new global SyncUnsafeCell<Option<[u8;4]>> in bitbox02::keystore, cleared on lock(), and returned by the new root_fingerprint() wrapper. Tests are updated to expect zero secure-chip events for the cached fingerprint and an error when locked. The SyncUnsafeCell is a local copy of core::sync::SyncUnsafeCell and is correctly Sync only when T: Sync.
Changed components
src/keystore.csrc/keystore.hsrc/rust/bitbox02-rust/src/bip39.rssrc/rust/bitbox02-rust/src/keystore.rssrc/rust/bitbox02-rust/src/lib.rssrc/rust/bitbox02/src/keystore.rssrc/rust/util/src/cell.rssrc/rust/util/src/lib.rsInspect captured patch +130 / −15
diff --git a/src/keystore.c b/src/keystore.c
index 4219d59..c884f94 100644
--- a/src/keystore.c
+++ b/src/keystore.c
@@ -448,7 +448,7 @@ keystore_error_t keystore_unlock(
return result;
}
-bool keystore_unlock_bip39(const char* mnemonic_passphrase)
+bool keystore_unlock_bip39(const char* mnemonic_passphrase, uint8_t* root_fingerprint_out)
{
if (!_is_unlocked_device) {
return false;
@@ -467,7 +467,8 @@ bool keystore_unlock_bip39(const char* mnemonic_passphrase)
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(bip39_seed, sizeof(bip39_seed)),
+ rust_util_bytes_mut(root_fingerprint_out, 4));
if (!_retain_bip39_seed(bip39_seed)) {
return false;
diff --git a/src/keystore.h b/src/keystore.h
index 655a714..9587c6e 100644
--- a/src/keystore.h
+++ b/src/keystore.h
@@ -106,9 +106,13 @@ keystore_unlock(const char* password, uint8_t* remaining_attempts_out, int* secu
/** Unlocks the bip39 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 char* mnemonic_passphrase);
+USE_RESULT bool keystore_unlock_bip39(
+ const char* mnemonic_passphrase,
+ uint8_t* root_fingerprint_out);
/**
* Locks the keystore (resets to state before `keystore_unlock()`).
diff --git a/src/rust/bitbox02-rust/src/bip39.rs b/src/rust/bitbox02-rust/src/bip39.rs
index 50ffde4..e055fca 100644
--- a/src/rust/bitbox02-rust/src/bip39.rs
+++ b/src/rust/bitbox02-rust/src/bip39.rs
@@ -32,19 +32,30 @@ pub fn get_word(idx: u16) -> Result<zeroize::Zeroizing<String>, ()> {
/// 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.
+/// `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 out: util::bytes::BytesMut,
+ 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 =
+ let bip39_seed: zeroize::Zeroizing<[u8; 64]> =
zeroize::Zeroizing::new(mnemonic.to_seed_normalized(passphrase.to_str().unwrap()));
- out.as_mut().clone_from_slice(&bip39_seed[..]);
+ 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)]
@@ -73,6 +84,7 @@ mod tests {
seed: &'static str,
passphrase: &'static core::ffi::CStr,
expected_bip39_seed: &'static str,
+ expected_root_fingerprint: &'static str,
}
let tests = &[
@@ -81,47 +93,62 @@ mod tests {
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
+ );
}
}
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index e6be355..de968df 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -89,11 +89,7 @@ pub fn get_xpub_twice(keypath: &[u32]) -> Result<bip32::Xpub, ()> {
/// according to:
/// https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#serialization-format
pub fn root_fingerprint() -> Result<Vec<u8>, ()> {
- Ok(get_xpub_twice(&[])?
- .pubkey_hash160()
- .get(..4)
- .ok_or(())?
- .to_vec())
+ keystore::root_fingerprint()
}
fn bip85_entropy(keypath: &[u32]) -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
@@ -342,13 +338,17 @@ mod tests {
bitbox02::securechip::fake_event_counter_reset();
assert_eq!(root_fingerprint(), Ok(vec![0x02, 0x40, 0xe9, 0x2a]));
- assert_eq!(bitbox02::securechip::fake_event_counter(), 2);
+ // fingerprint is precomputed during bip39 unlock, so takes no securechip events.
+ assert_eq!(bitbox02::securechip::fake_event_counter(), 0);
mock_unlocked_using_mnemonic(
"small agent wife animal marine cloth exit thank stool idea steel frame",
"",
);
assert_eq!(root_fingerprint(), Ok(vec![0xf4, 0x0b, 0x46, 0x9a]));
+
+ keystore::lock();
+ assert_eq!(root_fingerprint(), Err(()));
}
#[test]
diff --git a/src/rust/bitbox02-rust/src/lib.rs b/src/rust/bitbox02-rust/src/lib.rs
index 1439743..811090a 100644
--- a/src/rust/bitbox02-rust/src/lib.rs
+++ b/src/rust/bitbox02-rust/src/lib.rs
@@ -36,7 +36,7 @@ pub mod hal;
pub mod hash;
pub mod hww;
pub mod keystore;
-mod secp256k1;
+pub mod secp256k1;
#[cfg(feature = "app-u2f")]
mod u2f;
mod version;
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index 44cb4d6..596871c 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -13,11 +13,13 @@
// limitations under the License.
extern crate alloc;
-use alloc::string::{String, ToString};
+use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
+use util::cell::SyncUnsafeCell;
+
use bitcoin::secp256k1::{All, Secp256k1};
use core::convert::TryInto;
@@ -28,6 +30,8 @@ use bitbox02_sys::keystore_error_t;
const EC_PUBLIC_KEY_LEN: usize = 33;
pub const MAX_SEED_LENGTH: usize = bitbox02_sys::KEYSTORE_MAX_SEED_LENGTH as usize;
+static ROOT_FINGERPRINT: SyncUnsafeCell<Option<[u8; 4]>> = SyncUnsafeCell::new(None);
+
pub fn is_locked() -> bool {
unsafe { bitbox02_sys::keystore_is_locked() }
}
@@ -87,23 +91,39 @@ pub fn unlock(password: &str) -> Result<(), Error> {
pub fn lock() {
unsafe { bitbox02_sys::keystore_lock() }
+
+ unsafe { ROOT_FINGERPRINT.write(None) }
}
pub fn unlock_bip39(mnemonic_passphrase: &str) -> Result<(), Error> {
+ let mut root_fingerprint = [0u8; 4];
if unsafe {
bitbox02_sys::keystore_unlock_bip39(
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));
+ }
+
Ok(())
} else {
Err(Error::CannotUnlockBIP39)
}
}
+pub fn root_fingerprint() -> Result<Vec<u8>, ()> {
+ if is_locked() {
+ return Err(());
+ }
+ unsafe { ROOT_FINGERPRINT.read().ok_or(()).map(|fp| fp.to_vec()) }
+}
+
pub fn create_and_store_seed(password: &str, host_entropy: &[u8]) -> Result<(), Error> {
match unsafe {
bitbox02_sys::keystore_create_and_store_seed(
@@ -485,9 +505,12 @@ mod tests {
.unwrap();
crate::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!(unlock("password").is_ok());
+ assert!(root_fingerprint().is_err());
assert!(unlock_bip39("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/util/src/cell.rs b/src/rust/util/src/cell.rs
new file mode 100644
index 0000000..04918a8
--- /dev/null
+++ b/src/rust/util/src/cell.rs
@@ -0,0 +1,59 @@
+// 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.
+
+// Same as `core::sync::SyncUnsafeCell`, which is still in nightly. Can remove once it is stable.
+pub struct SyncUnsafeCell<T: ?Sized> {
+ value: core::cell::UnsafeCell<T>,
+}
+
+// Implement Sync if the wrapped type is Sync.
+unsafe impl<T: ?Sized + Sync> Sync for SyncUnsafeCell<T> {}
+
+impl<T> SyncUnsafeCell<T> {
+ pub const fn new(val: T) -> Self {
+ SyncUnsafeCell {
+ value: core::cell::UnsafeCell::new(val),
+ }
+ }
+
+ /// Reads the value from `self` without moving it. This leaves the
+ /// memory in `self` unchanged.
+ ///
+ /// # Safety
+ ///
+ /// This is unsafe because it allows accessing the interior value without
+ /// synchronization. The caller must ensure no other code is currently
+ /// writing to this cell during the read operation.
+ pub unsafe fn read(&self) -> T
+ where
+ T: Sized,
+ {
+ unsafe { self.value.get().read() }
+ }
+
+ /// Overwrites a memory location with the given value without reading or
+ /// dropping the old value.
+ ///
+ /// # Safety
+ ///
+ /// This is unsafe because it allows accessing the interior value without
+ /// synchronization. The caller must ensure no other code is currently
+ /// accessing this cell (either reading or writing) during the write operation.
+ pub unsafe fn write(&self, val: T)
+ where
+ T: Sized,
+ {
+ unsafe { self.value.get().write(val) }
+ }
+}
diff --git a/src/rust/util/src/lib.rs b/src/rust/util/src/lib.rs
index 35581ac..1c90970 100644
--- a/src/rust/util/src/lib.rs
+++ b/src/rust/util/src/lib.rs
@@ -16,6 +16,7 @@
pub mod ascii;
pub mod bip32;
pub mod bytes;
+pub mod cell;
pub mod decimal;
pub mod log;
pub mod name;
Why this scored 19/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.