8000 Update SslAesTransport for older firmware versions (#1362) · python-kasa/python-kasa@0a95a41 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0a95a41

Browse files
sdb9696rytilahti
andauthored
Update SslAesTransport for older firmware versions (#1362)
Older firmware versions do not encrypt the payload. Tested to work with C110 hw 2.0 fw 1.3.7 Build 230823 Rel.57279n(5553) --------- Co-authored-by: Teemu R. <tpr@iki.fi>
1 parent 883d522 commit 0a95a41

File tree

3 files changed

+363
-23
lines changed

3 files changed

+363
-23
lines changed

kasa/exceptions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def from_int(value: int) -> SmartErrorCode:
132132

133133
# Camera error codes
134134
SESSION_EXPIRED = -40401
135+
BAD_USERNAME = -40411 # determined from testing
135136
HOMEKIT_LOGIN_FAIL = -40412
136137
DEVICE_BLOCKED = -40404
137138
DEVICE_FACTORY = -40405

kasa/transports/sslaestransport.py

Lines changed: 145 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def __init__(
126126
self._password = ch["pwd"]
127127
self._username = ch["un"]
128128
self._local_nonce: str | None = None
129+
self._send_secure = True
129130

130131
_LOGGER.debug("Created AES transport for %s", self._host)
131132

@@ -162,7 +163,13 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode:
162163
return error_code
163164

164165
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'
165168
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+
166173
if error_code_raw is None:
167174
return None
168175
try:
@@ -208,6 +215,10 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
208215
else:
209216
url = self._app_url
210217

218+
_LOGGER.debug(
219+
"Sending secure passthrough from %s",
220+
self._host,
221+
)
211222
encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore
212223
passthrough_request = {
213224
"method": "securePassthrough",
@@ -292,6 +303,34 @@ async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
292303
) from ex
293304
return ret_val # type: ignore[return-value]
294305

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+
295334
@staticmethod
296335
def generate_confirm_hash(
297336
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
340379

341380
async def perform_handshake(self) -> None:
342381
"""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
345426

346427
async def perform_handshake2(
347428
self, local_nonce: str, server_nonce: str, pwd_hash: str
@@ -393,33 +474,81 @@ async def perform_handshake2(
393474
self._state = TransportState.ESTABLISHED
394475
_LOGGER.debug("Handshake2 complete ...")
395476

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:
397503
"""Perform the handshake1."""
398504
resp_dict = None
399505
if self._username:
400506
local_nonce = secrets.token_bytes(8).hex().upper()
401507
resp_dict = await self.try_send_handshake1(self._username, local_nonce)
402508

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+
403521
# Try the default username. If it fails raise the original error_code
404522
if (
405523
not resp_dict
406524
or (error_code := self._get_response_error(resp_dict))
407525
is not SmartErrorCode.INVALID_NONCE
408526
or "nonce" not in resp_dict["result"].get("data", {})
409527
):
528+
_LOGGER.debug("Trying default credentials to %s", self._host)
410529
local_nonce = secrets.token_bytes(8).hex().upper()
411530
default_resp_dict = await self.try_send_handshake1(
412531
self._default_credentials.username, local_nonce
413532
)
533+
# INVALID_NONCE means device should perform secure login
414534
if (
415535
default_error_code := self._get_response_error(default_resp_dict)
416536
) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[
417537
"result"
418538
].get("data", {}):
419-
_LOGGER.debug("Connected to {self._host} with default username")
539+
_LOGGER.debug("Connected to %s with default username", self._host)
420540
self._username = self._default_credentials.username
421541
error_code = default_error_code
422542
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
423552

424553
# If the default login worked it's ok not to provide credentials but if
425554
# it didn't raise auth error here.
@@ -451,12 +580,8 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
451580

452581
server_nonce = resp_dict["result"]["data"]["nonce"]
453582
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())
460585

461586
expected_confirm_sha256 = self.generate_confirm_hash(
462587
local_nonce, server_nonce, pwd_hash
@@ -468,7 +593,9 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
468593
if TYPE_CHECKING:
469594
assert self._credentials
470595
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+
472599
expected_confirm_md5 = self.generate_confirm_hash(
473600
local_nonce, server_nonce, pwd_hash
474601
)
@@ -478,11 +605,12 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
478605

479606
msg = f"Server response doesn't match our challenge on ip {self._host}"
480607
_LOGGER.debug(msg)
608+
481609
raise AuthenticationError(msg)
482610

483611
async def try_send_handshake1(self, username: str, local_nonce: str) -> dict:
484612
"""Perform the handshake."""
485-
_LOGGER.debug("Will to send handshake1...")
613+
_LOGGER.debug("Sending handshake1...")
486614

487615
body = {
488616
"method": "login",
@@ -501,7 +629,7 @@ async def try_send_handshake1(self, username: str, local_nonce: str) -> dict:
501629
ssl=await self._get_ssl_context(),
502630
)
503631

504-
_LOGGER.debug("Device responded with: %s", resp_dict)
632+
_LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict)
505633

506634
if status_code != 200:
507635
raise KasaException(
@@ -516,7 +644,10 @@ async def send(self, request: str) -> dict[str, Any]:
516644
if self._state is TransportState.HANDSHAKE_REQUIRED:
517645
await self.perform_handshake()
518646

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

521652
async def close(self) -> None:
522653
"""Close the http client and reset internal state."""

0 commit comments

Comments
 (0)
0