Skip to content

fix(bip137): align recid with the script type#866

Draft
qlrd wants to merge 3 commits into
selfcustody:developfrom
qlrd:fix/bip137
Draft

fix(bip137): align recid with the script type#866
qlrd wants to merge 3 commits into
selfcustody:developfrom
qlrd:fix/bip137

Conversation

@qlrd

@qlrd qlrd commented May 29, 2026

Copy link
Copy Markdown
Member

What is this PR for?

Minor bug fix.

Previously, the header byte could not reflect the script type's recid when using sparrow <2.5.x for sign messages on some script types (mostly p2sh-p2wpkh). Through some manual experiments with different versions of sparrow , a possibility that a header byte could mismatched to the script type causes verification to fail on those versions.

This commit add a check of a recid as well structure a bip137.py that is strict on context of sparrow 2.5.x and lenient for sparrow <2.5.x (similar what used in electrum clients), as well routes raw hashes to be not signed as bip137 but instead as der one.

Changes made to:

  • Code
  • Tests
  • Docs
  • CHANGELOG

Did you build the code and tested on device?

  • Yes, build and tested on Amigo / Sparrow / krux-file-signer:
    • Sparrow 2.3.1
    • Sparrow 2.4.2
    • Sparrow 2.5.2
    • krux file signer with and without sign [-u] flag

What is the purpose of this pull request?

  • Bug fix
  • New feature
  • Docs update
  • Other

Context

The bip322 will be added to a follow up so the diffs could be simplified

Steps to reproduce (WIP)

Context: built different versions of Sparrow (at least 2 with version <2.5.x) from source and made P2PKH, P2SH_P2WPKH and P2WPKH sign message procedures with a flashed firmware on device with a simple oi (hi on portuguese) on Sparrow on given address.

Below was noted some different behaviours for some different Sparrow's versions. Since used different mnemonics on testnet and testnet4 networks, i think any mnemonic here will be sufficient.

While reproduce expects these results below -- deselecting any mode on Sparrow (Standard(electrum), trezor(BIP137) and BIP322 -- when available):

Sparrow 2.3.1

  • P2PKH: raw / electrum / trezor modes -> all electrum after verify;
  • P2SH-P2PWPKH: raw / electrum / trezor modes -> all trezorBIP137 after verify;
  • P2PWPKH -> raw / electrum / trezor modes -> all electrum after verify;

Sparrow 2.4.2

  • P2PKH: raw / electrum / trezor modes -> all trezorBIP137 after verify;
  • P2SH-P2PWPKH: raw / electrum / trezor modes -> all trezorBIP137 after verify;
  • P2PWPKH -> raw / electrum / trezor modes -> all trezorBIP137 after verify;

Sparrow 2.5.2

  • P2PKH: raw / electrum / trezor modes -> all electrum after verify1;
  • P2SH-P2PWPKH: raw / electrum / trezor modes -> all trezorBIP137 after verify2;
  • P2PWPKH: raw / electrum / trezor modes -> all electrum after verify;

Krux file signer

Download and update to main branch; and run the following command:

uv run poe sign -f README.md # follow the procedures, krux will show `raw hash`.

Then the command below should produce:

uv run poe verify -f README.md -s README.md.sig -p pubkey.pem
Poe => python src/ksigner.py verify -f README.md -s README.md.sig -p pubkey.pem
[HH:MM:ss MM/DD/YY time] Verifying signature:
Signature Verified Successfully

Also need to verify with --uncompressed flag:

uv run poe sign -f README.md -u -s README.md.u.sig
uv run poe verify -f README.md -s README.md.u.sig -p pubkey.pem 
Poe => python src/ksigner.py verify -f README.md -s README.md.sig -p pubkey.pem
[HH:MM:ss MM/DD/YY time] Verifying signature:
Signature Verified Successfully

Note that the -u flag should produce different pubvkeys.

--

Footnotes

  1. Sparrow disables trezor(BIP137) and BIP322 for P2PKH (not disabled on previous versions)

  2. Sparrow disable BIP322 for P2SH_P2WPKH (not disabled on previous versions)

@qlrd qlrd changed the base branch from main to develop May 29, 2026 00:52
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.39%. Comparing base (1b311fa) to head (10d3c5d).

Files with missing lines Patch % Lines
src/krux/bip137.py 96.77% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #866      +/-   ##
===========================================
- Coverage    97.39%   97.39%   -0.01%     
===========================================
  Files           83       84       +1     
  Lines        10845    10882      +37     
===========================================
+ Hits         10562    10598      +36     
- Misses         283      284       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@qlrd qlrd changed the title Fix/bip137 fix(bip137): wallet script type bip137 header May 29, 2026
@qlrd qlrd force-pushed the fix/bip137 branch 3 times, most recently from e35223d to 4c95cf7 Compare May 29, 2026 13:09
@qlrd qlrd marked this pull request as ready for review May 29, 2026 13:10

