From 30f217b8ab2f4970dac313244956181cc97debc6 Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Nov 2023 13:17:10 +0000 Subject: [PATCH] Add klap protocol (#509) * Add support for the new encryption protocol This adds support for the new TP-Link discovery and encryption protocols. It is currently incomplete - only devices without username and password are current supported, and single device discovery is not implemented. Discovery should find both old and new devices. When accessing a device by IP the --klap option can be specified on the command line to active the new connection protocol. sdb9696 - This commit also contains 16 later commits from Simon Wilkinson squashed into the original * Update klap changes 2023 to fix encryption, deal with kasa credential switching and work with new discovery changes * Move from aiohttp to httpx * Changes following review comments --------- Co-authored-by: Simon Wilkinson --- kasa/__init__.py | 5 +- kasa/cli.py | 23 +- kasa/discover.py | 161 +++++++++-- kasa/klapprotocol.py | 485 ++++++++++++++++++++++++++++++++ kasa/protocol.py | 41 ++- kasa/smartdevice.py | 49 ++-- kasa/tests/test_discovery.py | 76 ++++- kasa/tests/test_klapprotocol.py | 306 ++++++++++++++++++++ poetry.lock | 212 +++++++++++++- pyproject.toml | 4 +- 10 files changed, 1297 insertions(+), 65 deletions(-) create mode 100755 kasa/klapprotocol.py create mode 100644 kasa/tests/test_klapprotocol.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 4ccf6286..989e507f 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -21,7 +21,8 @@ from kasa.exceptions import ( SmartDeviceException, UnsupportedDeviceException, ) -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.klapprotocol import TPLinkKlap +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer @@ -35,6 +36,8 @@ __version__ = version("python-kasa") __all__ = [ "Discover", "TPLinkSmartHomeProtocol", + "TPLinkProtocol", + "TPLinkKlap", "SmartBulb", "SmartBulbPreset", "TurnOnBehaviors", diff --git a/kasa/cli.py b/kasa/cli.py index 3bc77934..7280dd33 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -11,6 +11,7 @@ from typing import Any, Dict, cast import asyncclick as click from kasa import ( + AuthenticationException, Credentials, Discover, SmartBulb, @@ -308,8 +309,9 @@ async def discover(ctx, timeout, show_unsupported): sem = asyncio.Semaphore() discovered = dict() unsupported = [] + auth_failed = [] - async def print_unsupported(data: Dict): + async def print_unsupported(data: str): unsupported.append(data) if show_unsupported: echo(f"Found unsupported device (tapo/unknown encryption): {data}") @@ -318,12 +320,15 @@ async def discover(ctx, timeout, show_unsupported): echo(f"Discovering devices on {target} for {timeout} seconds") async def print_discovered(dev: SmartDevice): - await dev.update() - async with sem: - discovered[dev.host] = dev.internal_state - ctx.obj = dev - await ctx.invoke(state) - echo() + try: + await dev.update() + async with sem: + discovered[dev.host] = dev.internal_state + ctx.obj = dev + await ctx.invoke(state) + echo() + except AuthenticationException as aex: + auth_failed.append(str(aex)) await Discover.discover( target=target, @@ -343,6 +348,10 @@ async def discover(ctx, timeout, show_unsupported): else ", to show them use: kasa discover --show-unsupported" ) ) + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + for fail in auth_failed: + echo(fail) return discovered diff --git a/kasa/discover.py b/kasa/discover.py index 5b11bed5..9625f7c3 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -4,17 +4,23 @@ import binascii import ipaddress import logging import socket -from typing import Awaitable, Callable, Dict, Optional, Type, cast +from typing import Awaitable, Callable, Dict, Optional, Set, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout +try: + from pydantic.v1 import BaseModel, Field +except ImportError: + from pydantic import BaseModel, Field + from kasa.credentials import Credentials from kasa.exceptions import UnsupportedDeviceException from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.klapprotocol import TPLinkKlap +from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException from kasa.smartdimmer import SmartDimmer @@ -44,7 +50,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, - on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None, + on_unsupported: Optional[Callable[[str], Awaitable[None]]] = None, port: Optional[int] = None, discovered_event: Optional[asyncio.Event] = None, credentials: Optional[Credentials] = None, @@ -64,6 +70,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): self.discovered_event = discovered_event self.credentials = credentials self.timeout = timeout + self.seen_hosts: Set[str] = set() def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -95,43 +102,36 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" ip, port = addr - if ( - ip in self.discovered_devices - or ip in self.unsupported_devices - or ip in self.invalid_device_exceptions - ): + # Prevent multiple entries due multiple broadcasts + if ip in self.seen_hosts: return + self.seen_hosts.add(ip) - if port == self.discovery_port: - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - - elif port == Discover.DISCOVERY_PORT_2: - info = json_loads(data[16:]) - self.unsupported_devices[ip] = info + device = None + try: + if port == self.discovery_port: + device = Discover._get_device_instance_legacy(data, ip, port) + elif port == Discover.DISCOVERY_PORT_2: + device = Discover._get_device_instance( + data, ip, port, self.credentials or Credentials() + ) + else: + return + except UnsupportedDeviceException as udex: + _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) + self.unsupported_devices[ip] = str(udex) if self.on_unsupported is not None: - asyncio.ensure_future(self.on_unsupported(info)) - _LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info) + asyncio.ensure_future(self.on_unsupported(str(udex))) if self.discovered_event is not None: self.discovered_event.set() return - - try: - device_class = Discover._get_device_class(info) except SmartDeviceException as ex: - _LOGGER.debug( - "[DISCOVERY] Unable to find device type from %s: %s", info, ex - ) + _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") self.invalid_device_exceptions[ip] = ex if self.discovered_event is not None: self.discovered_event.set() return - device = device_class( - ip, port=port, credentials=self.credentials, timeout=self.timeout - ) - device.update_from_discover_info(info) - self.discovered_devices[ip] = device if self.on_discovered is not None: @@ -269,6 +269,10 @@ class Discover: to discovery requests. :param host: Hostname of device to query + :param port: Optionally set a different port for the device + :param timeout: Timeout for discovery + :param credentials: Optionally provide credentials for + devices requiring them :rtype: SmartDevice :return: Object for querying/controlling found device. """ @@ -344,6 +348,7 @@ class Discover: port: Optional[int] = None, timeout=5, credentials: Optional[Credentials] = None, + protocol_class: Optional[Type[TPLinkProtocol]] = None, ) -> SmartDevice: """Connect to a single device by the given IP address. @@ -358,12 +363,20 @@ class Discover: The device type is discovered by querying the device. :param host: Hostname of device to query + :param port: Optionally set a different port for the device + :param timeout: Timeout for discovery + :param credentials: Optionally provide credentials for + devices requiring them + :param protocol_class: Optionally provide the protocol class + to use. :rtype: SmartDevice :return: Object for querying/controlling found device. """ unknown_dev = SmartDevice( host=host, port=port, credentials=credentials, timeout=timeout ) + if protocol_class is not None: + unknown_dev.protocol = protocol_class(host, credentials=credentials) await unknown_dev.update() device_class = Discover._get_device_class(unknown_dev.internal_state) dev = device_class( @@ -399,5 +412,95 @@ class Discover: return SmartLightStrip return SmartBulb + raise UnsupportedDeviceException("Unknown device type: %s" % type_) - raise SmartDeviceException("Unknown device type: %s" % type_) + @staticmethod + def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice: + """Get SmartDevice from legacy 9999 response.""" + try: + info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) + except Exception as ex: + raise SmartDeviceException( + f"Unable to read response from device: {ip}: {ex}" + ) from ex + + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + + device_class = Discover._get_device_class(info) + device = device_class(ip, port=port) + device.update_from_discover_info(info) + return device + + @staticmethod + def _get_device_instance( + data: bytes, ip: str, port: int, credentials: Credentials + ) -> SmartDevice: + """Get SmartDevice from the new 20002 response.""" + try: + info = json_loads(data[16:]) + discovery_result = DiscoveryResult(**info["result"]) + except Exception as ex: + raise UnsupportedDeviceException( + f"Unable to read response from device: {ip}: {ex}" + ) from ex + + if ( + discovery_result.mgt_encrypt_schm.encrypt_type == "KLAP" + and discovery_result.mgt_encrypt_schm.lv is None + ): + type_ = discovery_result.device_type + device_class = None + if type_.upper() == "IOT.SMARTPLUGSWITCH": + device_class = SmartPlug + + if device_class: + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + device = device_class(ip, port=port, credentials=credentials) + device.update_from_discover_info(discovery_result.get_dict()) + device.protocol = TPLinkKlap(ip, credentials=credentials) + return device + else: + raise UnsupportedDeviceException( + f"Unsupported device {ip} of type {type_}: {info}" + ) + else: + raise UnsupportedDeviceException(f"Unsupported device {ip}: {info}") + + +class DiscoveryResult(BaseModel): + """Base model for discovery result.""" + + class Config: + """Class for configuring model behaviour.""" + + allow_population_by_field_name = True + + class EncryptionScheme(BaseModel): + """Base model for encryption scheme of discovery result.""" + + is_support_https: Optional[bool] = None + encrypt_type: Optional[str] = None + http_port: Optional[int] = None + lv: Optional[int] = None + + device_type: str = Field(alias="device_type_text") + device_model: str = Field(alias="model") + ip: str = Field(alias="alias") + mac: str + mgt_encrypt_schm: EncryptionScheme + + device_id: Optional[str] = Field(default=None, alias="device_type_hash") + owner: Optional[str] = Field(default=None, alias="device_owner_hash") + hw_ver: Optional[str] = None + is_support_iot_cloud: Optional[bool] = None + obd_src: Optional[str] = None + factory_default: Optional[bool] = None + + def get_dict(self) -> dict: + """Return a dict for this discovery result. + + containing only the values actually set and with aliases as field names. + """ + return self.dict( + by_alias=True, exclude_unset=True, exclude_none=True, exclude_defaults=True + ) diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py new file mode 100755 index 00000000..36a42c58 --- /dev/null +++ b/kasa/klapprotocol.py @@ -0,0 +1,485 @@ +"""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 +200 if succesful. It generally will be because this +implemenation checks the auth_hash it recevied during handshake1 + +encryption: local_seed, remote_seed and auth_hash are now used +for encryption. The last 4 bytes of the initialisation vector +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 + +""" + +import asyncio +import datetime +import hashlib +import logging +import secrets +import time +from pprint import pformat as pf +from typing import Any, Dict, Optional, Tuple, Union + +import httpx +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .credentials import Credentials +from .exceptions import AuthenticationException, SmartDeviceException +from .json import dumps as json_dumps +from .json import loads as json_loads +from .protocol import TPLinkProtocol + +_LOGGER = logging.getLogger(__name__) +logging.getLogger("httpx").propagate = False + + +def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() + + +def _md5(payload: bytes) -> bytes: + digest = hashes.Hash(hashes.MD5()) # noqa: S303 + digest.update(payload) + hash = digest.finalize() + return hash + + +class TPLinkKlap(TPLinkProtocol): + """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. + """ + + DEFAULT_PORT = 80 + DEFAULT_TIMEOUT = 5 + DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} + KASA_SETUP_EMAIL = "kasa@tp-link.net" + KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 + SESSION_COOKIE_NAME = "TP_SESSIONID" + + def __init__( + self, + host: str, + *, + credentials: Optional[Credentials] = None, + timeout: Optional[int] = None, + ) -> None: + super().__init__(host=host, port=self.DEFAULT_PORT) + + self.credentials = ( + credentials + if credentials and credentials.username and credentials.password + else Credentials(username="", password="") + ) + + self._local_seed: Optional[bytes] = None + self.local_auth_hash = self.generate_auth_hash(self.credentials) + self.local_auth_owner = self.generate_owner_hash(self.credentials).hex() + self.kasa_setup_auth_hash = None + self.blank_auth_hash = None + self.handshake_lock = asyncio.Lock() + self.query_lock = asyncio.Lock() + self.handshake_done = False + + self.encryption_session: Optional[KlapEncryptionSession] = None + self.session_expire_at: Optional[float] = None + + self.timeout = timeout if timeout else self.DEFAULT_TIMEOUT + self.session_cookie = None + self.http_client: Optional[httpx.AsyncClient] = None + + _LOGGER.debug("Created KLAP object for %s", self.host) + + async def client_post(self, url, params=None, data=None): + """Send an http post request to the device.""" + response_data = None + cookies = None + if self.session_cookie: + cookies = httpx.Cookies() + cookies.set(self.SESSION_COOKIE_NAME, self.session_cookie) + self.http_client.cookies.clear() + resp = await self.http_client.post( + url, + params=params, + data=data, + timeout=self.timeout, + cookies=cookies, + ) + if resp.status_code == 200: + response_data = resp.content + + return resp.status_code, response_data + + async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: + """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 + + url = f"http://{self.host}/app/handshake1" + + response_status, response_data = await self.client_post(url, data=payload) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 posted at %s. Host is %s, Response" + + "status is %s, Request was %s", + datetime.datetime.now(), + self.host, + response_status, + payload.hex(), + ) + + if response_status != 200: + raise AuthenticationException( + f"Device {self.host} responded with {response_status} to handshake1" + ) + + remote_seed: bytes = response_data[0:16] + server_hash = response_data[16:] + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 success at %s. Host is %s, " + + "Server remote_seed is: %s, server hash is: %s", + datetime.datetime.now(), + self.host, + remote_seed.hex(), + server_hash.hex(), + ) + + local_seed_auth_hash = _sha256(local_seed + self.local_auth_hash) + + # Check the response from the device with local credentials + if local_seed_auth_hash == server_hash: + _LOGGER.debug("handshake1 hashes match with expected credentials") + return local_seed, remote_seed, self.local_auth_hash # type: ignore + + # Now check against the default kasa setup credentials + if not self.kasa_setup_auth_hash: + kasa_setup_creds = Credentials( + username=TPLinkKlap.KASA_SETUP_EMAIL, + password=TPLinkKlap.KASA_SETUP_PASSWORD, + ) + self.kasa_setup_auth_hash = TPLinkKlap.generate_auth_hash(kasa_setup_creds) + + kasa_setup_seed_auth_hash = _sha256( + local_seed + self.kasa_setup_auth_hash # type: ignore + ) + if kasa_setup_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s" + + " but an authentication with kasa setup credentials matched", + self.host, + ) + return local_seed, remote_seed, self.kasa_setup_auth_hash # type: ignore + + # Finally check against blank credentials if not already blank + if self.credentials != (blank_creds := Credentials(username="", password="")): + if not self.blank_auth_hash: + self.blank_auth_hash = TPLinkKlap.generate_auth_hash(blank_creds) + blank_seed_auth_hash = _sha256(local_seed + self.blank_auth_hash) # type: ignore + if blank_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s" + + " but an authentication with blank credentials matched", + self.host, + ) + return local_seed, remote_seed, self.blank_auth_hash # type: ignore + + msg = f"Server response doesn't match our challenge on ip {self.host}" + _LOGGER.debug(msg) + raise AuthenticationException(msg) + + async def perform_handshake2( + self, local_seed, remote_seed, auth_hash + ) -> "KlapEncryptionSession": + """Perform handshake2.""" + # Handshake 2 has the following payload: + # sha256(serverBytes | authenticator) + + url = f"http://{self.host}/app/handshake2" + + payload = _sha256(remote_seed + auth_hash) + + response_status, response_data = await self.client_post(url, data=payload) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake2 posted %s. Host is %s, Response status is %s, " + + "Request was %s", + datetime.datetime.now(), + self.host, + response_status, + payload.hex(), + ) + + if response_status != 200: + raise AuthenticationException( + f"Device {self.host} responded with {response_status} to handshake2" + ) + + return KlapEncryptionSession(local_seed, remote_seed, auth_hash) + + async def perform_handshake(self) -> Any: + """Perform handshake1 and handshake2. + + Sets the encryption_session if successful. + """ + _LOGGER.debug("Starting handshake with %s", self.host) + self.handshake_done = False + self.session_expire_at = None + self.session_cookie = None + + local_seed, remote_seed, auth_hash = await self.perform_handshake1() + self.session_cookie = self.http_client.cookies.get( # type: ignore + TPLinkKlap.SESSION_COOKIE_NAME + ) + # The device returns a TIMEOUT cookie on handshake1 which + # it doesn't like to get back so we store the one we want + + self.session_expire_at = time.time() + 86400 + self.encryption_session = await self.perform_handshake2( + local_seed, remote_seed, auth_hash + ) + self.handshake_done = True + + _LOGGER.debug("Handshake with %s complete", self.host) + + 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 + ) + + @staticmethod + def generate_auth_hash(creds: Credentials): + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username or "" + pw = creds.password or "" + return _md5(_md5(un.encode()) + _md5(pw.encode())) + + @staticmethod + def generate_owner_hash(creds: Credentials): + """Return the MD5 hash of the username in this object.""" + un = creds.username or "" + return _md5(un.encode()) + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device retrying for retry_count on failure.""" + if isinstance(request, dict): + request = json_dumps(request) + assert isinstance(request, str) # noqa: S101 + + async with self.query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: str, retry_count: int = 3) -> Dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except httpx.CloseError as sdex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {sdex}" + ) from sdex + continue + except httpx.ConnectError as cex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {cex}" + ) from cex + except TimeoutError as tex: + await self.close() + raise SmartDeviceException( + f"Unable to connect to the device, timed out: {self.host}: {tex}" + ) from tex + except AuthenticationException as auex: + _LOGGER.debug("Unable to authenticate with %s, not retrying", self.host) + raise auex + except Exception as ex: + await self.close() + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) + raise SmartDeviceException( + f"Unable to connect to the device: {self.host}: {ex}" + ) from ex + continue + + # make mypy happy, this should never be reached.. + raise SmartDeviceException("Query reached somehow to unreachable") + + async def _execute_query(self, request: str, retry_count: int) -> Dict: + if not self.http_client: + self.http_client = httpx.AsyncClient() + + if not self.handshake_done or self.handshake_session_expired(): + try: + await self.perform_handshake() + + except AuthenticationException as auex: + _LOGGER.debug( + "Unable to complete handshake for device %s, " + + "authentication failed", + self.host, + ) + raise auex + + # Check for mypy + if self.encryption_session is not None: + payload, seq = self.encryption_session.encrypt(request.encode()) + + url = f"http://{self.host}/app/request" + + response_status, response_data = await self.client_post( + url, + params={"seq": seq}, + data=payload, + ) + + msg = ( + f"at {datetime.datetime.now()}. Host is {self.host}, " + + f"Retry count is {retry_count}, Sequence is {seq}, " + + f"Response status is {response_status}, Request was {request}" + ) + if response_status != 200: + _LOGGER.error("Query failed after succesful authentication " + msg) + # If we failed with a security error, force a new handshake next time. + if response_status == 403: + self.handshake_done = False + raise AuthenticationException( + f"Got a security error from {self.host} after handshake " + + "completed" + ) + else: + raise SmartDeviceException( + f"Device {self.host} responded with {response_status} to" + + f"request with seq {seq}" + ) + else: + _LOGGER.debug("Query posted " + msg) + + # Check for mypy + if self.encryption_session is not None: + decrypted_response = self.encryption_session.decrypt(response_data) + + json_payload = json_loads(decrypted_response) + + _LOGGER.debug( + "%s << %s", + self.host, + _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), + ) + + return json_payload + + async def close(self) -> None: + """Close the protocol.""" + client = self.http_client + self.http_client = None + if client: + await client.aclose() + + +class KlapEncryptionSession: + """Class to represent an encryption session and it's internal state. + + i.e. sequence number which the device expects to increment. + """ + + def __init__(self, local_seed, remote_seed, user_hash): + 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) + self._sig = self._sig_derive(local_seed, remote_seed, user_hash) + + def _key_derive(self, local_seed, remote_seed, user_hash): + payload = b"lsk" + local_seed + remote_seed + user_hash + return hashlib.sha256(payload).digest()[:16] + + def _iv_derive(self, local_seed, remote_seed, user_hash): + # 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) + + def _sig_derive(self, local_seed, remote_seed, user_hash): + # 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] + + def _iv_seq(self): + seq = self._seq.to_bytes(4, "big", signed=True) + iv = self._iv + seq + return iv + + def encrypt(self, msg): + """Encrypt the data and increment the sequence number.""" + self._seq = self._seq + 1 + if isinstance(msg, str): + msg = msg.encode("utf-8") + + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) + encryptor = cipher.encryptor() + padder = padding.PKCS7(128).padder() + padded_data = padder.update(msg) + padder.finalize() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + + digest = hashes.Hash(hashes.SHA256()) + digest.update( + self._sig + self._seq.to_bytes(4, "big", signed=True) + ciphertext + ) + signature = digest.finalize() + + return (signature + ciphertext, self._seq) + + def decrypt(self, msg): + """Decrypt the data.""" + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) + decryptor = cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode() diff --git a/kasa/protocol.py b/kasa/protocol.py index 7ab2c47f..c49ab223 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -14,6 +14,7 @@ import contextlib import errno import logging import struct +from abc import ABC, abstractmethod from pprint import pformat as pf from typing import Dict, Generator, Optional, Union @@ -21,6 +22,7 @@ from typing import Dict, Generator, Optional, Union # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout +from .credentials import Credentials from .exceptions import SmartDeviceException from .json import dumps as json_dumps from .json import loads as json_loads @@ -29,7 +31,31 @@ _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} -class TPLinkSmartHomeProtocol: +class TPLinkProtocol(ABC): + """Base class for all TP-Link Smart Home communication.""" + + def __init__( + self, + host: str, + *, + port: Optional[int] = None, + credentials: Optional[Credentials] = None, + ) -> None: + """Create a protocol object.""" + self.host = host + self.port = port + self.credentials = credentials + + @abstractmethod + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: + """Query the device for the protocol. Abstract method to be overriden.""" + + @abstractmethod + async def close(self) -> None: + """Close the protocol. Abstract method to be overriden.""" + + +class TPLinkSmartHomeProtocol(TPLinkProtocol): """Implementation of the TP-Link Smart Home protocol.""" INITIALIZATION_VECTOR = 171 @@ -38,11 +64,18 @@ class TPLinkSmartHomeProtocol: BLOCK_SIZE = 4 def __init__( - self, host: str, *, port: Optional[int] = None, timeout: Optional[int] = None + self, + host: str, + *, + port: Optional[int] = None, + timeout: Optional[int] = None, + credentials: Optional[Credentials] = None, ) -> None: """Create a protocol object.""" - self.host = host - self.port = port or TPLinkSmartHomeProtocol.DEFAULT_PORT + super().__init__( + host=host, port=port or self.DEFAULT_PORT, credentials=credentials + ) + self.reader: Optional[asyncio.StreamReader] = None self.writer: Optional[asyncio.StreamWriter] = None self.query_lock = asyncio.Lock() diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 3e9bd953..1ae86b4f 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -24,7 +24,7 @@ from .credentials import Credentials from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module -from .protocol import TPLinkSmartHomeProtocol +from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ def requires_update(f): @functools.wraps(f) async def wrapped(*args, **kwargs): self = args[0] - if self._last_update is None: + if self._last_update is None and f.__name__ not in self._sys_info: raise SmartDeviceException( "You need to await update() to access the data" ) @@ -82,7 +82,7 @@ def requires_update(f): @functools.wraps(f) def wrapped(*args, **kwargs): self = args[0] - if self._last_update is None: + if self._last_update is None and f.__name__ not in self._sys_info: raise SmartDeviceException( "You need to await update() to access the data" ) @@ -213,8 +213,9 @@ class SmartDevice: """ self.host = host self.port = port - - self.protocol = TPLinkSmartHomeProtocol(host, port=port, timeout=timeout) + self.protocol: TPLinkProtocol = TPLinkSmartHomeProtocol( + host, port=port, timeout=timeout + ) self.credentials = credentials _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown @@ -222,6 +223,7 @@ class SmartDevice: # checks in accessors. the @updated_required decorator does not ensure # mypy that these are not accessed incorrectly. self._last_update: Any = None + self._sys_info: Any = None # TODO: this is here to avoid changing tests self._features: Set[str] = set() self.modules: Dict[str, Any] = {} @@ -374,8 +376,14 @@ class SmartDevice: def update_from_discover_info(self, info: Dict[str, Any]) -> None: """Update state from info from the discover call.""" - self._last_update = info - self._set_sys_info(info["system"]["get_sysinfo"]) + if "system" in info and (sys_info := info["system"].get("get_sysinfo")): + self._last_update = info + self._set_sys_info(sys_info) + else: + # This allows setting of some info properties directly + # from partial discovery info that will then be found + # by the requires_update decorator + self._set_sys_info(info) def _set_sys_info(self, sys_info: Dict[str, Any]) -> None: """Set sys_info.""" @@ -388,21 +396,26 @@ class SmartDevice: @property # type: ignore @requires_update def sys_info(self) -> Dict[str, Any]: - """Return system information.""" + """ + Return system information. + + Do not call this function from within the SmartDevice + class itself as @requires_update will be affected for other properties. + """ return self._sys_info # type: ignore @property # type: ignore @requires_update def model(self) -> str: """Return device model.""" - sys_info = self.sys_info + sys_info = self._sys_info return str(sys_info["model"]) @property # type: ignore @requires_update def alias(self) -> str: """Return device name (alias).""" - sys_info = self.sys_info + sys_info = self._sys_info return str(sys_info["alias"]) async def set_alias(self, alias: str) -> None: @@ -454,14 +467,14 @@ class SmartDevice: "oemId", "dev_name", ] - sys_info = self.sys_info + sys_info = self._sys_info return {key: sys_info[key] for key in keys if key in sys_info} @property # type: ignore @requires_update def location(self) -> Dict: """Return geographical location.""" - sys_info = self.sys_info + sys_info = self._sys_info loc = {"latitude": None, "longitude": None} if "latitude" in sys_info and "longitude" in sys_info: @@ -479,7 +492,7 @@ class SmartDevice: @requires_update def rssi(self) -> Optional[int]: """Return WiFi signal strength (rssi).""" - rssi = self.sys_info.get("rssi") + rssi = self._sys_info.get("rssi") return None if rssi is None else int(rssi) @property # type: ignore @@ -489,14 +502,14 @@ class SmartDevice: :return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab """ - sys_info = self.sys_info - + sys_info = self._sys_info mac = sys_info.get("mac", sys_info.get("mic_mac")) if not mac: raise SmartDeviceException( "Unknown mac, please submit a bug report with sys_info output." ) - + mac = mac.replace("-", ":") + # Format a mac that has no colons (usually from mic_mac field) if ":" not in mac: mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac)) @@ -607,13 +620,13 @@ class SmartDevice: @requires_update def on_since(self) -> Optional[datetime]: """Return pretty-printed on-time, or None if not available.""" - if "on_time" not in self.sys_info: + if "on_time" not in self._sys_info: return None if self.is_off: return None - on_time = self.sys_info["on_time"] + on_time = self._sys_info["on_time"] return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 7aeabe2f..148e567c 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -6,8 +6,8 @@ import sys import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol -from kasa.discover import _DiscoverProtocol, json_dumps -from kasa.exceptions import UnsupportedDeviceException +from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps +from kasa.exceptions import AuthenticationException, UnsupportedDeviceException from .conftest import bulb, dimmer, lightstrip, plug, strip @@ -51,7 +51,7 @@ async def test_type_detection_lightstrip(dev: SmartDevice): async def test_type_unknown(): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - with pytest.raises(SmartDeviceException): + with pytest.raises(UnsupportedDeviceException): Discover._get_device_class(invalid_info) @@ -239,3 +239,73 @@ async def test_discover_invalid_responses(msg, data, mocker): proto.datagram_received(data, ("127.0.0.1", 9999)) assert len(proto.discovered_devices) == 0 + + +AUTHENTICATION_DATA_KLAP = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "ip": "127.0.0.1", + "mac": "12-34-56-78-90-AB", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "KLAP", + "http_port": 80, + }, + }, + "error_code": 0, +} + + +async def test_discover_single_authentication(mocker): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + + def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + mocker.patch.object( + SmartDevice, + "update", + side_effect=AuthenticationException("Failed to authenticate"), + ) + + # Test with a valid unsupported response + discovery_data = AUTHENTICATION_DATA_KLAP + with pytest.raises( + AuthenticationException, + match="Failed to authenticate", + ): + await Discover.discover_single(host) + + mocker.patch.object(SmartDevice, "update") + device = await Discover.discover_single(host) + assert device.device_type == DeviceType.Plug + + +async def test_device_update_from_new_discovery_info(): + device = SmartDevice("127.0.0.7") + discover_info = DiscoveryResult(**AUTHENTICATION_DATA_KLAP["result"]) + discover_dump = discover_info.get_dict() + device.update_from_discover_info(discover_dump) + + assert device.alias == discover_dump["alias"] + assert device.mac == discover_dump["mac"].replace("-", ":") + assert device.model == discover_dump["model"] + + with pytest.raises( + SmartDeviceException, + match=re.escape("You need to await update() to access the data"), + ): + assert device.supported_modules diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py new file mode 100644 index 00000000..991dbe6f --- /dev/null +++ b/kasa/tests/test_klapprotocol.py @@ -0,0 +1,306 @@ +import errno +import json +import logging +import secrets +import struct +import sys +import time +from contextlib import nullcontext as does_not_raise + +import httpx +import pytest + +from ..credentials import Credentials +from ..exceptions import AuthenticationException, SmartDeviceException +from ..klapprotocol import KlapEncryptionSession, TPLinkKlap, _sha256 + + +class _mock_response: + def __init__(self, status_code, content: bytes): + self.status_code = status_code + self.content = content + + +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_retries(mocker, retry_count): + conn = mocker.patch.object( + TPLinkKlap, "client_post", side_effect=Exception("dummy exception") + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=retry_count) + + assert conn.call_count == retry_count + 1 + + +async def test_protocol_no_retry_on_connection_error(mocker): + conn = mocker.patch.object( + TPLinkKlap, + "client_post", + side_effect=httpx.ConnectError("foo"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 1 + + +async def test_protocol_retry_recoverable_error(mocker): + conn = mocker.patch.object( + TPLinkKlap, + "client_post", + side_effect=httpx.CloseError("foo"), + ) + with pytest.raises(SmartDeviceException): + await TPLinkKlap("127.0.0.1").query({}, retry_count=5) + + assert conn.call_count == 6 + + +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_reconnect(mocker, retry_count): + remaining = retry_count + + def _fail_one_less_than_retry_count(*_, **__): + nonlocal remaining, encryption_session + remaining -= 1 + if remaining: + raise Exception("Simulated post failure") + # Do the encrypt just before returning the value so the incrementing sequence number is correct + encrypted, seq = encryption_session.encrypt('{"great":"success"}') + return 200, encrypted + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + protocol = TPLinkKlap("127.0.0.1") + protocol.handshake_done = True + protocol.session_expire_at = time.time() + 86400 + protocol.encryption_session = encryption_session + mocker.patch.object( + TPLinkKlap, "client_post", side_effect=_fail_one_less_than_retry_count + ) + + response = await protocol.query({}, retry_count=retry_count) + assert response == {"great": "success"} + + +@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) +async def test_protocol_logging(mocker, caplog, log_level): + caplog.set_level(log_level) + logging.getLogger("kasa").setLevel(log_level) + + def _return_encrypted(*_, **__): + nonlocal encryption_session + # Do the encrypt just before returning the value so the incrementing sequence number is correct + encrypted, seq = encryption_session.encrypt('{"great":"success"}') + return 200, encrypted + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + protocol = TPLinkKlap("127.0.0.1") + + protocol.handshake_done = True + protocol.session_expire_at = time.time() + 86400 + protocol.encryption_session = encryption_session + mocker.patch.object(TPLinkKlap, "client_post", side_effect=_return_encrypted) + + response = await protocol.query({}) + assert response == {"great": "success"} + if log_level == logging.DEBUG: + assert "success" in caplog.text + else: + assert "success" not in caplog.text + + +def test_encrypt(): + d = json.dumps({"foo": 1, "bar": 2}) + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + assert d == encryption_session.decrypt(encrypted) + + +def test_encrypt_unicode(): + d = "{'snowman': '\u2603'}" + + seed = secrets.token_bytes(16) + auth_hash = TPLinkKlap.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + decrypted = encryption_session.decrypt(encrypted) + + assert d == decrypted + + +@pytest.mark.parametrize( + "device_credentials, expectation", + [ + (Credentials("foo", "bar"), does_not_raise()), + (Credentials("", ""), does_not_raise()), + ( + Credentials(TPLinkKlap.KASA_SETUP_EMAIL, TPLinkKlap.KASA_SETUP_PASSWORD), + does_not_raise(), + ), + ( + Credentials("shouldfail", "shouldfail"), + pytest.raises(AuthenticationException), + ), + ], + ids=("client", "blank", "kasa_setup", "shouldfail"), +) +async def test_handshake1(mocker, device_credentials, expectation): + async def _return_handshake1_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash + + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(device_credentials) + + mocker.patch.object( + httpx.AsyncClient, "post", side_effect=_return_handshake1_response + ) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + protocol.http_client = httpx.AsyncClient() + with expectation: + ( + local_seed, + device_remote_seed, + auth_hash, + ) = await protocol.perform_handshake1() + + assert local_seed == client_seed + assert device_remote_seed == server_seed + assert device_auth_hash == auth_hash + await protocol.close() + + +async def test_handshake(mocker): + async def _return_handshake_response(url, params=None, data=None, *_, **__): + nonlocal response_status, client_seed, server_seed, device_auth_hash + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(response_status, b"") + + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object( + httpx.AsyncClient, "post", side_effect=_return_handshake_response + ) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + protocol.http_client = httpx.AsyncClient() + + response_status = 200 + await protocol.perform_handshake() + assert protocol.handshake_done is True + + response_status = 403 + with pytest.raises(AuthenticationException): + await protocol.perform_handshake() + assert protocol.handshake_done is False + await protocol.close() + + +async def test_query(mocker): + async def _return_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash, protocol, seq + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(200, b"") + elif url == "http://127.0.0.1/app/request": + encryption_session = KlapEncryptionSession( + protocol.encryption_session.local_seed, + protocol.encryption_session.remote_seed, + protocol.encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt('{"great": "success"}') + seq = seq + return _mock_response(200, encrypted) + + client_seed = None + last_seq = None + seq = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + for _ in range(10): + resp = await protocol.query({}) + assert resp == {"great": "success"} + # Check the protocol is incrementing the sequence number + assert last_seq is None or last_seq + 1 == seq + last_seq = seq + + +@pytest.mark.parametrize( + "response_status, expectation", + [ + ((403, 403, 403), pytest.raises(AuthenticationException)), + ((200, 403, 403), pytest.raises(AuthenticationException)), + ((200, 200, 403), pytest.raises(AuthenticationException)), + ((200, 200, 400), pytest.raises(SmartDeviceException)), + ], + ids=("handshake1", "handshake2", "request", "non_auth_error"), +) +async def test_authentication_failures(mocker, response_status, expectation): + async def _return_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash, response_status + + if url == "http://127.0.0.1/app/handshake1": + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) + elif url == "http://127.0.0.1/app/handshake2": + return _mock_response(response_status[1], b"") + elif url == "http://127.0.0.1/app/request": + return _mock_response(response_status[2], None) + + client_seed = None + + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = TPLinkKlap.generate_auth_hash(client_credentials) + + mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + + protocol = TPLinkKlap("127.0.0.1", credentials=client_credentials) + + with expectation: + await protocol.query({}) diff --git a/poetry.lock b/poetry.lock index 180b6cd0..4224f188 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -107,6 +107,82 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -306,6 +382,51 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "41.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, + {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, + {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, + {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, + {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, + {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, + {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, + {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, + {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, + {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" version = "0.3.7" @@ -357,6 +478,62 @@ files = [ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.1" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.1-py3-none-any.whl", hash = "sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0"}, + {file = "httpcore-1.0.1.tar.gz", hash = "sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] + +[[package]] +name = "httpx" +version = "0.25.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, + {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "identify" version = "2.5.27" @@ -516,6 +693,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -746,6 +933,17 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "2.3.0" @@ -1033,6 +1231,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1040,8 +1239,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1058,6 +1264,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1065,6 +1272,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1466,4 +1674,4 @@ speedups = ["kasa-crypt", "orjson"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "888a000414d6140156c0f878af06470505ed6edaab936af8a607d396c6252bf9" +content-hash = "097b5cdfc1d2ccf3e89d306242f0f3a9a84e53c039f939df4e55d13c471f6084" diff --git a/pyproject.toml b/pyproject.toml index b41b242a..24682df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ python = "^3.8" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 asyncclick = ">=8" pydantic = ">=1" +cryptography = ">=1.9" +async-timeout = ">=3.0.0" +httpx = ">=0.25.0" # speed ups orjson = { "version" = ">=3.9.1", optional = true } @@ -34,7 +37,6 @@ sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } myst-parser = { version = "*", optional = true } docutils = { version = ">=0.17", optional = true } -async-timeout = ">=3.0.0" [tool.poetry.group.dev.dependencies] pytest = "*"