Skip to content

Commit 1a8cf38

Browse files
committed
bitcoin/policies: show provably unspendable Taproot internal keys
In Taproot policies, it is a common pattern to use an unspendable internal public key if one only wants to use script path spends, e.g. tr(UNSPENDABLE,{...}) https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs decribes that one could use the NUMS point for that: > One example of such a point is H = lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is constructed by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X coordinate. Wallet policy keys however must be xpubs, and also it is not desirable to use the NUMS point, as described in https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304: > 1. unspendable keys should be indistinguishable from a random key for an external observer; > 2. in a descriptor with the range operator (like the wallet policies > compatible with most known wallet account formats), each > change/address_index combination must generate a different unspendable > pubkey, and they should not be relatable to each other (in order to > avoid fingerprinting); The proposal in https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21 to use an xpub with the NUMS public key and a chain_code derived as the hash from the xpubs in the descriptor was adopted by Liana wallet. This commit implements this. Note that even though this proposal it not a standard yet, it is still provably unspendable, so we can display this info to the user. A future standard to achieve the same can be included later.
1 parent 747c442 commit 1a8cf38

File tree

2 files changed

+222
-7
lines changed

2 files changed

+222
-7
lines changed

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

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,11 @@ impl<'a> ParsedPolicy<'a> {
416416
BtcCoin::Tbtc | BtcCoin::Rbtc | BtcCoin::Tltc => bip32::XPubType::Tpub,
417417
};
418418
let num_keys = policy.keys.len();
419+
420+
let taproot_unspendable_internal_key_index = self.taproot_is_unspendable_internal_key()?;
421+
419422
for (i, key) in policy.keys.iter().enumerate() {
420-
let key_str = match key {
423+
let mut key_str = match key {
421424
pb::KeyOriginInfo {
422425
root_fingerprint,
423426
keypath,
@@ -441,14 +444,14 @@ impl<'a> ParsedPolicy<'a> {
441444
}
442445
_ => return Err(Error::InvalidInput),
443446
};
447+
if self.is_our_key[i] {
448+
key_str = format!("This device: {}", key_str)
449+
} else if Some(i) == taproot_unspendable_internal_key_index {
450+
key_str = format!("Provably unspendable: {}", key_str)
451+
}
444452
confirm::confirm(&confirm::Params {
445453
title: &format!("Key {}/{}", i + 1, num_keys),
446-
body: (if self.is_our_key[i] {
447-
format!("This device: {}", key_str)
448-
} else {
449-
key_str
450-
})
451-
.as_str(),
454+
body: key_str.as_str(),
452455
scrollable: true,
453456
longtouch: i == num_keys - 1 && matches!(mode, Mode::Advanced),
454457
accept_is_nextarrow: true,
@@ -562,6 +565,77 @@ impl<'a> ParsedPolicy<'a> {
562565
_ => Err(Error::Generic),
563566
}
564567
}
568+
569+
/// Returns `Some(index of internal key)` if this is a Taproot policy and the Taproot internal
570+
/// key is provably unspendable, and `None` otherwise.
571+
///
572+
/// We consider it provably unspendable if the internal xpub's public key is the NUMS point and
573+
/// the xpub's chain code is the sha256() of the concatenation of all the public keys (33 byte
574+
/// compressed) in the taptree left-to-right.
575+
///
576+
/// See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21
577+
///
578+
/// This is not a standard yet, but it is provably unspendable in any case, so showing this info
579+
/// to the user can't hurt.
580+
fn taproot_is_unspendable_internal_key(&self) -> Result<Option<usize>, Error> {
581+
match &self.descriptor {
582+
Descriptor::Tr(tr) => {
583+
let (internal_key_index, _, _) = parse_wallet_policy_pk(tr.inner.internal_key())
584+
.map_err(|_| Error::InvalidInput)?;
585+
let internal_xpub = self
586+
.policy
587+
.keys
588+
.get(internal_key_index)
589+
.ok_or(Error::InvalidInput)?
590+
.xpub
591+
.as_ref()
592+
.ok_or(Error::InvalidInput)?;
593+
594+
// See
595+
// https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs:
596+
// > One example of such a point is H =
597+
// > lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is constructed
598+
// > by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X
599+
// > coordinate.
600+
const NUMS: [u8; 33] = [
601+
0x02, 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60,
602+
0x35, 0xe9, 0x7a, 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47,
603+
0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0,
604+
];
605+
606+
if internal_xpub.depth != [0u8; 1]
607+
|| internal_xpub.parent_fingerprint.as_slice() != [0u8; 4]
608+
|| internal_xpub.child_num != 0
609+
|| internal_xpub.public_key.as_slice() != NUMS
610+
{
611+
return Ok(None);
612+
}
613+
614+
let chain_code: [u8; 32] = {
615+
let mut hasher = Sha256::new();
616+
for pk in tr.inner.iter_scripts().flat_map(|(_, ms)| ms.iter_pk()) {
617+
let (key_index, _, _) =
618+
parse_wallet_policy_pk(&pk).map_err(|_| Error::InvalidInput)?;
619+
let key_info =
620+
self.policy.keys.get(key_index).ok_or(Error::InvalidInput)?;
621+
hasher.update(
622+
&key_info
623+
.xpub
624+
.as_ref()
625+
.ok_or(Error::InvalidInput)?
626+
.public_key,
627+
);
628+
}
629+
hasher.finalize().into()
630+
};
631+
if chain_code != internal_xpub.chain_code.as_slice() {
632+
return Ok(None);
633+
}
634+
Ok(Some(internal_key_index))
635+
}
636+
_ => Ok(None),
637+
}
638+
}
565639
}
566640

567641
/// Parses a policy as specified by 'Wallet policies': https://github.com/bitcoin/bips/pull/1389.
@@ -1499,4 +1573,48 @@ mod tests {
14991573
"6160dc5cf72b79380e9e715c75ae54573b81dcb4ed8ab2e90fde5d661e443781",
15001574
);
15011575
}
1576+
1577+
#[test]
1578+
fn test_tr_unspendable_internal_key() {
1579+
mock_unlocked_using_mnemonic(
1580+
"sudden tenant fault inject concert weather maid people chunk youth stumble grit",
1581+
"",
1582+
);
1583+
1584+
let k0 = pb::KeyOriginInfo {
1585+
root_fingerprint: vec![],
1586+
keypath: vec![],
1587+
xpub: Some(parse_xpub("tpubD6NzVbkrYhZ4WNrreqKvZr3qeJR7meg2BgaGP9upLkt7bp5SY6AAhY8vaN8ThfCjVcK6ZzE6kZbinszppNoGKvypeTmhyQ6uvUptXEXqknv").unwrap()),
1588+
};
1589+
let k1 = pb::KeyOriginInfo {
1590+
root_fingerprint: hex::decode("ffd63c8d").unwrap(),
1591+
keypath: vec![48 + HARDENED, 1 + HARDENED, 0 + HARDENED, 2 + HARDENED],
1592+
xpub: Some(parse_xpub("tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N").unwrap()),
1593+
};
1594+
let k2 = make_our_key(KEYPATH_ACCOUNT);
1595+
1596+
{
1597+
let policy_str = "tr(@0/<0;1>/*,{and_v(v:multi_a(1,@1/<2;3>/*,@2/<2;3>/*),older(2)),multi_a(2,@1/<0;1>/*,@2/<0;1>/*)})";
1598+
let policy = make_policy(policy_str, &[k0.clone(), k1.clone(), k2.clone()]);
1599+
let parsed_policy = parse(&policy, BtcCoin::Tbtc).unwrap();
1600+
assert_eq!(
1601+
parsed_policy.taproot_is_unspendable_internal_key(),
1602+
Ok(Some(0))
1603+
);
1604+
}
1605+
1606+
{
1607+
// Different order is allowed, BIP-388 merely says "should" enforce ordered keys, not
1608+
// "must".
1609+
// See https://github.com/bitcoin/bips/blob/master/bip-0388.mediawiki#additional-rules
1610+
let policy_str = "tr(@1/<0;1>/*,{and_v(v:multi_a(1,@0/<2;3>/*,@2/<2;3>/*),older(2)),multi_a(2,@0/<0;1>/*,@2/<0;1>/*)})";
1611+
1612+
let policy = make_policy(policy_str, &[k1.clone(), k0.clone(), k2.clone()]);
1613+
let parsed_policy = parse(&policy, BtcCoin::Tbtc).unwrap();
1614+
assert_eq!(
1615+
parsed_policy.taproot_is_unspendable_internal_key(),
1616+
Ok(Some(1))
1617+
);
1618+
}
1619+
}
15021620
}

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3297,6 +3297,103 @@ mod tests {
32973297
assert!(unsafe { !PREVTX_REQUESTED });
32983298
}
32993299

3300+
// Tests that unspendable internal Taproot keys are displayed as such.
3301+
#[test]
3302+
fn test_policy_tr_unspendable_internal_key() {
3303+
let transaction = alloc::rc::Rc::new(core::cell::RefCell::new(Transaction::new_policy()));
3304+
3305+
mock_host_responder(transaction.clone());
3306+
3307+
let policy_str = "tr(@0/<0;1>/*,{and_v(v:multi_a(1,@1/<2;3>/*,@2/<2;3>/*),older(2)),multi_a(2,@1/<0;1>/*,@2/<0;1>/*)})";
3308+
3309+
static mut UI_COUNTER: u32 = 0;
3310+
mock(Data {
3311+
ui_confirm_create: Some(Box::new(move |params| {
3312+
match unsafe {
3313+
UI_COUNTER += 1;
3314+
UI_COUNTER
3315+
} {
3316+
1 => {
3317+
assert_eq!(params.title, "Spend from");
3318+
assert_eq!(params.body, "BTC Testnet\npolicy with\n3 keys");
3319+
}
3320+
2 => {
3321+
assert_eq!(params.title, "Name");
3322+
assert_eq!(params.body, "test policy account name");
3323+
}
3324+
3 => {
3325+
assert_eq!(params.title, "");
3326+
assert_eq!(params.body, "Show policy\ndetails?");
3327+
}
3328+
4 => {
3329+
assert_eq!(params.title, "Policy");
3330+
assert_eq!(params.body, policy_str);
3331+
}
3332+
5 => {
3333+
assert_eq!(params.title, "Key 1/3");
3334+
assert_eq!(params.body, "Provably unspendable: tpubD6NzVbkrYhZ4WNrreqKvZr3qeJR7meg2BgaGP9upLkt7bp5SY6AAhY8vaN8ThfCjVcK6ZzE6kZbinszppNoGKvypeTmhyQ6uvUptXEXqknv");
3335+
}
3336+
6 => {
3337+
assert_eq!(params.title, "Key 2/3");
3338+
assert_eq!(params.body, "[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N");
3339+
}
3340+
7 => {
3341+
assert_eq!(params.title, "Key 3/3");
3342+
assert_eq!(params.body, "This device: [93531fa9/48'/1'/0'/3']tpubDEjJGD6BCCuA7VHrbk3gMeQ5HocbZ4eSQ121DcvCkC8xaeRFjyoJC9iVrSz1bWfNwAY5K2Vfz5bnHR3y4RrqVpkc5ikz4trfhSyosZPrcnk");
3343+
}
3344+
_ => {}
3345+
}
3346+
true
3347+
})),
3348+
ui_transaction_address_create: Some(Box::new(move |_amount, _address| true)),
3349+
ui_transaction_fee_create: Some(Box::new(|_total, _fee, _longtouch| true)),
3350+
..Default::default()
3351+
});
3352+
3353+
mock_unlocked_using_mnemonic(
3354+
"sudden tenant fault inject concert weather maid people chunk youth stumble grit",
3355+
"",
3356+
);
3357+
bitbox02::random::mock_reset();
3358+
// For the policy registration below.
3359+
mock_memory();
3360+
3361+
let keypath_account = &[48 + HARDENED, 1 + HARDENED, 0 + HARDENED, 3 + HARDENED];
3362+
3363+
let policy = pb::btc_script_config::Policy {
3364+
policy: policy_str.into(),
3365+
keys: vec![
3366+
pb::KeyOriginInfo {
3367+
root_fingerprint: vec![],
3368+
keypath: vec![],
3369+
xpub: Some(parse_xpub("tpubD6NzVbkrYhZ4WNrreqKvZr3qeJR7meg2BgaGP9upLkt7bp5SY6AAhY8vaN8ThfCjVcK6ZzE6kZbinszppNoGKvypeTmhyQ6uvUptXEXqknv").unwrap()),
3370+
},
3371+
pb::KeyOriginInfo {
3372+
root_fingerprint: hex::decode("ffd63c8d").unwrap(),
3373+
keypath: vec![48 + HARDENED, 1 + HARDENED, 0 + HARDENED, 2 + HARDENED],
3374+
xpub: Some(parse_xpub("tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N").unwrap()),
3375+
},
3376+
pb::KeyOriginInfo {
3377+
root_fingerprint: crate::keystore::root_fingerprint().unwrap(),
3378+
keypath: keypath_account.to_vec(),
3379+
xpub: Some(crate::keystore::get_xpub(keypath_account).unwrap().into()),
3380+
},
3381+
],
3382+
};
3383+
3384+
// Register policy.
3385+
let policy_hash = super::super::policies::get_hash(pb::BtcCoin::Tbtc, &policy).unwrap();
3386+
bitbox02::memory::multisig_set_by_hash(&policy_hash, "test policy account name").unwrap();
3387+
3388+
assert!(block_on(process(
3389+
&transaction
3390+
.borrow()
3391+
.init_request_policy(policy, keypath_account),
3392+
))
3393+
.is_ok());
3394+
assert!(unsafe { UI_COUNTER >= 7 });
3395+
}
3396+
33003397
/// Test that a policy with derivations other than `/**` work.
33013398
#[test]
33023399
fn test_policy_different_multipath_derivations() {

0 commit comments

Comments
 (0)