diff --git a/CHANGELOG.md b/CHANGELOG.md index 5141c97d8..01c3bff7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Switch from the pure-Python urtypes and foundation-ur-py packages to the new uUR - Replace custom quirc (library to decode QR codes from images) with k_quirc - Fix default theme contrast failures for Light, CypherPink, network indicators, and Amigo info panels +- Check for recids in BIP-137 message signing # Changelog 26.04.0 - April 2025 diff --git a/src/krux/bip137.py b/src/krux/bip137.py new file mode 100644 index 000000000..04ef0ce9b --- /dev/null +++ b/src/krux/bip137.py @@ -0,0 +1,76 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2026 Krux contributors + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +P2PKH_UNCOMPRESSED_HEADER = 27 +P2PKH_HEADER = 31 +P2SH_P2WPKH_HEADER = 35 +P2WPKH_HEADER = 39 +MESSAGE_MAGIC = b"\x18Bitcoin Signed Message:\n" +RECID_OFFSET = P2PKH_HEADER - P2PKH_UNCOMPRESSED_HEADER + + +def message_commitment(message): + """Double-SHA256 commitment over the BIP-137 magic""" + from embit import compact + + try: + import uhashlib as hashlib + except ImportError: + import hashlib + + # BIP137 commitment message: + # `SHA256(SHA256(MAGIC // varint // message))` + varint = compact.to_bytes(len(message)) + _message = MESSAGE_MAGIC + varint + message + return hashlib.sha256(hashlib.sha256(_message).digest()).digest() + + +def build_header(raw_sig, script_type, compressed=True): + """Build header byte from raw signature and script_type""" + # Avoid some unexpected header + rsig = raw_sig[0] + if not P2PKH_UNCOMPRESSED_HEADER <= rsig <= P2PKH_HEADER + 3: + raise ValueError("Invalid sig header: %d" % rsig) + + # grab the 2 least significant bits as recId + # and normalize with a minimum p2pkh (uncompressed) flag + recid = (raw_sig[0] - P2PKH_UNCOMPRESSED_HEADER) & 3 + + if script_type == "p2sh-p2wpkh": + return P2SH_P2WPKH_HEADER + recid + if script_type == "p2wpkh": + return P2WPKH_HEADER + recid + + return ( + P2PKH_UNCOMPRESSED_HEADER + + recid + + (0 if not compressed and script_type == "p2pkh" else RECID_OFFSET) + ) + + +def sign(message, key, derivation, script_type="p2pkh", compressed=True): + """Sign a BIP137 `message` with `key` at `derivation` for some `script_type`""" + commitment = message_commitment(message) + raw_sig = key.sign_at(derivation, commitment) + header = build_header(raw_sig, script_type, compressed) + sig = bytes([header]) + raw_sig[1:] + return (commitment, sig) diff --git a/src/krux/pages/home_pages/sign_message_ui.py b/src/krux/pages/home_pages/sign_message_ui.py index 63c47e5fa..561535f22 100644 --- a/src/krux/pages/home_pages/sign_message_ui.py +++ b/src/krux/pages/home_pages/sign_message_ui.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -from embit import bip32, compact, script +from embit import bip32, script from embit.networks import NETWORKS import hashlib import binascii @@ -133,7 +133,8 @@ def get_bitcoin_address(self, derivation_path, script_type=""): return addr def _sign_at_address(self, message, derivation_str, address=""): - """Signs a message at a derived Bitcoin address""" + """Signs a BIP137 message at a derived Bitcoin address""" + from krux import bip137 derivation = bip32.parse_path(derivation_str) self._display_message_sign_prompt( @@ -143,15 +144,8 @@ def _sign_at_address(self, message, derivation_str, address=""): if not self.prompt(t("Sign?"), BOTTOM_PROMPT_LINE): return None - message_hash = hashlib.sha256( - hashlib.sha256( - b"\x18Bitcoin Signed Message:\n" - + compact.to_bytes(len(message)) - + message - ).digest() - ).digest() - - sig = self.ctx.wallet.key.sign_at(derivation, message_hash) + script_type = self.get_script_type_from_path(derivation_str) or "p2pkh" + _, sig = bip137.sign(message, self.ctx.wallet.key, derivation, script_type) self._display_signature(base_encode(sig, 64)) return sig @@ -234,6 +228,8 @@ def _sign_at_address_from_sd(self, data): def sign_standard_message(self, data): """Signs a standard message""" + from krux import bip137 + message_hash, is_raw_hash = self._compute_message_hash(data) if message_hash is None: return "" @@ -248,6 +244,8 @@ def sign_standard_message(self, data): ) if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE): return "" + else: + message_hash = bip137.message_commitment(data) self.ctx.display.clear() self.ctx.display.draw_centered_text( @@ -257,7 +255,13 @@ def sign_standard_message(self, data): if not self.prompt(t("Sign?"), BOTTOM_PROMPT_LINE): return "" - sig = self.ctx.wallet.key.sign(message_hash).serialize() + key = self.ctx.wallet.key + if is_raw_hash: + sig = key.sign(message_hash).serialize() + else: + _, sig = bip137.sign( + data, key, bip32.parse_path(key.derivation), key.script_type + ) self._display_signature(base_encode(sig, 64)) return sig diff --git a/tests/pages/home_pages/test_sign_message_ui.py b/tests/pages/home_pages/test_sign_message_ui.py index 66af15b60..88d00f594 100644 --- a/tests/pages/home_pages/test_sign_message_ui.py +++ b/tests/pages/home_pages/test_sign_message_ui.py @@ -68,7 +68,7 @@ def test_sign_message(mocker, m5stickv, tdata): BUTTON_ENTER, # Hex Public Key Text BUTTON_ENTER, # PK QR code ], - "MEQCIHKmpv1+vgPpFTN0JXjyrMK2TtLHVeJJ2TydPYmEt0RnAiBJVt/Y61ef5VlWjG08zf92AeF++BWdYm1Yd9IEy2cSqA==", + "KNDYjnAavccLPKVJ7u6RjWs9n2NzN+kHc8MHrrUiQRq9TEoXa3UAbwsjESNCi7wcAZ9Vw1N7K+ujZzasgEgsvL0=", "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", None, ), @@ -86,7 +86,7 @@ def test_sign_message(mocker, m5stickv, tdata): BUTTON_ENTER, # Hex Public Key Text BUTTON_ENTER, # PK QR code ], - "MEQCIEHpCMfQ+5mBAOH//OCxF6iojpVtIS6G7X+3r3qB/0CaAiAkbjW2SGrPLvju+O05yH2x/4EKL2qlkdWnquiVkUY3jQ==", + "KIyFNagItWNotujcQQ7AoXLO90ndVEmsQvXeJbaTnFKufb2eq+G1RKvxRw+eR887Rn0UgqAKyrji4GQQYGfxvkw=", "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", None, ), @@ -167,11 +167,11 @@ def test_sign_message(mocker, m5stickv, tdata): BUTTON_PAGE_PREV, # Move to "Go" BUTTON_ENTER, # Press "Go" (saved pubkey to SD) ], - "MEQCIHKmpv1+vgPpFTN0JXjyrMK2TtLHVeJJ2TydPYmEt0RnAiBJVt/Y61ef5VlWjG08zf92AeF++BWdYm1Yd9IEy2cSqA==", # 4 base64 for display_qr_codes / print_qr_prompt + "KNDYjnAavccLPKVJ7u6RjWs9n2NzN+kHc8MHrrUiQRq9TEoXa3UAbwsjESNCi7wcAZ9Vw1N7K+ujZzasgEgsvL0=", # 4 base64 for display_qr_codes / print_qr_prompt "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", # 5 pubkey for display_qr_codes / print_qr_prompt # 6 SD file binascii.b2a_base64( - "MEQCIHKmpv1+vgPpFTN0JXjyrMK2TtLHVeJJ2TydPYmEt0RnAiBJVt/Y61ef5VlWjG08zf92AeF++BWdYm1Yd9IEy2cSqA==".encode( + "KNDYjnAavccLPKVJ7u6RjWs9n2NzN+kHc8MHrrUiQRq9TEoXa3UAbwsjESNCi7wcAZ9Vw1N7K+ujZzasgEgsvL0=".encode( "utf-8" ), newline=False, @@ -206,7 +206,6 @@ def test_sign_message(mocker, m5stickv, tdata): with patch( "builtins.open", new=get_mock_open({"/sd/signed-message.sig": case[6]}) ) as mock_file: - # function being tested home.sign_message() @@ -263,7 +262,7 @@ def test_sign_message_invalid_derivations(mocker, m5stickv, tdata): ], "signmessage m84h ascii:hello", "m84h", - "MEQCIA7DCCFox6tQqC3GE9a5IMvm8cVu1Zh6OWcUE1gls77gAiAzLncevU9FFJRvG83ahSJ8hISimgtSSdRHye2rmijwlg==", + "J7jAswueRmlhh64HDmfIuVvo5pcMMgBYdSCXAnMh2NQsQUwYkyI1O8G0F/8ChIiOz2pfCiQwE0FfOXWTAtMsMDE=", "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", ), ( @@ -278,7 +277,7 @@ def test_sign_message_invalid_derivations(mocker, m5stickv, tdata): ], "signmessage m/8xh/0/0 ascii:hello", "m/8xh/0/0", - "MEQCIDL/XdIRF+v0wnN/JDOu2XYMTYqJaAyjuIDdSG4E/909AiASARSc1zfJsvUKC6MDQlM3E2lrkTx3iYfpJUWRZy/Vpg==", + "JwdAF2MY6uglo4E3Wxbbi9G6NyrEna4LyYBsUueJg8yoLQm39BHDJ+ulgRhXl2RI/BManG05oTmRyXOqjDfDrs0=", "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", ), ( # 2 - QR invalid (empty segment) @@ -293,15 +292,13 @@ def test_sign_message_invalid_derivations(mocker, m5stickv, tdata): ], "signmessage m//0 ascii:hello", "m//0", - "MEQCIDx4K8atJhGiaFdCCsgaUqFv22ncJ3AFHczFsNLZmbcGAiBuQdB5/pztOHjyQt0DKmuMKo799raQuRrXMVKK69ELjQ==", + "J0yYgnVLwEditYd+QwmJrXqFRplrBqfUfUHOpAF4m+EEW9MYf4BVp/SWPujLDYfQKWxbpRPVZdaYuyGm77bNSsE=", "02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b", ), ] - n = 0 - for case in cases: + for n, case in enumerate(cases): print("Case: ", n) - n = 0 # A mainnet wallet wallet = Wallet(tdata.SINGLESIG_SIGNING_KEY) @@ -372,7 +369,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): False, "a test message with a colon ':' character.", "3. bc1qgl5…3cn3", # bc1qgl5vlg0zdl7yvprgxj9fevsc6q6x5dmcyk3cn3 - "IN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", + "KN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", ), ( # 1 - Sign P2WPKH Testnet [ @@ -387,7 +384,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): False, "A test message.", "3. tb1qynp…m5km", - "ILc30ti8OPSpCtzfj7sNnftANBCuVpyRX7pnM3iAgOk9F9IUtnXNPus0+MF12y5HKYHAB6IVYr66sLmL3Vi3oEE=", + "KLc30ti8OPSpCtzfj7sNnftANBCuVpyRX7pnM3iAgOk9F9IUtnXNPus0+MF12y5HKYHAB6IVYr66sLmL3Vi3oEE=", ), ( # 2 - Sign P2TR Mainnet [ @@ -432,7 +429,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): False, "a test message with a colon ':' character.", "3. 38CahkV…sEAN", - "HyH8898c2S6eF8hTPGhRqLC6UQrJrhw/fdguBeFG0cCrOFkbG8TCVURXOgxXaEV93vrFlHyxNGEvL10IcsLtvvI=", + "IyH8898c2S6eF8hTPGhRqLC6UQrJrhw/fdguBeFG0cCrOFkbG8TCVURXOgxXaEV93vrFlHyxNGEvL10IcsLtvvI=", ), ( # 5 - Sign P2WPKH Mainnet - Save to SD card [ @@ -449,7 +446,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): True, # Sign to SD "A test message.", "3. bc1qgl5…3cn3", - "IN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", + "KN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", ), ( # 6 - Sign P2WPKH Mainnet - Load from and save to SD card [ @@ -468,7 +465,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): True, # Sign to SD "A test message.", "3. bc1qgl5…3cn3", - "IN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", + "KN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", ), ( # 7 - Sign empty - Load from and save to SD card [ @@ -488,7 +485,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): True, # Sign to SD "A test message.", "3. bc1qgl…cn3", - "IN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", + "KN/4LmcGRaI5sgvBP2mrTXQFvD6FecXd8La03SixPabsb/255ElRGTcXhicT3KFsNJbfQ9te909ZXeKMaqUcaPM=", ), ] case_count = 0 @@ -532,7 +529,7 @@ def test_sign_message_at_address(mocker, m5stickv, tdata): if case[2] != b"": ctx.display.draw_hcentered_text.assert_has_calls( - [mocker.call("Message:", 10, theme.highlight_color)] + [mocker.call("Message:", mocker.ANY, theme.highlight_color)] ) ctx.display.draw_hcentered_text.assert_has_calls( [mocker.call(case[4], mocker.ANY, max_lines=10)] @@ -577,20 +574,12 @@ def test_load_from_sd_card(mocker, m5stickv, tdata): ctx = create_ctx(mocker, btn_seq, wallet) sign_msg = SignMessage(ctx) - - # test sign at address with binary content (it can't be decoded to UTF8 and it is not sign to address) - sign_msg._sign_at_address_from_sd(file_content) == None - - # mock load of file + assert sign_msg._sign_at_address_from_sd(file_content) is None sign_msg._load_message = lambda: (file_content, FORMAT_NONE, filename) mocker.spy(sign_msg, "_export_signature") mocker.spy(sign_msg, "_export_to_qr") - - # Successful sign a binary (that can't be decoded) from sd card sign_msg.sign_message() - - # Assert signature sucessfully exported to QR sign_msg._export_signature.assert_called() sign_msg._export_to_qr.assert_called() diff --git a/tests/test_bip137.py b/tests/test_bip137.py new file mode 100644 index 000000000..86c9bd6f2 --- /dev/null +++ b/tests/test_bip137.py @@ -0,0 +1,298 @@ +import pytest + +from .pages.home_pages.test_home import tdata + +P2PKH_DERIV = "m/44h/0h/0h/0/3" +P2SH_P2WPKH_DERIV = "m/49h/0h/0h/0/3" +P2WPKH_DERIV = "m/84h/0h/0h/0/3" +P2TR_DERIV = "m/86h/0h/0h/0/3" + +MESSAGE = b"krux+bip137" + + +@pytest.fixture +def address_for(): + def _addr(pub, script_type, network): + from embit import script + + if script_type == "p2pkh": + return script.p2pkh(pub).address(network=network) + if script_type == "p2sh-p2wpkh": + return script.p2sh(script.p2wpkh(pub)).address(network=network) + if script_type == "p2wpkh": + return script.p2wpkh(pub).address(network=network) + if script_type == "p2tr": + return script.p2tr(pub).address(network=network) + return None + + return _addr + + +@pytest.fixture +def fake_raw_sig(): + def _sig(recid): + import secrets + from krux.bip137 import P2PKH_HEADER + + return bytes([P2PKH_HEADER + recid]) + secrets.token_bytes(64) + + return _sig + + +@pytest.fixture +def lenient_sign(): + def _sign(key, derivation, message): + import hashlib + from embit import compact + from krux.bip137 import MESSAGE_MAGIC + + commitment = hashlib.sha256( + hashlib.sha256( + MESSAGE_MAGIC + compact.to_bytes(len(message)) + message + ).digest() + ).digest() + sig = key.sign_at(derivation, commitment) + return (commitment, sig) + + return _sign + + +# An idealized strict verifier (Sparrow >=2.5.x). +# +# The numbers are defined in BIP137 and means and +# each one produces different check addresses: +# +# - 27-30: uncompressed-pubkey p2pkh +# - 31-34: compressed-pubkey p2pkh +# - 35-38: compressed-pubkey p2sh-p2wpkh (p2sh ones are found here) +# - >38: compressed-pubkey p2wpkh and beyond +@pytest.fixture +def strict_verify(address_for): + def _verify(sig, commitment, expected_address, network): + from embit import ec + from embit.util import secp256k1 + + header = sig[0] + if not 27 <= header <= 42: + return False + + recid = (header - 27) & 3 + if 27 <= header <= 30: + script_type, flag = "p2pkh", secp256k1.EC_UNCOMPRESSED + elif 31 <= header <= 34: + script_type, flag = "p2pkh", secp256k1.EC_COMPRESSED + elif 35 <= header <= 38: + script_type, flag = "p2sh-p2wpkh", secp256k1.EC_COMPRESSED + else: + script_type, flag = "p2wpkh", secp256k1.EC_COMPRESSED + + parsed = secp256k1.ecdsa_recoverable_signature_parse_compact(sig[1:], recid) + raw = secp256k1.ecdsa_recover(parsed, commitment) + pub = ec.PublicKey.parse(secp256k1.ec_pubkey_serialize(raw, flag)) + addr = address_for(pub, script_type, network) + return addr is not None and addr == expected_address + + return _verify + + +# Idealized lenient verifier (Sparrow <2.5.x/ Electrum). +# +# By lenient we mean that the verification still occurs for recids and flags, +# but the script type and thus address, will be trusted by a provided script +# type, not by a checked one. +@pytest.fixture +def lenient_verify(address_for): + def _verify(sig, commitment, expected_address, network, script_type): + from embit import ec + from embit.util import secp256k1 + + header = sig[0] + if not 27 <= header <= 42: + return False + recid = (header - 27) & 3 + flag = ( + secp256k1.EC_UNCOMPRESSED if 27 <= header <= 30 else secp256k1.EC_COMPRESSED + ) + + parsed = secp256k1.ecdsa_recoverable_signature_parse_compact(sig[1:], recid) + raw = secp256k1.ecdsa_recover(parsed, commitment) + pub = ec.PublicKey.parse(secp256k1.ec_pubkey_serialize(raw, flag)) + addr = address_for(pub, script_type, network) + return addr is not None and addr == expected_address + + return _verify + + +def test_message_commitment(mocker, m5stickv, tdata): + import binascii + from krux.bip137 import message_commitment + + assert binascii.hexlify(message_commitment(MESSAGE)) == ( + b"6f47a0896ff0eb30d36d73ef8783f9796abc01328807835eb258ee042094df22" + ) + + +def test_build_header(mocker, m5stickv, fake_raw_sig): + from krux.bip137 import ( + build_header, + P2PKH_HEADER, + P2SH_P2WPKH_HEADER, + P2WPKH_HEADER, + ) + + # (script_type, expected_header_base) + cases = [ + ("p2pkh", P2PKH_HEADER), + ("p2sh-p2wpkh", P2SH_P2WPKH_HEADER), + ("p2wpkh", P2WPKH_HEADER), + # lenient ones could sign p2tr using this conversion + ("p2tr", P2PKH_HEADER), + ("p2wsh", P2PKH_HEADER), + ] + for i, case in enumerate(cases): + print("Case: %d", i) + for recid in range(4): + assert build_header(fake_raw_sig(recid), case[0]) == case[1] + recid + + +def test_fail_build_header(mocker, m5stickv): + from krux.bip137 import build_header + + for bad in (0, 26, 35, 255): + raw = bytes([bad]) + b"\x00" * 64 + with pytest.raises(ValueError, match="Invalid sig header"): + build_header(raw, "p2pkh") + + +def test_lenient_signatures(mocker, m5stickv, tdata, lenient_sign): + from embit import bip32 + from krux.bip137 import ( + sign, + P2PKH_HEADER, + P2SH_P2WPKH_HEADER, + P2WPKH_HEADER, + ) + + # (derivation, script_type, current_header_base) + cases = [ + (P2PKH_DERIV, "p2pkh", P2PKH_HEADER), + (P2SH_P2WPKH_DERIV, "p2sh-p2wpkh", P2SH_P2WPKH_HEADER), + (P2WPKH_DERIV, "p2wpkh", P2WPKH_HEADER), + # lenient ones could sign p2tr using this conversion + (P2TR_DERIV, "p2tr", P2PKH_HEADER), + ] + key = tdata.SINGLESIG_SIGNING_KEY + for i, case in enumerate(cases): + print("Case: %d ", i) + derivation_str, script_type, current_header_base = case + derivation = bip32.parse_path(derivation_str) + + lenient_commitment, lenient_sig = lenient_sign(key, derivation, MESSAGE) + strict_commitment, strict_sig = sign(MESSAGE, key, derivation, script_type) + + assert lenient_commitment == strict_commitment + + # lenient sigs should sig on lenient verifiers + assert 31 <= lenient_sig[0] <= 34 + lenient_recid = lenient_sig[0] - P2PKH_HEADER + strict_recid = strict_sig[0] - case[2] + assert lenient_recid == strict_recid + assert strict_sig[0] == case[2] + strict_recid + assert lenient_sig[1:] == strict_sig[1:] + + +def test_strict_signatures( + mocker, + m5stickv, + tdata, + address_for, + lenient_sign, + lenient_verify, + strict_verify, +): + from embit import bip32 + from embit.networks import NETWORKS + from krux.bip137 import sign + + key = tdata.SINGLESIG_SIGNING_KEY + network = NETWORKS["main"] + + # Now we will not rely on provided script type + # (derivation, script_type, old_passes_strict, new_passes_strict) + cases = [ + (P2PKH_DERIV, "p2pkh"), + (P2SH_P2WPKH_DERIV, "p2sh-p2wpkh"), + (P2WPKH_DERIV, "p2wpkh"), + (P2TR_DERIV, "p2tr"), + ] + + for i, case in enumerate(cases): + print("Case %d, script=%s" % (i, case[1])) + derivation = bip32.parse_path(case[0]) + pub = key.root.derive(derivation).to_public().key + addr = address_for(pub, case[1], network) + + old_commitment, old_sig = lenient_sign(key, derivation, MESSAGE) + new_commitment, new_sig = sign(MESSAGE, key, derivation, case[1]) + + if case[1] == "p2pkh": + # build_header returns P2PKH_HEADER; old_sig == new_sig bytewise + assert strict_verify(new_sig, new_commitment, addr, network) + assert lenient_verify(new_sig, new_commitment, addr, network, case[1]) + elif case[1] == "p2tr": + # Strict rejects (BIP-137 has no taproot scheme) + assert not strict_verify(new_sig, new_commitment, addr, network) + assert lenient_verify(new_sig, new_commitment, addr, network, case[1]) + else: + # Strict accepts only the new form; lenient accepts both old + # and new header formats (for Sparrow <2.5.x / Electrum compatibility). + assert not strict_verify(old_sig, old_commitment, addr, network) + assert strict_verify(new_sig, new_commitment, addr, network) + assert lenient_verify(old_sig, old_commitment, addr, network, case[1]) + assert lenient_verify(new_sig, new_commitment, addr, network, case[1]) + + +def test_verify_uncompressed_p2pkh( + mocker, m5stickv, tdata, lenient_sign, strict_verify, lenient_verify +): + from embit import bip32, script + from embit.networks import NETWORKS + from krux.bip137 import ( + P2PKH_HEADER, + P2PKH_UNCOMPRESSED_HEADER, + sign, + ) + + # Same secret, two distinct p2pkh addresses (compressed && uncompressed) + key = tdata.SINGLESIG_SIGNING_KEY + network = NETWORKS["main"] + derivation = bip32.parse_path(P2PKH_DERIV) + pub = key.root.derive(derivation).to_public().key + + # compressed addr from uncompressed key + compressed_addr = script.p2pkh(pub).address(network=network) + pub.compressed = False + uncompressed_addr = script.p2pkh(pub).address(network=network) + + # check if the compressed addr not match the uncompressed addr one + assert compressed_addr != uncompressed_addr + + lenient_commitment, lenient_sig = lenient_sign(key, derivation, MESSAGE) + uncompressed_commitment, uncompressed_sig = sign( + MESSAGE, key, derivation, "p2pkh", compressed=False + ) + + assert uncompressed_sig[0] >= P2PKH_UNCOMPRESSED_HEADER + assert uncompressed_sig[0] < P2PKH_HEADER + assert strict_verify( + uncompressed_sig, uncompressed_commitment, uncompressed_addr, network + ) + assert lenient_verify( + uncompressed_sig, uncompressed_commitment, uncompressed_addr, network, "p2pkh" + ) + assert not strict_verify( + lenient_sig, lenient_commitment, uncompressed_addr, network + ) + assert not lenient_verify( + lenient_sig, lenient_commitment, uncompressed_addr, network, "p2pkh" + )