Skip to content

fix(dbtc): fence every door that mints a publicly-derivable Bitcoin output - #753

Merged
cryptskii merged 1 commit into
mainfrom
fix/dbtc-public-witness-fence
Aug 31, 2026
Merged

fix(dbtc): fence every door that mints a publicly-derivable Bitcoin output#753
cryptskii merged 1 commit into
mainfrom
fix/dbtc-public-witness-fence

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

PR A of the dBTC tap redesign: fence the doors that manufacture a Bitcoin
output behind a publicly-derivable key, and pin the defect with tests that go
red if it is ever restored. No migration path, no compatibility path, no
override.

The defect, reproduced

dsm_sdk/tests/dbtc_public_data_attack.rs derives a valid secp256k1 private
key controlling a tap's HTLC claim branch from public data alone
— and proves
it is the key the funded script actually commits to:

DBTC_POLICY_COMMIT      compile-time constant in this open-source tree
  -> manifold_seed      BLAKE3(TAG_DSM_MANIFOLD_SEED, policy_commit)
                        manifold_seeds.rs: "pure math ... no per-device
                        randomness ... every device computes the same seed"
  -> deposit_nonce      PUBLISHED in the vault advertisement (proto field 17,
                        and validation REJECTS an empty one)
  -> eta / preimage / claim_privkey
  -> witness [sig, preimage, TRUE, redeemScript]      SWEEPS THE UTXO

BitcoinTapSdk::derive_preimage_from_deposit_nonce is pub, takes only those
two public values, and its own doc advertises it as the path for nonces read
from storage-node advertisements. Test B pins the consequence: the depositor
and a stranger with no wallet, device or relationship derive the identical
spend authority.

This is not an implementation slip — it follows from the old formal
definitions. A future bearer redeems, the depositor is absent, the witness is
never transferred or stored, and it is reconstructed from vault/policy bytes.
The only object satisfying all of those at once is a witness made of public
data.

Three doors, not two

The plan named two. A reachability sweep of every on-chain broadcast site found
a third, and it is a genuine new-exposure door:

bitcoin.deposit.initiate            open_tap() mints the tap
                                    (its ONLY production caller)
bitcoin.deposit.fund_and_broadcast  puts BTC behind it
bitcoin.fractional.exit             pour_partial() pays the remainder into a
                                    SUCCESSOR P2WSH and broadcasts it

The successor draws a fresh deposit_nonce from OsRng, which looks like it
fixes the problem and does not: the advertisement publishes that nonce and
bitcoin_tap_sdk.rs:3462 refuses an empty one, so the successor claim key is a
function of public data again. A partial exit is therefore also a manufacturing
door — it re-locks value behind the same defect.

All three refuse independently. A fence on tap creation is not a fence on
funding, and neither is a fence on successor rotation.

What stays open, deliberately

Exit-direction routes move value OUT of a vulnerable tap and are left alone, so
nothing is stranded: bitcoin.full.sweep (verified to create no successor),
bitcoin.sweep.recover, bitcoin.claim.auto, bitcoin.deposit.refund. The
fenced partial exit names bitcoin.full.sweep in its refusal so the exit is
discoverable. exit_direction_routes_are_not_fenced asserts each of those four
does NOT emit the fence token.

Disposition of already-funded taps stays an owner decision — this cut does not
make it, it only keeps it possible.

bitcoin.tx.broadcast is a generic raw-transaction relay and is NOT fenced:
sending BTC to an address is something any wallet can do, and the property at
issue is DSM manufacturing and advertising the vulnerable output.

Mutation controls — three, each red by performing the forbidden action

Each fence removed individually. Every mutation turns exactly its own door's
test red while the other two stay green, which is what proves independence:

initiate removed        entered tap creation; failed at the vault layer's
                        intended_recipient rule
fund_and_broadcast      reached the vault lookup ("deposit not found") — the
  removed               exact fall-through the assertion forbids
fractional.exit         reached the NETWORK, fetching the vault advertisement
  removed               from storage nodes

the_fence_precedes_argument_validation pins the ordering from the other side:
with empty args an unfenced route reports a decode failure, so the fence must
win instead. Restored ⇒ 5/5 green.

Test A is the standing mutation control for the whole redesign: restore any
construction where the tap key is a function of public data and it goes red by
producing a spendable key.

Verification (exact gate commands, pinned 1.98.0)

workspace  --release   74 suites, 3941 passed / 0 failed
node       --release   11 suites, 270 passed / 0 failed, exit 0
make lint              exit 0
production safety      exit 0

…utput

The shipped tap derives its Bitcoin spend authority from public data: the
manifold seed is BLAKE3 over a compile-time policy commit ("pure math ... no
per-device randomness"), and the deposit nonce is published in the vault
advertisement. Anyone who can read an unauthenticated storage node can
reconstruct the claim private key and sweep the tap.

dbtc_public_data_attack.rs reproduces it end to end, deriving a valid
secp256k1 key and proving the funded script commits to it. Test A is the
standing mutation control for the redesign.

Three doors manufacture such an output and all three now refuse
independently. The third was found by a broadcast-site sweep and is not in
the original write-up: fractional exit's pour_partial() re-locks the
remainder into a successor P2WSH. Its fresh OsRng deposit nonce fixes
nothing, because the advertisement publishes it and rejects an empty one.

Exit-direction routes stay open so already-funded taps are not stranded, and
the fenced partial exit names bitcoin.full.sweep in its refusal. Disposition
of funded taps remains an owner decision.

Each fence was removed individually: every mutation turns only its own door's
test red, by reaching real work — tap creation, the vault lookup, and in the
fractional case the network.
@cryptskii
cryptskii merged commit 132aede into main Aug 31, 2026
18 checks passed
@cryptskii
cryptskii deleted the fix/dbtc-public-witness-fence branch August 31, 2026 03:47
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

Successfully merging this pull request may close these issues.

1 participant