Skip to content

Commit e22e60b

Browse files
committed
implement TLS support
Signed-off-by: emiliyank <e.kadiyski@gmail.com>
1 parent a9f5833 commit e22e60b

File tree

12 files changed

+1467
-11
lines changed

12 files changed

+1467
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
88

99
### Added
1010

11+
- TLS support with two-stage control (`set_transport_security()` and `set_verify_certificates()`) for encrypted connections to Hedera networks. TLS is enabled by default for hosted networks (mainnet, testnet, previewnet) and disabled for local networks (solo, localhost) (#855)
1112
- Add detail to `token_airdrop.py` and `token_airdrop_cancel.py`
1213
- Add workflow: github bot to respond to unverified PR commits (#750)
1314
- Add workflow: bot workflow which notifies developers of workflow failures in their pull requests.

docs/sdk_developers/setup.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,13 @@ FREEZE_KEY=...
183183
RECIPIENT_ID=...
184184
TOKEN_ID=...
185185
TOPIC_ID=...
186+
VERIFY_CERTS=true # Enable certificate verification for TLS (default: true)
186187
```
187188

188189
These are only needed if you're customizing example scripts.
189190

191+
**Note on TLS:** The SDK uses TLS by default for hosted networks (testnet, mainnet, previewnet). For local networks (solo, localhost), TLS is disabled by default.
192+
190193
### Verify Your Setup
191194

192195
Run the test suite to ensure everything is working:

examples/tls_query_balance.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
TLS Query Balance Example
3+
4+
Demonstrates how to connect to the Hedera network with TLS enabled.
5+
6+
Required environment variables:
7+
- OPERATOR_ID
8+
- OPERATOR_KEY
9+
Optional:
10+
- NETWORK (defaults to testnet)
11+
- VERIFY_CERTS (set to \"true\" to enforce certificate hash checks)
12+
13+
Run with:
14+
uv run examples/tls_query_balance.py
15+
"""
16+
17+
import os
18+
from dotenv import load_dotenv
19+
20+
from hiero_sdk_python import (
21+
Network,
22+
Client,
23+
AccountId,
24+
PrivateKey,
25+
CryptoGetAccountBalanceQuery,
26+
)
27+
28+
29+
def _bool_env(name: str, default: bool = False) -> bool:
30+
value = os.getenv(name)
31+
if value is None:
32+
return default
33+
return value.strip().lower() in {"1", "true", "yes"}
34+
35+
36+
def main():
37+
load_dotenv()
38+
39+
network_name = os.getenv("NETWORK", "testnet")
40+
operator_id_str = os.getenv("OPERATOR_ID")
41+
operator_key_str = os.getenv("OPERATOR_KEY")
42+
43+
if not operator_id_str or not operator_key_str:
44+
raise ValueError("OPERATOR_ID and OPERATOR_KEY must be set in the environment")
45+
46+
network = Network(network_name)
47+
client = Client(network)
48+
49+
# Enable TLS for consensus nodes. Mirror nodes already require TLS.
50+
client.set_transport_security(False)
51+
52+
verify_certs = _bool_env("VERIFY_CERTS", True)
53+
client.set_verify_certificates(verify_certs)
54+
55+
operator_id = AccountId.from_string(operator_id_str)
56+
operator_key = PrivateKey.from_string(operator_key_str)
57+
client.set_operator(operator_id, operator_key)
58+
59+
balance = CryptoGetAccountBalanceQuery().set_account_id(operator_id).execute(client)
60+
print(f"Operator account {operator_id} balance: {balance.hbars.to_hbars()} hbars")
61+
62+
63+
if __name__ == "__main__":
64+
main()
65+

src/hiero_sdk_python/client/client.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Client module for interacting with the Hedera network.
33
"""
44

5-
from typing import NamedTuple, List, Union
5+
from typing import NamedTuple, List, Union, Optional
66

77
import grpc
88

@@ -50,9 +50,11 @@ def __init__(self, network: Network = None) -> None:
5050
def _init_mirror_stub(self) -> None:
5151
"""
5252
Connect to a mirror node for topic message subscriptions.
53-
We now use self.network.get_mirror_address() for a configurable mirror address.
53+
Mirror nodes always use TLS (mandatory). We use self.network.get_mirror_address()
54+
for a configurable mirror address, which should use port 443 for HTTPS connections.
5455
"""
5556
mirror_address = self.network.get_mirror_address()
57+
# Mirror nodes always require TLS - secure_channel is mandatory
5658
self.mirror_channel = grpc.secure_channel(mirror_address, grpc.ssl_channel_credentials())
5759
self.mirror_stub = mirror_consensus_grpc.ConsensusServiceStub(self.mirror_channel)
5860

@@ -103,6 +105,54 @@ def close(self) -> None:
103105

104106
self.mirror_stub = None
105107

108+
def set_transport_security(self, enabled: bool) -> "Client":
109+
"""
110+
Enable or disable TLS for consensus node connections.
111+
112+
Note:
113+
TLS is enabled by default for hosted networks (mainnet, testnet, previewnet).
114+
For local networks (solo, localhost) and custom networks, TLS is disabled by default.
115+
Use this method to override the default behavior.
116+
"""
117+
self.network.set_transport_security(enabled)
118+
return self
119+
120+
def is_transport_security(self) -> bool:
121+
"""
122+
Determine if TLS is enabled for consensus node connections.
123+
"""
124+
return self.network.is_transport_security()
125+
126+
def set_verify_certificates(self, verify: bool) -> "Client":
127+
"""
128+
Enable or disable verification of server certificates when TLS is enabled.
129+
130+
Note:
131+
Certificate verification is enabled by default for all networks.
132+
Use this method to disable verification (e.g., for testing with self-signed certificates).
133+
"""
134+
self.network.set_verify_certificates(verify)
135+
return self
136+
137+
def is_verify_certificates(self) -> bool:
138+
"""
139+
Determine if certificate verification is enabled.
140+
"""
141+
return self.network.is_verify_certificates()
142+
143+
def set_tls_root_certificates(self, root_certificates: Optional[bytes]) -> "Client":
144+
"""
145+
Provide custom root certificates for TLS connections.
146+
"""
147+
self.network.set_tls_root_certificates(root_certificates)
148+
return self
149+
150+
def get_tls_root_certificates(self) -> Optional[bytes]:
151+
"""
152+
Retrieve the configured root certificates for TLS connections.
153+
"""
154+
return self.network.get_tls_root_certificates()
155+
106156
def __enter__(self) -> "Client":
107157
"""
108158
Allows the Client to be used in a 'with' statement for automatic resource management.

src/hiero_sdk_python/client/network.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,20 @@ class Network:
1515
Manages the network configuration for connecting to the Hedera network.
1616
"""
1717

18+
# Mirror node gRPC addresses (always use TLS, port 443 for HTTPS)
1819
MIRROR_ADDRESS_DEFAULT: Dict[str,str] = {
1920
'mainnet': 'mainnet.mirrornode.hedera.com:443',
2021
'testnet': 'testnet.mirrornode.hedera.com:443',
2122
'previewnet': 'previewnet.mirrornode.hedera.com:443',
22-
'solo': 'localhost:5600'
23+
'solo': 'localhost:5600' # Local development only
2324
}
2425

26+
# Mirror node REST API base URLs (HTTPS for production networks, HTTP for localhost)
2527
MIRROR_NODE_URLS: Dict[str,str] = {
2628
'mainnet': 'https://mainnet-public.mirrornode.hedera.com',
2729
'testnet': 'https://testnet.mirrornode.hedera.com',
2830
'previewnet': 'https://previewnet.mirrornode.hedera.com',
29-
'solo': 'http://localhost:8080'
31+
'solo': 'http://localhost:8080' # Local development only
3032
}
3133

3234
DEFAULT_NODES: Dict[str,List[_Node]] = {
@@ -92,13 +94,25 @@ def __init__(
9294
mirror_address (str, optional): A mirror node address (host:port) for topic queries.
9395
If not provided,
9496
we'll use a default from MIRROR_ADDRESS_DEFAULT[network].
97+
98+
Note:
99+
TLS is enabled by default for hosted networks (mainnet, testnet, previewnet).
100+
For local networks (solo, localhost) and custom networks, TLS is disabled by default.
101+
Certificate verification is enabled by default for all networks.
102+
Use Client.set_transport_security() and Client.set_verify_certificates() to customize.
95103
"""
96104
self.network: str = network or 'testnet'
97105
self.mirror_address: str = mirror_address or self.MIRROR_ADDRESS_DEFAULT.get(
98106
network, 'localhost:5600'
99107
)
100108

101109
self.ledger_id = ledger_id or self.LEDGER_ID.get(network, bytes.fromhex('03'))
110+
111+
# Default TLS configuration: enabled for hosted networks, disabled for local/custom
112+
hosted_networks = ('mainnet', 'testnet', 'previewnet')
113+
self._transport_security: bool = self.network in hosted_networks
114+
self._verify_certificates: bool = True # Always enabled by default
115+
self._root_certificates: Optional[bytes] = None
102116

103117
if nodes is not None:
104118
final_nodes = nodes
@@ -114,6 +128,12 @@ def __init__(
114128
raise ValueError(f"No default nodes for network='{self.network}'")
115129

116130
self.nodes: List[_Node] = final_nodes
131+
132+
# Apply TLS configuration to all nodes
133+
for node in self.nodes:
134+
node._apply_transport_security(self._transport_security) # pylint: disable=protected-access
135+
node._set_verify_certificates(self._verify_certificates) # pylint: disable=protected-access
136+
node._set_root_certificates(self._root_certificates) # pylint: disable=protected-access
117137

118138
self._node_index: int = secrets.randbelow(len(self.nodes))
119139
self.current_node: _Node = self.nodes[self._node_index]
@@ -180,6 +200,91 @@ def _select_node(self) -> _Node:
180200

181201
def get_mirror_address(self) -> str:
182202
"""
183-
Return the configured mirror node address used for mirror queries.
203+
Return the configured mirror node address used for mirror gRPC queries.
204+
Mirror nodes always use TLS, so addresses should use port 443 for HTTPS.
184205
"""
185206
return self.mirror_address
207+
208+
def get_mirror_rest_url(self) -> str:
209+
"""
210+
Get the REST API base URL for the mirror node.
211+
Returns the URL in format: scheme://host[:port]/api/v1
212+
For non-localhost networks, defaults to https:// with port 443.
213+
"""
214+
base_url = self.MIRROR_NODE_URLS.get(self.network)
215+
if base_url:
216+
# MIRROR_NODE_URLS contains base URLs, append /api/v1
217+
return f"{base_url}/api/v1"
218+
219+
# Fallback: construct from mirror_address
220+
mirror_addr = self.mirror_address
221+
if ':' in mirror_addr:
222+
host, port_str = mirror_addr.rsplit(':', 1)
223+
try:
224+
port = int(port_str)
225+
except ValueError:
226+
port = 443
227+
else:
228+
host = mirror_addr
229+
port = 443
230+
231+
# Use HTTPS for non-localhost, HTTP for localhost
232+
if host in ('localhost', '127.0.0.1'):
233+
scheme = 'http'
234+
if port == 443:
235+
port = 8080 # Default REST port for localhost
236+
else:
237+
scheme = 'https'
238+
if port == 5600: # gRPC port, use 443 for REST
239+
port = 443
240+
241+
# Omit default ports
242+
if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
243+
return f"{scheme}://{host}/api/v1"
244+
return f"{scheme}://{host}:{port}/api/v1"
245+
246+
def set_transport_security(self, enabled: bool) -> None:
247+
"""
248+
Enable or disable TLS for consensus node connections.
249+
"""
250+
if self._transport_security == enabled:
251+
return
252+
for node in self.nodes:
253+
node._apply_transport_security(enabled) # pylint: disable=protected-access
254+
self._transport_security = enabled
255+
256+
def is_transport_security(self) -> bool:
257+
"""
258+
Determine if TLS is enabled for consensus node connections.
259+
"""
260+
return self._transport_security
261+
262+
def set_verify_certificates(self, verify: bool) -> None:
263+
"""
264+
Enable or disable server certificate verification when TLS is enabled.
265+
"""
266+
if self._verify_certificates == verify:
267+
return
268+
for node in self.nodes:
269+
node._set_verify_certificates(verify) # pylint: disable=protected-access
270+
self._verify_certificates = verify
271+
272+
def set_tls_root_certificates(self, root_certificates: Optional[bytes]) -> None:
273+
"""
274+
Provide custom root certificates to use when establishing TLS channels.
275+
"""
276+
self._root_certificates = root_certificates
277+
for node in self.nodes:
278+
node._set_root_certificates(root_certificates) # pylint: disable=protected-access
279+
280+
def get_tls_root_certificates(self) -> Optional[bytes]:
281+
"""
282+
Retrieve the configured root certificates used for TLS channels.
283+
"""
284+
return self._root_certificates
285+
286+
def is_verify_certificates(self) -> bool:
287+
"""
288+
Determine if certificate verification is enabled.
289+
"""
290+
return self._verify_certificates

src/hiero_sdk_python/managed_node_address.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ class _ManagedNodeAddress:
55
Represents a managed node address with a host and port.
66
This class is used to handle node addresses in the Hedera network.
77
"""
8+
PORT_NODE_PLAIN = 50211
9+
PORT_NODE_TLS = 50212
10+
PORT_MIRROR_TLS = 443
11+
PORT_MIRROR_PLAIN = 5600
12+
TLS_PORTS = {PORT_NODE_TLS, PORT_MIRROR_TLS}
13+
PLAIN_PORTS = {PORT_NODE_PLAIN, PORT_MIRROR_PLAIN}
814

915
# Regular expression to parse a host:port string
1016
HOST_PORT_PATTERN = re.compile(r'^(\S+):(\d+)$')
@@ -53,8 +59,48 @@ def _is_transport_security(self):
5359
Returns:
5460
bool: True if the port is a secure port (50212 or 443), False otherwise.
5561
"""
56-
return self._port == 50212 or self._port == 443
62+
return self._port in self.TLS_PORTS
5763

64+
def _to_secure(self):
65+
"""
66+
Return a new ManagedNodeAddress that uses the secure port when possible.
67+
"""
68+
if self._is_transport_security():
69+
return self
70+
71+
port = self._port
72+
if port == self.PORT_NODE_PLAIN:
73+
port = self.PORT_NODE_TLS
74+
elif port == self.PORT_MIRROR_PLAIN:
75+
port = self.PORT_MIRROR_TLS
76+
return _ManagedNodeAddress(self._address, port)
77+
78+
def _to_insecure(self):
79+
"""
80+
Return a new ManagedNodeAddress that uses the plaintext port when possible.
81+
"""
82+
if not self._is_transport_security():
83+
return self
84+
85+
port = self._port
86+
if port == self.PORT_NODE_TLS:
87+
port = self.PORT_NODE_PLAIN
88+
elif port == self.PORT_MIRROR_TLS:
89+
port = self.PORT_MIRROR_PLAIN
90+
return _ManagedNodeAddress(self._address, port)
91+
92+
def _get_host(self):
93+
"""
94+
Return the host component of the address.
95+
"""
96+
return self._address
97+
98+
def _get_port(self):
99+
"""
100+
Return the port component of the address.
101+
"""
102+
return self._port
103+
58104
def __str__(self):
59105
"""
60106
Get a string representation of the ManagedNodeAddress.

0 commit comments

Comments
 (0)