@odudex odudex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header-byte fix is correct: single-sig P2SH-P2WPKH and P2WPKH wallets were
emitting legacy-message signatures with the P2PKH-compressed header byte (31-34)
instead of the BIP-137 script-type headers (35-38 / 39-42). Signing now routes
through bip137.sign, which preserves the recovery id and rebases the header on
the script type. Verified against the regenerated vectors:

  • bc1qgl5... (P2WPKH): 32 -> 40 (39 + recid) OK
  • 38CahkV... (P2SH-P2WPKH): 31 -> 35 (35 + recid) OK
  • tb1qynp... (P2WPKH tnet): 32 -> 40 (39 + recid) OK

sign_at emits 31 + recid, so recovery_id = raw_header - 31 is correct and
the re-encoding only swaps the script-type base. Tests pass (5 passed).

Blocking: taproot message signing shouldn't stop working

This PR makes single-sig taproot (m/86') message signing fail. From a user's
perspective this is a regression / introduced bug, not a tightening: taproot
signing worked before and Sparrow verified the result as valid.

Mechanism: get_script_type_from_path("m/86'/...") returns "p2tr", which is
passed to bip137.sign -> build_header. build_header has no p2tr branch, so
it raises ValueError, which the menu catches and shows as an error screen.

Why it really was working (so this is a real loss of function, not removal of a
broken path):

  • Krux signs the standard BIP-137 commitment with an ECDSA-recoverable
    signature using the taproot internal key.
  • Lenient verifiers (Sparrow / Electrum-style) recover the pubkey and
    reconstruct the address of the claimed type from it - for taproot they apply
    the taproot tweak (same as script.p2tr) and the recovered+tweaked key matches
    the bc1p... address. They effectively ignore the header's script-type bits.
  • So the signature genuinely proves control of the taproot key and verifies in
    practice. It's just not a formally specified scheme (BIP-137 has no taproot;
    BIP-322 is the eventual standard).

