Skip to content

Commit 463b1f6

Browse files
committed
bitcoin/signtx: cache xpubs
When loading a transaction, every input and change address requires the public key in order to compute the pkScript, which is used in the sighash that is signed. For single-sig, these public keys are currently always at keypaths: - `m/(49'|84'|86')/coin'/account'/0/*` (receive inputs) - `m/(49'|84'|86')/coin'/account'/1/*` (change inputs) where `coin` and `account` are the same for all inputs/changes in the transaction. Instead of deriving the xpub at these keypaths repeatedly, we cache the xpubs at the account level and the receive/change level. The xpub then only has to be derived once for the account level and once for change/receive per script type. Benefits: - Greatly increased speed. Loading inputs is now about 3x faster for segwit inputs (with regularly sized previous transactions) and around 5-6x faster for Taproot transactions (where no previous transactions need to be streamed). - The seed only has to be used once (to derive the account-level xpub) instead of once per input/change. This increases security assuming the seed receives additional protection (currently it is sitting in RAM unencrypted after unlock, but that will likely change). Fewer seed accesses means it is harder to exploit a potential RAM extraction bug. Note that these benefits are for loading the transaction. When signing the inputs, the private key is required twice per input (once for the antiklepto commit and once for the signature), so xpub cacing does not apply there.
1 parent 38c13da commit 463b1f6

File tree

14 files changed

+534
-71
lines changed

14 files changed

+534
-71
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ customers cannot upgrade their bootloader, its changes are recorded separately.
77
## Firmware
88

99
### [Unreleased]
10+
- Increased performance when signing Bitcoin transactions
1011
- Warn if the transaction fee is higher than 10% of the coins sent
1112
- ETH Testnets: add Goerli and remove deprecated Rinkeby and Ropsten
1213

src/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# limitations under the License.
1616

1717
set(DBB-FIRMWARE-SOURCES
18+
${CMAKE_SOURCE_DIR}/src/bip32.c
1819
${CMAKE_SOURCE_DIR}/src/firmware_main_loop.c
1920
${CMAKE_SOURCE_DIR}/src/keystore.c
2021
${CMAKE_SOURCE_DIR}/src/random.c
@@ -271,6 +272,7 @@ add_custom_target(rust-bindgen
271272
--use-core
272273
--with-derive-default
273274
--ctypes-prefix util::c_types
275+
--allowlist-function bip32_derive_xpub
274276
--allowlist-function strftime
275277
--allowlist-function localtime
276278
--allowlist-function wally_free_string

src/bip32.c

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2023 Shift Crypto AG
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#include "bip32.h"
16+
17+
#include <string.h>
18+
#include <wally_bip32.h>
19+
20+
bool bip32_derive_xpub(
21+
const uint8_t* xpub78,
22+
const uint32_t* keypath,
23+
size_t keypath_len,
24+
uint8_t* xpub78_out)
25+
{
26+
if (keypath_len == 0) {
27+
memcpy(xpub78_out, xpub78, BIP32_SERIALIZED_LEN);
28+
return true;
29+
}
30+
31+
struct ext_key xpub = {0};
32+
if (bip32_key_unserialize(xpub78, BIP32_SERIALIZED_LEN, &xpub) != WALLY_OK) {
33+
return false;
34+
}
35+
struct ext_key derived_xpub = {0};
36+
if (bip32_key_from_parent_path(
37+
&xpub, keypath, keypath_len, BIP32_FLAG_KEY_PUBLIC, &derived_xpub) != WALLY_OK) {
38+
return false;
39+
}
40+
return bip32_key_serialize(
41+
&derived_xpub, BIP32_FLAG_KEY_PUBLIC, xpub78_out, BIP32_SERIALIZED_LEN) == WALLY_OK;
42+
}

src/bip32.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2023 Shift Crypto AG
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#ifndef _BIP32_H_
16+
#define _BIP32_H_
17+
18+
#include <stdbool.h>
19+
#include <stddef.h>
20+
#include <stdint.h>
21+
22+
#include <compiler_util.h>
23+
24+
USE_RESULT bool bip32_derive_xpub(
25+
const uint8_t* xpub78,
26+
const uint32_t* keypath,
27+
size_t keypath_len,
28+
uint8_t* xpub78_out);
29+
30+
#endif

src/rust/bitbox02-rust/src/bip32.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ impl Xpub {
5656
public_key: xpub[45..78].to_vec(),
5757
}))
5858
}
59+
5960
/// Serializes a protobuf XPub to bytes according to the BIP32 specification. If xpub_type is
6061
/// None, the four version bytes are skipped.
6162
pub fn serialize(&self, xpub_type: Option<XPubType>) -> Result<Vec<u8>, ()> {
@@ -100,6 +101,12 @@ impl Xpub {
100101
.into_string())
101102
}
102103

