Skip to content

Commit c5baa77

Browse files
authored
chore: update crypto public key (#2042)
Signed-off-by: MonaaEid <monaa_eid@hotmail.com>
1 parent 53cc7ae commit c5baa77

4 files changed

Lines changed: 211 additions & 91 deletions

File tree

src/hiero_sdk_python/crypto/public_key.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,90 @@ def to_bytes_der(self) -> bytes:
413413
encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo
414414
)
415415

416+
@classmethod
417+
def _encode_vlq(cls, value: int) -> bytes:
418+
"""
419+
Encode a value in Variable-Length Quantity (VLQ) format.
420+
"""
421+
if value < 0:
422+
raise ValueError("VLQ value must be non-negative")
423+
if value == 0:
424+
return b"\x00"
425+
426+
encoded = bytearray()
427+
while value > 0:
428+
encoded.insert(0, value & 0x7F)
429+
value >>= 7
430+
431+
# Set the high bit (0x80) on all bytes except the last
432+
for i in range(len(encoded) - 1):
433+
encoded[i] |= 0x80
434+
435+
return bytes(encoded)
436+
437+
@classmethod
438+
def _encode_der_length(cls, length: int) -> bytes:
439+
"""Encode a DER length field per X.690 standards."""
440+
if length < 0:
441+
raise ValueError("DER length must be non-negative")
442+
if length < 0x80:
443+
return bytes([length])
444+
445+
length_bytes = length.to_bytes((length.bit_length() + 7) // 8, "big")
446+
return bytes([0x80 | len(length_bytes)]) + length_bytes
447+
448+
@classmethod
449+
def _encode_der_oid(cls, oid: str) -> bytes:
450+
"""
451+
Encode a dotted OID string into DER OID bytes including tag and length.
452+
"""
453+
parts = [int(part) for part in oid.split(".")]
454+
455+
is_valid = len(parts) >= 2 and parts[0] in (0, 1, 2) and parts[1] >= 0 and (parts[0] == 2 or parts[1] < 40)
456+
if not is_valid:
457+
raise ValueError(f"Invalid OID structure for '{oid}'")
458+
459+
first, second = parts[0], parts[1]
460+
461+
# Encode the combined root using VLQ to handle edge cases correctly
462+
encoded = bytearray(cls._encode_vlq(40 * first + second))
463+
464+
for value in parts[2:]:
465+
encoded.extend(cls._encode_vlq(value))
466+
467+
return bytes([0x06]) + cls._encode_der_length(len(encoded)) + bytes(encoded)
468+
469+
@classmethod
470+
def _encode_der_sequence(cls, content: bytes) -> bytes:
471+
"""Encode DER SEQUENCE tag, length, and content per X.690 standards."""
472+
return bytes([0x30]) + cls._encode_der_length(len(content)) + content
473+
474+
@classmethod
475+
def _encode_der_bit_string(cls, content: bytes) -> bytes:
476+
"""
477+
Encode DER BIT STRING tag, length, and content per X.690 standards.
478+
"""
479+
payload = bytes([0x00]) + content
480+
return bytes([0x03]) + cls._encode_der_length(len(payload)) + payload
481+
482+
def to_bytes_der_ecdsa_compressed(self) -> bytes:
483+
"""
484+
Returns DER-encoded SubjectPublicKeyInfo for secp256k1 using a compressed SEC1 point.
485+
Raises:
486+
ValueError: If this key is not ECDSA secp256k1.
487+
"""
488+
if not self.is_ecdsa():
489+
raise ValueError("Compressed ECDSA DER export is only supported for ECDSA keys")
490+
491+
# id-ecPublicKey + secp256k1 OID algorithm identifier
492+
algorithm_id = self._encode_der_sequence(
493+
self._encode_der_oid("1.2.840.10045.2.1") + self._encode_der_oid("1.3.132.0.10")
494+
)
495+
compressed_point = self.to_bytes_ecdsa(compressed=True)
496+
subject_public_key = self._encode_der_bit_string(compressed_point)
497+
498+
return self._encode_der_sequence(algorithm_id + subject_public_key)
499+
416500
#
417501
# ---------------------------------
418502
# Type-specific (Ed25519, ECDSA secp256k1) to hex string.
@@ -440,6 +524,12 @@ def to_string_der(self) -> str:
440524
"""
441525
return self.to_bytes_der().hex()
442526

527+
def to_string_der_ecdsa_compressed(self) -> str:
528+
"""
529+
Returns DER SPKI hex for ECDSA secp256k1 using a compressed SEC1 point.
530+
"""
531+
return self.to_bytes_der_ecdsa_compressed().hex()
532+
443533
def to_string_raw(self) -> str:
444534
"""
445535
Catch all ed25519 or ecdsa for convenience.

src/hiero_sdk_python/utils/key_utils.py

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,30 @@
77

88
from __future__ import annotations
99

10-
from hiero_sdk_python.crypto.private_key import PrivateKey
11-
from hiero_sdk_python.crypto.public_key import PublicKey
10+
from hiero_sdk_python.crypto.key import Key
1211
from hiero_sdk_python.hapi.services import basic_types_pb2
1312

1413

15-
# Type alias for keys that can be either PrivateKey or PublicKey
16-
Key = PrivateKey | PublicKey
17-
18-
1914
def key_to_proto(key: Key | None) -> basic_types_pb2.Key | None:
2015
"""
21-
Helper function to convert a key (PrivateKey or PublicKey) to protobuf Key format.
16+
Helper function to convert an SDK key to protobuf Key format.
2217
23-
This function handles the conversion of SDK key types to protobuf format:
24-
- If a PrivateKey is provided, its corresponding public key is extracted and converted.
25-
- If a PublicKey is provided, it is converted directly to protobuf.
26-
- If None is provided, None is returned.
18+
This function handles any concrete subclass of Key by delegating to its
19+
to_proto_key() implementation. If None is provided, None is returned.
2720
2821
Args:
29-
key (Optional[Key]): The key to convert (PrivateKey or PublicKey), or None
22+
key (Optional[Key]): The key to convert, or None
3023
3124
Returns:
3225
basic_types_pb2.Key (Optional): The protobuf key or None if key is None
3326
3427
Raises:
35-
TypeError: If the provided key is not a PrivateKey, PublicKey, or None.
28+
TypeError: If the provided key is not a Key instance or None.
3629
"""
37-
if not key:
30+
if key is None:
3831
return None
3932

40-
# If it's a PrivateKey, get the public key first, then convert to proto
41-
if isinstance(key, PrivateKey):
42-
return key.public_key()._to_proto()
43-
44-
# If it's a PublicKey, convert directly to proto
45-
if isinstance(key, PublicKey):
46-
return key._to_proto()
33+
if isinstance(key, Key):
34+
return key.to_proto_key()
4735

48-
# Safety net: This will fail if a non-key is passed
49-
raise TypeError("Key must be of type PrivateKey or PublicKey")
36+
raise TypeError("Key must be of type PrivateKey or PublicKey, or another SDK Key implementation")

tests/unit/keys_public_test.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,91 @@ def test_from_bytes_invalid():
328328
PublicKey.from_bytes(data)
329329

330330

331+
# ------------------------------------------------------------------------------
332+
# Test: DER helper encoders and compressed DER export
333+
# ------------------------------------------------------------------------------
334+
def test_encode_der_length_short_and_long_forms():
335+
assert PublicKey._encode_der_length(0) == b"\x00"
336+
assert PublicKey._encode_der_length(0x7F) == b"\x7f"
337+
assert PublicKey._encode_der_length(0x80) == b"\x81\x80"
338+
assert PublicKey._encode_der_length(0x0100) == b"\x82\x01\x00"
339+
assert PublicKey._encode_der_length(0x1000000) == b"\x84\x01\x00\x00\x00"
340+
341+
342+
def test_encode_der_length_negative_raises():
343+
with pytest.raises(ValueError, match="non-negative"):
344+
PublicKey._encode_der_length(-1)
345+
346+
347+
def test_encode_der_oid_known_values():
348+
# id-ecPublicKey
349+
assert PublicKey._encode_der_oid("1.2.840.10045.2.1") == bytes.fromhex("06072a8648ce3d0201")
350+
# secp256k1
351+
assert PublicKey._encode_der_oid("1.3.132.0.10") == bytes.fromhex("06052b8104000a")
352+
353+
354+
def test_encode_der_oid_combined_root_multibyte():
355+
# "2.999" -> 2*40 + 999 = 1079, encoded as VLQ: 0x88 0x37
356+
result = PublicKey._encode_der_oid("2.999.1")
357+
assert result == bytes.fromhex("0603883701")
358+
assert result[0] == 0x06 # OID tag
359+
assert result[1] == 0x03 # Length of OID content
360+
361+
362+
def test_encode_der_oid_invalid_components_raise():
363+
for oid in ("1", "3.1.1", "1.40.1", "9.999.1"):
364+
with pytest.raises(ValueError, match=f"Invalid OID structure for '{oid}'"):
365+
PublicKey._encode_der_oid(oid)
366+
367+
with pytest.raises(ValueError, match="non-negative"):
368+
PublicKey._encode_der_oid("1.2.-1")
369+
370+
with pytest.raises(ValueError, match="invalid literal for int()"):
371+
PublicKey._encode_der_oid("1.999bit")
372+
with pytest.raises(ValueError):
373+
PublicKey._encode_der_oid("")
374+
with pytest.raises(ValueError):
375+
PublicKey._encode_der_oid("...")
376+
377+
378+
def test_encode_der_sequence_and_bit_string():
379+
assert PublicKey._encode_der_sequence(b"\x01\x02") == b"\x30\x02\x01\x02"
380+
assert PublicKey._encode_der_bit_string(b"\xaa\xbb") == b"\x03\x03\x00\xaa\xbb"
381+
382+
383+
def test_to_bytes_der_ecdsa_compressed_structure_and_roundtrip(ecdsa_keypair):
384+
_, pub = ecdsa_keypair
385+
public_key = PublicKey(pub)
386+
387+
der = public_key.to_bytes_der_ecdsa_compressed()
388+
compressed_point = public_key.to_bytes_ecdsa(compressed=True)
389+
390+
# Fixed SPKI prefix for secp256k1 compressed-point encoding.
391+
expected_prefix = bytes.fromhex("3036301006072a8648ce3d020106052b8104000a032200")
392+
assert der.startswith(expected_prefix)
393+
assert der[len(expected_prefix) :] == compressed_point
394+
395+
# Ensure produced DER is parseable and preserves the same public key bytes.
396+
loaded = PublicKey.from_der(der)
397+
assert loaded.is_ecdsa()
398+
assert loaded.to_bytes_ecdsa(compressed=True) == compressed_point
399+
400+
401+
def test_to_bytes_der_ecdsa_compressed_rejects_ed25519(ed25519_keypair):
402+
_, pub = ed25519_keypair
403+
public_key = PublicKey(pub)
404+
405+
with pytest.raises(ValueError, match="only supported for ECDSA"):
406+
public_key.to_bytes_der_ecdsa_compressed()
407+
408+
409+
def test_encode_vlq_values():
410+
assert PublicKey._encode_vlq(0) == b"\x00"
411+
assert PublicKey._encode_vlq(127) == b"\x7f"
412+
assert PublicKey._encode_vlq(128) == b"\x81\x00"
413+
assert PublicKey._encode_vlq(0x4000) == b"\x81\x80\x00"
414+
415+
331416
# ------------------------------------------------------------------------------
332417
# Test: from_string_xxx
333418
# ------------------------------------------------------------------------------

tests/unit/token_create_transaction_test.py

Lines changed: 26 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ def generate_transaction_id(account_id_proto):
6464
return TransactionId(valid_start=tx_timestamp, account_id=account_id_proto)
6565

6666

67+
def _mock_private_key(public_key_bytes: bytes, signature: bytes) -> MagicMock:
68+
"""Create a PrivateKey mock that returns a stable protobuf key representation."""
69+
key = MagicMock(spec=PrivateKey)
70+
key.sign.return_value = signature
71+
key.public_key().to_bytes_raw.return_value = public_key_bytes
72+
key.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=public_key_bytes)
73+
key.to_proto_key.return_value = basic_types_pb2.Key(ed25519=public_key_bytes)
74+
return key
75+
76+
6777
########### Basic Tests for Building Transactions ###########
6878

