Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions src/krux/bip137.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 16 additions & 12 deletions src/krux/pages/home_pages/sign_message_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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 ""
Expand All @@ -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(
Expand All @@ -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

Expand Down
43 changes: 16 additions & 27 deletions tests/pages/home_pages/test_sign_message_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand All @@ -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,
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -263,7 +262,7 @@ def test_sign_message_invalid_derivations(mocker, m5stickv, tdata):
],
"signmessage m84h ascii:hello",
"m84h",
"MEQCIA7DCCFox6tQqC3GE9a5IMvm8cVu1Zh6OWcUE1gls77gAiAzLncevU9FFJRvG83ahSJ8hISimgtSSdRHye2rmijwlg==",
"J7jAswueRmlhh64HDmfIuVvo5pcMMgBYdSCXAnMh2NQsQUwYkyI1O8G0F/8ChIiOz2pfCiQwE0FfOXWTAtMsMDE=",
"02707a62fdacc26ea9b63b1c197906f56ee0180d0bcf1966e1a2da34f5f3a09a9b",
),
(
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
[
Expand All @@ -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
[
Expand Down Expand Up @@ -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
[
Expand All @@ -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
[
Expand All @@ -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
[
Expand All @@ -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
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading