Skip to content

Commit e69cf65

Browse files
authored
feat: implement TLS support (#860)
Signed-off-by: emiliyank <e.kadiyski@gmail.com>
1 parent 2882580 commit e69cf65

File tree

12 files changed

+1524
-19
lines changed

12 files changed

+1524
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2020
- Added Acceptance Criteria section to Good First Issue template for better contributor guidance (#997)
2121
- Added __str__() to CustomRoyaltyFee and updated examples and tests accordingly (#986)
2222
- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362)
23+
- Add 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)
2324

2425
### Changed
2526

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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 _load_operator_credentials() -> tuple[AccountId, PrivateKey]:
37+
"""Load operator credentials from the environment."""
38+
operator_id_str = os.getenv("OPERATOR_ID")
39+
operator_key_str = os.getenv("OPERATOR_KEY")
40+
41+
if not operator_id_str or not operator_key_str:
42+
raise ValueError("OPERATOR_ID and OPERATOR_KEY must be set in the environment")
43+
44+
operator_id = AccountId.from_string(operator_id_str)
45+
operator_key = PrivateKey.from_string(operator_key_str)
46+
return operator_id, operator_key
47+
48+
49+
def setup_client() -> Client:
50+
"""Create and configure a client with TLS enabled using env settings."""
51+
network_name = os.getenv("NETWORK", "testnet")
52+
verify_certs = _bool_env("VERIFY_CERTS", True)
53+
54+
network = Network(network_name)
55+
client = Client(network)
56+
57+
# Enable TLS for hosted networks (mainnet, testnet, previewnet)
58+
# Disable TLS for local networks (localhost, solo, local)
59+
hosted_networks = ("mainnet", "testnet", "previewnet")
60+
local_networks = ("localhost", "solo", "local")
61+
62+
if network_name.lower() in hosted_networks:
63+
client.set_transport_security(True)
64+
elif network_name.lower() in local_networks:
65+
client.set_transport_security(False)
66+
# For custom networks, use Network's default (disabled)
67+
68+
client.set_verify_certificates(verify_certs)
69+
return client
70+
71+
72+
def query_account_balance(client: Client, account_id: AccountId):
73+
"""Execute a CryptoGetAccountBalanceQuery for the given account."""
74+
query = CryptoGetAccountBalanceQuery().set_account_id(account_id)
75+
balance = query.execute(client)
76+
print(f"Operator account {account_id} balance: {balance.hbars.to_hbars()} hbars")
77+
78+
79+
def main():
80+
load_dotenv()
81+
82+
operator_id, operator_key = _load_operator_credentials()
83+
client = setup_client()
84+
client.set_operator(operator_id, operator_key)
85+
86+
query_account_balance(client, operator_id)
87+
88+
89+
if __name__ == "__main__":
90+
main()
91+
92+
93+

src/hiero_sdk_python/client/client.py

Lines changed: 51 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,7 +50,8 @@ 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()
5657
if mirror_address.endswith(':50212') or mirror_address.endswith(':443'):
@@ -106,6 +107,54 @@ def close(self) -> None:
106107

107108
self.mirror_stub = None
108109

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

src/hiero_sdk_python/client/network.py

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Network module for managing Hedera SDK connections."""
22
import secrets
3-
from typing import Dict, List, Optional, Any
3+
from typing import Dict, List, Optional, Any, Tuple
44

55
import requests
66

@@ -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]
@@ -195,6 +215,130 @@ def _get_node(self, account_id: AccountId) -> Optional[_Node]:
195215

196216
def get_mirror_address(self) -> str:
197217
"""
198-
Return the configured mirror node address used for mirror queries.
218+
Return the configured mirror node address used for mirror gRPC queries.
219+
Mirror nodes always use TLS, so addresses should use port 443 for HTTPS.
199220
"""
200221
return self.mirror_address
222+
223+
def _parse_mirror_address(self) -> Tuple[str, int]:
224+
"""
225+
Parse mirror_address into host and port.
226+
227+
Returns:
228+
Tuple[str, int]: (host, port) tuple
229+
"""
230+
mirror_addr = self.mirror_address
231+
if ':' in mirror_addr:
232+
host, port_str = mirror_addr.rsplit(':', 1)
233+
try:
234+
port = int(port_str)
235+
except ValueError:
236+
port = 443
237+
else:
238+
host = mirror_addr
239+
port = 443
240+
return (host, port)
241+
242+
def _determine_scheme_and_port(self, host: str, port: int) -> Tuple[str, int]:
243+
"""
244+
Determine the scheme (http/https) and port for the REST URL.
245+
246+
Args:
247+
host: The hostname
248+
port: The port number
249+
250+
Returns:
251+
Tuple[str, int]: (scheme, port) tuple
252+
"""
253+
is_localhost = host in ('localhost', '127.0.0.1')
254+
255+
if is_localhost:
256+
scheme = 'http'
257+
if port == 443:
258+
port = 8080 # Default REST port for localhost
259+
else:
260+
scheme = 'https'
261+
if port == 5600: # gRPC port, use 443 for REST
262+
port = 443
263+
264+
return (scheme, port)
265+
266+
def _build_rest_url(self, scheme: str, host: str, port: int) -> str:
267+
"""
268+
Build the final REST URL with optional port.
269+
270+
Args:
271+
scheme: URL scheme (http or https)
272+
host: Hostname
273+
port: Port number
274+
275+
Returns:
276+
str: Complete REST URL with /api/v1 suffix
277+
"""
278+
is_default_port = (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80)
279+
280+
if is_default_port:
281+
return f"{scheme}://{host}/api/v1"
282+
return f"{scheme}://{host}:{port}/api/v1"
283+
284+
def get_mirror_rest_url(self) -> str:
285+
"""
286+
Get the REST API base URL for the mirror node.
287+
Returns the URL in format: scheme://host[:port]/api/v1
288+
For non-localhost networks, defaults to https:// with port 443.
289+
"""
290+
base_url = self.MIRROR_NODE_URLS.get(self.network)
291+
if base_url:
292+
# MIRROR_NODE_URLS contains base URLs, append /api/v1
293+
return f"{base_url}/api/v1"
294+
295+
# Fallback: construct from mirror_address
296+
host, port = self._parse_mirror_address()
297+
scheme, port = self._determine_scheme_and_port(host, port)
298+
return self._build_rest_url(scheme, host, port)
299+
300+
def set_transport_security(self, enabled: bool) -> None:
301+
"""
302+
Enable or disable TLS for consensus node connections.
303+
"""
304+
if self._transport_security == enabled:
305+
return
306+
for node in self.nodes:
307+
node._apply_transport_security(enabled) # pylint: disable=protected-access
308+
self._transport_security = enabled
309+
310+
def is_transport_security(self) -> bool:
311+
"""
312+
Determine if TLS is enabled for consensus node connections.
313+
"""
314+
return self._transport_security
315+
316+
def set_verify_certificates(self, verify: bool) -> None:
317+
"""
318+
Enable or disable server certificate verification when TLS is enabled.
319+
"""
320+
if self._verify_certificates == verify:
321+
return
322+
for node in self.nodes:
323+
node._set_verify_certificates(verify) # pylint: disable=protected-access
324+
self._verify_certificates = verify
325+
326+
def set_tls_root_certificates(self, root_certificates: Optional[bytes]) -> None:
327+
"""
328+
Provide custom root certificates to use when establishing TLS channels.
329+
"""
330+
self._root_certificates = root_certificates
331+
for node in self.nodes:
332+
node._set_root_certificates(root_certificates) # pylint: disable=protected-access
333+
334+
def get_tls_root_certificates(self) -> Optional[bytes]:
335+
"""
336+
Retrieve the configured root certificates used for TLS channels.
337+
"""
338+
return self._root_certificates
339+
340+
def is_verify_certificates(self) -> bool:
341+
"""
342+
Determine if certificate verification is enabled.
343+
"""
344+
return self._verify_certificates

0 commit comments

Comments
 (0)