Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow different derivation paths for different key placeholders of a TapRoot descriptor with scripts #267

Closed
crypto7world opened this issue Jul 8, 2024 · 6 comments

Comments

@crypto7world
Copy link

crypto7world commented Jul 8, 2024

Linked to #268

Context

I'm developing a Taproot Bitcoin wallet designed to allow on-chain backup access / inheritance of bitcoins. Kind of what Liana does, but in a full Taproot manner.

A possible descriptor of the wallet at some point in time could be this one (this is the external descriptor):

tr([44990794/86'/1'/6']tpubDDpFTt9TRJhoEfbwqVPcRBqKFUvCaYWShm9Ga1Kpefg4XWU8Pe2V12S2LYGQgLThAsJCMb4GNJggLApWdptZhX5DcMtQw1XiToM2RC2E2ML/0/*,and_v(v:pk([99ccb69a/86'/1'/1751476594'/0/0]02ee39732e7f49cf4c9bd9b3faec01ed6f62a668fef33fbec0f2708e4cebf5bc9b),and_v(v:older(8640),after(1751450400))))

It allows a key-path spend at any time from the owner keys under [44990794/86'/1'/6'] and after a fixed date (and if the UTXO is older than 8640 blocks or ~60 days) it allows spending using a backup key [99ccb69a/86'/1'/1751476594'/0/0].

As per the wallet.md document specification, I should be able to register this policy:

tr([44990794/86'/1'/6']tpubDDpFTt9TRJhoEfbwqVPcRBqKFUvCaYWShm9Ga1Kpefg4XWU8Pe2V12S2LYGQgLThAsJCMb4GNJggLApWdptZhX5DcMtQw1XiToM2RC2E2ML/**,and_v(v:pk([99ccb69a/86'/1'/1751476594'/0/0]02ee39732e7f49cf4c9bd9b3faec01ed6f62a668fef33fbec0f2708e4cebf5bc9b),and_v(v:older(8640),after(1751450400))))

Unfortunately there is currently an "implementation specific restriction" that prevent the use of compressed public keys in the policy.

Therefore, I tested using a policy with the backup key replaced by a corresponding xpub (even if it does not make much sense for my use-case):

tr([44990794/86'/1'/6']tpubDDpFTt9TRJhoEfbwqVPcRBqKFUvCaYWShm9Ga1Kpefg4XWU8Pe2V12S2LYGQgLThAsJCMb4GNJggLApWdptZhX5DcMtQw1XiToM2RC2E2ML/**,and_v(v:pk([99ccb69a/86'/1'/1751476594']tpubDC7qnUYnPu5wCdcRRDSMZnrowSqtwT9zpu9tuyUHnS9gbDnh9RJuDm2a37VwdVua86R8afJ9VzsGpFqKeD5a6APGr6qyvrtiAdztNym1jXN/**),and_v(v:older(8640),after(1751450400))))

I was able to register this policy with my Ledger Nano X, but then I discovered the bug.

Problem

It seems that the app-bitcoin-new (at least on testnet) is always using the same derivation steps for all the keys of the policy when it computes the Merkle hash of the taptree.

Returning to my example where the real policy is using [99ccb69a/86'/1'/1751476594'/0/0] as the backup key, the Ledger is only able to sign a PSBT (key spending path) with an input coming out of the Taproot address corresponding to [44990794/86'/1'/6'/0/0].
I could confirm that, if I change the policy to use [99ccb69a/86'/1'/1751476594'/0/1] as the backup key, then the Ledger can only sign a PSBT with an input coming out of [44990794/86'/1'/6'/0/1].

Further, I think we can see in
sign_psbt.c that the taptree hash is computed with a single is_change and a single address_index provided as argument in a wallet_derivation_info_t structure.

Expected behavior

When computing the Merkle root of the Taptree, the app should not assume that the derivation steps of every keys are the same. It put an unexpected obligation on a wallet software to always take care to derive addresses by "aligning" all the derivation steps for every xpub of the policy. It is not necessary, nor realistic in usecases outside of a multisig wallet (e.g. my usecase or Liana usecase).
The PSBT provide the tap_key_origins with each key derivation steps and should be taken into account.

Final note

To be fair, using the exact same derivation steps for all xpubs of a policy is a fair assumption even if it is not strictly necessary. The real problem here is that for inheritance/backup usecase, we don't want to use xpubs in the Tapscripts. Which brings me to open another issue.

@bigspider
Copy link
Collaborator

bigspider commented Jul 8, 2024

Hi, thanks for your comments!

You can read BIP-0388 for more precise info and more context on why the restrictions are there. I should indeed update the docs in this repo.

The limitation on the derivation steps is also true for BIP-380 descriptors, not just for wallet policies. That is, if you have:

tr(A/<0;1>/*, pk(B/<0;1>/*))

you can't allow things like tr(A/0/3, pk(B/1/7)), otherwise you have a combinatorial explosions of possible addresses that is impossible to scan for. Descriptors guarantee that you have only one (or a few) lists of possible addresses (typically, 1 list of receive addresses, and one list of change addresses), so software wallets can linearly scan these lists.

BIP-388 further restricts the derivations in order to avoid trivial fingerprinting. If a descriptor like

tr(A, pk(B/<0;1>/*))

was to be allowed, then all the addresses derived from this wallet/account would have the same pubkey for the keypath. That would be revealed when the script is spent, allowing an external observer to trivially link all the outputs on-chain as belonging to the same wallet as soon as they are spent.

Public keys are cheap, you can use a backup xpub from which the actual keys are derived, avoiding the fingerprinting issues. While the app doesn't enforce it, it is also recommended to use hardened derivations for the root xpubs used in the wallet policy.

@crypto7world
Copy link
Author

Ok thank you for your answer and your time, I understand the need of a single derivation path from every account-level xpubs, it makes sense.

I'm sorry to have open another issue, but can you answer my final question in the other issue about the roadmap for support of compressed pub keys and x-only pub keys in the tapscripts?

To be clear, I'm not referring to using something like tr(A, pk(B/<0;1>/*)) but rather like tr(A/<0;1>/*, pk(B)) with B being a single public key. I don't think it poses any security or privacy issue (but I would be happy to be corrected if I'm wrong again ^^).

@bigspider
Copy link
Collaborator

To be clear, I'm not referring to using something like tr(A, pk(B/<0;1>/*)) but rather like tr(A/<0;1>/*, pk(B)) with B being a single public key. I don't think it poses any security or privacy issue (but I would be happy to be corrected if I'm wrong again ^^).

You have the same problem: if you receive two separate UTXOs at tr(A/0/1, pk(B)) and tr(A/1/2, pk(B)), and later you spend both of them with the recovery path using B, they are both using the same pubkey B. Using tr(A/0/1, pk(B/0/1)) and tr(A/1/2, pk(B/1/2)) doesn't have this issue: as long as the pubkey B is kept private, all pubkeys derived from B look random to external observers.

@crypto7world
Copy link
Author

Granted, but in my backup/inheritance usecase the script paths are never used unless I'm dead or in another situation when I cannot use the main wallet (derived from A) anymore. Also, the script-path are not just pk(B), but and_v(v:pk(B),and_v(v:older(8640),after(1751450400))) which provide a somewhat clear signature when they are revealed anyway, due to the fact they share the same structure and timelock (that's a design choice that I can argue if you are interested).
On the other hand, I feel it's easier to obtain a single pub key from each of my heirs than an entire account xpub, but I admit that's not a technical limitation, rather a sentiment.

Can you tell me if Ledger plan to support using compressed pub keys and x-only pub keys in Tapscript at some point, and the time-scale? The documentation is really not clear whereas the "Implementation Specific Restrictions" (wallet.md) are temporary or definitive. I just want to be able correctly plan my own next steps, that's all.

@bigspider
Copy link
Collaborator

It is still unclear to me what would prevent you from using an xpub for B and use the appropriate derivations like all the other keys, instead of a single public key for all the UTXOs?

There is no plan to relax the wallet policy constraints, as that would weaken their security/privacy properties for existing use cases.

There is certainly an interest in generalizing the signing flow to allow for use cases not supported in the wallet policy model (see for example #210), but nothing conclusive at this point: the design space it's big, and it's not clear exactly what those use cases are, and how they should be supported.

@crypto7world
Copy link
Author

Thank you for your answer and your time, I will close the issue.

Regarding your inquiry, nothing prevents me from adjusting my design. It's just a lot of troubles to change code, docs, etc... and there is little value to do it for my particular use-case besides complying with the extra-limitations Ledger put on top of their own formal definition of a wallet policy. I'm a little frustrated by that, that's all.
That being said, it is feasible and it's not a regression so I suppose I will just get to work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants