-
-
Notifications
You must be signed in to change notification settings - Fork 239
Try default logon credentials in SslAesTransport #1195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0cee455
Try default logon credentials in SslAesTransport
sdb9696 0f1049d
Update post review
sdb9696 411b5ae
Merge branch 'master' into feat/default_creds
sdb9696 6e1bffc
Merge branch 'master' into feat/default_creds
sdb9696 0ea8700
Check for missing nonce in result
sdb9696 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,6 @@ | |
| import logging | ||
| import secrets | ||
| import ssl | ||
| import time | ||
| from enum import Enum, auto | ||
| from typing import TYPE_CHECKING, Any, Dict, cast | ||
|
|
||
|
|
@@ -29,7 +28,7 @@ | |
| from ..httpclient import HttpClient | ||
| from ..json import dumps as json_dumps | ||
| from ..json import loads as json_loads | ||
| from ..protocol import BaseTransport | ||
| from ..protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -71,7 +70,6 @@ class SslAesTransport(BaseTransport): | |
| "Accept": "application/json", | ||
| "Accept-Encoding": "gzip, deflate", | ||
| "User-Agent": "Tapo CameraClient Android", | ||
| "Connection": "close", | ||
| } | ||
| CIPHERS = ":".join( | ||
| [ | ||
|
|
@@ -96,7 +94,9 @@ def __init__( | |
| not self._credentials or self._credentials.username is None | ||
| ) and not self._credentials_hash: | ||
| self._credentials = Credentials() | ||
| self._default_credentials: Credentials | None = None | ||
| self._default_credentials: Credentials = get_default_credentials( | ||
| DEFAULT_CREDENTIALS["TAPOCAMERA"] | ||
| ) | ||
|
|
||
| if not config.timeout: | ||
| config.timeout = self.DEFAULT_TIMEOUT | ||
|
|
@@ -149,7 +149,7 @@ def credentials_hash(self) -> str | None: | |
| return base64.b64encode(json_dumps(ch).encode()).decode() | ||
| return None | ||
|
|
||
| def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: | ||
| def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: | ||
| error_code_raw = resp_dict.get("error_code") | ||
| try: | ||
| error_code = SmartErrorCode.from_int(error_code_raw) | ||
|
|
@@ -158,6 +158,10 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: | |
| "Device %s received unknown error code: %s", self._host, error_code_raw | ||
| ) | ||
| error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR | ||
| return error_code | ||
|
|
||
| def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: | ||
| error_code = self._get_response_error(resp_dict) | ||
| if error_code is SmartErrorCode.SUCCESS: | ||
| return | ||
| msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" | ||
|
|
@@ -325,6 +329,8 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: | |
| + f"status code {status_code} to handshake2" | ||
| ) | ||
| resp_dict = cast(dict, resp_dict) | ||
| self._handle_response_error_code(resp_dict, "Error in handshake2") | ||
|
|
||
| self._seq = resp_dict["result"]["start_seq"] | ||
| stok = resp_dict["result"]["stok"] | ||
| self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") | ||
|
|
@@ -337,42 +343,41 @@ async def perform_handshake2(self, local_nonce, server_nonce, pwd_hash) -> None: | |
| _LOGGER.debug("Handshake2 complete ...") | ||
|
|
||
| async def perform_handshake1(self) -> tuple[str, str, str]: | ||
| """Perform the handshake.""" | ||
| _LOGGER.debug("Will perform handshaking...") | ||
|
|
||
| if not self._username: | ||
| raise KasaException("Cannot connect to device with no credentials") | ||
| local_nonce = secrets.token_bytes(8).hex().upper() | ||
| # Device needs the content length or it will response with 500 | ||
| body = { | ||
| "method": "login", | ||
| "params": { | ||
| "cnonce": local_nonce, | ||
| "encrypt_type": "3", | ||
| "username": self._username, | ||
| }, | ||
| } | ||
| http_client = self._http_client | ||
| """Perform the handshake1.""" | ||
| resp_dict = None | ||
| if self._username: | ||
| local_nonce = secrets.token_bytes(8).hex().upper() | ||
| resp_dict = await self.try_send_handshake1(self._username, local_nonce) | ||
|
|
||
| status_code, resp_dict = await http_client.post( | ||
| self._app_url, | ||
| json=body, | ||
| headers=self._headers, | ||
| ssl=await self._get_ssl_context(), | ||
| ) | ||
|
|
||
| _LOGGER.debug("Device responded with: %s", resp_dict) | ||
|
|
||
| if status_code != 200: | ||
| raise KasaException( | ||
| f"{self._host} responded with an unexpected " | ||
| + f"status code {status_code} to handshake1" | ||
| # Try the default username. If it fails raise the original error_code | ||
| if ( | ||
| not resp_dict | ||
| or (error_code := self._get_response_error(resp_dict)) | ||
| is not SmartErrorCode.INVALID_NONCE | ||
| or "nonce" not in resp_dict["result"].get("data", {}) | ||
| ): | ||
| local_nonce = secrets.token_bytes(8).hex().upper() | ||
| default_resp_dict = await self.try_send_handshake1( | ||
| self._default_credentials.username, local_nonce | ||
| ) | ||
| if ( | ||
| default_error_code := self._get_response_error(default_resp_dict) | ||
| ) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[ | ||
| "result" | ||
| ].get("data", {}): | ||
| _LOGGER.debug("Connected to {self._host} with default username") | ||
| self._username = self._default_credentials.username | ||
| error_code = default_error_code | ||
| resp_dict = default_resp_dict | ||
|
|
||
| resp_dict = cast(dict, resp_dict) | ||
| error_code = SmartErrorCode.from_int(resp_dict["error_code"]) | ||
| if error_code != SmartErrorCode.INVALID_NONCE: | ||
| self._handle_response_error_code(resp_dict, "Unable to complete handshake") | ||
| if not self._username: | ||
| raise AuthenticationError( | ||
| "Credentials must be supplied to connect to {self._host}" | ||
| ) | ||
| if error_code is not SmartErrorCode.INVALID_NONCE or ( | ||
| resp_dict and "nonce" not in resp_dict["result"].get("data", {}) | ||
| ): | ||
| raise AuthenticationError("Error trying handshake1: {resp_dict}") | ||
|
|
||
| if TYPE_CHECKING: | ||
| resp_dict = cast(Dict[str, Any], resp_dict) | ||
|
|
@@ -381,10 +386,10 @@ async def perform_handshake1(self) -> tuple[str, str, str]: | |
| device_confirm = resp_dict["result"]["data"]["device_confirm"] | ||
| if self._credentials and self._credentials != Credentials(): | ||
| pwd_hash = _sha256_hash(self._credentials.password.encode()) | ||
| elif self._username and self._password: | ||
| pwd_hash = _sha256_hash(self._password.encode()) | ||
| else: | ||
| if TYPE_CHECKING: | ||
| assert self._pwd_hash | ||
| pwd_hash = self._pwd_hash | ||
| pwd_hash = _sha256_hash(self._default_credentials.password.encode()) | ||
|
|
||
| expected_confirm_sha256 = self.generate_confirm_hash( | ||
| local_nonce, server_nonce, pwd_hash | ||
|
|
@@ -408,19 +413,40 @@ async def perform_handshake1(self) -> tuple[str, str, str]: | |
| _LOGGER.debug(msg) | ||
| raise AuthenticationError(msg) | ||
|
|
||
| def _handshake_session_expired(self): | ||
| """Return true if session has expired.""" | ||
| return ( | ||
| self._session_expire_at is None | ||
| or self._session_expire_at - time.time() <= 0 | ||
| async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: | ||
| """Perform the handshake.""" | ||
| _LOGGER.debug("Will to send handshake1...") | ||
|
|
||
| body = { | ||
| "method": "login", | ||
| "params": { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably not worth assigning if this is used only once? |
||
| "cnonce": local_nonce, | ||
| "encrypt_type": "3", | ||
| "username": self._username, | ||
| }, | ||
| } | ||
| http_client = self._http_client | ||
|
|
||
| status_code, resp_dict = await http_client.post( | ||
| self._app_url, | ||
| json=body, | ||
| headers=self._headers, | ||
| ssl=await self._get_ssl_context(), | ||
| ) | ||
|
|
||
| _LOGGER.debug("Device responded with: %s", resp_dict) | ||
|
|
||
| if status_code != 200: | ||
| raise KasaException( | ||
| f"{self._host} responded with an unexpected " | ||
| + f"status code {status_code} to handshake1" | ||
| ) | ||
|
|
||
| return cast(dict, resp_dict) | ||
|
|
||
| async def send(self, request: str) -> dict[str, Any]: | ||
| """Send the request.""" | ||
| if ( | ||
| self._state is TransportState.HANDSHAKE_REQUIRED | ||
| or self._handshake_session_expired() | ||
rytilahti marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ): | ||
| if self._state is TransportState.HANDSHAKE_REQUIRED: | ||
| await self.perform_handshake() | ||
|
|
||
| return await self.send_secure_passthrough(request) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be better to invert the check, and bail out early on different errors?