keystore: port keystore_secp256k1_schnorr_sign to Rust
What changed, and why it matters
This commit rewrites a Bitcoin Schnorr signing function from C to Rust. It is a routine refactoring/porting change with no obvious security bug. The new Rust code does the same steps as the old C code: derive a private key, optionally tweak it, sign with a random auxiliary value, and return the signature. The old C implementation also verified the signature internally after signing; that post-sign verification step is removed in the Rust port, but the commit includes unit tests that verify produced signatures are valid.
Review whether the removed post-sign self-verification was a defense-in-depth requirement. If so, add an equivalent verification step in the Rust implementation or document the rationale for removing it. Otherwise, treat as a normal refactoring commit and ensure tests cover both tweaked and untweaked paths, which they do.
Security signals we found
Removal of post-sign self-verification: the C code verified every Schnorr signature immediately after creation; the Rust port does not.
Refactoring of cryptographic signing path that handles private keys and Schnorr/Taproot signatures.
Use of `zeroize::Zeroizing` and `Keypair` types in Rust for sensitive material.
Evidence from the diff
The change ports keystore_secp256k1_schnorr_sign from C (src/keystore.c) to Rust (src/rust/bitbox02-rust/src/keystore.rs). The C implementation used libsecp256k1’s secp256k1_schnorrsig_sign32 and then immediately called secp256k1_schnorrsig_verify to self-verify the signature. The Rust implementation uses bitcoin::secp256k1::Keypair::from_seckey_slice, optional add_xonly_tweak, and sign_schnorr_with_aux_rand, then returns sig.serialize(). The self-verification step is not retained. Call sites and FFI bindings are updated accordingly; tests are moved/added and pass. No memory-safety issues are evident; private key material is handled via zeroize::Zeroizing.
Changed components
src/keystore.csrc/keystore.hsrc/rust/bitbox02-rust/src/keystore.rssrc/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rssrc/rust/bitbox02/src/keystore.rssrc/rust/bitbox02/src/random.rssrc/rust/bitbox02-sys/build.rsInspect captured patch +81 / −160
diff --git a/src/keystore.c b/src/keystore.c
index b0e5c64..7f9983d 100644
--- a/src/keystore.c
+++ b/src/keystore.c
@@ -28,8 +28,6 @@
#include <rust/rust.h>
#include <secp256k1_ecdsa_s2c.h>
-#include <secp256k1_extrakeys.h>
-#include <secp256k1_schnorrsig.h>
// Change this ONLY via keystore_unlock() or keystore_lock()
static bool _is_unlocked_device = false;
@@ -579,64 +577,6 @@ bool keystore_get_ed25519_seed(uint8_t* seed_out)
return true;
}
-static bool _schnorr_keypair(
- const secp256k1_context* ctx,
- const uint32_t* keypath,
- size_t keypath_len,
- const uint8_t* tweak,
- secp256k1_keypair* keypair_out,
- secp256k1_xonly_pubkey* pubkey_out)
-{
- if (keystore_is_locked()) {
- return false;
- }
- uint8_t private_key[32] = {0};
- UTIL_CLEANUP_32(private_key);
- if (!rust_secp256k1_get_private_key(
- keypath, keypath_len, rust_util_bytes_mut(private_key, sizeof(private_key)))) {
- return false;
- }
-
- if (!secp256k1_keypair_create(ctx, keypair_out, private_key)) {
- return false;
- }
- if (tweak != NULL) {
- if (secp256k1_keypair_xonly_tweak_add(ctx, keypair_out, tweak) != 1) {
- return false;
- }
- }
- if (!secp256k1_keypair_xonly_pub(ctx, pubkey_out, NULL, keypair_out)) {
- return false;
- }
- return true;
-}
-
-static void _cleanup_keypair(secp256k1_keypair* keypair)
-{
- util_zero(keypair, sizeof(secp256k1_keypair));
-}
-
-bool keystore_secp256k1_schnorr_sign(
- const secp256k1_context* ctx,
- const uint32_t* keypath,
- size_t keypath_len,
- const uint8_t* msg32,
- const uint8_t* tweak,
- uint8_t* sig64_out)
-{
- secp256k1_keypair __attribute__((__cleanup__(_cleanup_keypair))) keypair = {0};
- secp256k1_xonly_pubkey pubkey = {0};
- if (!_schnorr_keypair(ctx, keypath, keypath_len, tweak, &keypair, &pubkey)) {
- return false;
- }
- uint8_t aux_rand[32] = {0};
- random_32_bytes(aux_rand);
- if (secp256k1_schnorrsig_sign32(ctx, sig64_out, msg32, &keypair, aux_rand) != 1) {
- return false;
- }
- return secp256k1_schnorrsig_verify(ctx, sig64_out, msg32, 32, &pubkey) == 1;
-}
-
#ifdef TESTING
void keystore_mock_unlocked(const uint8_t* seed, size_t seed_len, const uint8_t* bip39_seed)
{
diff --git a/src/keystore.h b/src/keystore.h
index cf7517d..a2c50f0 100644
--- a/src/keystore.h
+++ b/src/keystore.h
@@ -196,25 +196,6 @@ USE_RESULT bool keystore_get_u2f_seed(uint8_t* seed_out);
*/
USE_RESULT bool keystore_get_ed25519_seed(uint8_t* seed_out);
-/**
- * Sign a message that verifies against the pubkey tweaked using BIP-86.
- *
- * @param[in] ctx secp256k1 context
- * @param[in] keypath derivation keypath
- * @param[in] keypath_len number of elements in keypath
- * @param[in] msg32 32 byte message to sign
- * @param[in] tweak 32 bytes, tweak private key before signing with this tweak. Use NULL to not
- * tweak.
- * @param[out] sig64_out resulting 64 byte signature
- */
-USE_RESULT bool keystore_secp256k1_schnorr_sign(
- const secp256k1_context* ctx,
- const uint32_t* keypath,
- size_t keypath_len,
- const uint8_t* msg32,
- const uint8_t* tweak,
- uint8_t* sig64_out);
-
#ifdef TESTING
/**
* convenience to mock the keystore state (locked, seed) in tests.
diff --git a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
index 73f21fd..b2344ea 100644
--- a/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
@@ -1173,8 +1173,7 @@ async fn _process(
});
next_response.next.has_signature = true;
- next_response.next.signature = bitbox02::keystore::secp256k1_schnorr_sign(
- SECP256K1,
+ next_response.next.signature = crate::keystore::secp256k1_schnorr_sign(
&tx_input.keypath,
&sighash,
if let TaprootSpendInfo::KeySpend(tweak_hash) = &spend_info {
diff --git a/src/rust/bitbox02-rust/src/keystore.rs b/src/rust/bitbox02-rust/src/keystore.rs
index 1ec0b2a..0960438 100644
--- a/src/rust/bitbox02-rust/src/keystore.rs
+++ b/src/rust/bitbox02-rust/src/keystore.rs
@@ -156,6 +156,34 @@ pub fn bip85_ln(index: u32) -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
Ok(entropy)
}
+/// Sign a message using the private key at the keypath, which is optionally tweaked with the given
+/// tweak.
+pub fn secp256k1_schnorr_sign(
+ keypath: &[u32],
+ msg: &[u8; 32],
+ tweak: Option<&[u8; 32]>,
+) -> Result<[u8; 64], ()> {
+ let private_key = secp256k1_get_private_key(keypath)?;
+ let mut keypair =
+ bitcoin::secp256k1::Keypair::from_seckey_slice(SECP256K1, &private_key).map_err(|_| ())?;
+
+ if let Some(tweak) = tweak {
+ keypair = keypair
+ .add_xonly_tweak(
+ SECP256K1,
+ &bitcoin::secp256k1::Scalar::from_be_bytes(*tweak).map_err(|_| ())?,
+ )
+ .map_err(|_| ())?;
+ }
+
+ let sig = SECP256K1.sign_schnorr_with_aux_rand(
+ &bitcoin::secp256k1::Message::from_digest(*msg),
+ &keypair,
+ &bitbox02::random::random_32_bytes(),
+ );
+ Ok(sig.serialize())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -164,6 +192,8 @@ mod tests {
TEST_MNEMONIC, mock_memory, mock_unlocked, mock_unlocked_using_mnemonic,
};
+ use bitcoin::secp256k1;
+
#[test]
fn test_secp256k1_get_private_key() {
keystore::lock();
@@ -433,4 +463,54 @@ mod tests {
);
}
}
+
+ #[test]
+ fn test_secp256k1_schnorr_sign() {
+ mock_unlocked_using_mnemonic(
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
+ "",
+ );
+ let keypath = [86 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0];
+ let msg = [0x88u8; 32];
+
+ let expected_pubkey = {
+ let pubkey =
+ hex::decode("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115")
+ .unwrap();
+ secp256k1::XOnlyPublicKey::from_slice(&pubkey).unwrap()
+ };
+
+ // Test without tweak
+ bitbox02::random::fake_reset();
+ let sig = secp256k1_schnorr_sign(&keypath, &msg, None).unwrap();
+ assert!(
+ SECP256K1
+ .verify_schnorr(
+ &secp256k1::schnorr::Signature::from_slice(&sig).unwrap(),
+ &secp256k1::Message::from_digest_slice(&msg).unwrap(),
+ &expected_pubkey
+ )
+ .is_ok()
+ );
+
+ // Test with tweak
+ bitbox02::random::fake_reset();
+ let tweak = {
+ let tweak =
+ hex::decode("a39fb163dbd9b5e0840af3cc1ee41d5b31245c5dd8d6bdc3d026d09b8964997c")
+ .unwrap();
+ secp256k1::Scalar::from_be_bytes(tweak.try_into().unwrap()).unwrap()
+ };
+ let (tweaked_pubkey, _) = expected_pubkey.add_tweak(SECP256K1, &tweak).unwrap();
+ let sig = secp256k1_schnorr_sign(&keypath, &msg, Some(&tweak.to_be_bytes())).unwrap();
+ assert!(
+ SECP256K1
+ .verify_schnorr(
+ &secp256k1::schnorr::Signature::from_slice(&sig).unwrap(),
+ &secp256k1::Message::from_digest(msg),
+ &tweaked_pubkey
+ )
+ .is_ok()
+ );
+ }
}
diff --git a/src/rust/bitbox02-sys/build.rs b/src/rust/bitbox02-sys/build.rs
index b5ae6a5..d6d8f50 100644
--- a/src/rust/bitbox02-sys/build.rs
+++ b/src/rust/bitbox02-sys/build.rs
@@ -79,7 +79,6 @@ const ALLOWLIST_FNS: &[&str] = &[
"keystore_lock",
"keystore_mock_unlocked",
"keystore_secp256k1_nonce_commit",
- "keystore_secp256k1_schnorr_sign",
"keystore_secp256k1_sign",
"keystore_unlock",
"keystore_unlock_bip39",
diff --git a/src/rust/bitbox02/src/keystore.rs b/src/rust/bitbox02/src/keystore.rs
index 2193f2a..86a88c3 100644
--- a/src/rust/bitbox02/src/keystore.rs
+++ b/src/rust/bitbox02/src/keystore.rs
@@ -238,39 +238,12 @@ pub fn get_u2f_seed() -> Result<zeroize::Zeroizing<Vec<u8>>, ()> {
}
}
-pub fn secp256k1_schnorr_sign(
- secp: &Secp256k1<All>,
- keypath: &[u32],
- msg: &[u8; 32],
- tweak: Option<&[u8; 32]>,
-) -> Result<[u8; 64], ()> {
- let mut signature = [0u8; 64];
-
- match unsafe {
- bitbox02_sys::keystore_secp256k1_schnorr_sign(
- secp.ctx().as_ptr().cast(),
- keypath.as_ptr(),
- keypath.len() as _,
- msg.as_ptr(),
- match tweak {
- Some(t) => t.as_ptr(),
- None => core::ptr::null() as *const _,
- },
- signature.as_mut_ptr(),
- )
- } {
- true => Ok(signature),
- false => Err(()),
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::secp256k1;
use crate::testing::{mock_memory, mock_unlocked_using_mnemonic};
- use util::bip32::HARDENED;
#[test]
fn test_secp256k1_sign() {
@@ -310,56 +283,6 @@ mod tests {
);
}
- #[test]
- fn test_secp256k1_schnorr_sign() {
- mock_unlocked_using_mnemonic(
- "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
- "",
- );
- let keypath = [86 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0];
- let msg = [0x88u8; 32];
-
- let expected_pubkey = {
- let pubkey =
- hex::decode("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115")
- .unwrap();
- secp256k1::XOnlyPublicKey::from_slice(&pubkey).unwrap()
- };
-
- // Test without tweak
- crate::random::fake_reset();
- let secp = secp256k1::Secp256k1::new();
- let sig = secp256k1_schnorr_sign(&secp, &keypath, &msg, None).unwrap();
- assert!(
- secp.verify_schnorr(
- &secp256k1::schnorr::Signature::from_slice(&sig).unwrap(),
- &secp256k1::Message::from_digest_slice(&msg).unwrap(),
- &expected_pubkey
- )
- .is_ok()
- );
-
- // Test with tweak
- crate::random::fake_reset();
- let tweak = {
- let tweak =
- hex::decode("a39fb163dbd9b5e0840af3cc1ee41d5b31245c5dd8d6bdc3d026d09b8964997c")
- .unwrap();
- secp256k1::Scalar::from_be_bytes(tweak.try_into().unwrap()).unwrap()
- };
- let (tweaked_pubkey, _) = expected_pubkey.add_tweak(&secp, &tweak).unwrap();
- let sig =
- secp256k1_schnorr_sign(&secp, &keypath, &msg, Some(&tweak.to_be_bytes())).unwrap();
- assert!(
- secp.verify_schnorr(
- &secp256k1::schnorr::Signature::from_slice(&sig).unwrap(),
- &secp256k1::Message::from_digest_slice(&msg).unwrap(),
- &tweaked_pubkey
- )
- .is_ok()
- );
- }
-
#[test]
fn test_secp256k1_nonce_commit() {
let secp = secp256k1::Secp256k1::new();
diff --git a/src/rust/bitbox02/src/random.rs b/src/rust/bitbox02/src/random.rs
index 42d8b0b..2219648 100644
--- a/src/rust/bitbox02/src/random.rs
+++ b/src/rust/bitbox02/src/random.rs
@@ -29,7 +29,6 @@ pub fn mcu_32_bytes(out: &mut [u8; 32]) {
}
}
-#[cfg(feature = "testing")]
pub fn random_32_bytes() -> alloc::boxed::Box<zeroize::Zeroizing<[u8; 32]>> {
let mut out = alloc::boxed::Box::new(zeroize::Zeroizing::new([0u8; 32]));
unsafe { bitbox02_sys::random_32_bytes(out.as_mut_ptr()) }
Why this scored 11/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.