Skip to content

fix(signing): reject BIP86 change output with a non-taproot script type - #333

Merged
BitHighlander merged 2 commits into
developfrom
fix/taproot-change-script-type
Aug 3, 2026
Merged

fix(signing): reject BIP86 change output with a non-taproot script type#333
BitHighlander merged 2 commits into
developfrom
fix/taproot-change-script-type

Conversation

@BitHighlander

Copy link
Copy Markdown
Owner

Problem

isCrossAccountSegwitChangeForbidden() in lib/firmware/signing.c enforces that a change output's declared script type agrees with the BIP32 purpose it is derived under. It has arms for 44', 49' and 84'purpose 86' was never added when taproot support landed.

So a change output at m/86'/0'/0'/1/0 declaring PAYTOADDRESS:

  1. isCrossAccountSegwitChangeForbidden() — no 86' arm → false
  2. isCrossAccountSegwitChangeAllowed()86' is not in {44,49,84}false
  3. falls through to the generic check in check_change_bip32_path() — same path length, same account prefix, change chain 1, in-range index → true

is_change_output_script_type() accepts PAYTOADDRESS, so the output is treated as change: its confirmation screen is suppressed, while transaction.c serializes it as P2PKH.

Impact

A malicious host can route the entire change amount into a P2PKH script derived under a BIP86 path, with nothing shown on the display. The funds remain under the device's seed, but no standard wallet scans P2PKH under m/86', so they are effectively unrecoverable without hand-deriving that path. Not theft — silent, non-discoverable change.

Reachable in practice on release/7.15.0-rc23, which has the taproot input side (#330). develop has the same latent hole in the predicate but no SPENDTAPROOT yet, so this lands ahead of the merge-up rather than behind it.

Fix

One arm, mirroring the three that already exist:

if (out_purpose == (0x80000000 | 86) &&
    rhs_script_type != OutputScriptType_PAYTOTAPROOT)
  return true;

Rejecting here does not fail the signing session — the output simply stops being treated as change and gets confirmed on screen like any other recipient. The reverse direction (44'/49'/84' claiming PAYTOTAPROOT) was already covered by the existing arms; this closes the one-sided hole.

isCrossAccountSegwitChangeForbidden loses static and gains a prototype in signing.h so the predicate can be unit-tested directly — it is pure, touching neither coin nor the signing statics.

Deliberately not changed

isCrossAccountSegwitChangeAllowed() still omits 86'. Mixing an 84' input with 86' change currently fails the account-prefix memcmp and gets confirmed on screen — conservative and correct. Widening that set is a feature, not part of this fix.

Verification

unittests/firmware/signing.cpp pins both directions of the rule.

  • With the fix, on this branch: 391 tests from 33 test cases ran. [ PASSED ] 391 tests.
  • With the new arm removed, the regression test fails on all three assertions and make xunit aborts with Error 2. The test is not vacuous.

Also re-run on release/7.15.0-rc23 with the same patch: 392 tests ... [ PASSED ] 392, no regressions.

Gate 3: no OLED change to capture in the fixed direction — the fix adds a standard output-confirmation screen for a case that previously rendered nothing. Happy to capture the before/after frames if you want it on the record.

isCrossAccountSegwitChangeForbidden() enforced purpose/script-type
agreement for BIP44, BIP49 and BIP84 change paths, but purpose 86' was
never added when taproot support landed. A change output at m/86'/.../1/i
declaring PAYTOADDRESS therefore skipped both mixed-mode guards and fell
through to the generic path check in check_change_bip32_path(), which
accepted it: same length, same account prefix, change chain, in-range
index.

The output was consequently treated as change, so its confirmation
screen was suppressed, while transaction.c serialized it as P2PKH. A
malicious host could route the entire change amount into a script that
no BIP86 wallet scans for. The funds stay under the device's seed, but
they are invisible to normal recovery until the path is derived by hand,
and nothing was shown on the display.

The reverse direction (44'/49'/84' claiming PAYTOTAPROOT) was already
covered by the existing arms; this closes the one-sided hole. Rejecting
here does not fail the signing session -- the output simply stops being
treated as change and gets confirmed on screen like any other recipient.

unittests/firmware/signing.cpp pins both directions. Verified failing
without the new arm (make xunit -> Error 2) and passing with it.
@BitHighlander
BitHighlander merged commit 7b16796 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