Skip to content

fix(taproot): send the witness and tx footer to the host - #337

Merged
BitHighlander merged 3 commits into
developfrom
fix/taproot-witness-presence-flag
Aug 4, 2026
Merged

fix(taproot): send the witness and tx footer to the host#337
BitHighlander merged 3 commits into
developfrom
fix/taproot-witness-presence-flag

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

The bug

signing_sign_segwit_input()'s taproot branch signs with bip340_sign() directly instead of signing_sign_hash(). That helper is where every other input type sets its nanopb presence flags:

static bool signing_sign_hash(...) {
  resp.serialized.has_signature_index = true;
  resp.serialized.signature_index = idx1;
  resp.serialized.has_signature = true;
  resp.serialized.has_serialized_tx = true;   // <-- the taproot branch never set this

signing_txack() memsets resp on every message, so nothing carried it over. nanopb therefore omitted serialized_tx from the wire entirely, and the host lost the 66-byte witness (01 40 <64-byte schnorr sig>) plus the 4-byte locktime footer appended after it — 70 bytes.

The result declares the segwit marker/flag but carries no witness stack and no locktime. It is unparseable and every node rejects it.

Reproduced on hardware

A P2TR spend on rc25 (740e65b5, KeepKeyZcash) returned a valid Schnorr signature alongside an 83-byte serialized transaction:

0100000000010111111111...11110000000000fdffffff01905f01000000000019
76a914759d6677091e973b9e9d99f19c68fbf43e3f05f988ac
^version ^marker/flag        ... ends after the output. No witness. No locktime.

This is not cosmetic: the vault broadcasts serializedTx verbatim (txbuilder/index.ts:824,828), so a real taproot spend would have pushed a truncated transaction to the network.

Why CI missed it

signature and serialized_tx are separate nanopb fields with independent presence flags, and signature was populated correctly the whole time. Every taproot test read only that field:

test assertion
test_send_p2tr (signatures, _) — discarded serialized
test_send_mixed_p2tr_and_legacy (signatures, _) — discarded serialized
test_send_p2tr_with_change assertIn(EXPECTED_CHANGE_SCRIPT, serialized) — a phase-1 output byte, transmitted well before any witness, so it survives a truncated suffix

The fix

One line, plus the comment explaining why the branch is special.

Test coverage (python-keepkey test/taproot-serialized-tx-coverage)

Following Trezor upstream, which funnels every success-path taproot test through assert_tx_matches()CTransaction.deserialize(), this adds assertCompleteSegwitTx(): a strict BIP-144 parse that must consume exactly len(raw). A segwit marker promises witness data, so a dropped witness now runs the stream off the end instead of passing unnoticed. It returns per-input witness stacks, letting the tests assert a key-path spend carries exactly one 64-byte element and a legacy input still serializes its empty 0x00 witness.

Each test also pins the full serialization. Goldens were captured from a fixed-firmware emulator run and independently rederived from the inputs and the existing EXPECTED_* witnesses; both agree byte-for-byte.

Verified both directions:

  • fixed firmware — 9 passed across all three taproot test files
  • mutation (truncate to 83 bytes, i.e. the shipped bug) — AssertionError: tx truncated at offset 83: wanted 1 more byte(s) of 83 total

Report gate

validate_junit() accepted skip as a waiver. Correct for build-flag-gated features; wrong for a capability the build claims to have. Every taproot test opens with requires_taproot(), so had that capability regressed, all six would have skipped and the PDF would still have certified a green run.

MUST_RUN_MODULES makes a skip in those modules a skipped-but-required failure. Verified: taproot passing validates clean; taproot skipping yields six failures (B21–B26) where it previously reported success. B21/B22/B23 prose now states what is actually proven.

Same-class findings (not fixed here)

The bug class is a hand-rolled branch duplicating a shared helper and forgetting one of its side effects. A sweep found more, filed for follow-up rather than widening this PR:

  1. The footer append is a second unguarded consumer — the if (idx1 == inputs_count - 1) block extends serialized_tx.size without setting the flag, inheriting whatever the branch above decided. This is why the locktime vanished with the witness, and the next witness type added reproduces this identically.
  2. BIP-341 accumulator init/use asymmetry — the SHA256 contexts are sha256_Init'd only inside the else of if (overwintered), but updated/finalised under coin->has_taproot && coin->taproot alone, and overwintered has no coin-family check. SignTx(coin_name="Bitcoin", overwintered=true) with a SPENDTAPROOT input would sign over uninitialised hash state. Inferred, not demonstrated — the signature would not verify on chain, but state crosses transactions.
  3. coins.c account_prefix() has no m/86' case — degrades the BIP-86 confirm label to a raw path.
  4. fsm_msgGetPublicKey has no SPENDTAPROOT branch — BIP-86 account xpub requests hard-fail. Reads as a gap, not policy. Worked around host-side in hdwallet (keepkey/hdwallet fix/taproot-account-xpub-wire-type); worth deciding explicitly and pinning with a test.

Verification

  • clang-format 20.1.8 (CI's pinned version): file unchanged
  • emulator built from this branch; 9/9 taproot tests pass
  • mutation-checked in an ephemeral container

signing_sign_segwit_input()'s taproot branch signs with bip340_sign()
directly instead of signing_sign_hash(), which is where every other
input type sets resp.serialized.has_serialized_tx. signing_txack()
memsets resp on every message, so the flag never carried over and
nanopb omitted serialized_tx from the wire entirely.

The host therefore lost the 66-byte witness (01 40 <64-byte schnorr
sig>) and the 4-byte locktime footer appended after it -- 70 bytes --
leaving a transaction that declares the segwit marker/flag but carries
no witness stack and no locktime. Reproduced on hardware: a P2TR spend
returned a valid signature alongside an 83-byte unparseable serialized
tx, which the host would have broadcast verbatim.

The signature field was unaffected, which is why the existing taproot
tests passed: they assert signatures and discard the serialized tx.
Picks up the BIP-144 completeness assertions that catch this bug and the
MUST_RUN_MODULES report gate, so CI exercises both.
…s fixes

Eight emulator captures on the abandon test seed covering both defects rc25
shipped: the BIP-86 and P2WSH address screens now rendering complete rather
than cut at 42 characters, and the confirm screens for the three taproot
signing flows, showing the amount/destination/fee are unaffected by the
serialization fix.

Emulator captures do not satisfy Gate-3 on their own -- the README records
what is still owed on device.
@BitHighlander

Copy link
Copy Markdown
Owner Author

Gate-3 evidence added: docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/ — eight emulator OLED frames on the abandon test seed, with a manifest carrying per-frame sha256.

The address frame is the one worth looking at. rc25 rendered m/86'/0'/0'/0/0 cut at 42 characters while the QR beside it encoded all 62, so the two disagreed with nothing to indicate it. It now shows the complete published BIP-86 value bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr across two lines. 02 shows the same defect fixed for P2WSH multisig, which predates taproot.

Frames 03-08 are the confirm screens for the three signing flows, establishing that amount, destination and fee are unchanged by the serialization fix.

These are emulator captures and do not satisfy Gate-3 on their own; the README records what is still owed on device (compare a bc1p receive address against its QR character-for-character, and press through a P2TR spend).

@BitHighlander
BitHighlander merged commit 484892e into develop Aug 4, 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