~/Randomizing unspendable keys in taproot descriptors$
Why using a fixed NUMS key for taproot descriptors leaks privacy, and how to randomize it.
This post is a follow-up to Adding taproot descriptor compilation to bdk-cli.
The problem with a fixed NUMS key
In the previous post we used the BIP-341 NUMS point as the internal key:
tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{pk(A),pk(B)})
This works - the key path is unspendable, funds can only move through the script tree. But every bdk-cli user gets the exact same internal key. An on-chain observer can scan for this well-known point and immediately learn:
- The key path is intentionally disabled (script-path-only spending)
- The output was created by one of many tools that follow the BIP-341 recommendation for unspendable keys
- The user is using a specific wallet construction pattern
A fixed NUMS key acts as a fingerprint. These outputs become distinguishable from taproot outputs that use real key-path spending, which defeats the privacy benefit of taproot making all outputs look the same.
Randomization approach
Instead of using H directly, we compute H + rG where:
H- the BIP-341 NUMS point (no known discrete logarithm)r- a freshly generated random scalarG- the secp256k1 generator point
Each compilation produces a unique internal key. Since the discrete log of H is unknown, H + rG is equally unspendable for any r - you'd need to know the discrete log of H to spend via the key path.
The r value is returned alongside the descriptor so the user can later verify the key is indeed derived from the NUMS point (by recomputing H + rG and checking it matches the internal key).
Implementation
let secp = Secp256k1::new();
let r_secret = SecretKey::new(&mut rand::thread_rng());
let nums_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)?;
let nums_point = PublicKey::from_x_only_public_key(nums_key, Parity::Even);
let internal_key_point = nums_point
.add_exp_tweak(&secp, &Scalar::from(r_secret))?;
let (xonly_internal_key, _) = internal_key_point.x_only_public_key();
add_exp_tweak computes P + scalar*G in one step - cleaner than generating r*G as a separate public key and combining two points.
The output now includes r:
% bdk-cli compile "or(pk(A),pk(B))" -t tr
{
"descriptor": "tr(2dd09dd0...,{pk(A),pk(B)})#anvu48aj",
"r": "275a5882...380cdcfd"
}
Each invocation gives a different descriptor and r. Non-taproot types are unchanged.
Well, bdk-cli can't verify r out of the box just yet - but I'm working on it, stay tuned.
The PR: bitcoindevkit/bdk-cli#225.
Links
- BIP-341: Taproot
- Issue #218 - randomized NUMS key request
- PR #225 - implementation
- PR #208 - previous: taproot descriptor compilation