Required outcome: taproot message signing must keep working. Acceptable options:

  1. Keep the previous behavior - give build_header a p2tr branch that returns a
    valid recoverable header so Krux keeps emitting the Sparrow-verifiable
    signature (this is the no-regression path). The segwit header fix (#865) is
    independent and does not require dropping taproot.
  2. Implement proper BIP-322 signing for taproot.

What is NOT acceptable: shipping a version where taproot signing errors out.

Other required changes

  1. Misleading error message in build_header.
    "%s legacy sign not supported" implies the script type itself is
    unsupported. p2tr is a supported wallet type; the limitation is that BIP-137
    legacy message signing has no taproot scheme. If any reject path remains,
    reword to make that clear and point at BIP-322. Also drop the trailing space
    (currently shown verbatim as ValueError('p2tr legacy sign not supported ')).

  2. Add the MIT license header to src/krux/bip137.py.
    Every other src/krux/*.py file opens with the MIT block; this module starts
    straight at P2PKH_HEADER = 31.

  3. Dead module-level constants. P2PKH_HEADER / P2SH_P2WPKH_HEADER / P2WPKH_HEADER
    are defined but never used; build_header hardcodes the literals 31/35/39.
    Use the constants or drop them so they cannot drift.

  4. Unreachable assertion in test_sign_message_p2tr_bip137.
    The assert ctx.input.wait_for_button.call_count == len(btn_seq) sits after
    the raising call inside the with pytest.raises(...) block, so it never runs.
    Move it after the block or delete it. (This test should change anyway once
    taproot signing is restored.)

Suggested (non-blocking)

  1. Commit says "add test vectors for bip137 module" but there are no direct unit
    tests for bip137.py; coverage is only via the UI. The API-boundary guards in
    build_header / sign are never exercised. Add tests/test_bip137.py.

@qlrd qlrd marked this pull request as draft June 1, 2026 21:36
@qlrd qlrd force-pushed the fix/bip137 branch 3 times, most recently from 5d5b2eb to dec7eb1 Compare June 5, 2026 00:12
@odudex

odudex commented Jun 5, 2026

Copy link
Copy Markdown
Member

Why a menu is needed?
Krux should just show the message and sign, everything else should be under the hood.

@qlrd qlrd force-pushed the fix/bip137 branch 2 times, most recently from 590ba6a to da35069 Compare June 5, 2026 20:41
@odudex

odudex commented Jun 6, 2026

Copy link
Copy Markdown
Member

This needs to be simplified.
For example, there's no need for translated error messages. A simple, single generic raised error is enough.

@qlrd qlrd force-pushed the fix/bip137 branch 2 times, most recently from 2d7f727 to a8274da Compare June 6, 2026 22:13
@qlrd qlrd marked this pull request as ready for review June 8, 2026 13:06
@qlrd qlrd requested a review from odudex June 8, 2026 13:06
@qlrd qlrd changed the title fix(bip137): wallet script type bip137 header fix(bip137): add support to bip322 and fallback to bip137 Jun 8, 2026
@qlrd qlrd force-pushed the fix/bip137 branch 2 times, most recently from 372d0e0 to e00edad Compare June 8, 2026 13:11
@qlrd qlrd marked this pull request as draft June 8, 2026 15:23
@qlrd qlrd marked this pull request as ready for review June 8, 2026 16:40
Comment thread src/krux/bip137.py Outdated

def build_header(raw_sig, script_type):
"""Build header byte from raw signature and script_type"""
recid = raw_sig[0] - P2PKH_HEADER

@jdlcdl jdlcdl Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

un-settled thoughts: wouldn't we just left-fshift 6 bits (correction: byte0 & 3) so that we grab the 2 least significant bits as recId?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what i found, it need a "normalization" ((byte0 - 27) & 3 -- the p2pkh one), an this works, confirm?

@jdlcdl jdlcdl Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect I'm missing an important aspect of bip137, but I'll explain my understanding of at least the header byte, so that I can be corrected on my misunderstanding.

  1. header byte was set by some ecdsa library when the signature was created,
  2. we're just trying to dissect the header so that we know what type of address this is, without modifying the signature,
  3. the 2 least significant bits are the recId and we can have this from any sample_byte between 0-255 via sample_byte & 3 (0b00000011 and any number will result 0-3)
  4. if we were to subtract 27 from the header byte, which has the 2 least-significant bits high/on/one, then we'd be mucking with whatever recId was set by the ec library.

@qlrd qlrd Jun 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your concerns @jdlcdl (EDIT: i would be glad if i can be corrected on any misunderstanding of mine).

I did that from an interpretation between BitcoinJ sample on BIP137 and ecdsa_sign_recoverable.

From what i understood, on the signing side, embit gave a 65-byte recoverable signature with recId, internally -- (sig + bytes([0..3])).

After, we read that and packed the byte0 = 27 + recId + 4.

So, when raw_sig came with byte0 (27 + recId + 4) — which is why i did (header - 27) & 3 and asked for a review on that.

Comment thread tests/test_bip137.py Outdated
Comment thread tests/test_bip137.py Outdated
@qlrd qlrd force-pushed the fix/bip137 branch 3 times, most recently from 4f31abb to 556305b Compare June 21, 2026 15:14
@qlrd qlrd force-pushed the fix/bip137 branch 2 times, most recently from 2bc1f42 to 7b9c71f Compare June 23, 2026 23:30
@qlrd qlrd marked this pull request as ready for review June 23, 2026 23:37
@odudex

odudex commented Jun 24, 2026

Copy link
Copy Markdown
Member

PR is adding 1,659 lines. Can you make it more concise?

@qlrd

qlrd commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

PR is adding 1,659 lines. Can you make it more concise?

I will try here, thought in some ways but just started to evaluate it

@qlrd

qlrd commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

since majority of diffs are tests, IMO would be simpler to split PRs, with the first one as bip137 (this one, with all checks stated here -- (EDIT: in this thread) and the second as bip322, wdyt @odudex, @jdlcdl?

EDIT: Needed to update ksigner to verify with uncompressed keys: selfcustody/krux-file-signer#51

@qlrd qlrd marked this pull request as draft June 24, 2026 19:47
@qlrd qlrd changed the title fix(bip137): add support to bip322 and fallback to bip137 fix(bip137): add checks for recid on sign message Jul 1, 2026
@qlrd qlrd marked this pull request as ready for review July 2, 2026 18:16
@qlrd

qlrd commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

removed all bip322 as suggested before the diff gets big and added some battery of manual tests to reproduce

@qlrd qlrd changed the title fix(bip137): add checks for recid on sign message fix(bip137): align recid with the script type Jul 6, 2026
Copilot AI review requested due to automatic review settings July 6, 2026 19:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes BIP-137 message signing header construction so the recovery ID (“recid”) aligns with the script type (notably improving interoperability with older Sparrow versions), and updates message-signing flows to treat raw hashes differently from BIP-137 messages.

Changes:

  • Introduces a dedicated krux/bip137.py module with header construction and message commitment logic.
  • Updates Sign Message UI flows to sign BIP-137 messages via the new module, while routing raw-hash signing to DER signatures.
  • Adds/updates tests to cover strict vs lenient verification behavior and updated UI signature expectations; updates changelog entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_bip137.py Adds new unit tests for BIP-137 commitment/header behavior and strict/lenient verification expectations.
tests/pages/home_pages/test_sign_message_ui.py Updates UI test vectors to match new signature behavior and adjusts assertions.
src/krux/pages/home_pages/sign_message_ui.py Switches signing paths to use krux.bip137 for non-raw-hash messages and keeps DER signing for raw hashes.
src/krux/bip137.py New module implementing BIP-137 message commitment and header/recid handling.
CHANGELOG.md Notes the recid-related message-signing fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/pages/home_pages/test_sign_message_ui.py Outdated
Comment thread tests/test_bip137.py Outdated
Comment thread src/krux/bip137.py Outdated
Comment thread src/krux/bip137.py Outdated
Comment thread src/krux/bip137.py Outdated
Comment thread tests/pages/home_pages/test_sign_message_ui.py Outdated
@qlrd qlrd marked this pull request as draft July 6, 2026 19:59
@qlrd

qlrd commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Sorry for copilot messages, forgot to deactivate it

qlrd added 3 commits July 13, 2026 14:45
This commit add checks for signing message UI using recid following spec
from BIP137.
Added unit tests agains BIP137 spec and apply lenient agains strict
checks.
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.

4 participants