6979

@@ -311,40 +321,14 @@ def test_sign_transaction(mock_account_ids, mock_client):
311321
private_key.sign.return_value = b"signature"
312322
private_key.public_key().to_bytes_raw.return_value = b"public_key"
313323

314-
private_key_admin = MagicMock(spec=PrivateKey)
315-
private_key_admin.sign.return_value = b"admin_signature"
316-
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
317-
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
318-
319-
private_key_supply = MagicMock(spec=PrivateKey)
320-
private_key_supply.sign.return_value = b"supply_signature"
321-
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
322-
323-
private_key_freeze = MagicMock(spec=PrivateKey)
324-
private_key_freeze.sign.return_value = b"freeze_signature"
325-
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
326-
327-
private_key_wipe = MagicMock(spec=PrivateKey)
328-
private_key_wipe.sign.return_value = b"wipe_signature"
329-
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
330-
331-
private_key_metadata = MagicMock(spec=PrivateKey)
332-
private_key_metadata.sign.return_value = b"metadata_signature"
333-
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
334-
335-
private_key_pause = MagicMock(spec=PrivateKey)
336-
private_key_pause.sign.return_value = b"pause_signature"
337-
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
338-
339-
private_key_kyc = MagicMock(spec=PrivateKey)
340-
private_key_kyc.sign.return_value = b"kyc_signature"
341-
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
342-
343-
private_key_fee_schedule = MagicMock(spec=PrivateKey)
344-
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
345-
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(
346-
ed25519=b"fee_schedule_public_key"
347-
)
324+
private_key_admin = _mock_private_key(b"admin_public_key", b"admin_signature")
325+
private_key_supply = _mock_private_key(b"supply_public_key", b"supply_signature")
326+
private_key_freeze = _mock_private_key(b"freeze_public_key", b"freeze_signature")
327+
private_key_wipe = _mock_private_key(b"wipe_public_key", b"wipe_signature")
328+
private_key_metadata = _mock_private_key(b"metadata_public_key", b"metadata_signature")
329+
private_key_pause = _mock_private_key(b"pause_public_key", b"pause_signature")
330+
private_key_kyc = _mock_private_key(b"kyc_public_key", b"kyc_signature")
331+
private_key_fee_schedule = _mock_private_key(b"fee_schedule_public_key", b"fee_schedule_signature")
348332

