From 5d907b32ac5b5ad233429bab5e059f674a9d455a Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 1 Oct 2026 13:42:58 +0300 Subject: [PATCH 1/2] feat: add signer-based signing with sign_with, sign_with_operator and set_operator_with Route every signing path through a signer callback so keys can stay in an HSM or KMS. - Add Signer type and Transaction.sign_with(public_key, signer); sign() now wraps it - Add Transaction.sign_with_operator(client) and internal _apply_signers() - Add Client.set_operator_with(account_id, public_key, signer); set_operator returns self - Extend Operator with public_key and signer fields - Sign query payments with the operator signer instead of the private key - execute() signs with the operator only when the operator is the payer - batchify() signs through sign_with_operator() Signed-off-by: Ntege Daniel --- examples/client/client_set_operator_with.py | 117 +++++ .../transaction_sign_with_external_signer.py | 110 +++++ src/hiero_sdk_python/__init__.py | 2 + src/hiero_sdk_python/client/client.py | 76 ++- src/hiero_sdk_python/crypto/signer.py | 54 +++ src/hiero_sdk_python/query/query.py | 25 +- .../transaction/transaction.py | 138 ++++-- .../transaction_sign_with_e2e_test.py | 131 ++++++ tests/unit/transaction_sign_with_test.py | 433 ++++++++++++++++++ 9 files changed, 1035 insertions(+), 51 deletions(-) create mode 100644 examples/client/client_set_operator_with.py create mode 100644 examples/transaction/transaction_sign_with_external_signer.py create mode 100644 src/hiero_sdk_python/crypto/signer.py create mode 100644 tests/integration/transaction_sign_with_e2e_test.py create mode 100644 tests/unit/transaction_sign_with_test.py diff --git a/examples/client/client_set_operator_with.py b/examples/client/client_set_operator_with.py new file mode 100644 index 000000000..04be52c9c --- /dev/null +++ b/examples/client/client_set_operator_with.py @@ -0,0 +1,117 @@ +""" +Set up a client whose operator signs through a callback instead of a private key. + +`Client.set_operator_with(account_id, public_key, signer)` lets the operator key +stay in an HSM or a cloud KMS. The client then uses the signer to pay for every +transaction and query it executes. + +This example simulates the HSM with a callback that wraps OPERATOR_KEY. + +Usage: + uv run examples/client/client_set_operator_with.py + python examples/client/client_set_operator_with.py +""" + +import os +import sys + +from dotenv import load_dotenv + +from hiero_sdk_python import ( + AccountCreateTransaction, + AccountId, + AccountInfoQuery, + Client, + Hbar, + Network, + PrivateKey, + ResponseCode, + Signer, +) + + +load_dotenv() + +NETWORK_NAME = os.getenv("NETWORK", "testnet").lower() +OPERATOR_ID = os.getenv("OPERATOR_ID") +OPERATOR_KEY = os.getenv("OPERATOR_KEY") + + +def simulated_hsm(private_key: PrivateKey) -> Signer: + """ + Return a signer callback standing in for an HSM or KMS. + + A real signer would send body_bytes to the HSM and return the raw signature. + """ + + def signer(body_bytes: bytes) -> bytes: + print(f" HSM asked to sign {len(body_bytes)} bytes") + return private_key.sign(body_bytes) + + return signer + + +def setup_client() -> Client: + """Create a client whose operator signs through the simulated HSM.""" + if not OPERATOR_ID or not OPERATOR_KEY: + raise ValueError("OPERATOR_ID and OPERATOR_KEY environment variables are required") + + operator_id = AccountId.from_string(OPERATOR_ID) + operator_key = PrivateKey.from_string(OPERATOR_KEY) + + # Only the public key and the callback reach the client + client = Client(Network(NETWORK_NAME)).set_operator_with( + operator_id, operator_key.public_key(), simulated_hsm(operator_key) + ) + + print(f"Network: {client.network.network}") + print(f"Client initialized with signer-based operator {client.operator_account_id}") + print(f"Operator private key held by the client: {client.operator_private_key}") + return client + + +def create_account(client: Client) -> AccountId: + """Create an account; the operator pays and signs through the HSM.""" + print("Creating an account...") + receipt = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ed25519().public_key()) + .set_initial_balance(Hbar(1)) + .execute(client) + ) + if receipt.status != ResponseCode.SUCCESS: + raise RuntimeError(f"Account creation failed with status: {ResponseCode(receipt.status).name}") + + print(f"Created account {receipt.account_id}") + return receipt.account_id + + +def query_account_info(client: Client, account_id: AccountId) -> None: + """Run a paid query; the query payment is signed through the HSM too.""" + print("Querying account info...") + info = AccountInfoQuery().set_account_id(account_id).execute(client) + print(f"Account {info.account_id} has a balance of {info.balance}") + + +def main(): + """ + 1. Set up a client with set_operator_with() and a simulated HSM signer. + + 2. Execute a transaction paid for by the signer-based operator. + 3. Execute a paid query, whose payment is signed by the same signer. + """ + try: + client = setup_client() + + account_id = create_account(client) + query_account_info(client, account_id) + + client.close() + + except Exception as exc: + print(f"Error: {exc}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/transaction/transaction_sign_with_external_signer.py b/examples/transaction/transaction_sign_with_external_signer.py new file mode 100644 index 000000000..dd220941e --- /dev/null +++ b/examples/transaction/transaction_sign_with_external_signer.py @@ -0,0 +1,110 @@ +""" +Sign a transaction with an external signer instead of a private key. + +`sign_with(public_key, signer)` hands each transaction body to a callback and +attaches the signature it returns. This is how keys held in an HSM or a cloud KMS +sign transactions: the private key never enters the SDK. + +This example simulates the HSM with a callback that wraps a locally generated key. + +Usage: + uv run examples/transaction/transaction_sign_with_external_signer.py + python examples/transaction/transaction_sign_with_external_signer.py +""" + +import sys + +from dotenv import load_dotenv + +from hiero_sdk_python import ( + AccountCreateTransaction, + AccountId, + Client, + Hbar, + PrivateKey, + ResponseCode, + Signer, + TransferTransaction, +) + + +load_dotenv() + + +def setup_client() -> Client: + """Initialize the client from the OPERATOR_ID and OPERATOR_KEY environment variables.""" + client = Client.from_env() + print(f"Network: {client.network.network}") + print(f"Client initialized with operator {client.operator_account_id}") + return client + + +def simulated_hsm(private_key: PrivateKey) -> Signer: + """ + Return a signer callback standing in for an HSM or KMS. + + A real signer would send body_bytes to the HSM and return the raw signature: + 64 bytes for Ed25519, or the 64-byte r || s over keccak256(body_bytes) for ECDSA. + """ + + def signer(body_bytes: bytes) -> bytes: + print(f" HSM asked to sign {len(body_bytes)} bytes") + return private_key.sign(body_bytes) + + return signer + + +def create_account(client: Client, key: PrivateKey) -> AccountId: + """Create an account controlled by the HSM key.""" + receipt = ( + AccountCreateTransaction().set_key_without_alias(key.public_key()).set_initial_balance(Hbar(2)).execute(client) + ) + if receipt.status != ResponseCode.SUCCESS: + raise RuntimeError(f"Account creation failed with status: {ResponseCode(receipt.status).name}") + + print(f"Created account {receipt.account_id} with an ECDSA key held by the simulated HSM") + return receipt.account_id + + +def transfer_with_external_signer(client: Client, account_id: AccountId, key: PrivateKey) -> None: + """Transfer HBAR out of the HSM account, signing through the callback.""" + tx = ( + TransferTransaction() + .add_hbar_transfer(account_id, -Hbar(1).to_tinybars()) + .add_hbar_transfer(client.operator_account_id, Hbar(1).to_tinybars()) + .freeze_with(client) + ) + + # The signer is called once per transaction body, i.e. once per node + tx.sign_with(key.public_key(), simulated_hsm(key)) + print(f"Transaction signed by the HSM key: {tx.is_signed_by(key.public_key())}") + + # The operator pays the fee, so execute() adds the operator signature + receipt = tx.execute(client) + if receipt.status != ResponseCode.SUCCESS: + raise RuntimeError(f"Transfer failed with status: {ResponseCode(receipt.status).name}") + + print(f"Transfer succeeded: {receipt.status == ResponseCode.SUCCESS}") + + +def main(): + """ + 1. Set up a client with an operator. + + 2. Create an account whose key lives in a (simulated) HSM. + 3. Transfer HBAR from that account, signing with sign_with() instead of sign(). + """ + try: + client = setup_client() + hsm_key = PrivateKey.generate_ecdsa() + + account_id = create_account(client, hsm_key) + transfer_with_external_signer(client, account_id, hsm_key) + + except Exception as exc: + print(f"Error: {exc}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 3b51b4f6f..e14ed66d0 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -50,6 +50,7 @@ from .crypto.evm_address import EvmAddress from .crypto.private_key import PrivateKey from .crypto.public_key import PublicKey +from .crypto.signer import Signer # Duration from .Duration import Duration @@ -193,6 +194,7 @@ # Crypto "PrivateKey", "PublicKey", + "Signer", "EvmAddress", # Tokens "TokenCreateTransaction", diff --git a/src/hiero_sdk_python/client/client.py b/src/hiero_sdk_python/client/client.py index 361324d06..6f49f3ede 100644 --- a/src/hiero_sdk_python/client/client.py +++ b/src/hiero_sdk_python/client/client.py @@ -13,6 +13,8 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.crypto.public_key import PublicKey +from hiero_sdk_python.crypto.signer import Signer from hiero_sdk_python.hapi.mirror import ( consensus_service_pb2_grpc as mirror_consensus_grpc, ) @@ -35,10 +37,17 @@ class Operator(NamedTuple): - """A named tuple for the operator's account ID and private key.""" + """ + A named tuple for the operator's account ID and signing credentials. + + private_key is None when the operator was set with Client.set_operator_with(). + public_key and signer are always set on an Operator returned by Client.operator. + """ account_id: AccountId - private_key: PrivateKey + private_key: PrivateKey | None + public_key: PublicKey | None = None + signer: Signer | None = None class Client: @@ -51,6 +60,8 @@ def __init__(self, network: Network = None) -> None: """ self.operator_account_id: AccountId = None self.operator_private_key: PrivateKey = None + self._operator_public_key: PublicKey | None = None + self._operator_signer: Signer | None = None if network is None: network = Network() @@ -186,21 +197,72 @@ def for_network(cls, network_map: dict[str, AccountId], network_name: str | None nodes = [_Node(account_id, address, None) for address, account_id in network_map.items()] return cls(Network(network=network_name, nodes=nodes)) - def set_operator(self, account_id: AccountId, private_key: PrivateKey) -> None: - """Sets the operator credentials (account ID and private key).""" - self.operator_account_id = account_id + def set_operator(self, account_id: AccountId, private_key: PrivateKey) -> Client: + """ + Sets the operator credentials (account ID and private key). + + Args: + account_id (AccountId): The operator account, which pays for transactions and queries. + private_key (PrivateKey): The operator account's private key. + + Returns: + Client: This client instance for fluent chaining. + """ + self.set_operator_with(account_id, private_key.public_key(), private_key.sign) self.operator_private_key = private_key + return self + + def set_operator_with(self, account_id: AccountId, public_key: PublicKey, signer: Signer) -> Client: + """ + Sets the operator with a signer callback instead of a private key. + + Use this when the operator key is held outside the process, e.g. in an HSM or KMS. + The signer receives the canonical body bytes and must return the raw signature. + After this call, operator_private_key is None. + + Args: + account_id (AccountId): The operator account, which pays for transactions and queries. + public_key (PublicKey): The public key matching the signer. + signer (Signer): The signing callback. + + Returns: + Client: This client instance for fluent chaining. + """ + self.operator_account_id = account_id + self.operator_private_key = None + self._operator_public_key = public_key + self._operator_signer = signer + return self + + @property + def operator_public_key(self) -> PublicKey | None: + """The operator's public key, or None if no operator signer is set.""" + if self.operator_private_key is not None: + return self.operator_private_key.public_key() + return self._operator_public_key + + @property + def operator_signer(self) -> Signer | None: + """The operator's signing callback, or None if no operator signer is set.""" + if self.operator_private_key is not None: + return self.operator_private_key.sign + return self._operator_signer @property def operator(self) -> Operator | None: """ - Returns an Operator namedtuple if both account ID and private key are set, + Returns an Operator namedtuple if both the account ID and a signer are set, otherwise None. """ - if self.operator_account_id and self.operator_private_key: + public_key = self.operator_public_key + signer = self.operator_signer + + if self.operator_account_id and public_key is not None and signer is not None: return Operator( account_id=self.operator_account_id, private_key=self.operator_private_key, + public_key=public_key, + signer=signer, ) return None diff --git a/src/hiero_sdk_python/crypto/signer.py b/src/hiero_sdk_python/crypto/signer.py new file mode 100644 index 000000000..1867d42e8 --- /dev/null +++ b/src/hiero_sdk_python/crypto/signer.py @@ -0,0 +1,54 @@ +"""Signer callback type and helpers for building signature pairs.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from hiero_sdk_python.hapi.services import basic_types_pb2 + + +if TYPE_CHECKING: + from hiero_sdk_python.crypto.public_key import PublicKey + + +Signer = Callable[[bytes], bytes] +""" +A signing callback: takes the canonical body bytes and returns the raw signature. + +Ed25519 signers return the 64-byte signature. ECDSA (secp256k1) signers return the +64-byte ``r || s`` signature over the keccak256 hash of the bytes, as +``PrivateKey.sign`` does. This lets keys live outside the process, e.g. in an HSM or KMS. +""" + + +def _sign_to_signature_pair( + public_key: PublicKey, + signer: Signer, + body_bytes: bytes, +) -> basic_types_pb2.SignaturePair: + """ + Signs body_bytes with signer and wraps the result in a SignaturePair for public_key. + + Args: + public_key (PublicKey): The public key matching the signer. + signer (Signer): The signing callback. + body_bytes (bytes): The bytes to sign. + + Returns: + SignaturePair: The signature pair, keyed by the raw public key bytes. + + Raises: + TypeError: If the signer does not return bytes. + """ + signature = signer(body_bytes) + + if not isinstance(signature, bytes): + raise TypeError(f"signer must return bytes, got {type(signature).__name__}") + + public_key_bytes = public_key.to_bytes_raw() + + if public_key.is_ed25519(): + return basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ed25519=signature) + + return basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ECDSA_secp256k1=signature) diff --git a/src/hiero_sdk_python/query/query.py b/src/hiero_sdk_python/query/query.py index 6690c03ec..14d11ce3c 100644 --- a/src/hiero_sdk_python/query/query.py +++ b/src/hiero_sdk_python/query/query.py @@ -9,7 +9,8 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel from hiero_sdk_python.client.client import Client, Operator -from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.crypto.public_key import PublicKey +from hiero_sdk_python.crypto.signer import Signer, _sign_to_signature_pair from hiero_sdk_python.exceptions import PrecheckError, ReceiptStatusError from hiero_sdk_python.executable import _Executable, _ExecutionState, _Method from hiero_sdk_python.hapi.services import ( @@ -183,9 +184,14 @@ def _make_request_header(self) -> query_header_pb2.QueryHeader: return header if self.operator is not None and not self._node_account_ids.is_empty and self.payment_amount is not None: + # An Operator built by hand may only carry a private key + payer_public_key = self.operator.public_key or self.operator.private_key.public_key() + payer_signer = self.operator.signer or self.operator.private_key.sign + payment_tx = self._build_query_payment_transaction( payer_account_id=self.operator.account_id, - payer_private_key=self.operator.private_key, + payer_public_key=payer_public_key, + payer_signer=payer_signer, node_account_id=self._node_account_ids.current, amount=self.payment_amount, ) @@ -196,7 +202,8 @@ def _make_request_header(self) -> query_header_pb2.QueryHeader: def _build_query_payment_transaction( self, payer_account_id: AccountId, - payer_private_key: PrivateKey, + payer_public_key: PublicKey, + payer_signer: Signer, node_account_id: AccountId, amount: Hbar, ) -> transaction_pb2.Transaction: @@ -207,7 +214,8 @@ def _build_query_payment_transaction( Args: payer_account_id: The account ID of the payer - payer_private_key: The private key of the payer + payer_public_key: The public key of the payer + payer_signer: The signing callback of the payer node_account_id: The account ID of the node amount (Hbar): The amount to pay @@ -244,14 +252,7 @@ def _build_query_payment_transaction( body_bytes = transaction_body.SerializeToString() # Sign the transaction body - signature = payer_private_key.sign(body_bytes) - public_key_bytes = payer_private_key.public_key().to_bytes_raw() - - # Create signature pair - if payer_private_key.is_ed25519(): - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ed25519=signature) - else: - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ECDSA_secp256k1=signature) + sig_pair = _sign_to_signature_pair(payer_public_key, payer_signer, body_bytes) # Create signature map signature_map = basic_types_pb2.SignatureMap(sigPair=[sig_pair]) diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index f83298bf9..b45f50337 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -6,6 +6,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.client.client import Client from hiero_sdk_python.crypto.key import Key +from hiero_sdk_python.crypto.signer import Signer, _sign_to_signature_pair from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.executable import _Executable, _ExecutionState from hiero_sdk_python.hapi.services import basic_types_pb2, timestamp_pb2, transaction_contents_pb2, transaction_pb2 @@ -23,6 +24,7 @@ if TYPE_CHECKING: from hiero_sdk_python.crypto.private_key import PrivateKey + from hiero_sdk_python.crypto.public_key import PublicKey from hiero_sdk_python.schedule.schedule_create_transaction import ( ScheduleCreateTransaction, ) @@ -61,6 +63,8 @@ def __init__(self) -> None: # This allows us to maintain the signatures for each unique transaction # and ensures that the correct signatures are used when submitting transactions self._signature_map: dict[bytes, basic_types_pb2.SignatureMap] = {} + # Signers registered through sign_with(), kept so rebuilt bodies can be re-signed + self._signers: list[tuple[PublicKey, Signer]] = [] # changed from int: 2_000_000 to Hbar: 2 self._default_transaction_fee = Hbar(2) self.operator_account_id = None @@ -175,34 +179,107 @@ def sign(self, private_key: PrivateKey) -> Transaction: Raises: Exception: If the transaction body has not been built. """ + return self.sign_with(private_key.public_key(), private_key.sign) + + def sign_with(self, public_key: PublicKey, signer: Signer) -> Transaction: + """ + Signs the transaction with a signer callback instead of a private key. + + Use this when the key is held outside the process, e.g. in an HSM or KMS. + The signer is called once for every transaction body (every chunk and every node), + with the exact body bytes, and must return the raw signature. + + Signing again with a key that already signed every body is a no-op. + If the signer raises, the transaction is left unchanged. + + Args: + public_key (PublicKey): The public key matching the signer. + signer (Signer): The signing callback. + + Returns: + Transaction: The current transaction instance for method chaining. + + Raises: + Exception: If the transaction body has not been built. + TypeError: If the signer is not callable or does not return bytes. + """ # We require the transaction to be frozen before signing self._require_frozen() - # We sign the bodies for each node in case we need to switch nodes during execution. - for node_bytes_map in self._transaction_body_bytes.values(): - for body_bytes in node_bytes_map.values(): - signature = private_key.sign(body_bytes) + if not callable(signer): + raise TypeError("signer must be callable") - public_key_bytes = private_key.public_key().to_bytes_raw() + public_key_bytes = public_key.to_bytes_raw() + if self._has_signer(public_key_bytes) or self.is_signed_by(public_key): + return self - if private_key.is_ed25519(): - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ed25519=signature) - else: - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ECDSA_secp256k1=signature) + self._write_signature_pairs(self._collect_signature_pairs(public_key, signer)) + self._signers.append((public_key, signer)) - # We initialize the signature map for this body_bytes if it doesn't exist yet - self._signature_map.setdefault(body_bytes, basic_types_pb2.SignatureMap()) + return self - # deduplication check - already_signed = any( - sp.pubKeyPrefix == public_key_bytes for sp in self._signature_map[body_bytes].sigPair - ) + def sign_with_operator(self, client: Client) -> Transaction: + """ + Signs the transaction with the client's operator. - # append only if not already signed - if not already_signed: - self._signature_map[body_bytes].sigPair.append(sig_pair) + Freezes the transaction with the client first if it is not frozen yet. - return self + Args: + client (Client): The client whose operator signs the transaction. + + Returns: + Transaction: The current transaction instance for method chaining. + + Raises: + ValueError: If the client has no operator. + """ + operator = client.operator + if operator is None: + raise ValueError("Client must have an operator to sign with the operator.") + + if not self._transaction_body_bytes: + self.freeze_with(client) + + return self.sign_with(operator.public_key, operator.signer) + + def _apply_signers(self) -> None: + """Re-signs every body that lacks a signature from a registered signer, e.g. after a rebuild.""" + pending = [] + for public_key, signer in self._signers: + pending.extend(self._collect_signature_pairs(public_key, signer)) + + self._write_signature_pairs(pending) + + def _collect_signature_pairs( + self, public_key: PublicKey, signer: Signer + ) -> list[tuple[bytes, basic_types_pb2.SignaturePair]]: + """ + Signs every body not yet signed by public_key, without touching the signature map. + + We sign the bodies for each node in case we need to switch nodes during execution. + """ + public_key_bytes = public_key.to_bytes_raw() + + return [ + (body_bytes, _sign_to_signature_pair(public_key, signer, body_bytes)) + for node_bytes_map in self._transaction_body_bytes.values() + for body_bytes in node_bytes_map.values() + if not self._is_body_signed_by(body_bytes, public_key_bytes) + ] + + def _write_signature_pairs(self, signature_pairs: list[tuple[bytes, basic_types_pb2.SignaturePair]]) -> None: + """Adds collected signature pairs to the signature map of their body.""" + for body_bytes, sig_pair in signature_pairs: + self._signature_map.setdefault(body_bytes, basic_types_pb2.SignatureMap()).sigPair.append(sig_pair) + + def _has_signer(self, public_key_bytes: bytes) -> bool: + """Checks if a signer for the given raw public key is registered.""" + return any(public_key.to_bytes_raw() == public_key_bytes for public_key, _ in self._signers) + + def _is_body_signed_by(self, body_bytes: bytes, public_key_bytes: bytes) -> bool: + """Checks if the signature map of a body contains a signature for the given raw public key.""" + sig_map = self._signature_map.get(body_bytes) + return sig_map is not None and any(sig_pair.pubKeyPrefix == public_key_bytes for sig_pair in sig_map.sigPair) def _to_proto(self): """ @@ -426,8 +503,10 @@ def execute( if self.operator_account_id is None: self.operator_account_id = client.operator_account_id - if not self.is_signed_by(client.operator_private_key.public_key()): - self.sign(client.operator_private_key) + # Sign with the operator only when it pays for the transaction + operator = client.operator + if operator is not None and self._transaction_ids.get(0).account_id == operator.account_id: + self.sign_with(operator.public_key, operator.signer) # Call the _execute function from executable.py to handle the actual execution response: TransactionResponse = self._execute(client, timeout) @@ -456,16 +535,11 @@ def is_signed_by(self, public_key): if not self._transaction_body_bytes: return False - for node_transaction_bodies in self._transaction_body_bytes.values(): - for body_bytes in node_transaction_bodies.values(): - sig_map = self._signature_map.get(body_bytes) - - if sig_map is None or not any( - sig_pair.pubKeyPrefix == public_key_bytes for sig_pair in sig_map.sigPair - ): - return False - - return True + return all( + self._is_body_signed_by(body_bytes, public_key_bytes) + for node_transaction_bodies in self._transaction_body_bytes.values() + for body_bytes in node_transaction_bodies.values() + ) def build_transaction_body(self) -> transaction_pb2.TransactionBody: """ @@ -1023,7 +1097,7 @@ def batchify(self, client: Client, batch_key: Key): self._require_not_frozen() self.set_batch_key(batch_key) self.freeze_with(client) - self.sign(client.operator_private_key) + self.sign_with_operator(client) return self def estimate_fee(self) -> FeeEstimateQuery: diff --git a/tests/integration/transaction_sign_with_e2e_test.py b/tests/integration/transaction_sign_with_e2e_test.py new file mode 100644 index 000000000..b81d97f3d --- /dev/null +++ b/tests/integration/transaction_sign_with_e2e_test.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction +from hiero_sdk_python.file.file_contents_query import FileContentsQuery +from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.query.account_info_query import AccountInfoQuery +from hiero_sdk_python.response_code import ResponseCode +from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction + + +def simulated_hsm(private_key: PrivateKey): + """Return a signer callback that keeps the private key out of the SDK, like an HSM would.""" + + def signer(body_bytes: bytes) -> bytes: + return private_key.sign(body_bytes) + + return signer + + +def create_account(env, key: PrivateKey): + """Create an account owned by key, funded with 2 HBAR.""" + receipt = ( + AccountCreateTransaction() + .set_key_without_alias(key.public_key()) + .set_initial_balance(Hbar(2)) + .execute(env.client) + ) + assert receipt.status == ResponseCode.SUCCESS, f"Account creation failed: {ResponseCode(receipt.status).name}" + return receipt.account_id + + +@pytest.mark.integration +@pytest.mark.parametrize("key_factory", [PrivateKey.generate_ed25519, PrivateKey.generate_ecdsa]) +def test_sign_with_external_signer_executes(env, key_factory): + """Test that a transaction signed through a signer callback executes, for Ed25519 and ECDSA.""" + key = key_factory() + account_id = create_account(env, key) + + tx = ( + TransferTransaction() + .add_hbar_transfer(account_id, -Hbar(1).to_tinybars()) + .add_hbar_transfer(env.operator_id, Hbar(1).to_tinybars()) + .freeze_with(env.client) + .sign_with(key.public_key(), simulated_hsm(key)) + ) + receipt = tx.execute(env.client) + + assert receipt.status == ResponseCode.SUCCESS, f"Transfer failed: {ResponseCode(receipt.status).name}" + + +@pytest.mark.integration +def test_client_with_signer_based_operator_executes_transaction_and_paid_query(env): + """Test that a client set up with set_operator_with can pay for transactions and queries.""" + client = Client(env.client.network) + client.set_operator_with(env.operator_id, env.operator_key.public_key(), simulated_hsm(env.operator_key)) + assert client.operator_private_key is None + + receipt = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ed25519().public_key()) + .set_initial_balance(Hbar(1)) + .execute(client) + ) + assert receipt.status == ResponseCode.SUCCESS, f"Account creation failed: {ResponseCode(receipt.status).name}" + + info = AccountInfoQuery().set_account_id(receipt.account_id).execute(client) + assert info.account_id == receipt.account_id + + +@pytest.mark.integration +def test_transaction_with_non_operator_payer_skips_operator_signature(env): + """Test that the operator does not sign a transaction it does not pay for.""" + payer_key = PrivateKey.generate_ed25519() + payer_id = create_account(env, payer_key) + + tx = ( + TransferTransaction() + .add_hbar_transfer(payer_id, -Hbar(1).to_tinybars()) + .add_hbar_transfer(env.operator_id, Hbar(1).to_tinybars()) + .set_transaction_id(TransactionId.generate(payer_id)) + .freeze_with(env.client) + .sign(payer_key) + ) + receipt = tx.execute(env.client) + + assert receipt.status == ResponseCode.SUCCESS, f"Transfer failed: {ResponseCode(receipt.status).name}" + assert tx.is_signed_by(payer_key.public_key()) + assert not tx.is_signed_by(env.operator_key.public_key()) + + +@pytest.mark.integration +def test_chunked_file_append_signed_with_external_signer_executes(env): + """Test that sign_with signs every chunk for every node of a FileAppendTransaction.""" + file_key = PrivateKey.generate_ecdsa() + create_receipt = ( + FileCreateTransaction() + .set_keys(file_key.public_key()) + .set_contents(b"") + .freeze_with(env.client) + .sign(file_key) + .execute(env.client) + ) + assert create_receipt.status == ResponseCode.SUCCESS, ( + f"File creation failed: {ResponseCode(create_receipt.status).name}" + ) + + contents = b"signer-based chunk " * 60 # 1,140 bytes, 3 chunks of 512 + tx = ( + FileAppendTransaction() + .set_file_id(create_receipt.file_id) + .set_chunk_size(512) + .set_contents(contents) + .set_node_account_ids(env.client.get_node_account_ids()) + .freeze_with(env.client) + .sign_with(file_key.public_key(), simulated_hsm(file_key)) + ) + assert len(tx._transaction_ids) == 3 + assert tx.is_signed_by(file_key.public_key()) + + receipt = tx.execute(env.client) + assert receipt.status == ResponseCode.SUCCESS, f"File append failed: {ResponseCode(receipt.status).name}" + + file_contents = FileContentsQuery().set_file_id(create_receipt.file_id).execute(env.client) + assert file_contents == contents diff --git a/tests/unit/transaction_sign_with_test.py b/tests/unit/transaction_sign_with_test.py new file mode 100644 index 000000000..ad1002a5f --- /dev/null +++ b/tests/unit/transaction_sign_with_test.py @@ -0,0 +1,433 @@ +"""Unit tests for signer-based signing: sign_with, sign_with_operator and Client.set_operator_with.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from hiero_sdk_python import Signer +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.client.client import Client, Operator +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction +from hiero_sdk_python.hapi.services import transaction_contents_pb2 +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.query.token_info_query import TokenInfoQuery +from hiero_sdk_python.transaction.transaction import Transaction +from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.transaction.transaction_response import TransactionResponse +from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction + + +pytestmark = pytest.mark.unit + +NODE_IDS = [AccountId(0, 0, 3), AccountId(0, 0, 4)] +PAYER_ID = AccountId(0, 0, 1984) + + +class RecordingSigner: + """A signer that wraps a PrivateKey, like an HSM would, and records every call.""" + + def __init__(self, private_key: PrivateKey): + self.private_key = private_key + self.calls: list[bytes] = [] + + def __call__(self, body_bytes: bytes) -> bytes: + self.calls.append(body_bytes) + return self.private_key.sign(body_bytes) + + +def _transfer(payer: AccountId = PAYER_ID) -> TransferTransaction: + return ( + TransferTransaction() + .add_hbar_transfer(payer, -1) + .add_hbar_transfer(AccountId(0, 0, 2), 1) + .set_transaction_id(TransactionId.generate(payer)) + .set_node_account_ids(NODE_IDS) + ) + + +def _all_bodies(tx: Transaction) -> list[bytes]: + return [body for node_bodies in tx._transaction_body_bytes.values() for body in node_bodies.values()] + + +def _sig_pairs_for(tx: Transaction, body_bytes: bytes, public_key) -> list: + sig_map = tx._signature_map.get(body_bytes) + if sig_map is None: + return [] + return [pair for pair in sig_map.sigPair if pair.pubKeyPrefix == public_key.to_bytes_raw()] + + +def _assert_signed_once_per_body(tx: Transaction, key: PrivateKey, signer: RecordingSigner): + bodies = _all_bodies(tx) + assert signer.calls == bodies + + public_key = key.public_key() + for body in bodies: + pairs = _sig_pairs_for(tx, body, public_key) + assert len(pairs) == 1 + signature = pairs[0].ed25519 if key.is_ed25519() else pairs[0].ECDSA_secp256k1 + public_key.verify(signature, body) + + +def test_signer_is_exported_from_package_root(): + """Test that Signer is importable from the package root.""" + assert Signer is not None + + +def test_sign_with_signs_every_body_of_multi_node_transaction(): + """Test that sign_with calls the signer once per node body, with the exact body bytes.""" + key = PrivateKey.generate_ed25519() + signer = RecordingSigner(key) + tx = _transfer().freeze() + + tx.sign_with(key.public_key(), signer) + + assert len(_all_bodies(tx)) == len(NODE_IDS) + _assert_signed_once_per_body(tx, key, signer) + + +def test_sign_with_signs_every_body_of_chunked_transaction(file_id): + """Test that sign_with signs every chunk for every node.""" + key = PrivateKey.generate_ed25519() + signer = RecordingSigner(key) + tx = ( + FileAppendTransaction() + .set_file_id(file_id) + .set_chunk_size(10) + .set_contents(b"a" * 30) + .set_transaction_id(TransactionId.generate(PAYER_ID)) + .set_node_account_ids(NODE_IDS) + .freeze() + ) + + tx.sign_with(key.public_key(), signer) + + assert len(_all_bodies(tx)) == 3 * len(NODE_IDS) + _assert_signed_once_per_body(tx, key, signer) + + +@pytest.mark.parametrize( + "key, field", + [ + (PrivateKey.generate_ed25519(), "ed25519"), + (PrivateKey.generate_ecdsa(), "ECDSA_secp256k1"), + ], +) +def test_sign_with_uses_signature_field_matching_key_type(key, field): + """Test that Ed25519 keys populate ed25519 and ECDSA keys populate ECDSA_secp256k1.""" + tx = _transfer().freeze() + + tx.sign_with(key.public_key(), key.sign) + + for body in _all_bodies(tx): + (pair,) = _sig_pairs_for(tx, body, key.public_key()) + assert pair.WhichOneof("signature") == field + + +def test_sign_with_same_key_twice_is_noop(): + """Test that signing twice gives one sig pair per body and skips the signer the second time.""" + key = PrivateKey.generate_ed25519() + signer = RecordingSigner(key) + tx = _transfer().freeze() + + tx.sign_with(key.public_key(), signer) + calls_after_first_sign = len(signer.calls) + tx.sign_with(key.public_key(), signer) + tx.sign(key) + + assert len(signer.calls) == calls_after_first_sign + for body in _all_bodies(tx): + assert len(_sig_pairs_for(tx, body, key.public_key())) == 1 + + +def test_sign_with_requires_frozen_transaction(): + """Test that sign_with raises on an unfrozen transaction, like sign does.""" + key = PrivateKey.generate_ed25519() + + with pytest.raises(Exception, match="Transaction is not frozen"): + _transfer().sign_with(key.public_key(), key.sign) + + +def test_sign_with_raising_signer_leaves_transaction_unchanged(): + """Test that a signer failing midway does not leave partial signatures behind.""" + key = PrivateKey.generate_ed25519() + tx = _transfer().freeze() + tx.sign(PrivateKey.generate_ed25519()) + before = {body: sig_map.SerializeToString() for body, sig_map in tx._signature_map.items()} + + calls = [] + + def flaky_signer(body_bytes: bytes) -> bytes: + calls.append(body_bytes) + if len(calls) == 2: + raise RuntimeError("HSM unavailable") + return key.sign(body_bytes) + + with pytest.raises(RuntimeError, match="HSM unavailable"): + tx.sign_with(key.public_key(), flaky_signer) + + assert {body: sig_map.SerializeToString() for body, sig_map in tx._signature_map.items()} == before + assert not tx._signers[1:] + + +def test_sign_with_rejects_non_bytes_signature(): + """Test that a signer returning something other than bytes raises TypeError.""" + key = PrivateKey.generate_ed25519() + tx = _transfer().freeze() + + with pytest.raises(TypeError, match="signer must return bytes"): + tx.sign_with(key.public_key(), lambda body_bytes: key.sign(body_bytes).hex()) + + assert tx._signature_map == {} + + +def test_sign_with_rejects_non_callable_signer(): + """Test that sign_with raises TypeError when the signer is not callable.""" + key = PrivateKey.generate_ed25519() + + with pytest.raises(TypeError, match="signer must be callable"): + _transfer().freeze().sign_with(key.public_key(), b"not a signer") + + +def test_sign_with_on_restored_transaction_dedups_and_adds(): + """Test that restored signatures are deduplicated and new ones are added after from_bytes.""" + restored_key = PrivateKey.generate_ed25519() + new_key = PrivateKey.generate_ecdsa() + tx = _transfer().set_node_account_ids([NODE_IDS[0]]).freeze().sign(restored_key) + + restored = Transaction.from_bytes(tx.to_bytes()) + restored_signer = RecordingSigner(restored_key) + new_signer = RecordingSigner(new_key) + + restored.sign_with(restored_key.public_key(), restored_signer) + restored.sign_with(new_key.public_key(), new_signer) + + assert restored_signer.calls == [] + (body,) = _all_bodies(restored) + assert len(_sig_pairs_for(restored, body, restored_key.public_key())) == 1 + assert len(_sig_pairs_for(restored, body, new_key.public_key())) == 1 + + +def test_sign_registers_signer(): + """Test that sign() routes through sign_with and registers the key's signer.""" + key = PrivateKey.generate_ecdsa() + tx = _transfer().freeze() + + tx.sign(key) + + assert tx.is_signed_by(key.public_key()) + assert [pk.to_bytes_raw() for pk, _ in tx._signers] == [key.public_key().to_bytes_raw()] + + +def test_apply_signers_signs_rebuilt_bodies(): + """Test that _apply_signers re-signs bodies that lack a signature from a registered signer.""" + key = PrivateKey.generate_ed25519() + signer = RecordingSigner(key) + tx = _transfer().freeze().sign_with(key.public_key(), signer) + + rebuilt_tx_id = TransactionId.generate(PAYER_ID) + rebuilt_body = b"rebuilt body bytes" + tx._transaction_body_bytes[rebuilt_tx_id] = {NODE_IDS[0]: rebuilt_body} + signer.calls.clear() + + tx._apply_signers() + + assert signer.calls == [rebuilt_body] + assert len(_sig_pairs_for(tx, rebuilt_body, key.public_key())) == 1 + + +def test_sign_with_operator_requires_operator(): + """Test that sign_with_operator raises when the client has no operator.""" + client = Client.for_testnet() + + with pytest.raises(ValueError, match="Client must have an operator"): + _transfer().freeze().sign_with_operator(client) + + client.close() + + +def test_sign_with_operator_freezes_unfrozen_transaction(mock_client): + """Test that sign_with_operator freezes the transaction with the client first.""" + tx = TransferTransaction().add_hbar_transfer(PAYER_ID, -1).add_hbar_transfer(AccountId(0, 0, 2), 1) + + tx.sign_with_operator(mock_client) + + assert tx._transaction_body_bytes + assert tx.is_signed_by(mock_client.operator_private_key.public_key()) + + +def test_sign_with_operator_uses_signer_based_operator(mock_client): + """Test that sign_with_operator signs through the operator's signer callback.""" + key = PrivateKey.generate_ecdsa() + signer = RecordingSigner(key) + mock_client.set_operator_with(PAYER_ID, key.public_key(), signer) + tx = _transfer().freeze() + + tx.sign_with_operator(mock_client) + + _assert_signed_once_per_body(tx, key, signer) + + +def test_set_operator_populates_private_key_and_operator(): + """Test that set_operator stays backward compatible and returns the client.""" + client = Client.for_testnet() + key = PrivateKey.generate_ed25519() + + assert client.set_operator(PAYER_ID, key) is client + + assert client.operator_private_key is key + assert client.operator_public_key.to_bytes_raw() == key.public_key().to_bytes_raw() + assert client.operator.account_id == PAYER_ID + assert client.operator.private_key is key + assert client.operator.signer(b"data") == key.sign(b"data") + + client.close() + + +def test_set_operator_with_leaves_private_key_none(): + """Test that set_operator_with stores the public key and signer, with no private key.""" + client = Client.for_testnet() + key = PrivateKey.generate_ed25519() + client.set_operator(AccountId(0, 0, 1), PrivateKey.generate_ed25519()) + + assert client.set_operator_with(PAYER_ID, key.public_key(), key.sign) is client + + assert client.operator_private_key is None + assert client.operator_signer == key.sign + assert client.operator == Operator(PAYER_ID, None, key.public_key(), key.sign) + + client.close() + + +def test_operator_supports_positional_construction(): + """Test that Operator keeps working when built with account ID and private key only.""" + key = PrivateKey.generate_ed25519() + + operator = Operator(PAYER_ID, key) + + assert operator.private_key is key + assert operator.public_key is None + assert operator.signer is None + + +def test_operator_private_key_assigned_directly_still_signs(): + """Test that assigning operator_private_key directly still yields a usable operator.""" + client = Client.for_testnet() + key = PrivateKey.generate_ed25519() + + client.operator_account_id = PAYER_ID + client.operator_private_key = key + + assert client.operator.public_key.to_bytes_raw() == key.public_key().to_bytes_raw() + assert client.operator.signer(b"data") == key.sign(b"data") + + client.close() + + +def test_execute_signs_with_operator_when_operator_is_payer(mock_client): + """Test that execute adds the operator signature when the operator pays.""" + tx = _transfer(payer=mock_client.operator_account_id) + + with patch.object(Transaction, "_execute", return_value=TransactionResponse()): + tx.execute(mock_client, wait_for_receipt=False) + + assert tx.is_signed_by(mock_client.operator_private_key.public_key()) + + +def test_execute_skips_operator_signature_when_operator_is_not_payer(mock_client): + """Test that execute does not add the operator signature for another payer.""" + payer_key = PrivateKey.generate_ed25519() + tx = _transfer(payer=AccountId(0, 0, 5555)).freeze_with(mock_client).sign(payer_key) + + with patch.object(Transaction, "_execute", return_value=TransactionResponse()): + tx.execute(mock_client, wait_for_receipt=False) + + assert tx.is_signed_by(payer_key.public_key()) + for body in _all_bodies(tx): + assert _sig_pairs_for(tx, body, mock_client.operator_private_key.public_key()) == [] + + +def test_execute_without_operator_does_not_crash(mock_client): + """Test that a fully signed transaction can be submitted from an operator-less client.""" + payer_key = PrivateKey.generate_ed25519() + tx = _transfer().freeze().sign(payer_key) + mock_client.operator_account_id = None + mock_client.operator_private_key = None + + with patch.object(Transaction, "_execute", return_value=TransactionResponse()): + tx.execute(mock_client, wait_for_receipt=False) + + for body in _all_bodies(tx): + assert len(tx._signature_map[body].sigPair) == 1 + + +def test_execute_signs_with_signer_based_operator(mock_client): + """Test that execute signs through the operator's signer callback.""" + key = PrivateKey.generate_ecdsa() + signer = RecordingSigner(key) + mock_client.set_operator_with(PAYER_ID, key.public_key(), signer) + tx = _transfer(payer=PAYER_ID) + + with patch.object(Transaction, "_execute", return_value=TransactionResponse()): + tx.execute(mock_client, wait_for_receipt=False) + + _assert_signed_once_per_body(tx, key, signer) + + +def test_batchify_signs_with_signer_based_operator(mock_client): + """Test that batchify signs through the operator's signer callback.""" + key = PrivateKey.generate_ed25519() + signer = RecordingSigner(key) + mock_client.set_operator_with(PAYER_ID, key.public_key(), signer) + + tx = ( + TransferTransaction() + .add_hbar_transfer(PAYER_ID, -1) + .add_hbar_transfer(AccountId(0, 0, 2), 1) + .batchify(mock_client, PrivateKey.generate_ed25519()) + ) + + _assert_signed_once_per_body(tx, key, signer) + + +def _payment_signature_pair(query: TokenInfoQuery): + header = query._make_request_header() + assert header.HasField("payment") + + signed_transaction = transaction_contents_pb2.SignedTransaction() + signed_transaction.ParseFromString(header.payment.signedTransactionBytes) + (pair,) = signed_transaction.sigMap.sigPair + return signed_transaction.bodyBytes, pair + + +def test_query_payment_signs_with_signer_based_operator(mock_client): + """Test that query payment is signed through the operator's signer callback.""" + key = PrivateKey.generate_ecdsa() + signer = RecordingSigner(key) + mock_client.set_operator_with(PAYER_ID, key.public_key(), signer) + + query = TokenInfoQuery().set_node_account_ids([NODE_IDS[0]]) + query.operator = mock_client.operator + query.payment_amount = Hbar(1) + + body_bytes, pair = _payment_signature_pair(query) + + assert signer.calls == [body_bytes] + assert pair.pubKeyPrefix == key.public_key().to_bytes_raw() + key.public_key().verify(pair.ECDSA_secp256k1, body_bytes) + + +def test_query_payment_supports_private_key_only_operator(): + """Test that query payment still works with an Operator built from a private key only.""" + key = PrivateKey.generate_ed25519() + + query = TokenInfoQuery().set_node_account_ids([NODE_IDS[0]]) + query.operator = Operator(PAYER_ID, key) + query.payment_amount = Hbar(1) + + body_bytes, pair = _payment_signature_pair(query) + + assert pair.pubKeyPrefix == key.public_key().to_bytes_raw() + key.public_key().verify(pair.ed25519, body_bytes) From 6ff0ee5a4617128dd7b522dda6b9afab6e9f4845 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Tue, 6 Oct 2026 23:40:29 +0300 Subject: [PATCH 2/2] chore(examples): drop redundant load_dotenv from external signer example Client.from_env() already loads .env. Signed-off-by: Ntege Daniel --- .../transaction/transaction_sign_with_external_signer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/transaction/transaction_sign_with_external_signer.py b/examples/transaction/transaction_sign_with_external_signer.py index dd220941e..7fa69c881 100644 --- a/examples/transaction/transaction_sign_with_external_signer.py +++ b/examples/transaction/transaction_sign_with_external_signer.py @@ -14,8 +14,6 @@ import sys -from dotenv import load_dotenv - from hiero_sdk_python import ( AccountCreateTransaction, AccountId, @@ -28,9 +26,6 @@ ) -load_dotenv() - - def setup_client() -> Client: """Initialize the client from the OPERATOR_ID and OPERATOR_KEY environment variables.""" client = Client.from_env()