python-mytnb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mytnb/__init__.py +26 -0
- mytnb/__main__.py +5 -0
- mytnb/auth.py +67 -0
- mytnb/cli.py +379 -0
- mytnb/client/__init__.py +3 -0
- mytnb/client/auth.py +159 -0
- mytnb/client/client.py +239 -0
- mytnb/client/config.py +35 -0
- mytnb/client/legacy.py +120 -0
- mytnb/client/rest.py +117 -0
- mytnb/crypto.py +142 -0
- mytnb/exceptions.py +47 -0
- mytnb/models.py +236 -0
- python_mytnb-0.1.0.dist-info/METADATA +165 -0
- python_mytnb-0.1.0.dist-info/RECORD +18 -0
- python_mytnb-0.1.0.dist-info/WHEEL +4 -0
- python_mytnb-0.1.0.dist-info/entry_points.txt +2 -0
- python_mytnb-0.1.0.dist-info/licenses/LICENSE +21 -0
mytnb/client/client.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""myTNB API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import tls_client
|
|
9
|
+
|
|
10
|
+
from mytnb.auth import Credentials
|
|
11
|
+
from mytnb.client.auth import login
|
|
12
|
+
from mytnb.client.config import USER_AGENT
|
|
13
|
+
from mytnb.client.legacy import _LegacyTransport
|
|
14
|
+
from mytnb.client.rest import _RestTransport
|
|
15
|
+
from mytnb.models import AccountUsage, BREligibility, SMRAccount
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MyTNBClient:
|
|
19
|
+
"""Client for interacting with the myTNB API.
|
|
20
|
+
|
|
21
|
+
This client supports two API backends:
|
|
22
|
+
- Legacy ASMX API (mytnbapp.tnb.com.my) - uses encrypted payloads
|
|
23
|
+
- REST API (api.mytnb.com.my) - uses JWT authentication
|
|
24
|
+
|
|
25
|
+
For the REST API, you need an API key and authorization token.
|
|
26
|
+
The legacy API payloads are automatically encrypted using the
|
|
27
|
+
embedded RSA public key + AES-256-CBC.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
credentials: Credentials,
|
|
33
|
+
timeout: float = 30.0,
|
|
34
|
+
*,
|
|
35
|
+
use_staging_key: bool = False,
|
|
36
|
+
):
|
|
37
|
+
self._credentials = credentials
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._use_staging_key = use_staging_key
|
|
40
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
41
|
+
self._tls_session: Optional[tls_client.Session] = None
|
|
42
|
+
self._rest: Optional[_RestTransport] = None
|
|
43
|
+
self._legacy: Optional[_LegacyTransport] = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def credentials(self) -> Credentials:
|
|
47
|
+
"""The authenticated credentials for this client."""
|
|
48
|
+
return self._credentials
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def _client(self) -> httpx.AsyncClient:
|
|
52
|
+
if self._http_client is None or self._http_client.is_closed:
|
|
53
|
+
self._http_client = httpx.AsyncClient(
|
|
54
|
+
timeout=self._timeout,
|
|
55
|
+
headers={"User-Agent": USER_AGENT},
|
|
56
|
+
)
|
|
57
|
+
return self._http_client
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _legacy_client(self) -> tls_client.Session:
|
|
61
|
+
"""Get or create a tls_client session for legacy ASMX requests.
|
|
62
|
+
|
|
63
|
+
Uses an Android TLS fingerprint to bypass CloudFront WAF.
|
|
64
|
+
"""
|
|
65
|
+
if self._tls_session is None:
|
|
66
|
+
self._tls_session = tls_client.Session(
|
|
67
|
+
client_identifier="okhttp4_android_13",
|
|
68
|
+
)
|
|
69
|
+
return self._tls_session
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _rest_transport(self) -> _RestTransport:
|
|
73
|
+
if self._rest is None:
|
|
74
|
+
self._rest = _RestTransport(self._client, self._credentials)
|
|
75
|
+
return self._rest
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def _legacy_transport(self) -> _LegacyTransport:
|
|
79
|
+
if self._legacy is None:
|
|
80
|
+
self._legacy = _LegacyTransport(
|
|
81
|
+
self._legacy_client,
|
|
82
|
+
self._credentials,
|
|
83
|
+
self._timeout,
|
|
84
|
+
self._use_staging_key,
|
|
85
|
+
)
|
|
86
|
+
return self._legacy
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
"""Close the HTTP client."""
|
|
90
|
+
if self._http_client and not self._http_client.is_closed:
|
|
91
|
+
await self._http_client.aclose()
|
|
92
|
+
|
|
93
|
+
async def __aenter__(self) -> "MyTNBClient":
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
97
|
+
await self.close()
|
|
98
|
+
|
|
99
|
+
login = classmethod(login)
|
|
100
|
+
|
|
101
|
+
# ── REST API endpoints ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async def get_bill_eligibility(
|
|
104
|
+
self, contract_accounts: list[str], user_id: str
|
|
105
|
+
) -> list[BREligibility]:
|
|
106
|
+
"""Get bill rendering eligibility indicators for accounts."""
|
|
107
|
+
body = {"caNos": contract_accounts, "userID": user_id}
|
|
108
|
+
data = await self._rest_transport.post(
|
|
109
|
+
"BillRendering/api/v1/BillRendering/BREligibilityIndicators",
|
|
110
|
+
body=body,
|
|
111
|
+
)
|
|
112
|
+
content = data.get("content", [])
|
|
113
|
+
return [BREligibility.model_validate(item) for item in content]
|
|
114
|
+
|
|
115
|
+
async def get_draft_application_status(self) -> dict:
|
|
116
|
+
"""Get draft application status for MyHome."""
|
|
117
|
+
data = await self._rest_transport.post(
|
|
118
|
+
"myhome/myhome-svc/api/v1/GetDraftApplication/GetDraftApplicationStatus",
|
|
119
|
+
use_bearer=True,
|
|
120
|
+
)
|
|
121
|
+
return data.get("content") or data
|
|
122
|
+
|
|
123
|
+
async def get_eligibility_icons(self) -> dict:
|
|
124
|
+
"""Get more icon list (eligibility features)."""
|
|
125
|
+
user_info = self._credentials.user_info
|
|
126
|
+
device_info = self._credentials.device_info
|
|
127
|
+
|
|
128
|
+
body = {}
|
|
129
|
+
if user_info and device_info:
|
|
130
|
+
body = {
|
|
131
|
+
"usrInf": {
|
|
132
|
+
"userName": user_info.user_name,
|
|
133
|
+
"userID": user_info.user_id,
|
|
134
|
+
"sspuid": user_info.user_id,
|
|
135
|
+
"deviceID": device_info.device_id,
|
|
136
|
+
"language": user_info.language,
|
|
137
|
+
"sec_auth_k1": self._credentials.secure_key or "",
|
|
138
|
+
"sec_auth_k2": "",
|
|
139
|
+
"isWhiteList": False,
|
|
140
|
+
},
|
|
141
|
+
"deviceInf": device_info.to_dict(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
data = await self._rest_transport.post(
|
|
145
|
+
"Eligibility/api/v1/Eligibility/GetMoreIconList",
|
|
146
|
+
body=body,
|
|
147
|
+
)
|
|
148
|
+
return data.get("content") or data
|
|
149
|
+
|
|
150
|
+
# ── Legacy API endpoints (auto-encrypted) ───────────────────────
|
|
151
|
+
|
|
152
|
+
async def get_account_usage_smart(
|
|
153
|
+
self,
|
|
154
|
+
account_number: str,
|
|
155
|
+
*,
|
|
156
|
+
is_owner: bool = True,
|
|
157
|
+
) -> AccountUsage:
|
|
158
|
+
"""Get smart meter account usage data."""
|
|
159
|
+
data = {
|
|
160
|
+
"contractAccount": account_number,
|
|
161
|
+
"isOwner": "true" if is_owner else "false",
|
|
162
|
+
"metercode": "",
|
|
163
|
+
"usrInf": self._legacy_transport.base_user_info(),
|
|
164
|
+
}
|
|
165
|
+
result = await self._legacy_transport.post("GetAccountUsageSmart", data)
|
|
166
|
+
return AccountUsage.from_api_response(result.get("data", {}))
|
|
167
|
+
|
|
168
|
+
async def get_smr_accounts(
|
|
169
|
+
self,
|
|
170
|
+
contract_accounts: list[str],
|
|
171
|
+
) -> list[SMRAccount]:
|
|
172
|
+
"""Get Smart Meter Reading account statuses."""
|
|
173
|
+
data = {
|
|
174
|
+
"contractAccounts": contract_accounts,
|
|
175
|
+
"usrInf": self._legacy_transport.base_user_info(),
|
|
176
|
+
}
|
|
177
|
+
result = await self._legacy_transport.post("GetAccountsSMRIcon", data)
|
|
178
|
+
accounts = result.get("data", [])
|
|
179
|
+
return [SMRAccount.model_validate(acc) for acc in accounts]
|
|
180
|
+
|
|
181
|
+
async def get_services(self) -> dict:
|
|
182
|
+
"""Get available services (V4)."""
|
|
183
|
+
data = {"usrInf": self._legacy_transport.base_user_info()}
|
|
184
|
+
return await self._legacy_transport.post("GetServicesV4", data)
|
|
185
|
+
|
|
186
|
+
async def get_energy_recommendations(
|
|
187
|
+
self,
|
|
188
|
+
account_number: str,
|
|
189
|
+
*,
|
|
190
|
+
is_owner: bool = True,
|
|
191
|
+
) -> dict:
|
|
192
|
+
"""Get energy budget recommendations."""
|
|
193
|
+
data = {
|
|
194
|
+
"contractAccount": account_number,
|
|
195
|
+
"isOwner": "true" if is_owner else "false",
|
|
196
|
+
"usrInf": self._legacy_transport.base_user_info(),
|
|
197
|
+
}
|
|
198
|
+
result = await self._legacy_transport.post("GetUserEBRecommendations", data)
|
|
199
|
+
return result.get("data") or result
|
|
200
|
+
|
|
201
|
+
async def get_account_due_amount(
|
|
202
|
+
self,
|
|
203
|
+
account_number: str,
|
|
204
|
+
*,
|
|
205
|
+
is_owner: bool = True,
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""Get account due amount."""
|
|
208
|
+
data = {
|
|
209
|
+
"contractAccount": account_number,
|
|
210
|
+
"isOwnedAccount": "true" if is_owner else "false",
|
|
211
|
+
"usrInf": self._legacy_transport.base_user_info(),
|
|
212
|
+
}
|
|
213
|
+
result = await self._legacy_transport.post("GetAccountDueAmount", data)
|
|
214
|
+
return result.get("data") or result
|
|
215
|
+
|
|
216
|
+
async def get_bill_history(
|
|
217
|
+
self,
|
|
218
|
+
account_number: str,
|
|
219
|
+
*,
|
|
220
|
+
is_owner: bool = True,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""Get bill payment history."""
|
|
223
|
+
data = {
|
|
224
|
+
"contractAccount": account_number,
|
|
225
|
+
"isOwnedAccount": "true" if is_owner else "false",
|
|
226
|
+
"usrInf": self._legacy_transport.base_user_info(),
|
|
227
|
+
}
|
|
228
|
+
result = await self._legacy_transport.post("GetBillHistory", data)
|
|
229
|
+
return result.get("data") or result
|
|
230
|
+
|
|
231
|
+
async def get_current_usage(self, account_number: str) -> dict:
|
|
232
|
+
"""Get a simplified summary of current usage."""
|
|
233
|
+
usage = await self.get_account_usage_smart(account_number)
|
|
234
|
+
return {
|
|
235
|
+
"current_usage_kwh": usage.current_usage_kwh,
|
|
236
|
+
"current_cost_rm": usage.current_cost_rm,
|
|
237
|
+
"projected_cost_rm": usage.projected_cost_rm,
|
|
238
|
+
"date_range": usage.date_range,
|
|
239
|
+
}
|
mytnb/client/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""myTNB API client configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from mytnb.exceptions import AuthenticationError, GeoBlockedError, RateLimitError
|
|
8
|
+
|
|
9
|
+
# Base URLs
|
|
10
|
+
LEGACY_BASE_URL = "https://mytnbapp.tnb.com.my/v7/mytnbws.asmx"
|
|
11
|
+
REST_BASE_URL = "https://api.mytnb.com.my"
|
|
12
|
+
SITECORE_LOGIN_URL = "https://www.mytnb.com.my/api/sitecore/Account/Login"
|
|
13
|
+
SSO_HANDLER_URL = "https://myaccount.mytnb.com.my/SSO/SSOHandler"
|
|
14
|
+
|
|
15
|
+
# Default user agents
|
|
16
|
+
USER_AGENT = "RestSharp/110.2.0.0"
|
|
17
|
+
USER_AGENT_IOS = "myTNB/1425 CFNetwork/3860.500.112 Darwin/25.4.0"
|
|
18
|
+
|
|
19
|
+
# Default API key for token generation (embedded in the mobile app)
|
|
20
|
+
DEFAULT_API_KEY = "gpUS5pe4aO2yMbId7bFa13dGfYYnBWbjn3vqn0d7"
|
|
21
|
+
|
|
22
|
+
# Default security key for legacy ASMX requests (embedded in the mobile app)
|
|
23
|
+
DEFAULT_SECURE_KEY_K1 = "E6148656-205B-494C-BC95-CC241423E72F"
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_http_status(status_code: int, context: str = "API") -> None:
|
|
29
|
+
"""Check for common HTTP error status codes."""
|
|
30
|
+
if status_code == 403:
|
|
31
|
+
raise GeoBlockedError()
|
|
32
|
+
if status_code == 401:
|
|
33
|
+
raise AuthenticationError("Authentication failed", error_code="401")
|
|
34
|
+
if status_code == 429:
|
|
35
|
+
raise RateLimitError(f"Rate limited by {context}")
|
mytnb/client/legacy.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Legacy ASMX API transport (mytnbapp.tnb.com.my) with encryption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tls_client
|
|
11
|
+
|
|
12
|
+
from mytnb.auth import Credentials
|
|
13
|
+
from mytnb.client.config import (
|
|
14
|
+
DEFAULT_SECURE_KEY_K1,
|
|
15
|
+
LEGACY_BASE_URL,
|
|
16
|
+
USER_AGENT,
|
|
17
|
+
_check_http_status,
|
|
18
|
+
)
|
|
19
|
+
from mytnb.crypto import encrypt_request
|
|
20
|
+
from mytnb.exceptions import (
|
|
21
|
+
APIError,
|
|
22
|
+
MyTNBError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _LegacyTransport:
|
|
29
|
+
"""Internal helper for legacy ASMX API encrypted requests."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session: tls_client.Session,
|
|
34
|
+
credentials: Credentials,
|
|
35
|
+
timeout: float,
|
|
36
|
+
use_staging_key: bool,
|
|
37
|
+
):
|
|
38
|
+
self._session = session
|
|
39
|
+
self._credentials = credentials
|
|
40
|
+
self._timeout = timeout
|
|
41
|
+
self._use_staging_key = use_staging_key
|
|
42
|
+
|
|
43
|
+
def headers(self) -> dict[str, str]:
|
|
44
|
+
"""Build headers for legacy ASMX API requests."""
|
|
45
|
+
headers: dict[str, str] = {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"Accept": "application/json,text/json,text/x-json,text/javascript,application/xml,text/xml",
|
|
48
|
+
"Lang": "EN",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if self._credentials.user_info:
|
|
52
|
+
headers["UserInfo"] = json.dumps(self._credentials.user_info.to_dict())
|
|
53
|
+
|
|
54
|
+
if self._credentials.secure_key:
|
|
55
|
+
headers["SecureKey"] = self._credentials.secure_key
|
|
56
|
+
|
|
57
|
+
return headers
|
|
58
|
+
|
|
59
|
+
def base_user_info(self) -> dict:
|
|
60
|
+
"""Build the usrInf object from credentials for legacy ASMX requests."""
|
|
61
|
+
ui = self._credentials.user_info
|
|
62
|
+
di = self._credentials.device_info
|
|
63
|
+
if not ui:
|
|
64
|
+
raise MyTNBError("user_info is required for legacy API calls")
|
|
65
|
+
return {
|
|
66
|
+
"eid": ui.user_name,
|
|
67
|
+
"sspuid": ui.user_id,
|
|
68
|
+
"did": di.device_id if di else "",
|
|
69
|
+
"ft": "",
|
|
70
|
+
"lang": ui.language,
|
|
71
|
+
"sec_auth_k1": DEFAULT_SECURE_KEY_K1,
|
|
72
|
+
"sec_auth_k2": "",
|
|
73
|
+
"ses_param1": "",
|
|
74
|
+
"ses_param2": "",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def post(self, endpoint: str, data: Any) -> dict:
|
|
78
|
+
"""Make a POST request to the legacy ASMX API.
|
|
79
|
+
|
|
80
|
+
Automatically encrypts the data using AES-256-CBC + RSA-OAEP.
|
|
81
|
+
Uses tls_client with an Android TLS fingerprint to bypass CloudFront WAF.
|
|
82
|
+
"""
|
|
83
|
+
url = f"{LEGACY_BASE_URL}/{endpoint}"
|
|
84
|
+
req_headers = self.headers()
|
|
85
|
+
req_headers["User-Agent"] = USER_AGENT
|
|
86
|
+
|
|
87
|
+
payload = encrypt_request(data, use_staging_key=self._use_staging_key)
|
|
88
|
+
body = {"dt": payload.to_dict()}
|
|
89
|
+
|
|
90
|
+
response = await asyncio.to_thread(
|
|
91
|
+
self._session.post,
|
|
92
|
+
url,
|
|
93
|
+
headers=req_headers,
|
|
94
|
+
json=body,
|
|
95
|
+
timeout_seconds=int(self._timeout),
|
|
96
|
+
)
|
|
97
|
+
logger.debug("Legacy POST %s → %s", endpoint, response.status_code)
|
|
98
|
+
|
|
99
|
+
_check_http_status(response.status_code, context="legacy API")
|
|
100
|
+
|
|
101
|
+
if response.status_code != 200:
|
|
102
|
+
raise APIError(
|
|
103
|
+
message=f"Legacy API request failed with status {response.status_code}",
|
|
104
|
+
error_code=str(response.status_code),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
data = response.json()
|
|
108
|
+
|
|
109
|
+
d = data.get("d", {})
|
|
110
|
+
error_code = d.get("ErrorCode")
|
|
111
|
+
if error_code and error_code not in ("7200", "7204"):
|
|
112
|
+
display_msg = d.get("DisplayMessage") or d.get("displayMessage")
|
|
113
|
+
msg = display_msg or d.get("Message") or d.get("message") or "Unknown error"
|
|
114
|
+
raise APIError(
|
|
115
|
+
message=msg,
|
|
116
|
+
error_code=error_code,
|
|
117
|
+
display_message=display_msg,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return d
|
mytnb/client/rest.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""REST API transport (api.mytnb.com.my)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from mytnb.auth import Credentials
|
|
12
|
+
from mytnb.client.config import REST_BASE_URL, _check_http_status
|
|
13
|
+
from mytnb.exceptions import APIError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _RestTransport:
|
|
19
|
+
"""Internal helper for REST API HTTP requests."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: httpx.AsyncClient, credentials: Credentials):
|
|
22
|
+
self._client = client
|
|
23
|
+
self._credentials = credentials
|
|
24
|
+
|
|
25
|
+
def headers(self) -> dict[str, str]:
|
|
26
|
+
"""Build headers for REST API requests."""
|
|
27
|
+
headers: dict[str, str] = {
|
|
28
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
29
|
+
"Accept": "*/*",
|
|
30
|
+
"x-api-key": self._credentials.api_key,
|
|
31
|
+
"Authorization": self._credentials.authorization_token,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if self._credentials.device_info:
|
|
35
|
+
view_info = {
|
|
36
|
+
"DeviceToken": self._credentials.device_info.device_id,
|
|
37
|
+
"AppVersion": self._credentials.device_info.app_version,
|
|
38
|
+
"RoleId": self._credentials.user_info.role_id
|
|
39
|
+
if self._credentials.user_info
|
|
40
|
+
else "16",
|
|
41
|
+
"Lang": "EN",
|
|
42
|
+
"FontSize": "L",
|
|
43
|
+
"OSType": self._credentials.device_info.os_type,
|
|
44
|
+
}
|
|
45
|
+
headers["ViewInfo"] = json.dumps(view_info)
|
|
46
|
+
|
|
47
|
+
if self._credentials.channel_api_key:
|
|
48
|
+
headers["ApiKey"] = self._credentials.channel_api_key
|
|
49
|
+
|
|
50
|
+
return headers
|
|
51
|
+
|
|
52
|
+
def bearer_headers(self) -> dict[str, str]:
|
|
53
|
+
"""Build headers for Bearer-token authenticated endpoints."""
|
|
54
|
+
headers = self.headers()
|
|
55
|
+
if self._credentials.bearer_token:
|
|
56
|
+
headers["Authorization"] = f"Bearer {self._credentials.bearer_token}"
|
|
57
|
+
return headers
|
|
58
|
+
|
|
59
|
+
async def post(
|
|
60
|
+
self,
|
|
61
|
+
path: str,
|
|
62
|
+
body: Optional[dict] = None,
|
|
63
|
+
params: Optional[dict] = None,
|
|
64
|
+
use_bearer: bool = False,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""Make a POST request to the REST API."""
|
|
67
|
+
url = f"{REST_BASE_URL}/{path.lstrip('/')}"
|
|
68
|
+
req_headers = self.bearer_headers() if use_bearer else self.headers()
|
|
69
|
+
|
|
70
|
+
if params is None:
|
|
71
|
+
params = {"environment": "Prod"}
|
|
72
|
+
|
|
73
|
+
response = await self._client.post(
|
|
74
|
+
url,
|
|
75
|
+
headers=req_headers,
|
|
76
|
+
json=body or {},
|
|
77
|
+
params=params,
|
|
78
|
+
)
|
|
79
|
+
logger.debug("REST POST %s → %s", path, response.status_code)
|
|
80
|
+
|
|
81
|
+
_check_http_status(response.status_code)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
data = response.json()
|
|
84
|
+
|
|
85
|
+
status = data.get("statusDetail", {})
|
|
86
|
+
if status.get("code") and status["code"] != "7200":
|
|
87
|
+
logger.error(
|
|
88
|
+
"REST API error code=%s desc=%s",
|
|
89
|
+
status.get("code"),
|
|
90
|
+
status.get("description"),
|
|
91
|
+
)
|
|
92
|
+
raise APIError(
|
|
93
|
+
message=status.get("description", "Unknown error"),
|
|
94
|
+
error_code=status.get("code"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
async def get(
|
|
100
|
+
self,
|
|
101
|
+
path: str,
|
|
102
|
+
params: Optional[dict] = None,
|
|
103
|
+
use_bearer: bool = False,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Make a GET request to the REST API."""
|
|
106
|
+
url = f"{REST_BASE_URL}/{path.lstrip('/')}"
|
|
107
|
+
req_headers = self.bearer_headers() if use_bearer else self.headers()
|
|
108
|
+
|
|
109
|
+
if params is None:
|
|
110
|
+
params = {"environment": "Prod"}
|
|
111
|
+
|
|
112
|
+
response = await self._client.get(url, headers=req_headers, params=params)
|
|
113
|
+
logger.debug("REST GET %s → %s", path, response.status_code)
|
|
114
|
+
|
|
115
|
+
_check_http_status(response.status_code)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
return response.json()
|
mytnb/crypto.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Encryption module for myTNB API request payloads.
|
|
2
|
+
|
|
3
|
+
Implements the AES-256-CBC + RSA-OAEP hybrid encryption used by the
|
|
4
|
+
myTNB mobile app (APISecurityManager).
|
|
5
|
+
|
|
6
|
+
Encryption flow:
|
|
7
|
+
1. Serialize request data to JSON
|
|
8
|
+
2. Generate random AES-256 key (32 bytes) and IV (16 bytes)
|
|
9
|
+
3. Encrypt JSON with AES-256-CBC, PKCS7 padding → base64 → `ae`
|
|
10
|
+
4. Encrypt AES key with RSA-OAEP (SHA-1) using embedded public key → base64 → `ak`
|
|
11
|
+
5. Base64-encode the IV → `av`
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from cryptography.hazmat.primitives import hashes
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
26
|
+
|
|
27
|
+
# ── RSA Public Keys (extracted from myTNB.Mobile.Resources.Keys) ──────────
|
|
28
|
+
|
|
29
|
+
# Production key (PKey.txt) - 2048-bit RSA, exponent 65537
|
|
30
|
+
_PROD_MODULUS_B64 = (
|
|
31
|
+
"2PUbfII3l4qZYNfvoIavtqL5PoXbnX093tJFHjre6Bspsy8gPMBoerv/GsjOpWVl"
|
|
32
|
+
"f44y9ey9XUBuIzFYLYmtfAG0CQX90pJ4aDgnUpCiw02D/NShwVRwmujyFjhB3T"
|
|
33
|
+
"beBbHofue/4KHZrbz2UQD0AgnC/HZiHRp2rFoWRGcud+xrUH6NJiF5YPYRgGKRi/"
|
|
34
|
+
"s0xOn4xHgU2kpDCuE9/u2HFwxcJQZM+ekQNzo3OMSM53IiTZocToVEi82fJRCBi"
|
|
35
|
+
"VuprgR3kpoK9gwQkvoScRNY8qcEhLmQr/qJKoI6jBLLkgdvKJoqAlUtKGy9XBsI5"
|
|
36
|
+
"v9JNV0p5IHFgyAhxP701uHKIQ=="
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_PROD_EXPONENT_B64 = "AQAB"
|
|
40
|
+
|
|
41
|
+
# Staging key (SKey.txt) - for development/testing
|
|
42
|
+
_STAGING_MODULUS_B64 = (
|
|
43
|
+
"5NMh+MJ+Mb9Fly2tyOtA+SgUE/M5sfYx0xDyfFLuXvQwzTyyHUSRTGvSk1kv4Gz"
|
|
44
|
+
"7hY4AFHk+/0loQ4YxaWByFh+mMzu2JVJT4iR+xtLTMeY81wbELl78crKevMutJG"
|
|
45
|
+
"WPX8DEOBrAdpLo2REu6KB085sULrHhbVX9h2aLt0YFYb+IKErxWbkTkI2/VRQjR2"
|
|
46
|
+
"tU9kOmLxUTTH76ibkVD2GfD8AtZhKNJXSINuIPkovZ8sZPCQI11nhHurc07diCKF"
|
|
47
|
+
"6YHqJADwe6vukmhaa2Flyc03weQFBopFx3NcQvI69lrOf/URr0GZj8HX8vq8SWuK"
|
|
48
|
+
"GSbcvPuA3+5FsrEBIFKZg3wQ=="
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_STAGING_EXPONENT_B64 = "AQAB"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_rsa_public_key(modulus_b64: str, exponent_b64: str) -> RSAPublicKey:
|
|
55
|
+
"""Build an RSA public key from base64-encoded modulus and exponent."""
|
|
56
|
+
n = int.from_bytes(base64.b64decode(modulus_b64), byteorder="big")
|
|
57
|
+
e = int.from_bytes(base64.b64decode(exponent_b64), byteorder="big")
|
|
58
|
+
return rsa.RSAPublicNumbers(e, n).public_key()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_PROD_KEY = _build_rsa_public_key(_PROD_MODULUS_B64, _PROD_EXPONENT_B64)
|
|
62
|
+
_STAGING_KEY = _build_rsa_public_key(_STAGING_MODULUS_B64, _STAGING_EXPONENT_B64)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── PKCS7 padding ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
|
68
|
+
"""Apply PKCS7 padding."""
|
|
69
|
+
pad_len = block_size - (len(data) % block_size)
|
|
70
|
+
return data + bytes([pad_len] * pad_len)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pkcs7_unpad(data: bytes) -> bytes:
|
|
74
|
+
"""Remove PKCS7 padding."""
|
|
75
|
+
pad_len = data[-1]
|
|
76
|
+
if pad_len < 1 or pad_len > 16:
|
|
77
|
+
raise ValueError("Invalid PKCS7 padding")
|
|
78
|
+
if data[-pad_len:] != bytes([pad_len] * pad_len):
|
|
79
|
+
raise ValueError("Invalid PKCS7 padding")
|
|
80
|
+
return data[:-pad_len]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class EncryptedPayload:
|
|
87
|
+
"""Encrypted request payload (dt object)."""
|
|
88
|
+
|
|
89
|
+
ae: str # Base64(AES-256-CBC(JSON data))
|
|
90
|
+
ak: str # Base64(RSA-OAEP(AES key))
|
|
91
|
+
av: str # Base64(IV)
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict[str, str]:
|
|
94
|
+
return {"ae": self.ae, "ak": self.ak, "av": self.av}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def encrypt_request(
|
|
98
|
+
data: Any,
|
|
99
|
+
*,
|
|
100
|
+
use_staging_key: bool = False,
|
|
101
|
+
) -> EncryptedPayload:
|
|
102
|
+
"""Encrypt a request payload for the myTNB legacy API.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
data: The request data (will be JSON-serialized).
|
|
106
|
+
use_staging_key: Use the staging RSA key instead of production.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
EncryptedPayload with ae, ak, av fields.
|
|
110
|
+
"""
|
|
111
|
+
rsa_key = _STAGING_KEY if use_staging_key else _PROD_KEY
|
|
112
|
+
|
|
113
|
+
# 1. Serialize to JSON bytes
|
|
114
|
+
json_str = json.dumps(data, separators=(",", ":"))
|
|
115
|
+
plaintext = json_str.encode("utf-8")
|
|
116
|
+
|
|
117
|
+
# 2. Generate random AES-256 key and IV
|
|
118
|
+
aes_key = os.urandom(32) # 256-bit key
|
|
119
|
+
iv = os.urandom(16) # 128-bit IV
|
|
120
|
+
|
|
121
|
+
# 3. AES-256-CBC encrypt with PKCS7 padding
|
|
122
|
+
padded = _pkcs7_pad(plaintext)
|
|
123
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
|
124
|
+
encryptor = cipher.encryptor()
|
|
125
|
+
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
|
126
|
+
ae = base64.b64encode(ciphertext).decode("ascii")
|
|
127
|
+
|
|
128
|
+
# 4. RSA-OAEP encrypt the AES key
|
|
129
|
+
encrypted_key = rsa_key.encrypt(
|
|
130
|
+
aes_key,
|
|
131
|
+
padding.OAEP(
|
|
132
|
+
mgf=padding.MGF1(algorithm=hashes.SHA1()),
|
|
133
|
+
algorithm=hashes.SHA1(),
|
|
134
|
+
label=None,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
ak = base64.b64encode(encrypted_key).decode("ascii")
|
|
138
|
+
|
|
139
|
+
# 5. Base64-encode the IV
|
|
140
|
+
av = base64.b64encode(iv).decode("ascii")
|
|
141
|
+
|
|
142
|
+
return EncryptedPayload(ae=ae, ak=ak, av=av)
|