2023-12-04 18:50:05 +00:00
|
|
|
"""Implementation of the TP-Link AES transport.
|
|
|
|
|
|
|
|
Based on the work of https://github.com/petretiandrea/plugp100
|
|
|
|
under compatible GNU GPL3 license.
|
|
|
|
"""
|
2024-04-16 18:21:20 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
import base64
|
|
|
|
import hashlib
|
|
|
|
import logging
|
|
|
|
import time
|
2024-06-19 18:24:12 +00:00
|
|
|
from collections.abc import AsyncGenerator
|
2024-01-24 08:50:25 +00:00
|
|
|
from enum import Enum, auto
|
2024-11-18 18:46:36 +00:00
|
|
|
from typing import TYPE_CHECKING, Any, cast
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
from cryptography.hazmat.primitives import hashes, padding, serialization
|
2023-12-04 18:50:05 +00:00
|
|
|
from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
2024-01-29 15:26:00 +00:00
|
|
|
from yarl import URL
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-11-13 17:50:21 +00:00
|
|
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
2024-11-12 13:40:44 +00:00
|
|
|
from kasa.deviceconfig import DeviceConfig
|
|
|
|
from kasa.exceptions import (
|
2023-12-10 15:41:53 +00:00
|
|
|
SMART_AUTHENTICATION_ERRORS,
|
|
|
|
SMART_RETRYABLE_ERRORS,
|
2024-02-21 15:52:55 +00:00
|
|
|
AuthenticationError,
|
|
|
|
DeviceError,
|
|
|
|
KasaException,
|
2023-12-10 15:41:53 +00:00
|
|
|
SmartErrorCode,
|
2024-06-06 16:01:58 +00:00
|
|
|
TimeoutError,
|
|
|
|
_ConnectionError,
|
2024-02-21 15:52:55 +00:00
|
|
|
_RetryableError,
|
2023-12-10 15:41:53 +00:00
|
|
|
)
|
2024-11-12 13:40:44 +00:00
|
|
|
from kasa.httpclient import HttpClient
|
|
|
|
from kasa.json import dumps as json_dumps
|
|
|
|
from kasa.json import loads as json_loads
|
|
|
|
|
|
|
|
from .basetransport import BaseTransport
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2024-01-24 09:11:27 +00:00
|
|
|
ONE_DAY_SECONDS = 86400
|
|
|
|
SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
def _sha1(payload: bytes) -> str:
|
|
|
|
sha1_algo = hashlib.sha1() # noqa: S324
|
|
|
|
sha1_algo.update(payload)
|
|
|
|
return sha1_algo.hexdigest()
|
|
|
|
|
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
class TransportState(Enum):
|
|
|
|
"""Enum for AES state."""
|
|
|
|
|
|
|
|
HANDSHAKE_REQUIRED = auto() # Handshake needed
|
|
|
|
LOGIN_REQUIRED = auto() # Login needed
|
|
|
|
ESTABLISHED = auto() # Ready to send requests
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
class AesTransport(BaseTransport):
|
|
|
|
"""Implementation of the AES encryption protocol.
|
|
|
|
|
|
|
|
AES is the name used in device discovery for TP-Link's TAPO encryption
|
|
|
|
protocol, sometimes used by newer firmware versions on kasa devices.
|
|
|
|
"""
|
|
|
|
|
2023-12-29 19:17:15 +00:00
|
|
|
DEFAULT_PORT: int = 80
|
2023-12-04 18:50:05 +00:00
|
|
|
SESSION_COOKIE_NAME = "TP_SESSIONID"
|
2024-01-24 09:11:27 +00:00
|
|
|
TIMEOUT_COOKIE_NAME = "TIMEOUT"
|
2023-12-04 18:50:05 +00:00
|
|
|
COMMON_HEADERS = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
"requestByApp": "true",
|
|
|
|
"Accept": "application/json",
|
|
|
|
}
|
2024-01-23 15:29:27 +00:00
|
|
|
CONTENT_LENGTH = "Content-Length"
|
|
|
|
KEY_PAIR_CONTENT_LENGTH = 314
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
*,
|
2023-12-29 19:17:15 +00:00
|
|
|
config: DeviceConfig,
|
2023-12-04 18:50:05 +00:00
|
|
|
) -> None:
|
2023-12-29 19:17:15 +00:00
|
|
|
super().__init__(config=config)
|
|
|
|
|
2024-01-03 21:46:08 +00:00
|
|
|
self._login_version = config.connection_type.login_version
|
2024-01-04 18:17:48 +00:00
|
|
|
if (
|
|
|
|
not self._credentials or self._credentials.username is None
|
|
|
|
) and not self._credentials_hash:
|
2024-01-03 21:46:08 +00:00
|
|
|
self._credentials = Credentials()
|
|
|
|
if self._credentials:
|
2024-01-23 14:44:32 +00:00
|
|
|
self._login_params = self._get_login_params(self._credentials)
|
2024-01-03 21:46:08 +00:00
|
|
|
else:
|
|
|
|
self._login_params = json_loads(
|
|
|
|
base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr]
|
|
|
|
)
|
2024-04-17 13:39:24 +00:00
|
|
|
self._default_credentials: Credentials | None = None
|
2024-01-18 09:57:33 +00:00
|
|
|
self._http_client: HttpClient = HttpClient(config)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.HANDSHAKE_REQUIRED
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
self._encryption_session: AesEncyptionSession | None = None
|
|
|
|
self._session_expire_at: float | None = None
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
self._session_cookie: dict[str, str] | None = None
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
self._key_pair: KeyPair | None = None
|
2024-09-10 16:24:38 +00:00
|
|
|
if config.aes_keys:
|
|
|
|
aes_keys = config.aes_keys
|
2024-10-16 14:28:27 +00:00
|
|
|
self._key_pair = KeyPair.create_from_der_keys(
|
|
|
|
aes_keys["private"], aes_keys["public"]
|
|
|
|
)
|
2024-02-03 14:28:20 +00:00
|
|
|
self._app_url = URL(f"http://{self._host}:{self._port}/app")
|
2024-04-17 13:39:24 +00:00
|
|
|
self._token_url: URL | None = None
|
2024-01-23 15:29:27 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
_LOGGER.debug("Created AES transport for %s", self._host)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2023-12-29 19:17:15 +00:00
|
|
|
@property
|
2024-01-24 08:50:25 +00:00
|
|
|
def default_port(self) -> int:
|
2023-12-29 19:17:15 +00:00
|
|
|
"""Default port for the transport."""
|
|
|
|
return self.DEFAULT_PORT
|
|
|
|
|
2024-01-03 21:46:08 +00:00
|
|
|
@property
|
2024-07-02 12:43:37 +00:00
|
|
|
def credentials_hash(self) -> str | None:
|
2024-01-03 21:46:08 +00:00
|
|
|
"""The hashed credentials used by the transport."""
|
2024-07-02 12:43:37 +00:00
|
|
|
if self._credentials == Credentials():
|
|
|
|
return None
|
2024-01-03 21:46:08 +00:00
|
|
|
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
|
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
def _get_login_params(self, credentials: Credentials) -> dict[str, str]:
|
2024-01-03 21:46:08 +00:00
|
|
|
"""Get the login parameters based on the login_version."""
|
2024-01-23 14:44:32 +00:00
|
|
|
un, pw = self.hash_credentials(self._login_version == 2, credentials)
|
2024-01-03 21:46:08 +00:00
|
|
|
password_field_name = "password2" if self._login_version == 2 else "password"
|
|
|
|
return {password_field_name: pw, "username": un}
|
|
|
|
|
2024-01-23 14:44:32 +00:00
|
|
|
@staticmethod
|
2024-04-17 13:39:24 +00:00
|
|
|
def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str]:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Hash the credentials."""
|
2024-01-24 08:50:25 +00:00
|
|
|
un = base64.b64encode(_sha1(credentials.username.encode()).encode()).decode()
|
2023-12-04 18:50:05 +00:00
|
|
|
if login_v2:
|
|
|
|
pw = base64.b64encode(
|
2024-01-23 14:44:32 +00:00
|
|
|
_sha1(credentials.password.encode()).encode()
|
2023-12-04 18:50:05 +00:00
|
|
|
).decode()
|
|
|
|
else:
|
2024-01-23 14:44:32 +00:00
|
|
|
pw = base64.b64encode(credentials.password.encode()).decode()
|
2023-12-04 18:50:05 +00:00
|
|
|
return un, pw
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _handle_response_error_code(self, resp_dict: dict, msg: str) -> None:
|
2024-06-27 14:58:45 +00:00
|
|
|
error_code_raw = resp_dict.get("error_code")
|
|
|
|
try:
|
2024-06-30 09:49:59 +00:00
|
|
|
error_code = SmartErrorCode.from_int(error_code_raw)
|
2024-06-27 14:58:45 +00:00
|
|
|
except ValueError:
|
2024-07-11 15:21:59 +00:00
|
|
|
_LOGGER.warning(
|
|
|
|
"Device %s received unknown error code: %s", self._host, error_code_raw
|
|
|
|
)
|
2024-06-27 14:58:45 +00:00
|
|
|
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
|
2024-06-30 09:49:59 +00:00
|
|
|
if error_code is SmartErrorCode.SUCCESS:
|
2023-12-20 17:08:04 +00:00
|
|
|
return
|
|
|
|
msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})"
|
|
|
|
if error_code in SMART_RETRYABLE_ERRORS:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise _RetryableError(msg, error_code=error_code)
|
2023-12-20 17:08:04 +00:00
|
|
|
if error_code in SMART_AUTHENTICATION_ERRORS:
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.HANDSHAKE_REQUIRED
|
2024-02-21 15:52:55 +00:00
|
|
|
raise AuthenticationError(msg, error_code=error_code)
|
|
|
|
raise DeviceError(msg, error_code=error_code)
|
2023-12-10 15:41:53 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
async def send_secure_passthrough(self, request: str) -> dict[str, Any]:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Send encrypted message as passthrough."""
|
2024-01-29 15:26:00 +00:00
|
|
|
if self._state is TransportState.ESTABLISHED and self._token_url:
|
|
|
|
url = self._token_url
|
|
|
|
else:
|
|
|
|
url = self._app_url
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore
|
|
|
|
passthrough_request = {
|
|
|
|
"method": "securePassthrough",
|
|
|
|
"params": {"request": encrypted_payload.decode()},
|
|
|
|
}
|
2024-01-18 09:57:33 +00:00
|
|
|
status_code, resp_dict = await self._http_client.post(
|
|
|
|
url,
|
|
|
|
json=passthrough_request,
|
|
|
|
headers=self.COMMON_HEADERS,
|
|
|
|
cookies_dict=self._session_cookie,
|
|
|
|
)
|
2023-12-08 14:22:58 +00:00
|
|
|
# _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}")
|
2023-12-10 15:41:53 +00:00
|
|
|
|
|
|
|
if status_code != 200:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2023-12-19 14:11:59 +00:00
|
|
|
f"{self._host} responded with an unexpected "
|
2023-12-10 15:41:53 +00:00
|
|
|
+ f"status code {status_code} to passthrough"
|
2023-12-04 18:50:05 +00:00
|
|
|
)
|
2023-12-10 15:41:53 +00:00
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
if TYPE_CHECKING:
|
2024-11-18 18:46:36 +00:00
|
|
|
resp_dict = cast(dict[str, Any], resp_dict)
|
2024-01-24 08:50:25 +00:00
|
|
|
assert self._encryption_session is not None
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
self._handle_response_error_code(
|
|
|
|
resp_dict, "Error sending secure_passthrough message"
|
|
|
|
)
|
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
raw_response: str = resp_dict["result"]["response"]
|
2024-02-14 19:13:28 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
response = self._encryption_session.decrypt(raw_response.encode())
|
|
|
|
ret_val = json_loads(response)
|
|
|
|
except Exception as ex:
|
|
|
|
try:
|
|
|
|
ret_val = json_loads(raw_response)
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Received unencrypted response over secure passthrough from %s",
|
|
|
|
self._host,
|
|
|
|
)
|
|
|
|
except Exception:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2024-02-14 19:13:28 +00:00
|
|
|
f"Unable to decrypt response from {self._host}, "
|
|
|
|
+ f"error: {ex}, response: {raw_response}",
|
|
|
|
ex,
|
|
|
|
) from ex
|
|
|
|
return ret_val # type: ignore[return-value]
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
async def perform_login(self) -> None:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Login to the device."""
|
2024-01-23 14:44:32 +00:00
|
|
|
try:
|
|
|
|
await self.try_login(self._login_params)
|
2024-07-11 15:21:59 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"%s: logged in with provided credentials",
|
|
|
|
self._host,
|
|
|
|
)
|
2024-02-21 15:52:55 +00:00
|
|
|
except AuthenticationError as aex:
|
2024-01-23 21:51:07 +00:00
|
|
|
try:
|
2024-01-24 08:50:25 +00:00
|
|
|
if aex.error_code is not SmartErrorCode.LOGIN_ERROR:
|
2024-01-23 21:51:07 +00:00
|
|
|
raise aex
|
2024-07-11 15:21:59 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"%s: trying login with default TAPO credentials",
|
|
|
|
self._host,
|
|
|
|
)
|
2024-01-23 21:51:07 +00:00
|
|
|
if self._default_credentials is None:
|
|
|
|
self._default_credentials = get_default_credentials(
|
|
|
|
DEFAULT_CREDENTIALS["TAPO"]
|
|
|
|
)
|
|
|
|
await self.perform_handshake()
|
|
|
|
await self.try_login(self._get_login_params(self._default_credentials))
|
|
|
|
_LOGGER.debug(
|
2024-07-11 15:21:59 +00:00
|
|
|
"%s: logged in with default TAPO credentials",
|
2024-01-23 21:51:07 +00:00
|
|
|
self._host,
|
2024-01-23 14:44:32 +00:00
|
|
|
)
|
2024-06-06 16:01:58 +00:00
|
|
|
except (AuthenticationError, _ConnectionError, TimeoutError):
|
2024-01-23 21:51:07 +00:00
|
|
|
raise
|
|
|
|
except Exception as ex:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2024-01-23 21:51:07 +00:00
|
|
|
"Unable to login and trying default "
|
2024-02-05 20:49:26 +00:00
|
|
|
+ f"login raised another exception: {ex}",
|
2024-01-23 21:51:07 +00:00
|
|
|
ex,
|
|
|
|
) from ex
|
2024-01-23 14:44:32 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
async def try_login(self, login_params: dict[str, Any]) -> None:
|
2024-01-23 14:44:32 +00:00
|
|
|
"""Try to login with supplied login_params."""
|
2023-12-19 14:11:59 +00:00
|
|
|
login_request = {
|
|
|
|
"method": "login_device",
|
2024-01-23 14:44:32 +00:00
|
|
|
"params": login_params,
|
2023-12-19 14:11:59 +00:00
|
|
|
"request_time_milis": round(time.time() * 1000),
|
|
|
|
}
|
|
|
|
request = json_dumps(login_request)
|
2023-12-29 19:42:02 +00:00
|
|
|
|
|
|
|
resp_dict = await self.send_secure_passthrough(request)
|
|
|
|
self._handle_response_error_code(resp_dict, "Error logging in")
|
2024-01-29 15:26:00 +00:00
|
|
|
login_token = resp_dict["result"]["token"]
|
|
|
|
self._token_url = self._app_url.with_query(f"token={login_token}")
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.ESTABLISHED
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-23 15:29:27 +00:00
|
|
|
async def _generate_key_pair_payload(self) -> AsyncGenerator:
|
|
|
|
"""Generate the request body and return an ascyn_generator.
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-23 15:29:27 +00:00
|
|
|
This prevents the key pair being generated unless a connection
|
|
|
|
can be made to the device.
|
|
|
|
"""
|
|
|
|
_LOGGER.debug("Generating keypair")
|
2024-09-10 16:24:38 +00:00
|
|
|
if not self._key_pair:
|
|
|
|
kp = KeyPair.create_key_pair()
|
|
|
|
self._config.aes_keys = {
|
2024-10-16 14:28:27 +00:00
|
|
|
"private": kp.private_key_der_b64,
|
|
|
|
"public": kp.public_key_der_b64,
|
2024-09-10 16:24:38 +00:00
|
|
|
}
|
|
|
|
self._key_pair = kp
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
pub_key = (
|
|
|
|
"-----BEGIN PUBLIC KEY-----\n"
|
2024-10-16 14:28:27 +00:00
|
|
|
+ self._key_pair.public_key_der_b64 # type: ignore[union-attr]
|
2023-12-04 18:50:05 +00:00
|
|
|
+ "\n-----END PUBLIC KEY-----\n"
|
|
|
|
)
|
|
|
|
handshake_params = {"key": pub_key}
|
|
|
|
request_body = {"method": "handshake", "params": handshake_params}
|
2024-08-30 14:13:14 +00:00
|
|
|
_LOGGER.debug("Handshake request: %s", request_body)
|
2024-01-23 15:29:27 +00:00
|
|
|
yield json_dumps(request_body).encode()
|
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
async def perform_handshake(self) -> None:
|
2024-01-23 15:29:27 +00:00
|
|
|
"""Perform the handshake."""
|
|
|
|
_LOGGER.debug("Will perform handshaking...")
|
|
|
|
|
2024-01-29 15:26:00 +00:00
|
|
|
self._token_url = None
|
2024-01-23 15:29:27 +00:00
|
|
|
self._session_expire_at = None
|
|
|
|
self._session_cookie = None
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-23 15:29:27 +00:00
|
|
|
# Device needs the content length or it will response with 500
|
|
|
|
headers = {
|
|
|
|
**self.COMMON_HEADERS,
|
|
|
|
self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH),
|
|
|
|
}
|
2024-01-24 09:11:27 +00:00
|
|
|
http_client = self._http_client
|
|
|
|
|
|
|
|
status_code, resp_dict = await http_client.post(
|
2024-02-03 14:28:20 +00:00
|
|
|
self._app_url,
|
2024-01-23 15:29:27 +00:00
|
|
|
json=self._generate_key_pair_payload(),
|
|
|
|
headers=headers,
|
2024-01-18 09:57:33 +00:00
|
|
|
cookies_dict=self._session_cookie,
|
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
_LOGGER.debug("Device responded with: %s", resp_dict)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2023-12-10 15:41:53 +00:00
|
|
|
if status_code != 200:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2023-12-19 14:11:59 +00:00
|
|
|
f"{self._host} responded with an unexpected "
|
2023-12-10 15:41:53 +00:00
|
|
|
+ f"status code {status_code} to handshake"
|
2023-12-04 18:50:05 +00:00
|
|
|
)
|
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
if TYPE_CHECKING:
|
2024-11-18 18:46:36 +00:00
|
|
|
resp_dict = cast(dict[str, Any], resp_dict)
|
2024-01-24 08:50:25 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
self._handle_response_error_code(resp_dict, "Unable to complete handshake")
|
|
|
|
|
2023-12-10 15:41:53 +00:00
|
|
|
handshake_key = resp_dict["result"]["key"]
|
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
if (
|
2024-01-24 19:43:42 +00:00
|
|
|
cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME) # type: ignore
|
2024-01-18 09:57:33 +00:00
|
|
|
) or (
|
2024-01-24 09:11:27 +00:00
|
|
|
cookie := http_client.get_cookie("SESSIONID") # type: ignore
|
2024-01-18 09:57:33 +00:00
|
|
|
):
|
|
|
|
self._session_cookie = {self.SESSION_COOKIE_NAME: cookie}
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-24 09:11:27 +00:00
|
|
|
timeout = int(
|
|
|
|
http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS
|
|
|
|
)
|
|
|
|
# There is a 24 hour timeout on the session cookie
|
|
|
|
# but the clock on the device is not always accurate
|
|
|
|
# so we set the expiry to 24 hours from now minus a buffer
|
|
|
|
self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS
|
2024-01-23 15:29:27 +00:00
|
|
|
if TYPE_CHECKING:
|
2024-01-24 08:50:25 +00:00
|
|
|
assert self._key_pair is not None
|
2023-12-10 15:41:53 +00:00
|
|
|
self._encryption_session = AesEncyptionSession.create_from_keypair(
|
2024-01-23 15:29:27 +00:00
|
|
|
handshake_key, self._key_pair
|
2023-12-10 15:41:53 +00:00
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.LOGIN_REQUIRED
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
_LOGGER.debug("Handshake with %s complete", self._host)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _handshake_session_expired(self) -> bool:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Return true if session has expired."""
|
|
|
|
return (
|
|
|
|
self._session_expire_at is None
|
|
|
|
or self._session_expire_at - time.time() <= 0
|
|
|
|
)
|
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
async def send(self, request: str) -> dict[str, Any]:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Send the request."""
|
2024-01-24 08:50:25 +00:00
|
|
|
if (
|
|
|
|
self._state is TransportState.HANDSHAKE_REQUIRED
|
|
|
|
or self._handshake_session_expired()
|
|
|
|
):
|
2023-12-19 14:11:59 +00:00
|
|
|
await self.perform_handshake()
|
2024-01-24 08:50:25 +00:00
|
|
|
if self._state is not TransportState.ESTABLISHED:
|
2024-01-23 14:44:32 +00:00
|
|
|
try:
|
|
|
|
await self.perform_login()
|
|
|
|
# After a login failure handshake needs to
|
|
|
|
# be redone or a 9999 error is received.
|
2024-02-21 15:52:55 +00:00
|
|
|
except AuthenticationError as ex:
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.HANDSHAKE_REQUIRED
|
2024-01-23 14:44:32 +00:00
|
|
|
raise ex
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2023-12-10 15:41:53 +00:00
|
|
|
return await self.send_secure_passthrough(request)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
async def close(self) -> None:
|
2024-01-23 22:15:18 +00:00
|
|
|
"""Close the http client and reset internal state."""
|
|
|
|
await self.reset()
|
|
|
|
await self._http_client.close()
|
2024-01-20 12:35:05 +00:00
|
|
|
|
2024-01-23 22:15:18 +00:00
|
|
|
async def reset(self) -> None:
|
|
|
|
"""Reset internal handshake and login state."""
|
2024-01-24 08:50:25 +00:00
|
|
|
self._state = TransportState.HANDSHAKE_REQUIRED
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
class AesEncyptionSession:
|
|
|
|
"""Class for an AES encryption session."""
|
|
|
|
|
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def create_from_keypair(
|
|
|
|
handshake_key: str, keypair: KeyPair
|
|
|
|
) -> AesEncyptionSession:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Create the encryption session."""
|
2024-10-16 14:28:27 +00:00
|
|
|
handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode())
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
key_and_iv = keypair.decrypt_handshake_key(handshake_key_bytes)
|
2023-12-04 18:50:05 +00:00
|
|
|
if key_and_iv is None:
|
|
|
|
raise ValueError("Decryption failed!")
|
|
|
|
|
|
|
|
return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:])
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def __init__(self, key: bytes, iv: bytes) -> None:
|
2023-12-04 18:50:05 +00:00
|
|
|
self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
|
|
self.padding_strategy = padding.PKCS7(algorithms.AES.block_size)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def encrypt(self, data: bytes) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Encrypt the message."""
|
|
|
|
encryptor = self.cipher.encryptor()
|
|
|
|
padder = self.padding_strategy.padder()
|
|
|
|
padded_data = padder.update(data) + padder.finalize()
|
|
|
|
encrypted = encryptor.update(padded_data) + encryptor.finalize()
|
|
|
|
return base64.b64encode(encrypted)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def decrypt(self, data: str | bytes) -> str:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Decrypt the message."""
|
|
|
|
decryptor = self.cipher.decryptor()
|
|
|
|
unpadder = self.padding_strategy.unpadder()
|
|
|
|
decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize()
|
|
|
|
unpadded_data = unpadder.update(decrypted) + unpadder.finalize()
|
|
|
|
return unpadded_data.decode()
|
|
|
|
|
|
|
|
|
|
|
|
class KeyPair:
|
|
|
|
"""Class for generating key pairs."""
|
|
|
|
|
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def create_key_pair(key_size: int = 1024) -> KeyPair:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Create a key pair."""
|
|
|
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
|
|
|
|
public_key = private_key.public_key()
|
2024-10-16 14:28:27 +00:00
|
|
|
return KeyPair(private_key, public_key)
|
|
|
|
|
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def create_from_der_keys(
|
|
|
|
private_key_der_b64: str, public_key_der_b64: str
|
|
|
|
) -> KeyPair:
|
2024-10-16 14:28:27 +00:00
|
|
|
"""Create a key pair."""
|
|
|
|
key_bytes = base64.b64decode(private_key_der_b64.encode())
|
|
|
|
private_key = cast(
|
|
|
|
rsa.RSAPrivateKey, serialization.load_der_private_key(key_bytes, None)
|
|
|
|
)
|
|
|
|
key_bytes = base64.b64decode(public_key_der_b64.encode())
|
|
|
|
public_key = cast(
|
|
|
|
rsa.RSAPublicKey, serialization.load_der_public_key(key_bytes, None)
|
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
return KeyPair(private_key, public_key)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def __init__(
|
|
|
|
self, private_key: rsa.RSAPrivateKey, public_key: rsa.RSAPublicKey
|
|
|
|
) -> None:
|
2024-10-16 14:28:27 +00:00
|
|
|
self.private_key = private_key
|
|
|
|
self.public_key = public_key
|
|
|
|
self.private_key_der_bytes = self.private_key.private_bytes(
|
2023-12-04 18:50:05 +00:00
|
|
|
encoding=serialization.Encoding.DER,
|
|
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
|
|
)
|
2024-10-16 14:28:27 +00:00
|
|
|
self.public_key_der_bytes = self.public_key.public_bytes(
|
2023-12-04 18:50:05 +00:00
|
|
|
encoding=serialization.Encoding.DER,
|
|
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
|
|
)
|
2024-10-16 14:28:27 +00:00
|
|
|
self.private_key_der_b64 = base64.b64encode(self.private_key_der_bytes).decode()
|
|
|
|
self.public_key_der_b64 = base64.b64encode(self.public_key_der_bytes).decode()
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
def get_public_pem(self) -> bytes:
|
|
|
|
"""Get public key in PEM encoding."""
|
|
|
|
return self.public_key.public_bytes(
|
|
|
|
encoding=serialization.Encoding.PEM,
|
|
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
2023-12-04 18:50:05 +00:00
|
|
|
)
|
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
def decrypt_handshake_key(self, encrypted_key: bytes) -> bytes:
|
|
|
|
"""Decrypt an aes handshake key."""
|
|
|
|
decrypted = self.private_key.decrypt(
|
|
|
|
encrypted_key, asymmetric_padding.PKCS1v15()
|
|
|
|
)
|
|
|
|
return decrypted
|
|
|
|
|
|
|
|
def decrypt_discovery_key(self, encrypted_key: bytes) -> bytes:
|
|
|
|
"""Decrypt an aes discovery key."""
|
|
|
|
decrypted = self.private_key.decrypt(
|
|
|
|
encrypted_key,
|
|
|
|
asymmetric_padding.OAEP(
|
|
|
|
mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303
|
|
|
|
algorithm=hashes.SHA1(), # noqa: S303
|
|
|
|
label=None,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
return decrypted
|