Skip to content

Commit 1e72552

Browse files
authored
Merge branch 'main' into workflow-label-trigger-simplify
2 parents f1e8984 + c5baa77 commit 1e72552

10 files changed

Lines changed: 277 additions & 96 deletions

File tree

.github/scripts/cron-admin-update-spam-list.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
const fs = require('fs').promises;
11+
const { LABEL_AUTOMATED } = require('./labels.js');
1112

1213
const SPAM_LIST_PATH = '.github/spam-list.txt';
1314
const dryRun = (process.env.DRY_RUN || 'false').toString().toLowerCase() === 'true';
@@ -229,7 +230,7 @@ module.exports = async ({github, context, core}) => {
229230
repo,
230231
title,
231232
body,
232-
labels: [LABEL_SPAM_LIST_UPDATE, 'automated']
233+
labels: [LABEL_SPAM_LIST_UPDATE, LABEL_AUTOMATED]
233234
});
234235
console.log('Issue created successfully');
235236
}

.github/scripts/labels.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Central location for label definitions to facilitate future changes.
3+
* This file serves as the single source of truth for all GitHub label names used by automated workflows.
4+
*/
5+
const LABEL_BROKEN_MARKDOWN_LINKS = 'notes: broken markdown links';
6+
const LABEL_AUTOMATED = 'notes: automated';
7+
8+
module.exports = {
9+
LABEL_BROKEN_MARKDOWN_LINKS,
10+
LABEL_AUTOMATED
11+
};

.github/workflows/cron-pr-check-broken-links.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
name: Cron – Check Broken Markdown Links
22

3+
concurrency:
4+
group: cron-check-broken-markdown-links
5+
cancel-in-progress: false
6+
37
on:
48
schedule:
59
- cron: "0 0 1 * *"
@@ -42,12 +46,14 @@ jobs:
4246
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
4347
with:
4448
script: |
49+
const { LABEL_BROKEN_MARKDOWN_LINKS, LABEL_AUTOMATED } = require('./.github/scripts/labels.js');
50+
4551
// Determine if this is a dry run
4652
const isManual = context.eventName === 'workflow_dispatch';
47-
const dryRun = isManual ? String(context.payload.inputs.dry_run).toLowerCase() === 'true' : false;
53+
const dryRun = isManual ? String(context.payload?.inputs?.dry_run).toLowerCase() === 'true' : false;
4854
4955
// Labels configuration
50-
const targetLabels = ['broken-markdown-links', 'automated'];
56+
const targetLabels = [LABEL_BROKEN_MARKDOWN_LINKS, LABEL_AUTOMATED];
5157
const issueTitle = "Scheduled Markdown Link Check Found Broken Links";
5258
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
5359
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Request triage review on beginner and Good First Issue PRs
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, labeled]
6+
7+
concurrency:
8+
group: triage-${{ github.event.pull_request.number }}
9+
cancel-in-progress: true
10+
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
jobs:
16+
request-triage:
17+
if: ${{ contains(github.event.pull_request.labels.*.name, 'Good First Issue') || contains(github.event.pull_request.labels.*.name, 'beginner') }}
18+
runs-on: [self-hosted,hl-sdk-py-lin-md]
19+
20+
steps:
21+
- name: Harden the runner (Audit all outbound calls)
22+
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
23+
with:
24+
egress-policy: audit
25+
26+
- name: Request triage review
27+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
28+
with:
29+
script: |
30+
const tag = "<!-- triage -->";
31+
const { data: comments } = await github.rest.issues.listComments({
32+
owner: context.repo.owner,
33+
repo: context.repo.repo,
34+
issue_number: context.payload.pull_request.number
35+
});
36+
if (!comments.some(c => c.body?.includes(tag))) {
37+
await github.rest.issues.createComment({
38+
owner: context.repo.owner,
39+
repo: context.repo.repo,
40+
issue_number: context.payload.pull_request.number,
41+
body: `${tag} Requesting triage review from: @hiero-ledger/hiero-sdk-python-triage`
42+
});
43+
}

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Thank you for your interest in contributing to the Hiero Python SDK!
3232
3. Follow [Setup Guide](docs/sdk_developers/training/setup)
3333
4. Follow [Workflow Guide](docs/sdk_developers/workflow.md)
3434
5. GPG and DCO sign commits [Quickstart Signing](docs/sdk_developers/training/workflow/07_signing_requirements.md)
35-
6. Submit a PR [Quickstart Submit PR](docs/sdk_developers/training/workflow/11_submit_pull_request.md)
35+
6. Submit a PR [Quickstart Submit PR](docs/sdk_developers/training/workflow/10_submit_pull_request.md)
3636

3737
**Detailed Docs:**
3838

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/query/account_balance_query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def _get_query_response(self, response: Any) -> crypto_get_account_balance_pb2.C
172172
"""
173173
return response.cryptogetAccountBalance
174174

175-
def _is_payment_required(self):
175+
def _is_payment_required(self) -> bool:
176176
"""
177177
Account balance query does not require payment.
178178

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
# ------------------------------------------------------------------------------

0 commit comments

Comments
 (0)