Skip to content

feat(taproot): BIP-340 signer, build wiring and official test vectors - #327

Merged
BitHighlander merged 2 commits into
developfrom
feature/taproot-bip340
Aug 3, 2026
Merged

feat(taproot): BIP-340 signer, build wiring and official test vectors#327
BitHighlander merged 2 commits into
developfrom
feature/taproot-bip340

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Jul 31, 2026

Copy link
Copy Markdown
Owner

First of three steps toward Taproot. Adds nothing to the signing path — this lands the crypto primitive and proves it against the spec, so the risky part is reviewable on its own and the ROM cost is known before committing to the rest.

Taproot is further along in this tree than the epic doc assumed

Piece State on develop
taproot column in coins.def true for Bitcoin and Testnet
bech32m segwit_addr.c:184 selects it for witness version > 0
Send to P2TR (PAYTOTAPROOT) ✅ script built at transaction.c:410, size accounted at :986
Proto (SPENDTAPROOT=5, PAYTOTAPROOT=6, taproot=28) ✅ in device-protocol
GetAddress for P2TR transaction.c:188 — explicit return 0
Spend from P2TR signing.c script_type checks never mention it
BIP-340 signer crypto/schnorr.c is Bitcoin ABC's BCH variant, and is not even in the build
BIP-341 sighash / BIP-86 tweak ❌ absent

So this is four things, not a greenfield.

This PR

Why hand-rolled instead of vendoring secp256k1-zkp

Trezor implements BIP-340 via vendored secp256k1-zkp + zkp_bip340.c — 20–40KB. The 7.15 line is 4–10KB from the ROM wall, so it does not fit. Building on the bn_* and scalar_multiply primitives already in the tree costs, measured with arm-none-eabi-gcc -Os -mcpu=cortex-m3 -mthumb:

   text    data     bss
   1274       0       0

Cost in shipped images from this PR is 0. cmake/caches/device.cmake builds with -ffunction-sections -fdata-sections -Wl,--gc-sections, and nothing references bip340 yet, so it garbage-collects out of every image. The 1274 bytes land in step 3, when the signing path references it — the ROM wall is not in play here.

The primitives are the ones already signing ECDSA with the same secrets, so this composes trusted code rather than introducing new field arithmetic. See the crypto PR for the structural diff against schnorr.c (it is smaller than the file it is derived from — the Jacobi symbol helper goes away).

Testing

unittests/crypto/bip340.cpp, 8/8 green locally against the real gtest harness:

  • byte-exact signatures for the 8 vectors with secret keys — BIP-340 signing is deterministic given aux_rand, so this is an equality check, not a round-trip
  • correct rejection of all 10 must-fail cases: pubkey off-curve, pubkey ≥ field size, has_even_y(R) false, negated message, negated s, sG - eP infinite with x(inf) as both 0 and 1, sig[0:32] not an x-coordinate, sig[0:32] = field size, sig[32:64] = curve order
  • x-only pubkey derivation matches for every vector with a secret key
  • out-of-range private keys (0 and n) rejected, with the output buffer pre-filled 0xFF so the zero-on-failure assertion proves something
  • ZeroSTakesTheSpecPath pins the absence of an s == 0 guard: s == 0 is in range per BIP-340 and must reject on the x-coordinate comparison after computing R = -eP, not bail out early
  • the tagged-hash double-tag construction pinned independently

Vectors are copied verbatim from bitcoin/bips and were diffed against the CSV mechanically rather than by eye — which caught a dropped nibble and a short message literal.

Next

  1. BIP-86 output key tweak + SPENDTAPROOT in compute_address — removes the return 0 at transaction.c:188, unblocks GetAddress for bc1p. Note segwit_addr_encode() only length-checks the witness program for v0 (segwit_addr.c:182), so a wrong-length taproot key would encode into a plausible-looking bc1p — pass exactly 32 bytes.
  2. BIP-341 sighash + the SPENDTAPROOT signing path + confirm UX — this is the one that needs Gate-3 OLED proof and an emulator smoke. Budget for latency: BIP-340 signing is 4 EC scalar multiplications because the self-verify roughly doubles it.

Merge order

Depends on keepkey/trezor-firmware#5. That merges first, then the submodule pin here gets re-pointed at its merge commit. As it stands the pin points at the branch head (reachable from origin/feature/bip340, so CI can fetch it).

First of three steps toward Taproot support.  Adds nothing to the signing
path yet -- this lands the primitive and proves it against the spec.

  - pin deps/crypto/trezor-firmware at the BIP-340 implementation
  - compile bip340.c into trezorcrypto
  - unittests/crypto/bip340.cpp: all 19 official BIP-340 vectors

Byte-exact signatures for the 8 vectors with secret keys, correct
rejection of all 10 must-fail cases (pubkey off-curve, pubkey >= field
size, has_even_y(R) false, sG - eP infinite with x(inf) as both 0 and 1,
sig[0:32] = field size, sig[32:64] = curve order), plus out-of-range
private keys and a check that the output buffer is zeroed on failure.

ROM cost measured with arm-none-eabi-gcc -Os -mcpu=cortex-m3 -mthumb:
1278 bytes of .text, no data, no bss.

Taproot is already further along in this tree than it looks: coins.def
carries taproot=true for Bitcoin and Testnet, segwit_addr.c selects
bech32m for witness versions above 0, and PAYTOTAPROOT outputs already
build and size correctly.  What is missing is the input side, which the
next two steps cover:

  2. BIP-86 output key tweak + SPENDTAPROOT in compute_address, which
     removes the `return 0` at transaction.c:188 and unblocks GetAddress
     for bc1p
  3. BIP-341 sighash + the SPENDTAPROOT signing path + confirm UX

Depends on keepkey/trezor-firmware#5; the submodule pin points at that
branch and needs re-pointing at its merge commit before this lands.
The buffer-zeroing assertion in SignRejectsOutOfRangeKeys passed whether or
not bip340_sign() cleared the output, because sig started zero-initialised.
Pre-fill with 0xFF so it proves something.

Adds two cases:

  - XOnlyPubkeyZeroesOnFailure, covering the matching contract now that
    bip340_get_xonly_pubkey() zeroes on failure too
  - ZeroSTakesTheSpecPath, pinning the ABSENCE of an s == 0 guard.  s == 0
    is in range per BIP-340 and must reject on the x-coordinate comparison
    after computing R = -eP, not bail out early.

Bumps the crypto pin to pick up the guard removal and the restored Bitcoin
ABC copyright notice.

8/8 green.
@BitHighlander
BitHighlander merged commit 958b41f into develop Aug 3, 2026
30 checks passed
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