@@ -126,6 +126,7 @@ def __init__(
126
126
self ._password = ch ["pwd" ]
127
127
self ._username = ch ["un" ]
128
128
self ._local_nonce : str | None = None
129
+ self ._send_secure = True
129
130
130
131
_LOGGER .debug ("Created AES transport for %s" , self ._host )
131
132
@@ -162,7 +163,13 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode:
162
163
return error_code
163
164
164
165
def _get_response_inner_error (self , resp_dict : Any ) -> SmartErrorCode | None :
166
+ # Device blocked errors have 'data' element at the root level, other inner
167
+ # errors are inside 'result'
165
168
error_code_raw = resp_dict .get ("data" , {}).get ("code" )
169
+
170
+ if error_code_raw is None :
171
+ error_code_raw = resp_dict .get ("result" , {}).get ("data" , {}).get ("code" )
172
+
166
173
if error_code_raw is None :
167
174
return None
168
175
try :
@@ -208,6 +215,10 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
208
215
else :
209
216
url = self ._app_url
210
217
218
+ _LOGGER .debug (
219
+ "Sending secure passthrough from %s" ,
220
+ self ._host ,
221
+ )
211
222
encrypted_payload = self ._encryption_session .encrypt (request .encode ()) # type: ignore
212
223
passthrough_request = {
213
224
"method" : "securePassthrough" ,
@@ -292,6 +303,34 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
292
303
) from ex
293
304
return ret_val # type: ignore[return-value]
294
305
306
+ async def send_unencrypted (self , request : str ) -> dict [str , Any ]:
307
+ """Send encrypted message as passthrough."""
308
+ url = cast (URL , self ._token_url )
309
+
310
+ _LOGGER .debug (
311
+ "Sending unencrypted to %s" ,
312
+ self ._host ,
313
+ )
314
+
315
+ status_code , resp_dict = await self ._http_client .post (
316
+ url ,
317
+ json = request ,
318
+ headers = self ._headers ,
319
+ ssl = await self ._get_ssl_context (),
320
+ )
321
+
322
+ if status_code != 200 :
323
+ raise KasaException (
324
+ f"{ self ._host } responded with an unexpected "
325
+ + f"status code { status_code } to unencrypted send"
326
+ )
327
+
328
+ self ._handle_response_error_code (resp_dict , "Error sending message" )
329
+
330
+ if TYPE_CHECKING :
331
+ resp_dict = cast (dict [str , Any ], resp_dict )
332
+ return resp_dict
333
+
295
334
@staticmethod
296
335
def generate_confirm_hash (
297
336
local_nonce : str , server_nonce : str , pwd_hash : str
@@ -340,8 +379,50 @@ def generate_tag(request: str, local_nonce: str, pwd_hash: str, seq: int) -> str
340
379
341
380
async def perform_handshake (self ) -> None :
342
381
"""Perform the handshake."""
343
- local_nonce , server_nonce , pwd_hash = await self .perform_handshake1 ()
344
- await self .perform_handshake2 (local_nonce , server_nonce , pwd_hash )
382
+ result = await self .perform_handshake1 ()
383
+ if result :
384
+ local_nonce , server_nonce , pwd_hash = result
385
+ await self .perform_handshake2 (local_nonce , server_nonce , pwd_hash )
386
+
387
+ async def try_perform_less_secure_login (self , username : str , password : str ) -> bool :
388
+ """Perform the md5 login."""
389
+ _LOGGER .debug ("Performing less secure login..." )
390
+
391
+ pwd_hash = _md5_hash (password .encode ())
392
+ body = {
393
+ "method" : "login" ,
394
+ "params" : {
395
+ "hashed" : True ,
396
+ "password" : pwd_hash ,
397
+ "username" : username ,
398
+ },
399
+ }
400
+
401
+ status_code , resp_dict = await self ._http_client .post (
402
+ self ._app_url ,
403
+ json = body ,
404
+ headers = self ._headers ,
405
+ ssl = await self ._get_ssl_context (),
406
+ )
407
+ if status_code != 200 :
408
+ raise KasaException (
409
+ f"{ self ._host } responded with an unexpected "
410
+ + f"status code { status_code } to login"
411
+ )
412
+ resp_dict = cast (dict , resp_dict )
413
+ if resp_dict .get ("error_code" ) == 0 and (
414
+ stok := resp_dict .get ("result" , {}).get ("stok" )
415
+ ):
416
+ _LOGGER .debug (
417
+ "Succesfully logged in to %s with less secure passthrough" , self ._host
418
+ )
419
+ self ._send_secure = False
420
+ self ._token_url = URL (f"{ str (self ._app_url )} /stok={ stok } /ds" )
421
+ self ._pwd_hash = pwd_hash
422
+ return True
423
+
424
+ _LOGGER .debug ("Unable to log in to %s with less secure login" , self ._host )
425
+ return False
345
426
346
427
async def perform_handshake2 (
347
428
self , local_nonce : str , server_nonce : str , pwd_hash : str
@@ -393,33 +474,81 @@ async def perform_handshake2(
393
474
self ._state = TransportState .ESTABLISHED
394
475
_LOGGER .debug ("Handshake2 complete ..." )
395
476
396
- async def perform_handshake1 (self ) -> tuple [str , str , str ]:
477
+ def _pwd_to_hash (self ) -> str :
478
+ """Return the password to hash."""
479
+ if self ._credentials and self ._credentials != Credentials ():
480
+ return self ._credentials .password
481
+
482
+ if self ._username and self ._password :
483
+ return self ._password
484
+
485
+ return self ._default_credentials .password
486
+
487
+ def _is_less_secure_login (self , resp_dict : dict [str , Any ]) -> bool :
488
+ result = (
489
+ self ._get_response_error (resp_dict ) is SmartErrorCode .SESSION_EXPIRED
490
+ and (data := resp_dict .get ("result" , {}).get ("data" , {}))
491
+ and (encrypt_type := data .get ("encrypt_type" ))
492
+ and (encrypt_type != ["3" ])
493
+ )
494
+ if result :
495
+ _LOGGER .debug (
496
+ "Received encrypt_type %s for %s, trying less secure login" ,
497
+ encrypt_type ,
498
+ self ._host ,
499
+ )
500
+ return result
501
+
502
+ async def perform_handshake1 (self ) -> tuple [str , str , str ] | None :
397
503
"""Perform the handshake1."""
398
504
resp_dict = None
399
505
if self ._username :
400
506
local_nonce = secrets .token_bytes (8 ).hex ().upper ()
401
507
resp_dict = await self .try_send_handshake1 (self ._username , local_nonce )
402
508
509
+ if (
510
+ resp_dict
511
+ and self ._is_less_secure_login (resp_dict )
512
+ and self ._get_response_inner_error (resp_dict )
513
+ is not SmartErrorCode .BAD_USERNAME
514
+ and await self .try_perform_less_secure_login (
515
+ cast (str , self ._username ), self ._pwd_to_hash ()
516
+ )
517
+ ):
518
+ self ._state = TransportState .ESTABLISHED
519
+ return None
520
+
403
521
# Try the default username. If it fails raise the original error_code
404
522
if (
405
523
not resp_dict
406
524
or (error_code := self ._get_response_error (resp_dict ))
407
525
is not SmartErrorCode .INVALID_NONCE
408
526
or "nonce" not in resp_dict ["result" ].get ("data" , {})
409
527
):
528
+ _LOGGER .debug ("Trying default credentials to %s" , self ._host )
410
529
local_nonce = secrets .token_bytes (8 ).hex ().upper ()
411
530
default_resp_dict = await self .try_send_handshake1 (
412
531
self ._default_credentials .username , local_nonce
413
532
)
533
+ # INVALID_NONCE means device should perform secure login
414
534
if (
415
535
default_error_code := self ._get_response_error (default_resp_dict )
416
536
) is SmartErrorCode .INVALID_NONCE and "nonce" in default_resp_dict [
417
537
"result"
418
538
].get ("data" , {}):
419
- _LOGGER .debug ("Connected to {self._host} with default username" )
539
+ _LOGGER .debug ("Connected to %s with default username" , self . _host )
420
540
self ._username = self ._default_credentials .username
421
541
error_code = default_error_code
422
542
resp_dict = default_resp_dict
543
+ # Otherwise could be less secure login
544
+ elif self ._is_less_secure_login (
545
+ default_resp_dict
546
+ ) and await self .try_perform_less_secure_login (
547
+ self ._default_credentials .username , self ._pwd_to_hash ()
548
+ ):
549
+ self ._username = self ._default_credentials .username
550
+ self ._state = TransportState .ESTABLISHED
551
+ return None
423
552
424
553
# If the default login worked it's ok not to provide credentials but if
425
554
# it didn't raise auth error here.
@@ -451,12 +580,8 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
451
580
452
581
server_nonce = resp_dict ["result" ]["data" ]["nonce" ]
453
582
device_confirm = resp_dict ["result" ]["data" ]["device_confirm" ]
454
- if self ._credentials and self ._credentials != Credentials ():
455
- pwd_hash = _sha256_hash (self ._credentials .password .encode ())
456
- elif self ._username and self ._password :
457
- pwd_hash = _sha256_hash (self ._password .encode ())
458
- else :
459
- pwd_hash = _sha256_hash (self ._default_credentials .password .encode ())
583
+
584
+ pwd_hash = _sha256_hash (self ._pwd_to_hash ().encode ())
460
585
461
586
expected_confirm_sha256 = self .generate_confirm_hash (
462
587
local_nonce , server_nonce , pwd_hash
@@ -468,7 +593,9 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
468
593
if TYPE_CHECKING :
469
594
assert self ._credentials
470
595
assert self ._credentials .password
471
- pwd_hash = _md5_hash (self ._credentials .password .encode ())
596
+
597
+ pwd_hash = _md5_hash (self ._pwd_to_hash ().encode ())
598
+
472
599
expected_confirm_md5 = self .generate_confirm_hash (
473
600
local_nonce , server_nonce , pwd_hash
474
601
)
@@ -478,11 +605,12 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
478
605
479
606
msg = f"Server response doesn't match our challenge on ip { self ._host } "
480
607
_LOGGER .debug (msg )
608
+
481
609
raise AuthenticationError (msg )
482
610
483
611
async def try_send_handshake1 (self , username : str , local_nonce : str ) -> dict :
484
612
"""Perform the handshake."""
485
- _LOGGER .debug ("Will to send handshake1..." )
613
+ _LOGGER .debug ("Sending handshake1..." )
486
614
487
615
body = {
488
616
"method" : "login" ,
@@ -501,7 +629,7 @@ async def try_send_handshake1(self, username: str, local_nonce: str) -> dict:
501
629
ssl = await self ._get_ssl_context (),
502
630
)
503
631
504
- _LOGGER .debug ("Device responded with: %s" , resp_dict )
632
+ _LOGGER .debug ("Device responded with status %s : %s" , status_code , resp_dict )
505
633
506
634
if status_code != 200 :
507
635
raise KasaException (
@@ -516,7 +644,10 @@ async def send(self, request: str) -> dict[str, Any]:
516
644
if self ._state is TransportState .HANDSHAKE_REQUIRED :
517
645
await self .perform_handshake ()
518
646
519
- return await self .send_secure_passthrough (request )
647
+ if self ._send_secure :
648
+ return await self .send_secure_passthrough (request )
649
+
650
+ return await self .send_unencrypted (request )
520
651
521
652
async def close (self ) -> None :
522
653
"""Close the http client and reset internal state."""
0 commit comments