Skip to content

Commit 1280cb1

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

File tree

12 files changed

+1508
-12
lines changed

12 files changed

+1508
-12
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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
66+

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

0 commit comments

Comments
 (0)