2023-11-20 13:17:10 +00:00
|
|
|
"""Implementation of the TP-Link Klap Home Protocol.
|
|
|
|
|
|
|
|
Encryption/Decryption methods based on the works of
|
|
|
|
Simon Wilkinson and Chris Weeldon
|
|
|
|
|
|
|
|
Klap devices that have never been connected to the kasa
|
|
|
|
cloud should work with blank credentials.
|
|
|
|
Devices that have been connected to the kasa cloud will
|
|
|
|
switch intermittently between the users cloud credentials
|
|
|
|
and default kasa credentials that are hardcoded.
|
|
|
|
This appears to be an issue with the devices.
|
|
|
|
|
|
|
|
The protocol works by doing a two stage handshake to obtain
|
|
|
|
and encryption key and session id cookie.
|
|
|
|
|
|
|
|
Authentication uses an auth_hash which is
|
|
|
|
md5(md5(username),md5(password))
|
|
|
|
|
|
|
|
handshake1: client sends a random 16 byte local_seed to the
|
|
|
|
device and receives a random 16 bytes remote_seed, followed
|
|
|
|
by sha256(local_seed + auth_hash). It also returns a
|
|
|
|
TP_SESSIONID in the cookie header. This implementation
|
|
|
|
then checks this value against the possible auth_hashes
|
|
|
|
described above (user cloud, kasa hardcoded, blank). If it
|
|
|
|
finds a match it moves onto handshake2
|
|
|
|
|
|
|
|
handshake2: client sends sha25(remote_seed + auth_hash) to
|
|
|
|
the device along with the TP_SESSIONID. Device responds with
|
2024-01-20 01:40:21 +00:00
|
|
|
200 if successful. It generally will be because this
|
|
|
|
implementation checks the auth_hash it received during handshake1
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
encryption: local_seed, remote_seed and auth_hash are now used
|
2024-01-20 01:40:21 +00:00
|
|
|
for encryption. The last 4 bytes of the initialization vector
|
2023-11-20 13:17:10 +00:00
|
|
|
are used as a sequence number that increments every time the
|
|
|
|
client calls encrypt and this sequence number is sent as a
|
|
|
|
url parameter to the device along with the encrypted payload
|
|
|
|
|
|
|
|
https://gist.github.com/chriswheeldon/3b17d974db3817613c69191c0480fe55
|
|
|
|
https://github.com/python-kasa/python-kasa/pull/117
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
import asyncio
|
2024-01-03 21:46:08 +00:00
|
|
|
import base64
|
2023-11-20 13:17:10 +00:00
|
|
|
import datetime
|
|
|
|
import hashlib
|
|
|
|
import logging
|
|
|
|
import secrets
|
2024-01-26 17:44:41 +00:00
|
|
|
import struct
|
2023-11-20 13:17:10 +00:00
|
|
|
import time
|
2024-11-10 18:55:13 +00:00
|
|
|
from asyncio import Future
|
2024-11-18 18:46:36 +00:00
|
|
|
from collections.abc import Generator
|
|
|
|
from typing import TYPE_CHECKING, Any, cast
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-26 16:57:56 +00:00
|
|
|
from cryptography.hazmat.primitives import padding
|
2023-11-20 13:17:10 +00:00
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
2024-01-29 15:26:00 +00:00
|
|
|
from yarl import URL
|
2023-11-20 13:17:10 +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 AuthenticationError, KasaException, _RetryableError
|
|
|
|
from kasa.httpclient import HttpClient
|
|
|
|
from kasa.json import loads as json_loads
|
2024-11-13 17:50:21 +00:00
|
|
|
from kasa.protocols.protocol import md5
|
2024-11-12 13:40:44 +00:00
|
|
|
|
|
|
|
from .basetransport import BaseTransport
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2024-01-24 09:11:27 +00:00
|
|
|
ONE_DAY_SECONDS = 86400
|
|
|
|
SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20
|
|
|
|
|
2024-01-26 17:44:41 +00:00
|
|
|
PACK_SIGNED_LONG = struct.Struct(">l").pack
|
|
|
|
|
2024-01-24 09:11:27 +00:00
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
def _sha256(payload: bytes) -> bytes:
|
2024-01-26 16:57:56 +00:00
|
|
|
return hashlib.sha256(payload).digest() # noqa: S324
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
def _sha1(payload: bytes) -> bytes:
|
2024-01-26 16:57:56 +00:00
|
|
|
return hashlib.sha1(payload).digest() # noqa: S324
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
|
|
|
|
class KlapTransport(BaseTransport):
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Implementation of the KLAP encryption protocol.
|
|
|
|
|
|
|
|
KLAP is the name used in device discovery for TP-Link's new encryption
|
|
|
|
protocol, used by newer firmware versions.
|
|
|
|
"""
|
|
|
|
|
2023-12-29 19:17:15 +00:00
|
|
|
DEFAULT_PORT: int = 80
|
2023-11-20 13:17:10 +00:00
|
|
|
SESSION_COOKIE_NAME = "TP_SESSIONID"
|
2024-01-24 09:11:27 +00:00
|
|
|
TIMEOUT_COOKIE_NAME = "TIMEOUT"
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
*,
|
2023-12-29 19:17:15 +00:00
|
|
|
config: DeviceConfig,
|
2023-11-20 13:17:10 +00:00
|
|
|
) -> None:
|
2023-12-29 19:17:15 +00:00
|
|
|
super().__init__(config=config)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
self._http_client = HttpClient(config)
|
2024-04-17 13:39:24 +00:00
|
|
|
self._local_seed: bytes | None = None
|
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:
|
|
|
|
self._local_auth_hash = self.generate_auth_hash(self._credentials)
|
|
|
|
self._local_auth_owner = self.generate_owner_hash(self._credentials).hex()
|
|
|
|
else:
|
|
|
|
self._local_auth_hash = base64.b64decode(self._credentials_hash.encode()) # type: ignore[union-attr]
|
2024-04-17 13:39:24 +00:00
|
|
|
self._default_credentials_auth_hash: dict[str, bytes] = {}
|
2024-11-10 18:55:13 +00:00
|
|
|
self._blank_auth_hash: bytes | None = None
|
2023-12-04 18:50:05 +00:00
|
|
|
self._handshake_lock = asyncio.Lock()
|
|
|
|
self._query_lock = asyncio.Lock()
|
2024-11-10 18:55:13 +00:00
|
|
|
self._handshake_done: bool = False
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
self._encryption_session: KlapEncryptionSession | None = None
|
|
|
|
self._session_expire_at: float | None = None
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
self._session_cookie: dict[str, Any] | None = None
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
_LOGGER.debug("Created KLAP transport for %s", self._host)
|
2024-02-03 14:28:20 +00:00
|
|
|
self._app_url = URL(f"http://{self._host}:{self._port}/app")
|
2024-01-29 15:26:00 +00:00
|
|
|
self._request_url = self._app_url / "request"
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-29 19:17:15 +00:00
|
|
|
@property
|
2024-11-10 18:55:13 +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(self._local_auth_hash).decode()
|
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Perform handshake1."""
|
|
|
|
local_seed: bytes = secrets.token_bytes(16)
|
|
|
|
|
|
|
|
# Handshake 1 has a payload of local_seed
|
|
|
|
# and a response of 16 bytes, followed by
|
|
|
|
# sha256(remote_seed | auth_hash)
|
|
|
|
|
|
|
|
payload = local_seed
|
|
|
|
|
2024-01-29 15:26:00 +00:00
|
|
|
url = self._app_url / "handshake1"
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
response_status, response_data = await self._http_client.post(url, data=payload)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
|
|
_LOGGER.debug(
|
2024-08-30 14:13:14 +00:00
|
|
|
"Handshake1 posted at %s. Host is %s, "
|
|
|
|
"Response status is %s, Request was %s",
|
2023-11-20 13:17:10 +00:00
|
|
|
datetime.datetime.now(),
|
2023-12-19 14:11:59 +00:00
|
|
|
self._host,
|
2023-11-20 13:17:10 +00:00
|
|
|
response_status,
|
|
|
|
payload.hex(),
|
|
|
|
)
|
|
|
|
|
|
|
|
if response_status != 200:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2023-12-19 14:11:59 +00:00
|
|
|
f"Device {self._host} responded with {response_status} to handshake1"
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
response_data = cast(bytes, response_data)
|
2023-11-20 13:17:10 +00:00
|
|
|
remote_seed: bytes = response_data[0:16]
|
|
|
|
server_hash = response_data[16:]
|
|
|
|
|
2024-02-05 20:49:26 +00:00
|
|
|
if len(server_hash) != 32:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2024-02-05 20:49:26 +00:00
|
|
|
f"Device {self._host} responded with unexpected klap response "
|
|
|
|
+ f"{response_data!r} to handshake1"
|
|
|
|
)
|
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Handshake1 success at %s. Host is %s, "
|
2024-08-30 14:13:14 +00:00
|
|
|
"Server remote_seed is: %s, server hash is: %s",
|
2023-11-20 13:17:10 +00:00
|
|
|
datetime.datetime.now(),
|
2023-12-19 14:11:59 +00:00
|
|
|
self._host,
|
2023-11-20 13:17:10 +00:00
|
|
|
remote_seed.hex(),
|
|
|
|
server_hash.hex(),
|
|
|
|
)
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
local_seed_auth_hash = self.handshake1_seed_auth_hash(
|
|
|
|
local_seed, remote_seed, self._local_auth_hash
|
|
|
|
) # type: ignore
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
# Check the response from the device with local credentials
|
|
|
|
if local_seed_auth_hash == server_hash:
|
|
|
|
_LOGGER.debug("handshake1 hashes match with expected credentials")
|
2023-12-04 18:50:05 +00:00
|
|
|
return local_seed, remote_seed, self._local_auth_hash # type: ignore
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-23 14:44:32 +00:00
|
|
|
# Now check against the default setup credentials
|
|
|
|
for key, value in DEFAULT_CREDENTIALS.items():
|
|
|
|
if key not in self._default_credentials_auth_hash:
|
|
|
|
default_credentials = get_default_credentials(value)
|
|
|
|
self._default_credentials_auth_hash[key] = self.generate_auth_hash(
|
|
|
|
default_credentials
|
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
|
2024-01-23 14:44:32 +00:00
|
|
|
default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash(
|
|
|
|
local_seed,
|
|
|
|
remote_seed,
|
|
|
|
self._default_credentials_auth_hash[key], # type: ignore
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
2024-01-23 14:44:32 +00:00
|
|
|
|
|
|
|
if default_credentials_seed_auth_hash == server_hash:
|
|
|
|
_LOGGER.debug(
|
2024-08-30 14:13:14 +00:00
|
|
|
"Server response doesn't match our expected hash on ip %s, "
|
|
|
|
"but an authentication with %s default credentials matched",
|
2024-01-23 14:44:32 +00:00
|
|
|
self._host,
|
2024-08-30 14:13:14 +00:00
|
|
|
key,
|
2024-01-23 14:44:32 +00:00
|
|
|
)
|
|
|
|
return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
# Finally check against blank credentials if not already blank
|
2024-01-03 18:26:52 +00:00
|
|
|
blank_creds = Credentials()
|
|
|
|
if self._credentials != blank_creds:
|
2023-12-04 18:50:05 +00:00
|
|
|
if not self._blank_auth_hash:
|
|
|
|
self._blank_auth_hash = self.generate_auth_hash(blank_creds)
|
|
|
|
|
|
|
|
blank_seed_auth_hash = self.handshake1_seed_auth_hash(
|
|
|
|
local_seed,
|
|
|
|
remote_seed,
|
|
|
|
self._blank_auth_hash, # type: ignore
|
|
|
|
)
|
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
if blank_seed_auth_hash == server_hash:
|
|
|
|
_LOGGER.debug(
|
2024-08-30 14:13:14 +00:00
|
|
|
"Server response doesn't match our expected hash on ip %s, "
|
|
|
|
"but an authentication with blank credentials matched",
|
2023-12-19 14:11:59 +00:00
|
|
|
self._host,
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
return local_seed, remote_seed, self._blank_auth_hash # type: ignore
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
msg = f"Server response doesn't match our challenge on ip {self._host}"
|
2023-11-20 13:17:10 +00:00
|
|
|
_LOGGER.debug(msg)
|
2024-02-21 15:52:55 +00:00
|
|
|
raise AuthenticationError(msg)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
async def perform_handshake2(
|
2024-11-10 18:55:13 +00:00
|
|
|
self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes
|
2024-04-17 13:39:24 +00:00
|
|
|
) -> KlapEncryptionSession:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Perform handshake2."""
|
|
|
|
# Handshake 2 has the following payload:
|
|
|
|
# sha256(serverBytes | authenticator)
|
|
|
|
|
2024-01-29 15:26:00 +00:00
|
|
|
url = self._app_url / "handshake2"
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
response_status, _ = await self._http_client.post(
|
|
|
|
url,
|
|
|
|
data=payload,
|
|
|
|
cookies_dict=self._session_cookie,
|
|
|
|
)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
|
|
_LOGGER.debug(
|
2024-08-30 14:13:14 +00:00
|
|
|
"Handshake2 posted %s. Host is %s, "
|
|
|
|
"Response status is %s, Request was %s",
|
2023-11-20 13:17:10 +00:00
|
|
|
datetime.datetime.now(),
|
2023-12-19 14:11:59 +00:00
|
|
|
self._host,
|
2023-11-20 13:17:10 +00:00
|
|
|
response_status,
|
|
|
|
payload.hex(),
|
|
|
|
)
|
|
|
|
|
|
|
|
if response_status != 200:
|
2024-02-05 20:49:26 +00:00
|
|
|
# This shouldn't be caused by incorrect
|
2024-02-21 15:52:55 +00:00
|
|
|
# credentials so don't raise AuthenticationError
|
|
|
|
raise KasaException(
|
2023-12-19 14:11:59 +00:00
|
|
|
f"Device {self._host} responded with {response_status} to handshake2"
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
return KlapEncryptionSession(local_seed, remote_seed, auth_hash)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
async def perform_handshake(self) -> None:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Perform handshake1 and handshake2.
|
|
|
|
|
|
|
|
Sets the encryption_session if successful.
|
|
|
|
"""
|
2023-12-19 14:11:59 +00:00
|
|
|
_LOGGER.debug("Starting handshake with %s", self._host)
|
2023-12-04 18:50:05 +00:00
|
|
|
self._handshake_done = False
|
|
|
|
self._session_expire_at = None
|
|
|
|
self._session_cookie = None
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
local_seed, remote_seed, auth_hash = await self.perform_handshake1()
|
2024-01-24 09:11:27 +00:00
|
|
|
http_client = self._http_client
|
|
|
|
if cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME): # type: ignore
|
2024-01-18 09:57:33 +00:00
|
|
|
self._session_cookie = {self.SESSION_COOKIE_NAME: cookie}
|
2023-11-20 13:17:10 +00:00
|
|
|
# The device returns a TIMEOUT cookie on handshake1 which
|
|
|
|
# it doesn't like to get back so we store the one we want
|
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
|
2024-07-16 12:25:32 +00:00
|
|
|
self._session_expire_at = (
|
|
|
|
time.monotonic() + timeout - SESSION_EXPIRE_BUFFER_SECONDS
|
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
self._encryption_session = await self.perform_handshake2(
|
2023-11-20 13:17:10 +00:00
|
|
|
local_seed, remote_seed, auth_hash
|
|
|
|
)
|
2023-12-04 18:50:05 +00:00
|
|
|
self._handshake_done = True
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-19 14:11:59 +00:00
|
|
|
_LOGGER.debug("Handshake with %s complete", self._host)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _handshake_session_expired(self) -> bool:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Return true if session has expired."""
|
|
|
|
return (
|
2023-12-04 18:50:05 +00:00
|
|
|
self._session_expire_at is None
|
2024-07-16 12:25:32 +00:00
|
|
|
or self._session_expire_at - time.monotonic() <= 0
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: # type: ignore[override]
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Send the request."""
|
2023-12-19 14:11:59 +00:00
|
|
|
if not self._handshake_done or self._handshake_session_expired():
|
|
|
|
await self.perform_handshake()
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
# Check for mypy
|
2023-12-04 18:50:05 +00:00
|
|
|
if self._encryption_session is not None:
|
|
|
|
payload, seq = self._encryption_session.encrypt(request.encode())
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-01-18 09:57:33 +00:00
|
|
|
response_status, response_data = await self._http_client.post(
|
2024-01-29 15:26:00 +00:00
|
|
|
self._request_url,
|
2023-11-20 13:17:10 +00:00
|
|
|
params={"seq": seq},
|
|
|
|
data=payload,
|
2024-01-18 09:57:33 +00:00
|
|
|
cookies_dict=self._session_cookie,
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
msg = (
|
2024-01-18 18:32:58 +00:00
|
|
|
f"Host is {self._host}, "
|
2023-12-04 18:50:05 +00:00
|
|
|
+ f"Sequence is {seq}, "
|
2023-11-20 13:17:10 +00:00
|
|
|
+ f"Response status is {response_status}, Request was {request}"
|
|
|
|
)
|
|
|
|
if response_status != 200:
|
2024-08-30 14:13:14 +00:00
|
|
|
_LOGGER.error("Query failed after successful authentication: %s", msg)
|
2023-11-20 13:17:10 +00:00
|
|
|
# If we failed with a security error, force a new handshake next time.
|
|
|
|
if response_status == 403:
|
2023-12-04 18:50:05 +00:00
|
|
|
self._handshake_done = False
|
2024-02-22 17:02:03 +00:00
|
|
|
raise _RetryableError(
|
2024-08-30 14:13:14 +00:00
|
|
|
"Got a security error from %s after handshake completed", self._host
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
else:
|
2024-02-21 15:52:55 +00:00
|
|
|
raise KasaException(
|
2024-08-30 14:13:14 +00:00
|
|
|
f"Device {self._host} responded with {response_status} to "
|
|
|
|
f"request with seq {seq}"
|
2023-11-20 13:17:10 +00:00
|
|
|
)
|
|
|
|
else:
|
2024-07-17 17:57:09 +00:00
|
|
|
_LOGGER.debug("Device %s query posted %s", self._host, msg)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-07-22 18:33:31 +00:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
assert self._encryption_session
|
2024-11-10 18:55:13 +00:00
|
|
|
assert isinstance(response_data, bytes)
|
2024-07-22 18:33:31 +00:00
|
|
|
try:
|
2023-12-04 18:50:05 +00:00
|
|
|
decrypted_response = self._encryption_session.decrypt(response_data)
|
2024-07-22 18:33:31 +00:00
|
|
|
except Exception as ex:
|
|
|
|
raise KasaException(
|
|
|
|
f"Error trying to decrypt device {self._host} response: {ex}"
|
|
|
|
) from ex
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
json_payload = json_loads(decrypted_response)
|
|
|
|
|
2024-07-17 17:57:09 +00:00
|
|
|
_LOGGER.debug("Device %s query response received", self._host)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
return json_payload
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
async def reset(self) -> None:
|
|
|
|
"""Reset internal handshake state."""
|
2023-12-10 15:41:53 +00:00
|
|
|
self._handshake_done = False
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def generate_auth_hash(creds: Credentials) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
2024-01-03 18:26:52 +00:00
|
|
|
un = creds.username
|
|
|
|
pw = creds.password
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
return md5(md5(un.encode()) + md5(pw.encode()))
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def handshake1_seed_auth_hash(
|
|
|
|
local_seed: bytes, remote_seed: bytes, auth_hash: bytes
|
2024-11-10 18:55:13 +00:00
|
|
|
) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
|
|
|
return _sha256(local_seed + auth_hash)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def handshake2_seed_auth_hash(
|
|
|
|
local_seed: bytes, remote_seed: bytes, auth_hash: bytes
|
2024-11-10 18:55:13 +00:00
|
|
|
) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
|
|
|
return _sha256(remote_seed + auth_hash)
|
|
|
|
|
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def generate_owner_hash(creds: Credentials) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Return the MD5 hash of the username in this object."""
|
2024-01-03 18:26:52 +00:00
|
|
|
un = creds.username
|
2023-12-04 18:50:05 +00:00
|
|
|
return md5(un.encode())
|
|
|
|
|
|
|
|
|
2023-12-29 19:17:15 +00:00
|
|
|
class KlapTransportV2(KlapTransport):
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Implementation of the KLAP encryption protocol with v2 hanshake hashes."""
|
|
|
|
|
|
|
|
@staticmethod
|
2024-11-10 18:55:13 +00:00
|
|
|
def generate_auth_hash(creds: Credentials) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
2024-01-03 18:26:52 +00:00
|
|
|
un = creds.username
|
|
|
|
pw = creds.password
|
2023-12-04 18:50:05 +00:00
|
|
|
|
|
|
|
return _sha256(_sha1(un.encode()) + _sha1(pw.encode()))
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def handshake1_seed_auth_hash(
|
|
|
|
local_seed: bytes, remote_seed: bytes, auth_hash: bytes
|
2024-11-10 18:55:13 +00:00
|
|
|
) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
|
|
|
return _sha256(local_seed + remote_seed + auth_hash)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def handshake2_seed_auth_hash(
|
|
|
|
local_seed: bytes, remote_seed: bytes, auth_hash: bytes
|
2024-11-10 18:55:13 +00:00
|
|
|
) -> bytes:
|
2023-12-04 18:50:05 +00:00
|
|
|
"""Generate an md5 auth hash for the protocol on the supplied credentials."""
|
|
|
|
return _sha256(remote_seed + local_seed + auth_hash)
|
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
|
|
|
|
class KlapEncryptionSession:
|
|
|
|
"""Class to represent an encryption session and it's internal state.
|
|
|
|
|
|
|
|
i.e. sequence number which the device expects to increment.
|
|
|
|
"""
|
|
|
|
|
2024-01-26 17:44:41 +00:00
|
|
|
_cipher: Cipher
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def __init__(self, local_seed: bytes, remote_seed: bytes, user_hash: bytes) -> None:
|
2023-11-20 13:17:10 +00:00
|
|
|
self.local_seed = local_seed
|
|
|
|
self.remote_seed = remote_seed
|
|
|
|
self.user_hash = user_hash
|
|
|
|
self._key = self._key_derive(local_seed, remote_seed, user_hash)
|
|
|
|
(self._iv, self._seq) = self._iv_derive(local_seed, remote_seed, user_hash)
|
2024-01-26 17:44:41 +00:00
|
|
|
self._aes = algorithms.AES(self._key)
|
2023-11-20 13:17:10 +00:00
|
|
|
self._sig = self._sig_derive(local_seed, remote_seed, user_hash)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _key_derive(
|
|
|
|
self, local_seed: bytes, remote_seed: bytes, user_hash: bytes
|
|
|
|
) -> bytes:
|
2023-11-20 13:17:10 +00:00
|
|
|
payload = b"lsk" + local_seed + remote_seed + user_hash
|
|
|
|
return hashlib.sha256(payload).digest()[:16]
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _iv_derive(
|
|
|
|
self, local_seed: bytes, remote_seed: bytes, user_hash: bytes
|
|
|
|
) -> tuple[bytes, int]:
|
2023-11-20 13:17:10 +00:00
|
|
|
# iv is first 16 bytes of sha256, where the last 4 bytes forms the
|
|
|
|
# sequence number used in requests and is incremented on each request
|
|
|
|
payload = b"iv" + local_seed + remote_seed + user_hash
|
|
|
|
fulliv = hashlib.sha256(payload).digest()
|
|
|
|
seq = int.from_bytes(fulliv[-4:], "big", signed=True)
|
|
|
|
return (fulliv[:12], seq)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _sig_derive(
|
|
|
|
self, local_seed: bytes, remote_seed: bytes, user_hash: bytes
|
|
|
|
) -> bytes:
|
2023-11-20 13:17:10 +00:00
|
|
|
# used to create a hash with which to prefix each request
|
|
|
|
payload = b"ldk" + local_seed + remote_seed + user_hash
|
|
|
|
return hashlib.sha256(payload).digest()[:28]
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _generate_cipher(self) -> None:
|
2024-01-26 17:44:41 +00:00
|
|
|
iv_seq = self._iv + PACK_SIGNED_LONG(self._seq)
|
|
|
|
cbc = modes.CBC(iv_seq)
|
|
|
|
self._cipher = Cipher(self._aes, cbc)
|
2023-11-20 13:17:10 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def encrypt(self, msg: bytes | str) -> tuple[bytes, int]:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Encrypt the data and increment the sequence number."""
|
2024-01-26 17:44:41 +00:00
|
|
|
self._seq += 1
|
|
|
|
self._generate_cipher()
|
|
|
|
|
2023-11-20 13:17:10 +00:00
|
|
|
if isinstance(msg, str):
|
|
|
|
msg = msg.encode("utf-8")
|
|
|
|
|
2024-01-26 17:44:41 +00:00
|
|
|
encryptor = self._cipher.encryptor()
|
2023-11-20 13:17:10 +00:00
|
|
|
padder = padding.PKCS7(128).padder()
|
|
|
|
padded_data = padder.update(msg) + padder.finalize()
|
|
|
|
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
2024-01-26 09:33:18 +00:00
|
|
|
signature = hashlib.sha256(
|
2024-01-26 17:44:41 +00:00
|
|
|
self._sig + PACK_SIGNED_LONG(self._seq) + ciphertext
|
2024-01-26 09:33:18 +00:00
|
|
|
).digest()
|
2023-11-20 13:17:10 +00:00
|
|
|
return (signature + ciphertext, self._seq)
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def decrypt(self, msg: bytes) -> str:
|
2023-11-20 13:17:10 +00:00
|
|
|
"""Decrypt the data."""
|
2024-01-26 17:44:41 +00:00
|
|
|
decryptor = self._cipher.decryptor()
|
2023-11-20 13:17:10 +00:00
|
|
|
dp = decryptor.update(msg[32:]) + decryptor.finalize()
|
|
|
|
unpadder = padding.PKCS7(128).unpadder()
|
|
|
|
plaintextbytes = unpadder.update(dp) + unpadder.finalize()
|
|
|
|
|
|
|
|
return plaintextbytes.decode()
|