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/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)