Skip to content

Commit bdb6644

Browse files
Added tests, improved documentation and error handling
1 parent 1d02a80 commit bdb6644

2 files changed

Lines changed: 65 additions & 12 deletions

File tree

bitcoinutils/keys.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -532,20 +532,25 @@ def __init__(self, hex_str: str = None, message: str = None, signature: bytes =
532532
"""
533533
Parameters
534534
----------
535-
hex_str : str
535+
hex_str : str, optional
536536
the public key in hex string
537537
538538
In case of generating public key from message and signature:-
539-
message : str
540-
the message
541-
signature : str
542-
the compressed signature in bytes
539+
message : str, optional
540+
The original message that was signed
541+
signature : bytes, optional
542+
A 65-byte Bitcoin signature (1-byte recovery ID + 64-byte ECDSA signature).
543543
544544
Raises
545545
------
546546
TypeError
547547
If first byte of public key (corresponding to SEC format) is
548548
invalid.
549+
If neither hex_str nor (message, signature) are provided
550+
ValueError
551+
If message is empty when attempting recovery
552+
If signature is not exactly 65 bytes
553+
If an invalid recovery ID is detected
549554
"""
550555
if hex_str:
551556
hex_str = hex_str.strip()
@@ -603,14 +608,23 @@ def __init__(self, hex_str: str = None, message: str = None, signature: bytes =
603608
uncompressed_hex = f"{x_coord:064x}{y_coord:064x}"
604609
uncompressed_hex_bytes = h_to_b(uncompressed_hex)
605610
self.key = VerifyingKey.from_string(uncompressed_hex_bytes, curve=SECP256k1)
606-
elif message and signature:
611+
elif message or signature:
612+
if not message:
613+
raise ValueError("Empty message provided for public key recovery.")
614+
607615
if(len(signature) != 65):
608-
raise ValueError("Invalid signature length")
616+
raise ValueError("Invalid signature length, must be exactly 65 bytes")
609617

610-
recovery_id = signature[0] - 31 #Extract recovery id
618+
# The compressed signature is of the format: recovery_id (1 byte) | r (32 bytes) | s (32 bytes)
619+
# We subtract the prefix(27) for uncompressed signatures and an additional 4 (31) for compressed signatures to get the recovery id
620+
recovery_id = signature[0] - 31
621+
if not (0 <= recovery_id <= 3): # A valid recovery ID is between 0 and 3
622+
raise ValueError(f"Invalid recovery ID: expected 31-34, got {signature[0]}")
611623

612624
signature = signature[1:] #Remove recovery id from signature
613625

626+
# All bitcoin signatures include the magic prefix. It is just a string
627+
# added to the message to distinguish Bitcoin-specific messages.
614628
message_magic = add_magic_prefix(message)
615629
# create message digest
616630
message_digest = hashlib.sha256(hashlib.sha256(message_magic).digest()).digest()
@@ -620,7 +634,7 @@ def __init__(self, hex_str: str = None, message: str = None, signature: bytes =
620634
)
621635
self.key = recovered_keys[recovery_id]
622636
else:
623-
raise TypeError("Parameters missing")
637+
raise TypeError("Either 'hex_str' or ('message', 'signature') must be provided.")
624638

625639
@classmethod
626640
def from_hex(cls, hex_str: str) -> PublicKey:
@@ -691,7 +705,7 @@ def is_y_even(self) -> bool:
691705

692706
@classmethod
693707
def from_message_signature(cls, message, signature):
694-
"""Creates a public key from a message signature
708+
"""Recovers a public key from a Bitcoin-signed message and a 65-byte compressed signature.
695709
"""
696710
#Note: Only works for compressed signatures because DER encoding does not contain the recovery id
697711
return cls(message=message, signature=signature)

tests/test_keys.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424
from bitcoinutils.script import Script
2525
from bitcoinutils.hdwallet import HDWallet
26+
from base64 import b64decode
2627

2728

2829
class TestPrivateKeys(unittest.TestCase):
@@ -72,6 +73,13 @@ def setUp(self):
7273
b"\x08\xa8\xfd\x17\xb4H\xa6\x85T\x19\x9cG\xd0\x8f\xfb\x10\xd4\xb8"
7374
)
7475
self.address = "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm"
76+
77+
# Message public key recovery tests
78+
self.valid_message = "Hello, Bitcoin!"
79+
# 65-byte Bitcoin signature (1-byte recovery ID + 64-byte ECDSA signature)
80+
self.valid_signature = b'\x1f\x0c\xfc\xd8V\xec27)\xa7\xfc\x02:\xda\xcfT\xb2*\x02\x16.\xe2s\x7f\x18[&^\xb3e\xee3"KN\xfct\x011Z[\x05\xb5\xea\n!\xe8\xce\x9em\x89/\xf2\xa0\x15\x83{\x7f\x9e\xba+\xb4\xf8&\x15'
81+
# Known valid public key corresponding to the message + signature
82+
self.expected_public_key = '02649abc7094d2783670255073ccfd132677555ca84045c5a005611f25ef51fdbf'
7583

7684
def test_pubkey_creation(self):
7785
pub1 = PublicKey(self.public_key_hex)
@@ -98,6 +106,38 @@ def test_pubkey_to_hash160(self):
98106
def test_pubkey_x_only(self):
99107
pub = PublicKey(self.public_key_hex)
100108
self.assertEqual(pub.to_x_only_hex(), self.public_key_hex[2:66])
109+
110+
#Tests for PublicKey recovery from message and signature
111+
def test_public_key_recovery_valid(self):
112+
"""Test successful public key recovery from a valid message and signature"""
113+
pubkey = PublicKey(message=self.valid_message, signature=self.valid_signature)
114+
self.assertEqual(pubkey.key.to_string("compressed").hex(), self.expected_public_key)
115+
116+
def test_invalid_signature_length(self):
117+
"""Test handling of invalid signature length (not 65 bytes)"""
118+
short_signature = self.valid_signature[:60] # Truncate signature to 60 bytes
119+
with self.assertRaises(ValueError) as context:
120+
PublicKey(message=self.valid_message, signature=short_signature)
121+
self.assertEqual(str(context.exception), "Invalid signature length, must be exactly 65 bytes")
122+
123+
def test_invalid_recovery_id(self):
124+
"""Test handling of an invalid recovery ID"""
125+
invalid_signature = bytes([50]) + self.valid_signature[1:] # Modify recovery ID to 50
126+
with self.assertRaises(ValueError) as context:
127+
PublicKey(message=self.valid_message, signature=invalid_signature)
128+
self.assertIn("Invalid recovery ID", str(context.exception))
129+
130+
def test_missing_parameters(self):
131+
"""Test that missing both hex_str and (message, signature) raises an error"""
132+
with self.assertRaises(TypeError) as context:
133+
PublicKey()
134+
self.assertEqual(str(context.exception), "Either 'hex_str' or ('message', 'signature') must be provided.")
135+
136+
def test_empty_message(self):
137+
"""Test handling of an empty message for public key recovery"""
138+
with self.assertRaises(ValueError) as context:
139+
PublicKey(message="", signature=self.valid_signature)
140+
self.assertEqual(str(context.exception), "Empty message provided for public key recovery.")
101141

102142

103143
class TestP2pkhAddresses(unittest.TestCase):
@@ -311,7 +351,6 @@ def test_legacy_address_from_mnemonic(self):
311351
hdw.from_path("m/44'/1'/0'/0/3")
312352
address = hdw.get_private_key().get_public_key().get_address()
313353
self.assertTrue(address.to_string(), self.legacy_address_m_44_1h_0h_0_3)
314-
315-
354+
316355
if __name__ == "__main__":
317356
unittest.main()

0 commit comments

Comments
 (0)