104+
/// Derives child xpub at the keypath. All keypath elements must be unhardened.
105+
pub fn derive(&self, keypath: &[u32]) -> Result<Self, ()> {
106+
let xpub_ser = self.serialize(Some(XPubType::Xpub))?;
107+
Xpub::from_bytes(&bitbox02::bip32::derive_xpub(&xpub_ser, keypath)?)
108+
}
109+
103110
/// Returns the 33 bytes secp256k1 compressed pubkey.
104111
pub fn public_key(&self) -> &[u8] {
105112
self.xpub.public_key.as_slice()
@@ -116,6 +123,17 @@ impl Xpub {
116123
pub fn pubkey_uncompressed(&self) -> Result<[u8; 65], ()> {
117124
bitbox02::keystore::secp256k1_pubkey_compressed_to_uncompressed(self.public_key())
118125
}
126+
127+
/// Return the tweaked taproot pubkey.
128+
///
129+
/// Instead of returning the original pubkey at the keypath directly, it is tweaked with the
130+
/// hash of the pubkey.
131+
///
132+
/// See
133+
/// https://github.com/bitcoin/bips/blob/edffe529056f6dfd33d8f716fb871467c3c09263/bip-0086.mediawiki#address-derivation
134+
pub fn schnorr_bip86_pubkey(&self) -> Result<[u8; 32], ()> {
135+
bitbox02::keystore::secp256k1_schnorr_bip86_pubkey(self.public_key())
136+
}
119137
}
120138

121139
/// Parses a Base58Check-encoded xpub string. The 4 version bytes are not checked and discarded.
@@ -178,6 +196,30 @@ mod tests {
178196
);
179197
}
180198

199+
#[test]
200+
fn test_derive() {
201+
let xpub_str = "xpub661MyMwAqRbcGpuMRXa55WgyqinF4dpxvqQK63xBHtnH5yK4e3cTLqbX9CP4mEMHUbqsjSQ8y3hhbAzuMhpn8eEiLNVSWYaVSbKMAtUPyYH";
202+
let xpub = Xpub::from(parse_xpub(xpub_str).unwrap());
203+
204+
assert_eq!(
205+
xpub.derive(&[])
206+
.unwrap()
207+
.serialize_str(XPubType::Xpub)
208+
.unwrap(),
209+
xpub_str,
210+
);
211+
let expected = "xpub6CYiDoWMtLVQNrc4tbAvuRk5wjsp6MFgtYEdBUV7TGLUjutavHdEKLu9KpTpRxEZULbSwM1UQPaQpqAhmWYvngXCGHGE7hSZFNofeSRzmk5";
212+
assert_eq!(
213+
xpub.derive(&[0, 1, 2])
214+
.unwrap()
215+
.serialize_str(XPubType::Xpub)
216+
.unwrap(),
217+
expected,
218+
);
219+
220+
assert!(xpub.derive(&[0, 1, util::bip32::HARDENED]).is_err());
221+
}
222+
181223
#[test]
182224
fn test_pubkey_hash160() {
183225
let xpub = Xpub::from(parse_xpub("xpub6GugPDcUhrSudznFss7wXvQV3gwFTEanxHdCyoNoHnZEr3PTbh2Fosg4JjfphaYAsqjBhmtTZ3Yo8tmGjSHtaPhExNiMCSvPzreqjrX4Wr7").unwrap());
@@ -201,4 +243,28 @@ mod tests {
201243
*b"\x04\x77\xa4\x4a\xa9\xe8\xc8\xfb\x51\x05\xef\x5e\xe2\x39\x4e\x8a\xed\x89\xad\x73\xfc\x74\x36\x14\x25\xf0\x63\x47\xec\xfe\x32\x61\x31\xe1\x33\x93\x67\xee\x3c\xbe\x87\x71\x92\x85\xa0\x7f\x77\x4b\x17\xeb\x93\x3e\xcf\x0b\x9b\x82\xac\xeb\xc1\x95\x22\x6d\x63\x42\x44",
202244
);
203245
}
246+
247+
#[test]
248+
fn test_schnorr_bip86_pubkey() {
249+
// Test vectors from:
250+
// https://github.com/bitcoin/bips/blob/edffe529056f6dfd33d8f716fb871467c3c09263/bip-0086.mediawiki#test-vectors
251+
// Here we only test the creation of the tweaked pubkkey. See `Payload::from_simple` for address generation.
252+
253+
let xpub = Xpub::from(parse_xpub("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ").unwrap());
254+
255+
assert_eq!(
256+
xpub.derive(&[0, 0]).unwrap().schnorr_bip86_pubkey().unwrap(),
257+
*b"\xa6\x08\x69\xf0\xdb\xcf\x1d\xc6\x59\xc9\xce\xcb\xaf\x80\x50\x13\x5e\xa9\xe8\xcd\xc4\x87\x05\x3f\x1d\xc6\x88\x09\x49\xdc\x68\x4c",
258+
);
259+
260+
assert_eq!(
261+
xpub.derive(&[0, 1]).unwrap().schnorr_bip86_pubkey().unwrap(),
262+
*b"\xa8\x2f\x29\x94\x4d\x65\xb8\x6a\xe6\xb5\xe5\xcc\x75\xe2\x94\xea\xd6\xc5\x93\x91\xa1\xed\xc5\xe0\x16\xe3\x49\x8c\x67\xfc\x7b\xbb",
263+
);
264+
265+
assert_eq!(
266+
xpub.derive(&[1, 0]).unwrap().schnorr_bip86_pubkey().unwrap(),
267+
*b"\x88\x2d\x74\xe5\xd0\x57\x2d\x5a\x81\x6c\xef\x00\x41\xa9\x6b\x6c\x1d\xe8\x32\xf6\xf9\x67\x6d\x96\x05\xc4\x4d\x5e\x9a\x97\xd3\xdc",
268+
);
269+
}
204270
}

