3131 "app_disabled" ,
3232 "session_expired" ,
3333 "bad_request" ,
34+ "server_error" ,
3435 "checksum_required" ,
3536 "checksum_mismatch" ,
3637}
@@ -77,6 +78,7 @@ def __init__(
7778 self ._raw_payload_b64 : Optional [str ] = None
7879 self ._signature : Optional [str ] = None
7980 self ._derived_key : Optional [bytes ] = None
81+ self ._sig_key : Optional [str ] = None
8082 self ._session_data : Optional [Dict [str , Any ]] = None
8183 self ._app_variables : Optional [Dict [str , Any ]] = None
8284 self ._license_variables : Optional [Dict [str , Any ]] = None
@@ -134,7 +136,12 @@ def _server_heartbeat(self) -> None:
134136 }
135137 response_obj = self ._post_json ("/auth/heartbeat" , body )
136138 expected_nonce = str (body .get ("nonce" , "" )).strip ()
137- self ._apply_signed_response (response_obj , expected_nonce = expected_nonce , license_key = None )
139+ self ._apply_signed_response (
140+ response_obj ,
141+ expected_nonce = expected_nonce ,
142+ license_key = None ,
143+ context = "heartbeat" ,
144+ )
138145
139146 def _local_heartbeat (self ) -> None :
140147 with self ._lock :
@@ -164,13 +171,19 @@ def _validate_and_store(self, license_key: str) -> None:
164171 }
165172 response_obj = self ._post_json ("/auth/validate" , body )
166173 expected_nonce = str (body .get ("nonce" , "" )).strip ()
167- self ._apply_signed_response (response_obj , expected_nonce = expected_nonce , license_key = license_key )
174+ self ._apply_signed_response (
175+ response_obj ,
176+ expected_nonce = expected_nonce ,
177+ license_key = license_key ,
178+ context = "validate" ,
179+ )
168180
169181 def _apply_signed_response (
170182 self ,
171183 response_obj : Dict [str , Any ],
172184 expected_nonce : str ,
173185 license_key : Optional [str ],
186+ context : str ,
174187 ) -> None :
175188 status = response_obj .get ("status" )
176189 if not self ._is_success_status (status ):
@@ -185,13 +198,22 @@ def _apply_signed_response(
185198 if received_nonce != expected_nonce :
186199 raise ValueError ("nonce_mismatch" )
187200
188- derived_key = self ._derive_key (expected_nonce )
201+ if context == "validate" :
202+ derived_key = self ._derive_validate_key (expected_nonce )
203+ elif context == "heartbeat" :
204+ derived_key = self ._derive_heartbeat_key (expected_nonce )
205+ else :
206+ raise ValueError (f"unknown_signing_context:{ context } " )
189207 self ._verify_signature (raw_payload_b64 , derived_key , signature )
190208
191209 session_token = str (payload_json .get ("sessionToken" , "" )).strip ()
192210 if not session_token :
193211 raise ValueError ("missing_sessionToken" )
194212
213+ new_sig_key = self ._extract_sig_key_from_session_token (session_token )
214+ if not new_sig_key :
215+ raise ValueError ("missing_sigKey" )
216+
195217 expires_from_token = self ._extract_expires_in_from_session_token (session_token )
196218 expires_from_payload = payload_json .get ("expiresIn" )
197219
@@ -210,6 +232,7 @@ def _apply_signed_response(
210232 self ._raw_payload_b64 = raw_payload_b64
211233 self ._signature = signature
212234 self ._derived_key = derived_key
235+ self ._sig_key = new_sig_key
213236 self ._session_data = dict (payload_json )
214237 self ._app_variables = self ._extract_optional_map (payload_json .get ("appVariables" ))
215238 self ._license_variables = self ._extract_optional_map (payload_json .get ("licenseVariables" ))
@@ -235,24 +258,21 @@ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
235258 try :
236259 with urllib .request .urlopen (request , timeout = self .request_timeout ) as response :
237260 raw_response = response .read ().decode ("utf-8" )
261+ status_code = int (getattr (response , "status" , 200 ))
238262 obj = self ._parse_response_object (raw_response )
239263 data .clear ()
240264 data .update (body )
241265 break
242266 except urllib .error .HTTPError as exc :
243- detail = ""
244- parsed : Optional [Dict [str , Any ]] = None
267+ status_code = int (exc .code )
245268 try :
246269 detail = exc .read ().decode ("utf-8" )
247- parsed = self ._parse_response_object (detail )
270+ obj = self ._parse_response_object (detail )
248271 except Exception :
249- detail = str (exc )
250- if parsed is not None :
251- obj = parsed
252- data .clear ()
253- data .update (body )
254- break
255- raise RuntimeError (f"http_error_{ exc .code } : { detail } " ) from exc
272+ raise RuntimeError (f"http_error_{ status_code } " ) from exc
273+ data .clear ()
274+ data .update (body )
275+ break
256276 except (urllib .error .URLError , socket .timeout , TimeoutError ) as exc :
257277 if network_attempt == 0 :
258278 network_attempt += 1
@@ -261,7 +281,11 @@ def _post_json(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]:
261281 self ._fail ("network_error" , exc )
262282 raise RuntimeError (f"url_error: { exc } " ) from exc
263283
264- if self ._extract_server_error (obj ) == "rate_limited" and rate_attempt < len (RATE_LIMIT_RETRY_DELAYS ):
284+ is_rate_limited = (
285+ status_code == 429
286+ or self ._extract_server_error (obj ) == "rate_limited"
287+ )
288+ if is_rate_limited and rate_attempt < len (RATE_LIMIT_RETRY_DELAYS ):
265289 time .sleep (RATE_LIMIT_RETRY_DELAYS [rate_attempt ])
266290 rate_attempt += 1
267291 continue
@@ -342,31 +366,54 @@ def _decode_base64_any(self, value: str) -> bytes:
342366 return base64 .urlsafe_b64decode (padded )
343367
344368 def _extract_expires_in_from_session_token (self , session_token : str ) -> Optional [int ]:
369+ payload = self ._decode_session_token_body (session_token )
370+ if payload is None :
371+ return None
372+ value = payload .get ("expiresIn" )
373+ if value is None :
374+ return None
375+ return int (value )
376+
377+ def _extract_sig_key_from_session_token (self , session_token : str ) -> Optional [str ]:
378+ payload = self ._decode_session_token_body (session_token )
379+ if payload is None :
380+ return None
381+ value = payload .get ("sigKey" )
382+ if not isinstance (value , str ) or not value :
383+ return None
384+ return value
385+
386+ def _decode_session_token_body (self , session_token : str ) -> Optional [Dict [str , Any ]]:
345387 parts = session_token .split ("." )
346388 if len (parts ) < 2 :
347389 return None
348- payload_part = parts [0 ]
349- padded = self ._add_base64_padding (payload_part )
390+ padded = self ._add_base64_padding (parts [0 ])
350391 try :
351392 decoded = base64 .urlsafe_b64decode (padded )
352393 payload = json .loads (decoded .decode ("utf-8" ))
353394 except Exception :
354395 return None
355- value = payload .get ("expiresIn" )
356- if value is None :
396+ if not isinstance (payload , dict ):
357397 return None
358- return int ( value )
398+ return payload
359399
360400 def _add_base64_padding (self , text : str ) -> str :
361401 remainder = len (text ) % 4
362402 if remainder == 0 :
363403 return text
364404 return text + ("=" * (4 - remainder ))
365405
366- def _derive_key (self , nonce : str ) -> bytes :
406+ def _derive_validate_key (self , nonce : str ) -> bytes :
367407 seed = f"{ self .app_secret } { nonce } " .encode ("utf-8" )
368408 return hashlib .sha256 (seed ).digest ()
369409
410+ def _derive_heartbeat_key (self , nonce : str ) -> bytes :
411+ sig_key = self ._sig_key
412+ if not sig_key :
413+ raise RuntimeError ("missing_sig_key" )
414+ seed = f"{ sig_key } { nonce } " .encode ("utf-8" )
415+ return hashlib .sha256 (seed ).digest ()
416+
370417 def _verify_signature (self , raw_payload_b64 : str , derived_key : bytes , signature : str ) -> None :
371418 expected = hmac .new (
372419 derived_key ,
@@ -430,6 +477,7 @@ def logout(self) -> None:
430477 self ._raw_payload_b64 = None
431478 self ._signature = None
432479 self ._derived_key = None
480+ self ._sig_key = None
433481 self ._session_data = None
434482 self ._app_variables = None
435483 self ._license_variables = None
0 commit comments