349333
token_tx = TokenCreateTransaction()
350334
token_tx.set_token_name("MyToken")
@@ -778,40 +762,14 @@ def test_build_and_sign_nft_transaction_to_proto(mock_account_ids, mock_client):
778762
private_key_private.sign.return_value = b"private_signature"
779763
private_key_private.public_key().to_bytes_raw.return_value = b"private_public_key"
780764

781-
private_key_admin = MagicMock(spec=PrivateKey)
782-
private_key_admin.sign.return_value = b"admin_signature"
783-
private_key_admin.public_key().to_bytes_raw.return_value = b"admin_public_key"
784-
private_key_admin.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"admin_public_key")
785-
786-
private_key_supply = MagicMock(spec=PrivateKey)
787-
private_key_supply.sign.return_value = b"supply_signature"
788-
private_key_supply.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"supply_public_key")
789-
790-
private_key_freeze = MagicMock(spec=PrivateKey)
791-
private_key_freeze.sign.return_value = b"freeze_signature"
792-
private_key_freeze.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"freeze_public_key")
793-
794-
private_key_wipe = MagicMock(spec=PrivateKey)
795-
private_key_wipe.sign.return_value = b"wipe_signature"
796-
private_key_wipe.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"wipe_public_key")
797-
798-
private_key_metadata = MagicMock(spec=PrivateKey)
799-
private_key_metadata.sign.return_value = b"metadata_signature"
800-
private_key_metadata.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"metadata_public_key")
801-
802-
private_key_pause = MagicMock(spec=PrivateKey)
803-
private_key_pause.sign.return_value = b"pause_signature"
804-
private_key_pause.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"pause_public_key")
805-
806-
private_key_kyc = MagicMock(spec=PrivateKey)
807-
private_key_kyc.sign.return_value = b"kyc_signature"
808-
private_key_kyc.public_key()._to_proto.return_value = basic_types_pb2.Key(ed25519=b"kyc_public_key")
809-
810-
private_key_fee_schedule = MagicMock(spec=PrivateKey)
811-
private_key_fee_schedule.sign.return_value = b"fee_schedule_signature"
812-
private_key_fee_schedule.public_key()._to_proto.return_value = basic_types_pb2.Key(
813-
ed25519=b"fee_schedule_public_key"
814-
)
765+
private_key_admin = _mock_private_key(b"admin_public_key", b"admin_signature")
766+
private_key_supply = _mock_private_key(b"supply_public_key", b"supply_signature")
767+
private_key_freeze = _mock_private_key(b"freeze_public_key", b"freeze_signature")
768+
private_key_wipe = _mock_private_key(b"wipe_public_key", b"wipe_signature")
769+
private_key_metadata = _mock_private_key(b"metadata_public_key", b"metadata_signature")
770+
private_key_pause = _mock_private_key(b"pause_public_key", b"pause_signature")
771+
private_key_kyc = _mock_private_key(b"kyc_public_key", b"kyc_signature")
772+
private_key_fee_schedule = _mock_private_key(b"fee_schedule_public_key", b"fee_schedule_signature")
815773

816774
# Build the transaction
817775
token_tx = TokenCreateTransaction()

0 commit comments

Comments
 (0)