fix(bip137): align recid with the script type#866
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
e35223d to
4c95cf7
Compare
odudex
left a comment
There was a problem hiding this comment.
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:
- 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. - Implement proper BIP-322 signing for taproot.
What is NOT acceptable: shipping a version where taproot signing errors out.
Other required changes
-
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 asValueError('p2tr legacy sign not supported ')). -
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 atP2PKH_HEADER = 31. -
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. -
Unreachable assertion in test_sign_message_p2tr_bip137.
Theassert ctx.input.wait_for_button.call_count == len(btn_seq)sits after
the raising call inside thewith 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)
- 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.
5d5b2eb to
dec7eb1
Compare
|
Why a menu is needed? |
590ba6a to
da35069
Compare
|
This needs to be simplified. |
2d7f727 to
a8274da
Compare
372d0e0 to
e00edad
Compare
|
|
||
| def build_header(raw_sig, script_type): | ||
| """Build header byte from raw signature and script_type""" | ||
| recid = raw_sig[0] - P2PKH_HEADER |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
from what i found, it need a "normalization" ((byte0 - 27) & 3 -- the p2pkh one), an this works, confirm?
There was a problem hiding this comment.
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.
- header byte was set by some ecdsa library when the signature was created,
- we're just trying to dissect the header so that we know what type of address this is, without modifying the signature,
- 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)
- 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.
There was a problem hiding this comment.
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.
4f31abb to
556305b
Compare
2bc1f42 to
7b9c71f
Compare
|
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 |
|
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 |
|
removed all bip322 as suggested before the diff gets big and added some battery of manual tests to reproduce |
recid with the script type
There was a problem hiding this comment.
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.pymodule 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.
|
Sorry for copilot messages, forgot to deactivate it |
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.
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.xfor sign messages on some script types (mostlyp2sh-p2wpkh). Through some manual experiments with different versions ofsparrow, 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
recidas well structure abip137.pythat is strict on context ofsparrow 2.5.xand lenient forsparrow <2.5.x(similar what used inelectrumclients), as well routesraw hashesto be not signed asbip137but instead asderone.Changes made to:
Did you build the code and tested on device?
sign [-u]flagWhat is the purpose of this pull request?
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 madeP2PKH,P2SH_P2WPKHandP2WPKHsign message procedures with a flashed firmware on device with a simpleoi(hion portuguese) on Sparrow on given address.Below was noted some different behaviours for some different Sparrow's versions. Since used different mnemonics on
testnetandtestnet4networks, i think any mnemonic here will be sufficient.While reproduce expects these results below -- deselecting any mode on Sparrow (
Standard(electrum),trezor(BIP137)andBIP322-- when available):Sparrow 2.3.1
Sparrow 2.4.2
Sparrow 2.5.2
Krux file signer
Download and update to
mainbranch; 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 SuccessfullyAlso need to verify with
--uncompressedflag: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 SuccessfullyNote that the
-uflag should produce different pubvkeys.--
Footnotes
Sparrow disables trezor(BIP137) and BIP322 for P2PKH (not disabled on previous versions) ↩
Sparrow disable BIP322 for P2SH_P2WPKH (not disabled on previous versions) ↩