src/rust/bitbox02-rust/src/hww/api/bitcoin.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,13 @@ pub fn derive_address_simple(
134134
coin_params.taproot_support,
135135
)
136136
.or(Err(Error::InvalidInput))?;
137-
Ok(common::Payload::from_simple(coin_params, simple_type, keypath)?.address(coin_params)?)
137+
Ok(common::Payload::from_simple(
138+
&mut crate::xpubcache::XpubCache::new(),
139+
coin_params,
140+
simple_type,
141+
keypath,
142+
)?
143+
.address(coin_params)?)
138144
}
139145

140146
/// Processes a SimpleType (single-sig) adress api call.

src/rust/bitbox02-rust/src/hww/api/bitcoin/common.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use super::pb;
1616
use super::Error;
1717

18-
use crate::keystore;
18+
use crate::xpubcache::Bip32XpubCache;
1919

2020
use alloc::string::String;
2121
use alloc::vec::Vec;
@@ -71,17 +71,19 @@ pub struct Payload {
7171

7272
impl Payload {
7373
pub fn from_simple(
74+
xpub_cache: &mut Bip32XpubCache,
7475
params: &Params,
7576
simple_type: SimpleType,
7677
keypath: &[u32],
7778
) -> Result<Self, Error> {
7879
match simple_type {
7980
SimpleType::P2wpkh => Ok(Payload {
80-
data: keystore::get_xpub(keypath)?.pubkey_hash160(),
81+
data: xpub_cache.get_xpub(keypath)?.pubkey_hash160(),
8182
output_type: BtcOutputType::P2wpkh,
8283
}),
8384
SimpleType::P2wpkhP2sh => {
84-
let payload_p2wpkh = Payload::from_simple(params, SimpleType::P2wpkh, keypath)?;
85+
let payload_p2wpkh =
86+
Payload::from_simple(xpub_cache, params, SimpleType::P2wpkh, keypath)?;
8587
let pkscript_p2wpkh = payload_p2wpkh.pk_script(params)?;
8688
Ok(Payload {
8789
data: bitbox02::hash160(&pkscript_p2wpkh).to_vec(),
@@ -91,7 +93,10 @@ impl Payload {
9193
SimpleType::P2tr => {
9294
if params.taproot_support {
9395
Ok(Payload {
94-
data: keystore::secp256k1_schnorr_bip86_pubkey(keypath)?.to_vec(),
96+
data: xpub_cache
97+
.get_xpub(keypath)?
98+
.schnorr_bip86_pubkey()?
99+
.to_vec(),
95100
output_type: BtcOutputType::P2tr,
96101
})
97102
} else {
@@ -144,6 +149,7 @@ impl Payload {
144149
/// Computes the payload data from a script config. The payload can then be used generate a
145150
/// pkScript or an address.
146151
pub fn from(
152+
xpub_cache: &mut Bip32XpubCache,
147153
params: &Params,
148154
keypath: &[u32],
149155
script_config_account: &pb::BtcScriptConfigWithKeypath,
@@ -158,7 +164,7 @@ impl Payload {
158164
} => {
159165
let simple_type = pb::btc_script_config::SimpleType::from_i32(*simple_type)
160166
.ok_or(Error::InvalidInput)?;
161-
Self::from_simple(params, simple_type, keypath)
167+
Self::from_simple(xpub_cache, params, simple_type, keypath)
162168
}
163169
pb::BtcScriptConfigWithKeypath {
164170
script_config:
@@ -533,10 +539,12 @@ mod tests {
533539
mock_unlocked_using_mnemonic(
534540
"sudden tenant fault inject concert weather maid people chunk youth stumble grit",
535541
);
542+
let mut xpub_cache = Bip32XpubCache::new();
536543
let coin_params = super::super::params::get(pb::BtcCoin::Btc);
537544
// p2wpkh
538545
assert_eq!(
539546
Payload::from_simple(
547+
&mut xpub_cache,
540548
coin_params,
541549
SimpleType::P2wpkh,
542550
&[84 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0]
@@ -550,6 +558,7 @@ mod tests {
550558
// p2wpkh-p2sh
551559
assert_eq!(
552560
Payload::from_simple(
561+
&mut xpub_cache,
553562
coin_params,
554563
SimpleType::P2wpkhP2sh,
555564
&[49 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0]
@@ -563,6 +572,7 @@ mod tests {
563572
// p2tr
564573
assert_eq!(
565574
Payload::from_simple(
575+
&mut xpub_cache,
566576
coin_params,
567577
SimpleType::P2tr,
568578
&[86 + HARDENED, 0 + HARDENED, 0 + HARDENED, 0, 0]

0 commit comments

Comments
 (0)