From f6fc0debcf227b74b72eab064753f9eccd69b482 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Tue, 17 Nov 2020 14:39:17 +0000 Subject: [PATCH 01/16] 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. --- kasa/cli.py | 10 +++- kasa/discover.py | 36 +++++++++++-- kasa/protocol.py | 124 ++++++++++++++++++++++++++++++++++++++++++++ kasa/smartdevice.py | 9 +++- kasa/smartplug.py | 4 +- pyproject.toml | 2 + 6 files changed, 175 insertions(+), 10 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 167179e36..14f367546 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -46,9 +46,10 @@ @click.option("--plug", default=False, is_flag=True) @click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) +@click.option("--klap", default=False, is_flag=True) @click.version_option() @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): +async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, klap): """A tool for controlling TP-Link smart home devices.""" # noqa if debug: logging.basicConfig(level=logging.DEBUG) @@ -67,6 +68,11 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): click.echo(f"No device with name {alias} found") return + if klap: + authentication = {"user":"", "password":""} + else: + authentication = None + if host is None: click.echo("No host name given, trying discovery..") await ctx.invoke(discover) @@ -78,7 +84,7 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): elif bulb: dev = SmartBulb(host) elif plug: - dev = SmartPlug(host) + dev = SmartPlug(host, authentication) elif strip: dev = SmartStrip(host) elif lightstrip: diff --git a/kasa/discover.py b/kasa/discover.py index e4091512e..340f48236 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -3,6 +3,7 @@ import json import logging import socket +import binascii from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast from kasa.protocol import TPLinkSmartHomeProtocol @@ -42,6 +43,7 @@ def __init__( self.on_discovered = on_discovered self.protocol = TPLinkSmartHomeProtocol() self.target = (target, Discover.DISCOVERY_PORT) + self.new_target = (target, Discover.NEW_DISCOVERY_PORT) self.discovered_devices = {} self.discovered_devices_raw = {} @@ -63,8 +65,12 @@ def do_discover(self) -> None: req = json.dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = self.protocol.encrypt(req) + new_req = binascii.unhexlify('020000010000000000000000463cb5d3') +# for i in range(self.discovery_packets): +# self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + _LOGGER.debug("[NEW DISCOVERY] %s >> magic_packet", self.target) for i in range(self.discovery_packets): - self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + self.transport.sendto(new_req, self.new_target) def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" @@ -72,11 +78,17 @@ def datagram_received(self, data, addr) -> None: if ip in self.discovered_devices: return - info = json.loads(self.protocol.decrypt(data)) + if port == 9999: + info = json.loads(self.protocol.decrypt(data)) + device_class = Discover._get_device_class(info) + device = device_class(ip) + else: + info = json.loads(data[16:]) + device_class = Discover._get_new_device_class(info) + device = device_class(ip, {"user":"","password":""}) + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - device_class = Discover._get_device_class(info) - device = device_class(ip) asyncio.ensure_future(device.update()) self.discovered_devices[ip] = device @@ -133,6 +145,8 @@ class Discover: DISCOVERY_PORT = 9999 + NEW_DISCOVERY_PORT = 20002 + DISCOVERY_QUERY = { "system": {"get_sysinfo": None}, "emeter": {"get_realtime": None}, @@ -242,6 +256,20 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: raise SmartDeviceException("Unknown device type: %s", type_) + def _get_new_device_class(info: dict) -> Type[SmartDevice]: + """Find SmartDevice subclass given new discovery payload.""" + if "result" not in info: + raise SmartDeviceException("No 'result' in discovery response") + + if "device_type" not in info["result"]: + raise SmartDeviceException("No 'device_type' in discovery result") + + dtype = info["result"]["device_type"] + + if dtype == "IOT.SMARTPLUGSWITCH": + return SmartPlug + + raise SmartDeviceExpection("Unknown device type: %s", dtype) if __name__ == "__main__": logging.basicConfig(level=logging.INFO) diff --git a/kasa/protocol.py b/kasa/protocol.py index 6ee6f72d6..154b274f7 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -13,8 +13,16 @@ import json import logging import struct +import aiohttp +import secrets +import hashlib +import binascii +import time +from Crypto.Cipher import AES +from Crypto.Util import Padding from pprint import pformat as pf from typing import Dict, Union +from yarl import URL from .exceptions import SmartDeviceException @@ -126,3 +134,119 @@ def decrypt(ciphertext: bytes) -> str: plaintext = bytes(buffer) return plaintext.decode() + +class TPLinkKLAP: + def __init__(self, host:str, authentication) -> None: + self.host = host + self.jar = aiohttp.CookieJar(unsafe = True, quote_cookie = False) + self.clientBytes = secrets.token_bytes(16) + self.authenticator = self.computeAuthenticator(authentication) + self.handshake_lock = asyncio.Lock() + self.handshake_done = False + + _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) + + @staticmethod + def computeAuthenticator(authentication: list) -> bytes: + username = bytearray(b'') + password = bytearray(b'') + return hashlib.md5(hashlib.md5(username).digest() + + hashlib.md5(password).digest()).digest() + + async def handshake(self, session) -> None: + _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) + + # Handshake 1 has a payload of clientBytes + # and a response of 16 bytes, followed by sha256(clientBytes | authenticator) + + url = "http://%s/app/handshake1" % self.host + resp = await session.post(url, data = self.clientBytes) + _LOGGER.debug("Got response of %d to handshake1", resp.status) + if resp.status != 200: + raise SmartDeviceException("Device responded with %d to handshake1" % resp.status) + response = await resp.read() + self.serverBytes = response[0:16] + serverHash = response[16:] + + _LOGGER.debug("Server bytes are: %s", binascii.hexlify(self.serverBytes)) + _LOGGER.debug("Server hash is: %s", binascii.hexlify(serverHash)) + + # Check the response from the device + localHash = hashlib.sha256(self.clientBytes + self.authenticator).digest() + + if localHash != serverHash: + _LOGGER.debug("Expected %s got %s in handshake1", + binascii.hexlify(localHash), binascii.hexlify(serverHash)) + raise SmartDeviceExpection("Server response doesn't match our challenge") + else: + _LOGGER.debug("handshake1 hashes match") + + # We need to include only the TP_SESSIONID cookie - aiohttp's cookie handling + # adds a bogus TIMEOUT cookie + cookie = session.cookie_jar.filter_cookies(url).get("TP_SESSIONID") + session.cookie_jar.clear() + session.cookie_jar.update_cookies({"TP_SESSIONID": cookie}, URL(url)) + _LOGGER.debug("Cookie is %s", cookie) + + # Handshake 2 has the following payload: + # sha256(serverBytes | authenticator) + url = "http://%s/app/handshake2" % self.host + payload = hashlib.sha256(self.serverBytes + self.authenticator).digest() + resp = await session.post(url, data = payload) + _LOGGER.debug("Got response of %d to handshake2", resp.status) + if resp.status != 200: + raise SmartDeviceException("Device responded with %d to handshake2" % resp.status) + + # Done handshaking, now we need to compute the encryption keys + agreedBytes = self.clientBytes + self.serverBytes + self.authenticator + self.encryptKey = hashlib.sha256(bytearray(b'lsk') + agreedBytes).digest()[:16] + self.hmacKey = hashlib.sha256(bytearray(b'ldk') + agreedBytes).digest()[:28] + fulliv = hashlib.sha256(bytearray(b'iv') + agreedBytes).digest() + self.iv = fulliv[:12] + self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) + self.handshake_done = True + + def encrypt(self, plaintext : bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(Padding.pad(plaintext, AES.block_size)) + signature = hashlib.sha256(self.hmacKey + seq.to_bytes(4, "big", signed = True) + ciphertext).digest() + return signature + ciphertext + + def decrypt(self, payload : bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + # In theory we should verify the hmac here too + return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) + + async def query(self, host: str, request: Union[str, Dict], retry_count: int = 3) -> Dict: + if host != self.host: + raise SmartDeviceException("Host %s doesn't match configured host %s") + + if isinstance(request, dict): + request = json.dumps(request) + + request = request.encode('utf-8') + + _LOGGER.debug("Sending request %s", request) + + try: + session = aiohttp.ClientSession(cookie_jar = self.jar) + + async with self.handshake_lock: + if not self.handshake_done: + await self.handshake(session) + + msg_seq = self.seq + msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed = True) + payload = self.encrypt(request, msg_iv, msg_seq) + + url = "http://%s/app/request" % self.host + resp = await session.post(url, params = {'seq' : msg_seq}, data = payload) + _LOGGER.debug("Got response of %d to request", resp.status) + if resp.status != 200: + raise SmartDeviceException("Device responded with %d to request with seq %d" % (resp.status, msg_seq)) + response = await resp.read() + plaintext = self.decrypt(response, msg_iv, msg_seq) + finally: + await session.close() + + return json.loads(plaintext) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 19589bbad..61c08051a 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -21,6 +21,7 @@ from .exceptions import SmartDeviceException from .protocol import TPLinkSmartHomeProtocol +from .protocol import TPLinkKLAP _LOGGER = logging.getLogger(__name__) @@ -213,14 +214,18 @@ class SmartDevice: """ - def __init__(self, host: str) -> None: + def __init__(self, host: str, authentication: None) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens """ self.host = host - self.protocol = TPLinkSmartHomeProtocol() + if authentication is None: + self.protocol = TPLinkSmartHomeProtocol() + else: + self.protocol = TPLinkKLAP(host, authentication) + self.emeter_type = "emeter" _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d23bc9396..a707f7635 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -36,8 +36,8 @@ class SmartPlug(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str) -> None: - super().__init__(host) + def __init__(self, host: str, authentication = None) -> None: + super().__init__(host, authentication) self.emeter_type = "emeter" self._device_type = DeviceType.Plug diff --git a/pyproject.toml b/pyproject.toml index 1bdf1716a..254f0d65f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ sphinx = { version = "^3", optional = true } m2r = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } +aiohttp = "^3.7.2" +pycryptodome = "^3.9.9" [tool.poetry.dev-dependencies] pytest = "^5" From efcbece8ab699108035c9bd3a876fbba7d7b7439 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Tue, 17 Nov 2020 21:17:38 +0000 Subject: [PATCH 02/16] Fix: Don't comment out old-style discovery Restore the incorrectly commented out old discovery mechanism, so both old and new devices are found. --- kasa/discover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 340f48236..fca6c286a 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -66,8 +66,8 @@ def do_discover(self) -> None: _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = self.protocol.encrypt(req) new_req = binascii.unhexlify('020000010000000000000000463cb5d3') -# for i in range(self.discovery_packets): -# self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + for i in range(self.discovery_packets): + self.transport.sendto(encrypted_req[4:], self.target) # type: ignore _LOGGER.debug("[NEW DISCOVERY] %s >> magic_packet", self.target) for i in range(self.discovery_packets): self.transport.sendto(new_req, self.new_target) From cacb9961d7954d7a55be778e39824f8c608bf957 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Wed, 18 Nov 2020 14:52:45 +0000 Subject: [PATCH 03/16] Update poetry.lock file for new dependencies --- poetry.lock | 644 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 426 insertions(+), 218 deletions(-) diff --git a/poetry.lock b/poetry.lock index f27a54d06..2528df61a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,18 +1,37 @@ [[package]] +name = "aiohttp" +version = "3.7.2" +description = "Async http client/server framework (asyncio)" category = "main" -description = "A configurable sidebar-enabled Sphinx theme" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = ">=3.0,<4.0" +attrs = ">=17.3.0" +chardet = ">=2.0,<4.0" +multidict = ">=4.5,<7.0" +typing-extensions = ">=3.6.5" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotlipy", "cchardet"] + +[[package]] name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "main" optional = true python-versions = "*" -version = "0.7.12" [[package]] -category = "main" -description = "High level compatibility layer for multiple asynchronous event loop implementations" name = "anyio" +version = "1.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" optional = false python-versions = ">=3.5.3" -version = "1.4.0" [package.dependencies] async-generator = "*" @@ -20,34 +39,42 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -curio = ["curio (>=0.9)"] +curio = ["curio (==0.9)", "curio (>=0.9)"] doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["coverage (>=4.5)", "hypothesis (>=4.0)", "pytest (>=3.7.2)", "uvloop"] trio = ["trio (>=0.12)"] [[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = "*" -version = "1.4.4" [[package]] -category = "main" -description = "Async generators and context managers for Python 3.5+" name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +category = "main" optional = false python-versions = ">=3.5" -version = "1.10" [[package]] +name = "async-timeout" +version = "3.0.1" +description = "Timeout context manager for asyncio programs" category = "main" -description = "A simple anyio-compatible fork of Click, for powerful command line utilities." +optional = false +python-versions = ">=3.5.3" + +[[package]] name = "asyncclick" +version = "7.0.9" +description = "A simple anyio-compatible fork of Click, for powerful command line utilities." +category = "main" optional = false python-versions = ">=3.6" -version = "7.0.9" [package.dependencies] anyio = "*" @@ -57,21 +84,20 @@ dev = ["coverage", "pytest-runner", "pytest-trio", "pytest (>=3)", "sphinx", "to docs = ["sphinx"] [[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" [[package]] -category = "dev" -description = "Classes Without Boilerplate" name = "attrs" +version = "19.3.0" +description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" [package.extras] azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] @@ -80,130 +106,129 @@ docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] [[package]] -category = "main" -description = "Internationalization utilities" name = "babel" +version = "2.8.0" +description = "Internationalization utilities" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.0" [package.dependencies] pytz = ">=2015.7" [[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." name = "certifi" +version = "2020.6.20" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = "*" -version = "2020.6.20" [[package]] -category = "dev" -description = "Validate configuration and produce human readable error messages." name = "cfgv" +version = "3.1.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "3.1.0" [[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" name = "chardet" +version = "3.0.4" +description = "Universal encoding detector for Python 2 and 3" +category = "main" optional = false python-versions = "*" -version = "3.0.4" [[package]] -category = "dev" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" name = "codecov" +version = "2.1.8" +description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.8" [package.dependencies] coverage = "*" requests = ">=2.7.9" [[package]] -category = "main" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" name = "colorama" +version = "0.4.3" +description = "Cross-platform colored terminal text." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" [[package]] -category = "dev" -description = "Code coverage measurement for Python" name = "coverage" +version = "5.2.1" +description = "Code coverage measurement for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.2.1" [package.extras] toml = ["toml"] [[package]] -category = "dev" -description = "Distribution utilities" name = "distlib" +version = "0.3.1" +description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" -version = "0.3.1" [[package]] -category = "main" -description = "Docutils -- Python Documentation Utilities" name = "docutils" +version = "0.16" +description = "Docutils -- Python Documentation Utilities" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.16" [[package]] -category = "dev" -description = "A platform independent file lock." name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" optional = false python-versions = "*" -version = "3.0.12" [[package]] -category = "dev" -description = "File identification library for Python" name = "identify" +version = "1.4.25" +description = "File identification library for Python" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "1.4.25" [package.extras] license = ["editdistance"] [[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" +version = "2.10" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" [[package]] -category = "main" -description = "Getting image size from png/jpeg/jpeg2000/gif file" name = "imagesize" +version = "1.2.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.2.0" [[package]] -category = "main" -description = "Read metadata from Python packages" name = "importlib-metadata" +version = "1.7.0" +description = "Read metadata from Python packages" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" [package.dependencies] zipp = ">=0.5" @@ -213,12 +238,12 @@ docs = ["sphinx", "rst.linker"] testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] [[package]] -category = "main" -description = "A very fast and expressive template engine." name = "jinja2" +version = "2.11.2" +description = "A very fast and expressive template engine." +category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.2" [package.dependencies] MarkupSafe = ">=0.23" @@ -227,154 +252,162 @@ MarkupSafe = ">=0.23" i18n = ["Babel (>=0.8)"] [[package]] -category = "main" -description = "Markdown and reStructuredText in a single file." name = "m2r" +version = "0.2.1" +description = "Markdown and reStructuredText in a single file." +category = "main" optional = true python-versions = "*" -version = "0.2.1" [package.dependencies] docutils = "*" mistune = "*" [[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" +version = "1.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" [[package]] -category = "main" -description = "The fastest markdown parser in pure Python" name = "mistune" +version = "0.8.4" +description = "The fastest markdown parser in pure Python" +category = "main" optional = true python-versions = "*" -version = "0.8.4" [[package]] -category = "dev" -description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" +version = "8.4.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "multidict" +version = "5.0.2" +description = "multidict implementation" +category = "main" optional = false python-versions = ">=3.5" -version = "8.4.0" [[package]] -category = "dev" -description = "Node.js virtual environment builder" name = "nodeenv" +version = "1.4.0" +description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = "*" -version = "1.4.0" [[package]] -category = "main" -description = "Core utilities for Python packages" name = "packaging" +version = "20.4" +description = "Core utilities for Python packages" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" [package.dependencies] pyparsing = ">=2.0.2" six = "*" [[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" [package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] [[package]] -category = "dev" -description = "A framework for managing and maintaining multi-language pre-commit hooks." name = "pre-commit" +version = "2.6.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.6.1" -version = "2.6.0" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" virtualenv = ">=20.0.8" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = "*" - [[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" +version = "1.9.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.0" [[package]] +name = "pycryptodome" +version = "3.9.9" +description = "Cryptographic library for Python" category = "main" -description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] name = "pygments" +version = "2.6.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" optional = true python-versions = ">=3.5" -version = "2.6.1" [[package]] -category = "main" -description = "Python parsing module" name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" [[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.5" -version = "5.4.3" [package.dependencies] -atomicwrites = ">=1.0" +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=17.4.0" -colorama = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" py = ">=1.5.0" wcwidth = "*" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - [package.extras] -checkqa-mypy = ["mypy (v0.761)"] +checkqa-mypy = ["mypy (==v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" -description = "Pytest support for asyncio." name = "pytest-asyncio" +version = "0.14.0" +description = "Pytest support for asyncio." +category = "dev" optional = false python-versions = ">= 3.5" -version = "0.14.0" [package.dependencies] pytest = ">=5.4.0" @@ -383,38 +416,38 @@ pytest = ">=5.4.0" testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] [[package]] -category = "dev" -description = "Formatting PyTest output for Azure Pipelines UI" name = "pytest-azurepipelines" +version = "0.8.0" +description = "Formatting PyTest output for Azure Pipelines UI" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.8.0" [package.dependencies] pytest = ">=3.5.0" [[package]] -category = "dev" -description = "Pytest plugin for measuring coverage." name = "pytest-cov" +version = "2.10.0" +description = "Pytest plugin for measuring coverage." +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.10.0" [package.dependencies] coverage = ">=4.4" pytest = ">=4.6" [package.extras] -testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] [[package]] -category = "dev" -description = "Thin-wrapper around the mock package for easier use with pytest" name = "pytest-mock" +version = "3.2.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" optional = false python-versions = ">=3.5" -version = "3.2.0" [package.dependencies] pytest = ">=2.7" @@ -423,12 +456,12 @@ pytest = ">=2.7" dev = ["pre-commit", "tox", "pytest-asyncio"] [[package]] -category = "dev" -description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." name = "pytest-sugar" +version = "0.9.4" +description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." +category = "dev" optional = false python-versions = "*" -version = "0.9.4" [package.dependencies] packaging = ">=14.1" @@ -436,28 +469,28 @@ pytest = ">=2.9" termcolor = ">=1.1.0" [[package]] -category = "main" -description = "World timezone definitions, modern and historical" name = "pytz" +version = "2020.1" +description = "World timezone definitions, modern and historical" +category = "main" optional = true python-versions = "*" -version = "2020.1" [[package]] -category = "dev" -description = "YAML parser and emitter for Python" name = "pyyaml" +version = "5.3.1" +description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.3.1" [[package]] -category = "main" -description = "Python HTTP for Humans." name = "requests" +version = "2.24.0" +description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.24.0" [package.dependencies] certifi = ">=2017.4.17" @@ -467,51 +500,50 @@ urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" [[package]] -category = "main" -description = "Sniff out which async library your code is running under" name = "sniffio" +version = "1.1.0" +description = "Sniff out which async library your code is running under" +category = "main" optional = false python-versions = ">=3.5" -version = "1.1.0" [[package]] -category = "main" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." name = "snowballstemmer" +version = "2.0.0" +description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." +category = "main" optional = true python-versions = "*" -version = "2.0.0" [[package]] -category = "main" -description = "Python documentation generator" name = "sphinx" +version = "3.1.2" +description = "Python documentation generator" +category = "main" optional = true python-versions = ">=3.5" -version = "3.1.2" [package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" alabaster = ">=0.7,<0.8" babel = ">=1.3" -colorama = ">=0.3.5" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} docutils = ">=0.12" imagesize = "*" +Jinja2 = ">=2.3" packaging = "*" +Pygments = ">=2.0" requests = ">=2.5.0" -setuptools = "*" snowballstemmer = ">=1.1" sphinxcontrib-applehelp = "*" sphinxcontrib-devhelp = "*" @@ -526,12 +558,12 @@ lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.780)", "docutils-s test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] [[package]] -category = "main" -description = "Read the Docs theme for Sphinx" name = "sphinx-rtd-theme" +version = "0.5.0" +description = "Read the Docs theme for Sphinx" +category = "main" optional = true python-versions = "*" -version = "0.5.0" [package.dependencies] sphinx = "*" @@ -540,114 +572,115 @@ sphinx = "*" dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] [[package]] -category = "main" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.2" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" name = "sphinxcontrib-htmlhelp" +version = "1.0.3" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.3" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest", "html5lib"] [[package]] -category = "main" -description = "A sphinx extension which renders display math in HTML via JavaScript" name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.1" [package.extras] test = ["pytest", "flake8", "mypy"] [[package]] -category = "main" -description = "Sphinx extension to include program output" name = "sphinxcontrib-programoutput" +version = "0.16" +description = "Sphinx extension to include program output" +category = "main" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "0.16" [package.dependencies] Sphinx = ">=1.7.0" [[package]] -category = "main" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "main" optional = true python-versions = ">=3.5" -version = "1.0.3" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "main" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." name = "sphinxcontrib-serializinghtml" +version = "1.1.4" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "main" optional = true python-versions = ">=3.5" -version = "1.1.4" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] test = ["pytest"] [[package]] -category = "dev" -description = "ANSII Color formatting for output in terminal." name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "dev" optional = false python-versions = "*" -version = "1.1.0" [[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" +version = "0.10.1" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" optional = false python-versions = "*" -version = "0.10.1" [[package]] -category = "dev" -description = "tox is a generic virtualenv management and test command line tool" name = "tox" +version = "3.18.1" +description = "tox is a generic virtualenv management and test command line tool" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "3.18.1" [package.dependencies] -colorama = ">=0.4.1" +colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} filelock = ">=3.0.0" +importlib-metadata = {version = ">=0.12,<2", markers = "python_version < \"3.8\""} packaging = ">=14" pluggy = ">=0.12.0" py = ">=1.4.17" @@ -655,72 +688,73 @@ six = ">=1.14.0" toml = ">=0.9.4" virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] [[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = "*" + +[[package]] name = "urllib3" +version = "1.25.10" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.10" [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] -category = "dev" -description = "Virtual Python Environment builder" name = "virtualenv" +version = "20.0.28" +description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "20.0.28" [package.dependencies] appdirs = ">=1.4.3,<2" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" +importlib-metadata = {version = ">=0.12,<2", markers = "python_version < \"3.8\""} six = ">=1.9.0,<2" -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] [[package]] -category = "dev" -description = "# Voluptuous is a Python data validation library" name = "voluptuous" +version = "0.11.7" +description = "# Voluptuous is a Python data validation library" +category = "dev" optional = false python-versions = "*" -version = "0.11.7" [[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" optional = false python-versions = "*" -version = "0.2.5" [[package]] -category = "dev" -description = "A rewrite of the builtin doctest module" name = "xdoctest" +version = "0.13.0" +description = "A rewrite of the builtin doctest module" +category = "dev" optional = false python-versions = "*" -version = "0.13.0" [package.dependencies] six = "*" @@ -731,12 +765,25 @@ optional = ["pygments", "colorama"] tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11"] [[package]] +name = "yarl" +version = "1.6.3" +description = "Yet another URL library" category = "main" -description = "Backport of pathlib-compatible object wrapper for zip files" -name = "zipp" optional = false python-versions = ">=3.6" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[[package]] +name = "zipp" version = "3.1.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] @@ -746,10 +793,46 @@ testing = ["jaraco.itertools", "func-timeout"] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] [metadata] -content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" +lock-version = "1.1" python-versions = "^3.7" +content-hash = "ee0bcce42ea453dff32dd36ba2bbcf6c3fb643cf1738d34240ab665655b5f0f7" [metadata.files] +aiohttp = [ + {file = "aiohttp-3.7.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0989ff15834a4503056d103077ec3652f9ea5699835e1ceaee46b91cf59830bf"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:8fbeeb2296bb9fe16071a674eadade7391be785ae0049610e64b60ead6abcdd7"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:48104c883099c0e614c5c38f98c1d174a2c68f52f58b2a6e5a07b59df78262ab"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:c9a415f4f2764ab6c7d63ee6b86f02a46b4df9bc11b0de7ffef206908b7bf0b4"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:7e26712871ebaf55497a60f55483dc5e74326d1fb0bfceab86ebaeaa3a266733"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:8319a55de469d5af3517dfe1f6a77f248f6668c5a552396635ef900f058882ef"}, + {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2aea79734ac5ceeac1ec22b4af4efb4efd6a5ca3d73d77ec74ed782cf318f238"}, + {file = "aiohttp-3.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:be9fa3fe94fc95e9bf84e84117a577c892906dd3cb0a95a7ae21e12a84777567"}, + {file = "aiohttp-3.7.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04dcbf6af1868048a9b4754b1684c669252aa2419aa67266efbcaaead42ced7"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e886611b100c8c93b753b457e645c5e4b8008ec443434d2a480e5a2bb3e6514"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cdbb65c361ff790c424365a83a496fc8dd1983689a5fb7c6852a9a3ff1710c61"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:8a8addd41320637c1445fea0bae1fd9fe4888acc2cd79217ee33e5d1c83cfe01"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:b822bf7b764283b5015e3c49b7bb93f37fc03545f4abe26383771c6b1c813436"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:ad5c3559e3cd64f746df43fa498038c91aa14f5d7615941ea5b106e435f3b892"}, + {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:835bd35e14e4f36414e47c195e6645449a0a1c3fd5eeae4b7f22cb4c5e4f503a"}, + {file = "aiohttp-3.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:11e087c316e933f1f52f3d4a09ce13f15ad966fc43df47f44ca4e8067b6a2e0d"}, + {file = "aiohttp-3.7.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f8c583c31c6e790dc003d9d574e3ed2c5b337947722965096c4d684e4f183570"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b84cef790cb93cec82a468b7d2447bf16e3056d2237b652e80f57d653b61da88"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:4afd8002d9238e5e93acf1a8baa38b3ddf1f7f0ebef174374131ff0c6c2d7973"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:a1f1cc11c9856bfa7f1ca55002c39070bde2a97ce48ef631468e99e2ac8e3fe6"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:7f1aeb72f14b9254296cdefa029c00d3c4550a26e1059084f2ee10d22086c2d0"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:67f8564c534d75c1d613186939cee45a124d7d37e7aece83b17d18af665b0d7a"}, + {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:184ead67248274f0e20b0cd6bb5f25209b2fad56e5373101cc0137c32c825c87"}, + {file = "aiohttp-3.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:6e0d1231a626d07b23f6fe904caa44efb249da4222d8a16ab039fb2348722292"}, + {file = "aiohttp-3.7.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:476b1f8216e59a3c2ffb71b8d7e1da60304da19f6000d422bacc371abb0fc43d"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:89c1aa729953b5ac6ca3c82dcbd83e7cdecfa5cf9792c78c154a642e6e29303d"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c53f1d2bd48f5f407b534732f5b3c6b800a58e70b53808637848d8a9ee127fe7"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:06efdb01ab71ec20786b592d510d1d354fbe0b2e4449ee47067b9ca65d45a006"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:027be45c4b37e21be81d07ae5242361d73eebad1562c033f80032f955f34df82"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:1c36b7ef47cfbc150314c2204cd73613d96d6d0982d41c7679b7cdcf43c0e979"}, + {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c588a0f824dc7158be9eec1ff465d1c868ad69a4dc518cd098cc11e4f7da09d9"}, + {file = "aiohttp-3.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:547b196a7177511da4f475fc81d0bb88a51a8d535c7444bbf2338b6dc82cb996"}, + {file = "aiohttp-3.7.2.tar.gz", hash = "sha256:c6da1af59841e6d43255d386a2c4bfb59c0a3b262bdb24325cc969d211be6070"}, +] alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, @@ -766,6 +849,10 @@ async-generator = [ {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, ] +async-timeout = [ + {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, + {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, +] asyncclick = [ {file = "asyncclick-7.0.9.tar.gz", hash = "sha256:62cebf3eca36d973802e2dd521ca1db11c5bf4544e9795e093d1a53cb688a8c2"}, ] @@ -916,8 +1003,48 @@ more-itertools = [ {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, ] +multidict = [ + {file = "multidict-5.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b82400ef848bbac6b9035a105ac6acaa1fb3eea0d164e35bbb21619b88e49fed"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b98af08d7bb37d3456a22f689819ea793e8d6961b9629322d7728c4039071641"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d4a6fb98e9e9be3f7d70fd3e852369c00a027bd5ed0f3e8ade3821bcad257408"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:2ab9cad4c5ef5c41e1123ed1f89f555aabefb9391d4e01fd6182de970b7267ed"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:62abab8088704121297d39c8f47156cb8fab1da731f513e59ba73946b22cf3d0"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:59182e975b8c197d0146a003d0f0d5dc5487ce4899502061d8df585b0f51fba2"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:76cbdb22f48de64811f9ce1dd4dee09665f84f32d6a26de249a50c1e90e244e0"}, + {file = "multidict-5.0.2-cp36-cp36m-win32.whl", hash = "sha256:653b2bbb0bbf282c37279dd04f429947ac92713049e1efc615f68d4e64b1dbc2"}, + {file = "multidict-5.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:c58e53e1c73109fdf4b759db9f2939325f510a8a5215135330fe6755921e4886"}, + {file = "multidict-5.0.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:359ea00e1b53ceef282232308da9d9a3f60d645868a97f64df19485c7f9ef628"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b561e76c9e21402d9a446cdae13398f9942388b9bff529f32dfa46220af54d00"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:9380b3f2b00b23a4106ba9dd022df3e6e2e84e1788acdbdd27603b621b3288df"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:1cd102057b09223b919f9447c669cf2efabeefb42a42ae6233f25ffd7ee31a79"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:d99da85d6890267292065e654a329e1d2f483a5d2485e347383800e616a8c0b1"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:f612e8ef8408391a4a3366e3508bab8ef97b063b4918a317cb6e6de4415f01af"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:6128d2c0956fd60e39ec7d1c8f79426f0c915d36458df59ddd1f0cff0340305f"}, + {file = "multidict-5.0.2-cp37-cp37m-win32.whl", hash = "sha256:9ed9b280f7778ad6f71826b38a73c2fdca4077817c64bc1102fdada58e75c03c"}, + {file = "multidict-5.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f65a2442c113afde52fb09f9a6276bbc31da71add99dc76c3adf6083234e07c6"}, + {file = "multidict-5.0.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:2576e30bbec004e863d87216bc34abe24962cc2e964613241a1c01c7681092ab"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:20cc9b2dd31761990abff7d0e63cd14dbfca4ebb52a77afc917b603473951a38"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:6566749cd78cb37cbf8e8171b5cd2cbfc03c99f0891de12255cf17a11c07b1a3"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6168839491a533fa75f3f5d48acbb829475e6c7d9fa5c6e245153b5f79b986a3"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e58db0e0d60029915f7fc95a8683fa815e204f2e1990f1fb46a7778d57ca8c35"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:8fa4549f341a057feec4c3139056ba73e17ed03a506469f447797a51f85081b5"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:06f39f0ddc308dab4e5fa282d145f90cd38d7ed75390fc83335636909a9ec191"}, + {file = "multidict-5.0.2-cp38-cp38-win32.whl", hash = "sha256:8efcf070d60fd497db771429b1c769a3783e3a0dd96c78c027e676990176adc5"}, + {file = "multidict-5.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:060d68ae3e674c913ec41a464916f12c4d7ff17a3a9ebbf37ba7f2c681c2b33e"}, + {file = "multidict-5.0.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:4a3f19da871befa53b48dd81ee48542f519beffa13090dc135fffc18d8fe36db"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:af271c2540d1cd2a137bef8d95a8052230aa1cda26dd3b2c73d858d89993d518"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3e61cc244fd30bd9fdfae13bdd0c5ec65da51a86575ff1191255cae677045ffe"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:4df708ef412fd9b59b7e6c77857e64c1f6b4c0116b751cb399384ec9a28baa66"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:cbabfc12b401d074298bfda099c58dfa5348415ae2e4ec841290627cb7cb6b2e"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:43c7a87d8c31913311a1ab24b138254a0ee89142983b327a2c2eab7a7d10fea9"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fa0503947a99a1be94f799fac89d67a5e20c333e78ddae16e8534b151cdc588a"}, + {file = "multidict-5.0.2-cp39-cp39-win32.whl", hash = "sha256:17847fede1aafdb7e74e01bb34ab47a1a1ea726e8184c623c45d7e428d2d5d34"}, + {file = "multidict-5.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a7b8b5bd16376c8ac2977748bd978a200326af5145d8d0e7f799e2b355d425b6"}, + {file = "multidict-5.0.2.tar.gz", hash = "sha256:e5bf89fe57f702a046c7ec718fe330ed50efd4bcf74722940db2eb0919cddb1c"}, +] nodeenv = [ {file = "nodeenv-1.4.0-py2.py3-none-any.whl", hash = "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"}, + {file = "nodeenv-1.4.0.tar.gz", hash = "sha256:26941644654d8dd5378720e38f62a3bac5f9240811fb3b8913d2663a17baa91c"}, ] packaging = [ {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, @@ -935,6 +1062,43 @@ py = [ {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, ] +pycryptodome = [ + {file = "pycryptodome-3.9.9-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:5598dc6c9dbfe882904e54584322893eff185b98960bbe2cdaaa20e8a437b6e5"}, + {file = "pycryptodome-3.9.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1cfdb92dca388e27e732caa72a1cc624520fe93752a665c3b6cd8f1a91b34916"}, + {file = "pycryptodome-3.9.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5f19e6ef750f677d924d9c7141f54bade3cd56695bbfd8a9ef15d0378557dfe4"}, + {file = "pycryptodome-3.9.9-cp27-cp27m-win32.whl", hash = "sha256:a3d8a9efa213be8232c59cdc6b65600276508e375e0a119d710826248fd18d37"}, + {file = "pycryptodome-3.9.9-cp27-cp27m-win_amd64.whl", hash = "sha256:50826b49fbca348a61529693b0031cdb782c39060fb9dca5ac5dff858159dc5a"}, + {file = "pycryptodome-3.9.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:19cb674df6c74a14b8b408aa30ba8a89bd1c01e23505100fb45f930fbf0ed0d9"}, + {file = "pycryptodome-3.9.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:28f75e58d02019a7edc7d4135203d2501dfc47256d175c72c9798f9a129a49a7"}, + {file = "pycryptodome-3.9.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:6d3baaf82681cfb1a842f1c8f77beac791ceedd99af911e4f5fabec32bae2259"}, + {file = "pycryptodome-3.9.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:946399d15eccebafc8ce0257fc4caffe383c75e6b0633509bd011e357368306c"}, + {file = "pycryptodome-3.9.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:eb01f9997e4d6a8ec8a1ad1f676ba5a362781ff64e8189fe2985258ba9cb9706"}, + {file = "pycryptodome-3.9.9-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:411745c6dce4eff918906eebcde78771d44795d747e194462abb120d2e537cd9"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:8f9f84059039b672a5a705b3c5aa21747867bacc30a72e28bf0d147cc8ef85ed"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7798e73225a699651888489fbb1dbc565e03a509942a8ce6194bbe6fb582a41f"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:46e96aeb8a9ca8b1edf9b1fd0af4bf6afcf3f1ca7fa35529f5d60b98f3e4e959"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:843e5f10ecdf9d307032b8b91afe9da1d6ed5bb89d0bbec5c8dcb4ba44008e11"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-win32.whl", hash = "sha256:b68794fba45bdb367eeb71249c26d23e61167510a1d0c3d6cf0f2f14636e62ee"}, + {file = "pycryptodome-3.9.9-cp36-cp36m-win_amd64.whl", hash = "sha256:60febcf5baf70c566d9d9351c47fbd8321da9a4edf2eff45c4c31c86164ca794"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:4ed27951b0a17afd287299e2206a339b5b6d12de9321e1a1575261ef9c4a851b"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:9000877383e2189dafd1b2fc68c6c726eca9a3cfb6d68148fbb72ccf651959b6"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:faa682c404c218e8788c3126c9a4b8fbcc54dc245b5b6e8ea5b46f3b63bd0c84"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:62c488a21c253dadc9f731a32f0ac61e4e436d81a1ea6f7d1d9146ed4d20d6bd"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-win32.whl", hash = "sha256:834b790bbb6bd18956f625af4004d9c15eed12d5186d8e57851454ae76d52215"}, + {file = "pycryptodome-3.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:70d807d11d508433daf96244ec1c64e55039e8a35931fc5ea9eee94dbe3cb6b5"}, + {file = "pycryptodome-3.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:27397aee992af69d07502126561d851ba3845aa808f0e55c71ad0efa264dd7d4"}, + {file = "pycryptodome-3.9.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d7ec2bd8f57c559dd24e71891c51c25266a8deb66fc5f02cc97c7fb593d1780a"}, + {file = "pycryptodome-3.9.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:e15bde67ccb7d4417f627dd16ffe2f5a4c2941ce5278444e884cb26d73ecbc61"}, + {file = "pycryptodome-3.9.9-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:5c3c4865730dfb0263f822b966d6d58429d8b1e560d1ddae37685fd9e7c63161"}, + {file = "pycryptodome-3.9.9-cp38-cp38-win32.whl", hash = "sha256:76b1a34d74bb2c91bce460cdc74d1347592045627a955e9a252554481c17c52f"}, + {file = "pycryptodome-3.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:6e4227849e4231a3f5b35ea5bdedf9a82b3883500e5624f00a19156e9a9ef861"}, + {file = "pycryptodome-3.9.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2a68df525b387201a43b27b879ce8c08948a430e883a756d6c9e3acdaa7d7bd8"}, + {file = "pycryptodome-3.9.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:a4599c0ca0fc027c780c1c45ed996d5bef03e571470b7b1c7171ec1e1a90914c"}, + {file = "pycryptodome-3.9.9-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b4e6b269a8ddaede774e5c3adbef6bf452ee144e6db8a716d23694953348cd86"}, + {file = "pycryptodome-3.9.9-cp39-cp39-win32.whl", hash = "sha256:a199e9ca46fc6e999e5f47fce342af4b56c7de85fae893c69ab6aa17531fb1e1"}, + {file = "pycryptodome-3.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:6e89bb3826e6f84501e8e3b205c22595d0c5492c2f271cbb9ee1c48eb1866645"}, + {file = "pycryptodome-3.9.9.tar.gz", hash = "sha256:910e202a557e1131b1c1b3f17a63914d57aac55cf9fb9b51644962841c3995c4"}, +] pygments = [ {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, @@ -1046,6 +1210,11 @@ tox = [ {file = "tox-3.18.1-py2.py3-none-any.whl", hash = "sha256:3d914480c46232c2d1a035482242535a26d76cc299e4fd28980c858463206f45"}, {file = "tox-3.18.1.tar.gz", hash = "sha256:5c82e40046a91dbc80b6bd08321b13b4380d8ce3bcb5b62616cb17aaddefbb3a"}, ] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] urllib3 = [ {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, @@ -1065,6 +1234,45 @@ xdoctest = [ {file = "xdoctest-0.13.0-py2.py3-none-any.whl", hash = "sha256:de861fd5230a46bd26c054b4981169dd963f813768cb62b62e104e4d2644ac94"}, {file = "xdoctest-0.13.0.tar.gz", hash = "sha256:4f113a430076561a9d7f31af65b5d5acda62ee06b05cb6894264cb9efb8196ac"}, ] +yarl = [ + {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, + {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, + {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, + {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, + {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, + {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, + {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, + {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, + {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, + {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, + {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, + {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, + {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, +] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, From 901b0ceb95d2450d2ce885367a1f0680b719fdc7 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Wed, 18 Nov 2020 15:09:41 +0000 Subject: [PATCH 04/16] Changes to pass commit checks --- kasa/cli.py | 2 +- kasa/discover.py | 14 +++--- kasa/protocol.py | 108 ++++++++++++++++++++++++++++---------------- kasa/smartdevice.py | 9 ++-- kasa/smartplug.py | 2 +- 5 files changed, 82 insertions(+), 53 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 14f367546..9f6799cdd 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -69,7 +69,7 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, kl return if klap: - authentication = {"user":"", "password":""} + authentication = {"user": "", "password": ""} else: authentication = None diff --git a/kasa/discover.py b/kasa/discover.py index fca6c286a..705343d79 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,9 +1,9 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio +import binascii import json import logging import socket -import binascii from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast from kasa.protocol import TPLinkSmartHomeProtocol @@ -65,12 +65,10 @@ def do_discover(self) -> None: req = json.dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = self.protocol.encrypt(req) - new_req = binascii.unhexlify('020000010000000000000000463cb5d3') + new_req = binascii.unhexlify("020000010000000000000000463cb5d3") for i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore - _LOGGER.debug("[NEW DISCOVERY] %s >> magic_packet", self.target) - for i in range(self.discovery_packets): - self.transport.sendto(new_req, self.new_target) + self.transport.sendto(new_req, self.new_target) # type: ignore def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" @@ -85,7 +83,7 @@ def datagram_received(self, data, addr) -> None: else: info = json.loads(data[16:]) device_class = Discover._get_new_device_class(info) - device = device_class(ip, {"user":"","password":""}) + device = device_class(ip, {"user": "", "password": ""}) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) @@ -256,6 +254,7 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: raise SmartDeviceException("Unknown device type: %s", type_) + @staticmethod def _get_new_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass given new discovery payload.""" if "result" not in info: @@ -269,7 +268,8 @@ def _get_new_device_class(info: dict) -> Type[SmartDevice]: if dtype == "IOT.SMARTPLUGSWITCH": return SmartPlug - raise SmartDeviceExpection("Unknown device type: %s", dtype) + raise SmartDeviceException("Unknown device type: %s", dtype) + if __name__ == "__main__": logging.basicConfig(level=logging.INFO) diff --git a/kasa/protocol.py b/kasa/protocol.py index 154b274f7..e5bcbded2 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,18 +10,18 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio +import binascii +import hashlib import json import logging +import secrets import struct +from pprint import pformat as pf +from typing import Dict, Union + import aiohttp -import secrets -import hashlib -import binascii -import time from Crypto.Cipher import AES from Crypto.Util import Padding -from pprint import pformat as pf -from typing import Dict, Union from yarl import URL from .exceptions import SmartDeviceException @@ -135,35 +135,45 @@ def decrypt(ciphertext: bytes) -> str: return plaintext.decode() + class TPLinkKLAP: - def __init__(self, host:str, authentication) -> None: + """Implementation of the KLAP encryption protocol. + + KLAP is the name used in device discovery for TP-Link's new encryption + protocol, which appeared with firmware 1.1.0. + """ + + def __init__(self, host: str, authentication) -> None: self.host = host - self.jar = aiohttp.CookieJar(unsafe = True, quote_cookie = False) + self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self.clientBytes = secrets.token_bytes(16) - self.authenticator = self.computeAuthenticator(authentication) + self.authenticator = self.__computeAuthenticator(authentication) self.handshake_lock = asyncio.Lock() self.handshake_done = False _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) @staticmethod - def computeAuthenticator(authentication: list) -> bytes: - username = bytearray(b'') - password = bytearray(b'') - return hashlib.md5(hashlib.md5(username).digest() - + hashlib.md5(password).digest()).digest() - - async def handshake(self, session) -> None: + def __computeAuthenticator(authentication: list) -> bytes: + username = bytearray(b"") + password = bytearray(b"") + return hashlib.md5( + hashlib.md5(username).digest() + hashlib.md5(password).digest() + ).digest() + + async def __handshake(self, session) -> None: _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) # Handshake 1 has a payload of clientBytes # and a response of 16 bytes, followed by sha256(clientBytes | authenticator) url = "http://%s/app/handshake1" % self.host - resp = await session.post(url, data = self.clientBytes) + resp = await session.post(url, data=self.clientBytes) _LOGGER.debug("Got response of %d to handshake1", resp.status) if resp.status != 200: - raise SmartDeviceException("Device responded with %d to handshake1" % resp.status) + raise SmartDeviceException( + "Device responded with %d to handshake1" % resp.status + ) response = await resp.read() self.serverBytes = response[0:16] serverHash = response[16:] @@ -175,9 +185,12 @@ async def handshake(self, session) -> None: localHash = hashlib.sha256(self.clientBytes + self.authenticator).digest() if localHash != serverHash: - _LOGGER.debug("Expected %s got %s in handshake1", - binascii.hexlify(localHash), binascii.hexlify(serverHash)) - raise SmartDeviceExpection("Server response doesn't match our challenge") + _LOGGER.debug( + "Expected %s got %s in handshake1", + binascii.hexlify(localHash), + binascii.hexlify(serverHash), + ) + raise SmartDeviceException("Server response doesn't match our challenge") else: _LOGGER.debug("handshake1 hashes match") @@ -192,60 +205,75 @@ async def handshake(self, session) -> None: # sha256(serverBytes | authenticator) url = "http://%s/app/handshake2" % self.host payload = hashlib.sha256(self.serverBytes + self.authenticator).digest() - resp = await session.post(url, data = payload) + resp = await session.post(url, data=payload) _LOGGER.debug("Got response of %d to handshake2", resp.status) if resp.status != 200: - raise SmartDeviceException("Device responded with %d to handshake2" % resp.status) + raise SmartDeviceException( + "Device responded with %d to handshake2" % resp.status + ) # Done handshaking, now we need to compute the encryption keys agreedBytes = self.clientBytes + self.serverBytes + self.authenticator - self.encryptKey = hashlib.sha256(bytearray(b'lsk') + agreedBytes).digest()[:16] - self.hmacKey = hashlib.sha256(bytearray(b'ldk') + agreedBytes).digest()[:28] - fulliv = hashlib.sha256(bytearray(b'iv') + agreedBytes).digest() + self.encryptKey = hashlib.sha256(bytearray(b"lsk") + agreedBytes).digest()[:16] + self.hmacKey = hashlib.sha256(bytearray(b"ldk") + agreedBytes).digest()[:28] + fulliv = hashlib.sha256(bytearray(b"iv") + agreedBytes).digest() self.iv = fulliv[:12] - self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) + self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) self.handshake_done = True - def encrypt(self, plaintext : bytes, iv: bytes, seq: int) -> bytes: + def __encrypt(self, plaintext: bytes, iv: bytes, seq: int) -> bytes: cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(Padding.pad(plaintext, AES.block_size)) - signature = hashlib.sha256(self.hmacKey + seq.to_bytes(4, "big", signed = True) + ciphertext).digest() + signature = hashlib.sha256( + self.hmacKey + seq.to_bytes(4, "big", signed=True) + ciphertext + ).digest() return signature + ciphertext - def decrypt(self, payload : bytes, iv: bytes, seq: int) -> bytes: + def __decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) # In theory we should verify the hmac here too return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) - async def query(self, host: str, request: Union[str, Dict], retry_count: int = 3) -> Dict: + async def query( + self, host: str, request: Union[str, Dict], retry_count: int = 3 + ) -> Dict: + """Request information from a TP-Link SmartHome Device. + + :param str host: host name or ip address of the device + :param request: command to send to the device (can be either dict or + json string) + :param retry_count: ignored, for backwards compatibility only + :return: response dict + """ if host != self.host: raise SmartDeviceException("Host %s doesn't match configured host %s") if isinstance(request, dict): request = json.dumps(request) - request = request.encode('utf-8') - _LOGGER.debug("Sending request %s", request) try: - session = aiohttp.ClientSession(cookie_jar = self.jar) + session = aiohttp.ClientSession(cookie_jar=self.jar) async with self.handshake_lock: if not self.handshake_done: - await self.handshake(session) + await self.__handshake(session) msg_seq = self.seq - msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed = True) - payload = self.encrypt(request, msg_iv, msg_seq) + msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed=True) + payload = self.__encrypt(request.encode("utf-8"), msg_iv, msg_seq) url = "http://%s/app/request" % self.host - resp = await session.post(url, params = {'seq' : msg_seq}, data = payload) + resp = await session.post(url, params={"seq": msg_seq}, data=payload) _LOGGER.debug("Got response of %d to request", resp.status) if resp.status != 200: - raise SmartDeviceException("Device responded with %d to request with seq %d" % (resp.status, msg_seq)) + raise SmartDeviceException( + "Device responded with %d to request with seq %d" + % (resp.status, msg_seq) + ) response = await resp.read() - plaintext = self.decrypt(response, msg_iv, msg_seq) + plaintext = self.__decrypt(response, msg_iv, msg_seq) finally: await session.close() diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 61c08051a..a9f589a3e 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -17,11 +17,10 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from .exceptions import SmartDeviceException -from .protocol import TPLinkSmartHomeProtocol -from .protocol import TPLinkKLAP +from .protocol import TPLinkKLAP, TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) @@ -214,7 +213,9 @@ class SmartDevice: """ - def __init__(self, host: str, authentication: None) -> None: + protocol: Union[TPLinkSmartHomeProtocol, TPLinkKLAP] + + def __init__(self, host: str, authentication: Optional[dict] = None) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens diff --git a/kasa/smartplug.py b/kasa/smartplug.py index a707f7635..018468b37 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -36,7 +36,7 @@ class SmartPlug(SmartDevice): For more examples, see the :class:`SmartDevice` class. """ - def __init__(self, host: str, authentication = None) -> None: + def __init__(self, host: str, authentication=None) -> None: super().__init__(host, authentication) self.emeter_type = "emeter" self._device_type = DeviceType.Plug From 3c2709dcbf4469f390104a9914cb6b1e5315f49e Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Wed, 18 Nov 2020 16:42:57 +0000 Subject: [PATCH 05/16] Teach pre-commit about pycryptodome --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7606de0b1..6c4767cbc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,4 +36,5 @@ repos: rev: v0.740 hooks: - id: mypy + additional_dependencies: [pycryptodome] # args: [--no-strict-optional, --ignore-missing-imports] From fbc7335de969e2a454fdb0aaf228e69441c422ca Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Wed, 18 Nov 2020 22:01:55 +0000 Subject: [PATCH 06/16] Add username and password authentication support for KLAP Add the --user and --password command line options which can be used to provide a username and password to authenticate to the smart device using the KLAP protocol. Check the 'owner' field in the discovery packet, and only attempt to authenticate to devices for which we have a password. --- kasa/cli.py | 36 +++++++++++++++++++++++++++++++----- kasa/discover.py | 38 +++++++++++++++++++++++++++++++++++++- kasa/protocol.py | 13 +++++++++---- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 9f6799cdd..45aa5d159 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -47,9 +47,23 @@ @click.option("--lightstrip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True) @click.option("--klap", default=False, is_flag=True) +@click.option( + "--user", + default="", + required=False, + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default="", + required=False, + help="Password to use to authenticate to device.", +) @click.version_option() @click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, klap): +async def cli( + ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, klap, user, password +): """A tool for controlling TP-Link smart home devices.""" # noqa if debug: logging.basicConfig(level=logging.DEBUG) @@ -68,8 +82,12 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, kl click.echo(f"No device with name {alias} found") return - if klap: - authentication = {"user": "", "password": ""} + if password != "" and user == "": + click.echo("Using a password requires a username") + return + + if klap or user != "": + authentication = {"user": user, "password": password} else: authentication = None @@ -80,7 +98,7 @@ async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip, kl else: if not bulb and not plug and not strip and not lightstrip: click.echo("No --strip nor --bulb nor --plug given, discovering..") - dev = await Discover.discover_single(host) + dev = await Discover.discover_single(host, authentication) elif bulb: dev = SmartBulb(host) elif plug: @@ -180,9 +198,17 @@ async def dump_discover(ctx, scrub): async def discover(ctx, timeout, discover_only, dump_raw): """Discover devices in the network.""" target = ctx.parent.params["target"] + user = ctx.parent.params["user"] + password = ctx.parent.params["password"] + + if user: + auth = {"user": user, "password": password} + else: + auth = None + click.echo(f"Discovering devices for {timeout} seconds") found_devs = await Discover.discover( - target=target, timeout=timeout, return_raw=dump_raw + target=target, timeout=timeout, return_raw=dump_raw, authentication=auth ) if not discover_only: for ip, dev in found_devs.items(): diff --git a/kasa/discover.py b/kasa/discover.py index 705343d79..ba00a3ffb 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,6 +1,7 @@ """Discovery module for TP-Link Smart Home devices.""" import asyncio import binascii +import hashlib import json import logging import socket @@ -36,6 +37,7 @@ def __init__( target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, + authentication: Optional[dict] = None, ): self.transport = None self.discovery_packets = discovery_packets @@ -46,6 +48,8 @@ def __init__( self.new_target = (target, Discover.NEW_DISCOVERY_PORT) self.discovered_devices = {} self.discovered_devices_raw = {} + self.authentication = authentication + self.emptyUser = hashlib.md5().digest() def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -83,7 +87,26 @@ def datagram_received(self, data, addr) -> None: else: info = json.loads(data[16:]) device_class = Discover._get_new_device_class(info) - device = device_class(ip, {"user": "", "password": ""}) + owner = Discover._get_new_owner(info) + if owner is not None: + owner_bin = bytes.fromhex(owner) + + _LOGGER.debug( + "[DISCOVERY] Device owner is %s, empty owner is %s", + owner_bin, + self.emptyUser, + ) + if owner is None or owner == "" or owner_bin == self.emptyUser: + _LOGGER.debug("[DISCOVERY] Device %s has no owner", ip) + device = device_class(ip, {"user": "", "password": ""}) + elif ( + owner_bin == hashlib.md5(self.authentication["user"].encode()).digest() + ): + _LOGGER.debug("[DISCOVERY] Device %s has authenticated owner", ip) + device = device_class(ip, self.authentication) + else: + _LOGGER.debug("[DISCOVERY] Found %s with unknown owner %s", ip, owner) + return _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) @@ -162,6 +185,7 @@ async def discover( discovery_packets=3, return_raw=False, interface=None, + authentication=None, ) -> Mapping[str, Union[SmartDevice, Dict]]: """Discover supported devices. @@ -188,6 +212,7 @@ async def discover( on_discovered=on_discovered, discovery_packets=discovery_packets, interface=interface, + authentication=authentication, ), local_addr=("0.0.0.0", 0), ) @@ -270,6 +295,17 @@ def _get_new_device_class(info: dict) -> Type[SmartDevice]: raise SmartDeviceException("Unknown device type: %s", dtype) + @staticmethod + def _get_new_owner(info: dict) -> Optional[str]: + """Find owner given new-style discovery payload.""" + if "result" not in info: + raise SmartDeviceException("No 'result' in discovery response") + + if "owner" not in info["result"]: + return None + + return info["result"]["owner"] + if __name__ == "__main__": logging.basicConfig(level=logging.INFO) diff --git a/kasa/protocol.py b/kasa/protocol.py index e5bcbded2..1b8be5f7c 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -17,7 +17,7 @@ import secrets import struct from pprint import pformat as pf -from typing import Dict, Union +from typing import Dict, Union, Optional import aiohttp from Crypto.Cipher import AES @@ -154,9 +154,14 @@ def __init__(self, host: str, authentication) -> None: _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) @staticmethod - def __computeAuthenticator(authentication: list) -> bytes: - username = bytearray(b"") - password = bytearray(b"") + def __computeAuthenticator(authentication: Optional[dict]) -> bytes: + if authentication is not None: + username = authentication["user"].encode() + password = authentication["password"].encode() + else: + username = bytearray(b"") + password = bytearray(b"") + _LOGGER.debug("[KLAP] Using username %s and password %s", username, password) return hashlib.md5( hashlib.md5(username).digest() + hashlib.md5(password).digest() ).digest() From 2bd9976118740b95eff1a3bc1cd7277875a32971 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Wed, 18 Nov 2020 22:42:02 +0000 Subject: [PATCH 07/16] Try forcing a newer mypy in pre-commit --- .pre-commit-config.yaml | 2 +- kasa/discover.py | 4 +++- kasa/protocol.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c4767cbc..887ed4440 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: additional_dependencies: [toml] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.740 + rev: v0.790 hooks: - id: mypy additional_dependencies: [pycryptodome] diff --git a/kasa/discover.py b/kasa/discover.py index ba00a3ffb..4492a5011 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -100,7 +100,9 @@ def datagram_received(self, data, addr) -> None: _LOGGER.debug("[DISCOVERY] Device %s has no owner", ip) device = device_class(ip, {"user": "", "password": ""}) elif ( - owner_bin == hashlib.md5(self.authentication["user"].encode()).digest() + self.authentication is not None + and owner_bin + == hashlib.md5(self.authentication["user"].encode()).digest() ): _LOGGER.debug("[DISCOVERY] Device %s has authenticated owner", ip) device = device_class(ip, self.authentication) diff --git a/kasa/protocol.py b/kasa/protocol.py index 1b8be5f7c..347bb2f80 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -17,7 +17,7 @@ import secrets import struct from pprint import pformat as pf -from typing import Dict, Union, Optional +from typing import Dict, Optional, Union import aiohttp from Crypto.Cipher import AES From b8df6ee1f801bd285b56cac7215942dac4fe2658 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 12:41:55 +0000 Subject: [PATCH 08/16] Add an authentication class Add a new class which holds authentication information for the KLAP protocol --- kasa/__init__.py | 2 ++ kasa/auth.py | 21 +++++++++++++++++++++ kasa/cli.py | 5 +++-- kasa/discover.py | 8 ++++---- kasa/protocol.py | 20 ++++---------------- kasa/smartdevice.py | 3 ++- 6 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 kasa/auth.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 911a7dc39..108dccdb8 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -12,6 +12,7 @@ to be handled by the user of the library. """ from importlib_metadata import version # type: ignore +from kasa.auth import Auth from kasa.discover import Discover from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol @@ -26,6 +27,7 @@ __all__ = [ + "Auth", "Discover", "TPLinkSmartHomeProtocol", "SmartBulb", diff --git a/kasa/auth.py b/kasa/auth.py new file mode 100644 index 000000000..efd431a82 --- /dev/null +++ b/kasa/auth.py @@ -0,0 +1,21 @@ +"""Authentication class for KASA username / passwords.""" +from hashlib import md5 + + +class Auth: + """Authentication for Kasa KLAP authentication.""" + + def __init__(self, user: str = "", password: str = ""): + self.user = user + self.password = password + self.md5user = md5(user.encode()).digest() + self.md5password = md5(password.encode()).digest() + self.md5auth = md5(self.md5user + self.md5password).digest() + + def authenticator(self): + """Return the KLAP authenticator for these credentials.""" + return self.md5auth + + def owner(self): + """Return the MD5 hash of the username in this object.""" + return self.md5user diff --git a/kasa/cli.py b/kasa/cli.py index 45aa5d159..d86a3e4ae 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -8,6 +8,7 @@ import asyncclick as click from kasa import ( + Auth, Discover, SmartBulb, SmartDevice, @@ -87,7 +88,7 @@ async def cli( return if klap or user != "": - authentication = {"user": user, "password": password} + authentication = Auth(user=user, password=password) else: authentication = None @@ -202,7 +203,7 @@ async def discover(ctx, timeout, discover_only, dump_raw): password = ctx.parent.params["password"] if user: - auth = {"user": user, "password": password} + auth = Auth(user=user, password=password) else: auth = None diff --git a/kasa/discover.py b/kasa/discover.py index 4492a5011..9e16e0352 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -7,6 +7,7 @@ import socket from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast +from kasa.auth import Auth from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb from kasa.smartdevice import SmartDevice, SmartDeviceException @@ -37,7 +38,7 @@ def __init__( target: str = "255.255.255.255", discovery_packets: int = 3, interface: Optional[str] = None, - authentication: Optional[dict] = None, + authentication: Optional[Auth] = None, ): self.transport = None self.discovery_packets = discovery_packets @@ -98,11 +99,10 @@ def datagram_received(self, data, addr) -> None: ) if owner is None or owner == "" or owner_bin == self.emptyUser: _LOGGER.debug("[DISCOVERY] Device %s has no owner", ip) - device = device_class(ip, {"user": "", "password": ""}) + device = device_class(ip, Auth()) elif ( self.authentication is not None - and owner_bin - == hashlib.md5(self.authentication["user"].encode()).digest() + and owner_bin == self.authentication.owner() ): _LOGGER.debug("[DISCOVERY] Device %s has authenticated owner", ip) device = device_class(ip, self.authentication) diff --git a/kasa/protocol.py b/kasa/protocol.py index 347bb2f80..2db961be0 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -17,13 +17,14 @@ import secrets import struct from pprint import pformat as pf -from typing import Dict, Optional, Union +from typing import Dict, Union import aiohttp from Crypto.Cipher import AES from Crypto.Util import Padding from yarl import URL +from .auth import Auth from .exceptions import SmartDeviceException _LOGGER = logging.getLogger(__name__) @@ -143,29 +144,16 @@ class TPLinkKLAP: protocol, which appeared with firmware 1.1.0. """ - def __init__(self, host: str, authentication) -> None: + def __init__(self, host: str, authentication: Auth = Auth()) -> None: self.host = host self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self.clientBytes = secrets.token_bytes(16) - self.authenticator = self.__computeAuthenticator(authentication) + self.authenticator = authentication.authenticator() self.handshake_lock = asyncio.Lock() self.handshake_done = False _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) - @staticmethod - def __computeAuthenticator(authentication: Optional[dict]) -> bytes: - if authentication is not None: - username = authentication["user"].encode() - password = authentication["password"].encode() - else: - username = bytearray(b"") - password = bytearray(b"") - _LOGGER.debug("[KLAP] Using username %s and password %s", username, password) - return hashlib.md5( - hashlib.md5(username).digest() + hashlib.md5(password).digest() - ).digest() - async def __handshake(self, session) -> None: _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index a9f589a3e..1912d4705 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -19,6 +19,7 @@ from enum import Enum from typing import Any, Dict, List, Optional, Union +from .auth import Auth from .exceptions import SmartDeviceException from .protocol import TPLinkKLAP, TPLinkSmartHomeProtocol @@ -215,7 +216,7 @@ class SmartDevice: protocol: Union[TPLinkSmartHomeProtocol, TPLinkKLAP] - def __init__(self, host: str, authentication: Optional[dict] = None) -> None: + def __init__(self, host: str, authentication: Optional[Auth] = None) -> None: """Create a new SmartDevice instance. :param str host: host name or ip address on which the device listens From a74e60d1df53cecae42133971185c66fa25947ee Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 13:16:10 +0000 Subject: [PATCH 09/16] Move KLAP protocol implementation into its own file --- kasa/klapprotocol.py | 164 +++++++++++++++++++++++++++++++++++++++++++ kasa/protocol.py | 145 -------------------------------------- kasa/smartdevice.py | 3 +- 3 files changed, 166 insertions(+), 146 deletions(-) create mode 100755 kasa/klapprotocol.py diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py new file mode 100755 index 000000000..4bff4c99b --- /dev/null +++ b/kasa/klapprotocol.py @@ -0,0 +1,164 @@ +"""Implementation of the TP-Link Smart Home Protocol. + +Encryption/Decryption methods based on the works of +Lubomir Stroetmann and Tobias Esser + +https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ +https://github.com/softScheck/tplink-smartplug/ + +which are licensed under the Apache License, Version 2.0 +http://www.apache.org/licenses/LICENSE-2.0 +""" +import asyncio +import binascii +import hashlib +import json +import logging +import secrets +from typing import Dict, Union + +import aiohttp +from Crypto.Cipher import AES +from Crypto.Util import Padding +from yarl import URL + +from .auth import Auth +from .exceptions import SmartDeviceException + +_LOGGER = logging.getLogger(__name__) + + +class TPLinkKLAP: + """Implementation of the KLAP encryption protocol. + + KLAP is the name used in device discovery for TP-Link's new encryption + protocol, which appeared with firmware 1.1.0. + """ + + def __init__(self, host: str, authentication: Auth = Auth()) -> None: + self.host = host + self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) + self.clientBytes = secrets.token_bytes(16) + self.authenticator = authentication.authenticator() + self.handshake_lock = asyncio.Lock() + self.handshake_done = False + + _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) + + async def __handshake(self, session) -> None: + _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) + + # Handshake 1 has a payload of clientBytes + # and a response of 16 bytes, followed by sha256(clientBytes | authenticator) + + url = "http://%s/app/handshake1" % self.host + resp = await session.post(url, data=self.clientBytes) + _LOGGER.debug("Got response of %d to handshake1", resp.status) + if resp.status != 200: + raise SmartDeviceException( + "Device responded with %d to handshake1" % resp.status + ) + response = await resp.read() + self.serverBytes = response[0:16] + serverHash = response[16:] + + _LOGGER.debug("Server bytes are: %s", binascii.hexlify(self.serverBytes)) + _LOGGER.debug("Server hash is: %s", binascii.hexlify(serverHash)) + + # Check the response from the device + localHash = hashlib.sha256(self.clientBytes + self.authenticator).digest() + + if localHash != serverHash: + _LOGGER.debug( + "Expected %s got %s in handshake1", + binascii.hexlify(localHash), + binascii.hexlify(serverHash), + ) + raise SmartDeviceException("Server response doesn't match our challenge") + else: + _LOGGER.debug("handshake1 hashes match") + + # We need to include only the TP_SESSIONID cookie - aiohttp's cookie handling + # adds a bogus TIMEOUT cookie + cookie = session.cookie_jar.filter_cookies(url).get("TP_SESSIONID") + session.cookie_jar.clear() + session.cookie_jar.update_cookies({"TP_SESSIONID": cookie}, URL(url)) + _LOGGER.debug("Cookie is %s", cookie) + + # Handshake 2 has the following payload: + # sha256(serverBytes | authenticator) + url = "http://%s/app/handshake2" % self.host + payload = hashlib.sha256(self.serverBytes + self.authenticator).digest() + resp = await session.post(url, data=payload) + _LOGGER.debug("Got response of %d to handshake2", resp.status) + if resp.status != 200: + raise SmartDeviceException( + "Device responded with %d to handshake2" % resp.status + ) + + # Done handshaking, now we need to compute the encryption keys + agreedBytes = self.clientBytes + self.serverBytes + self.authenticator + self.encryptKey = hashlib.sha256(bytearray(b"lsk") + agreedBytes).digest()[:16] + self.hmacKey = hashlib.sha256(bytearray(b"ldk") + agreedBytes).digest()[:28] + fulliv = hashlib.sha256(bytearray(b"iv") + agreedBytes).digest() + self.iv = fulliv[:12] + self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) + self.handshake_done = True + + def __encrypt(self, plaintext: bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(Padding.pad(plaintext, AES.block_size)) + signature = hashlib.sha256( + self.hmacKey + seq.to_bytes(4, "big", signed=True) + ciphertext + ).digest() + return signature + ciphertext + + def __decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + # In theory we should verify the hmac here too + return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) + + async def query( + self, host: str, request: Union[str, Dict], retry_count: int = 3 + ) -> Dict: + """Request information from a TP-Link SmartHome Device. + + :param str host: host name or ip address of the device + :param request: command to send to the device (can be either dict or + json string) + :param retry_count: ignored, for backwards compatibility only + :return: response dict + """ + if host != self.host: + raise SmartDeviceException("Host %s doesn't match configured host %s") + + if isinstance(request, dict): + request = json.dumps(request) + + _LOGGER.debug("Sending request %s", request) + + try: + session = aiohttp.ClientSession(cookie_jar=self.jar) + + async with self.handshake_lock: + if not self.handshake_done: + await self.__handshake(session) + + msg_seq = self.seq + msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed=True) + payload = self.__encrypt(request.encode("utf-8"), msg_iv, msg_seq) + + url = "http://%s/app/request" % self.host + resp = await session.post(url, params={"seq": msg_seq}, data=payload) + _LOGGER.debug("Got response of %d to request", resp.status) + if resp.status != 200: + raise SmartDeviceException( + "Device responded with %d to request with seq %d" + % (resp.status, msg_seq) + ) + response = await resp.read() + plaintext = self.__decrypt(response, msg_iv, msg_seq) + finally: + await session.close() + + return json.loads(plaintext) diff --git a/kasa/protocol.py b/kasa/protocol.py index 2db961be0..6ee6f72d6 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,21 +10,12 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio -import binascii -import hashlib import json import logging -import secrets import struct from pprint import pformat as pf from typing import Dict, Union -import aiohttp -from Crypto.Cipher import AES -from Crypto.Util import Padding -from yarl import URL - -from .auth import Auth from .exceptions import SmartDeviceException _LOGGER = logging.getLogger(__name__) @@ -135,139 +126,3 @@ def decrypt(ciphertext: bytes) -> str: plaintext = bytes(buffer) return plaintext.decode() - - -class TPLinkKLAP: - """Implementation of the KLAP encryption protocol. - - KLAP is the name used in device discovery for TP-Link's new encryption - protocol, which appeared with firmware 1.1.0. - """ - - def __init__(self, host: str, authentication: Auth = Auth()) -> None: - self.host = host - self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) - self.clientBytes = secrets.token_bytes(16) - self.authenticator = authentication.authenticator() - self.handshake_lock = asyncio.Lock() - self.handshake_done = False - - _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) - - async def __handshake(self, session) -> None: - _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) - - # Handshake 1 has a payload of clientBytes - # and a response of 16 bytes, followed by sha256(clientBytes | authenticator) - - url = "http://%s/app/handshake1" % self.host - resp = await session.post(url, data=self.clientBytes) - _LOGGER.debug("Got response of %d to handshake1", resp.status) - if resp.status != 200: - raise SmartDeviceException( - "Device responded with %d to handshake1" % resp.status - ) - response = await resp.read() - self.serverBytes = response[0:16] - serverHash = response[16:] - - _LOGGER.debug("Server bytes are: %s", binascii.hexlify(self.serverBytes)) - _LOGGER.debug("Server hash is: %s", binascii.hexlify(serverHash)) - - # Check the response from the device - localHash = hashlib.sha256(self.clientBytes + self.authenticator).digest() - - if localHash != serverHash: - _LOGGER.debug( - "Expected %s got %s in handshake1", - binascii.hexlify(localHash), - binascii.hexlify(serverHash), - ) - raise SmartDeviceException("Server response doesn't match our challenge") - else: - _LOGGER.debug("handshake1 hashes match") - - # We need to include only the TP_SESSIONID cookie - aiohttp's cookie handling - # adds a bogus TIMEOUT cookie - cookie = session.cookie_jar.filter_cookies(url).get("TP_SESSIONID") - session.cookie_jar.clear() - session.cookie_jar.update_cookies({"TP_SESSIONID": cookie}, URL(url)) - _LOGGER.debug("Cookie is %s", cookie) - - # Handshake 2 has the following payload: - # sha256(serverBytes | authenticator) - url = "http://%s/app/handshake2" % self.host - payload = hashlib.sha256(self.serverBytes + self.authenticator).digest() - resp = await session.post(url, data=payload) - _LOGGER.debug("Got response of %d to handshake2", resp.status) - if resp.status != 200: - raise SmartDeviceException( - "Device responded with %d to handshake2" % resp.status - ) - - # Done handshaking, now we need to compute the encryption keys - agreedBytes = self.clientBytes + self.serverBytes + self.authenticator - self.encryptKey = hashlib.sha256(bytearray(b"lsk") + agreedBytes).digest()[:16] - self.hmacKey = hashlib.sha256(bytearray(b"ldk") + agreedBytes).digest()[:28] - fulliv = hashlib.sha256(bytearray(b"iv") + agreedBytes).digest() - self.iv = fulliv[:12] - self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) - self.handshake_done = True - - def __encrypt(self, plaintext: bytes, iv: bytes, seq: int) -> bytes: - cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) - ciphertext = cipher.encrypt(Padding.pad(plaintext, AES.block_size)) - signature = hashlib.sha256( - self.hmacKey + seq.to_bytes(4, "big", signed=True) + ciphertext - ).digest() - return signature + ciphertext - - def __decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: - cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) - # In theory we should verify the hmac here too - return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) - - async def query( - self, host: str, request: Union[str, Dict], retry_count: int = 3 - ) -> Dict: - """Request information from a TP-Link SmartHome Device. - - :param str host: host name or ip address of the device - :param request: command to send to the device (can be either dict or - json string) - :param retry_count: ignored, for backwards compatibility only - :return: response dict - """ - if host != self.host: - raise SmartDeviceException("Host %s doesn't match configured host %s") - - if isinstance(request, dict): - request = json.dumps(request) - - _LOGGER.debug("Sending request %s", request) - - try: - session = aiohttp.ClientSession(cookie_jar=self.jar) - - async with self.handshake_lock: - if not self.handshake_done: - await self.__handshake(session) - - msg_seq = self.seq - msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed=True) - payload = self.__encrypt(request.encode("utf-8"), msg_iv, msg_seq) - - url = "http://%s/app/request" % self.host - resp = await session.post(url, params={"seq": msg_seq}, data=payload) - _LOGGER.debug("Got response of %d to request", resp.status) - if resp.status != 200: - raise SmartDeviceException( - "Device responded with %d to request with seq %d" - % (resp.status, msg_seq) - ) - response = await resp.read() - plaintext = self.__decrypt(response, msg_iv, msg_seq) - finally: - await session.close() - - return json.loads(plaintext) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 1912d4705..a0ee20d6b 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -21,7 +21,8 @@ from .auth import Auth from .exceptions import SmartDeviceException -from .protocol import TPLinkKLAP, TPLinkSmartHomeProtocol +from .klapprotocol import TPLinkKLAP +from .protocol import TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) From 75992bf6ac5ce10f7be7ef68421596aab963af7a Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 13:40:45 +0000 Subject: [PATCH 10/16] Address simpler review comments on the KLAPProtocol class --- kasa/klapprotocol.py | 68 +++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py index 4bff4c99b..0069b21f2 100755 --- a/kasa/klapprotocol.py +++ b/kasa/klapprotocol.py @@ -10,7 +10,6 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio -import binascii import hashlib import json import logging @@ -32,47 +31,52 @@ class TPLinkKLAP: """Implementation of the KLAP encryption protocol. KLAP is the name used in device discovery for TP-Link's new encryption - protocol, which appeared with firmware 1.1.0. + protocol, used by newer firmware versions. """ def __init__(self, host: str, authentication: Auth = Auth()) -> None: self.host = host self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) - self.clientBytes = secrets.token_bytes(16) + self.client_challenge = secrets.token_bytes(16) self.authenticator = authentication.authenticator() self.handshake_lock = asyncio.Lock() self.handshake_done = False _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) - async def __handshake(self, session) -> None: + @staticmethod + def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() + + async def _handshake(self, session) -> None: _LOGGER.debug("[KLAP] Starting handshake with %s", self.host) - # Handshake 1 has a payload of clientBytes + # Handshake 1 has a payload of client_challenge # and a response of 16 bytes, followed by sha256(clientBytes | authenticator) - url = "http://%s/app/handshake1" % self.host - resp = await session.post(url, data=self.clientBytes) + url = f"http://{self.host}/app/handshake1" + resp = await session.post(url, data=self.client_challenge) _LOGGER.debug("Got response of %d to handshake1", resp.status) if resp.status != 200: raise SmartDeviceException( "Device responded with %d to handshake1" % resp.status ) + response = await resp.read() - self.serverBytes = response[0:16] - serverHash = response[16:] + self.server_challenge = response[0:16] + server_hash = response[16:] - _LOGGER.debug("Server bytes are: %s", binascii.hexlify(self.serverBytes)) - _LOGGER.debug("Server hash is: %s", binascii.hexlify(serverHash)) + _LOGGER.debug("Server bytes are: %s", self.server_challenge.hex()) + _LOGGER.debug("Server hash is: %s", server_hash.hex()) # Check the response from the device - localHash = hashlib.sha256(self.clientBytes + self.authenticator).digest() + local_hash = self._sha256(self.client_challenge + self.authenticator) - if localHash != serverHash: + if local_hash != server_hash: _LOGGER.debug( "Expected %s got %s in handshake1", - binascii.hexlify(localHash), - binascii.hexlify(serverHash), + local_hash.hex(), + server_hash.hex(), ) raise SmartDeviceException("Server response doesn't match our challenge") else: @@ -87,8 +91,8 @@ async def __handshake(self, session) -> None: # Handshake 2 has the following payload: # sha256(serverBytes | authenticator) - url = "http://%s/app/handshake2" % self.host - payload = hashlib.sha256(self.serverBytes + self.authenticator).digest() + url = f"http://{self.host}/app/handshake2" + payload = self._sha256(self.server_challenge + self.authenticator) resp = await session.post(url, data=payload) _LOGGER.debug("Got response of %d to handshake2", resp.status) if resp.status != 200: @@ -97,24 +101,24 @@ async def __handshake(self, session) -> None: ) # Done handshaking, now we need to compute the encryption keys - agreedBytes = self.clientBytes + self.serverBytes + self.authenticator - self.encryptKey = hashlib.sha256(bytearray(b"lsk") + agreedBytes).digest()[:16] - self.hmacKey = hashlib.sha256(bytearray(b"ldk") + agreedBytes).digest()[:28] - fulliv = hashlib.sha256(bytearray(b"iv") + agreedBytes).digest() + agreed = self.client_challenge + self.server_challenge + self.authenticator + self.encrypt_key = self._sha256(b"lsk" + agreed)[:16] + self.hmac_key = self._sha256(b"ldk" + agreed)[:28] + fulliv = self._sha256(b"iv" + agreed) self.iv = fulliv[:12] self.seq = int.from_bytes(fulliv[-4:], "big", signed=True) self.handshake_done = True - def __encrypt(self, plaintext: bytes, iv: bytes, seq: int) -> bytes: - cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + def _encrypt(self, plaintext: bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encrypt_key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(Padding.pad(plaintext, AES.block_size)) - signature = hashlib.sha256( - self.hmacKey + seq.to_bytes(4, "big", signed=True) + ciphertext - ).digest() + signature = self._sha256( + self.hmac_key + seq.to_bytes(4, "big", signed=True) + ciphertext + ) return signature + ciphertext - def __decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: - cipher = AES.new(self.encryptKey, AES.MODE_CBC, iv) + def _decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: + cipher = AES.new(self.encrypt_key, AES.MODE_CBC, iv) # In theory we should verify the hmac here too return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) @@ -142,13 +146,13 @@ async def query( async with self.handshake_lock: if not self.handshake_done: - await self.__handshake(session) + await self._handshake(session) msg_seq = self.seq msg_iv = self.iv + msg_seq.to_bytes(4, "big", signed=True) - payload = self.__encrypt(request.encode("utf-8"), msg_iv, msg_seq) + payload = self._encrypt(request.encode("utf-8"), msg_iv, msg_seq) - url = "http://%s/app/request" % self.host + url = f"http://{self.host}/app/request" resp = await session.post(url, params={"seq": msg_seq}, data=payload) _LOGGER.debug("Got response of %d to request", resp.status) if resp.status != 200: @@ -157,7 +161,7 @@ async def query( % (resp.status, msg_seq) ) response = await resp.read() - plaintext = self.__decrypt(response, msg_iv, msg_seq) + plaintext = self._decrypt(response, msg_iv, msg_seq) finally: await session.close() From c3d1fc63d36813610221f2f3db8e68c1a26e0f0b Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 15:21:58 +0000 Subject: [PATCH 11/16] Add a base class for all protocols, and move shared logic there --- kasa/discover.py | 9 +++-- kasa/klapprotocol.py | 31 ++++------------ kasa/protocol.py | 84 ++++++++++++++++++++++++++------------------ kasa/smartdevice.py | 6 ++-- 4 files changed, 63 insertions(+), 67 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 9e16e0352..2f6633406 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -44,7 +44,6 @@ def __init__( self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered - self.protocol = TPLinkSmartHomeProtocol() self.target = (target, Discover.DISCOVERY_PORT) self.new_target = (target, Discover.NEW_DISCOVERY_PORT) self.discovered_devices = {} @@ -69,7 +68,7 @@ def do_discover(self) -> None: """Send number of discovery datagrams.""" req = json.dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) - encrypted_req = self.protocol.encrypt(req) + encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) new_req = binascii.unhexlify("020000010000000000000000463cb5d3") for i in range(self.discovery_packets): self.transport.sendto(encrypted_req[4:], self.target) # type: ignore @@ -82,7 +81,7 @@ def datagram_received(self, data, addr) -> None: return if port == 9999: - info = json.loads(self.protocol.decrypt(data)) + info = json.loads(TPLinkSmartHomeProtocol.decrypt(data)) device_class = Discover._get_device_class(info) device = device_class(ip) else: @@ -241,9 +240,9 @@ async def discover_single(host: str) -> SmartDevice: :rtype: SmartDevice :return: Object for querying/controlling found device. """ - protocol = TPLinkSmartHomeProtocol() + protocol = TPLinkSmartHomeProtocol(host) - info = await protocol.query(host, Discover.DISCOVERY_QUERY) + info = await protocol.query(Discover.DISCOVERY_QUERY) device_class = Discover._get_device_class(info) if device_class is not None: diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py index 0069b21f2..411ea0f39 100755 --- a/kasa/klapprotocol.py +++ b/kasa/klapprotocol.py @@ -11,10 +11,8 @@ """ import asyncio import hashlib -import json import logging import secrets -from typing import Dict, Union import aiohttp from Crypto.Cipher import AES @@ -23,11 +21,12 @@ from .auth import Auth from .exceptions import SmartDeviceException +from .protocol import TPLinkProtocol _LOGGER = logging.getLogger(__name__) -class TPLinkKLAP: +class TPLinkKLAP(TPLinkProtocol): """Implementation of the KLAP encryption protocol. KLAP is the name used in device discovery for TP-Link's new encryption @@ -35,13 +34,14 @@ class TPLinkKLAP: """ def __init__(self, host: str, authentication: Auth = Auth()) -> None: - self.host = host self.jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) self.client_challenge = secrets.token_bytes(16) self.authenticator = authentication.authenticator() self.handshake_lock = asyncio.Lock() self.handshake_done = False + super().__init__(host=host) + _LOGGER.debug("[KLAP] Created KLAP object for %s", self.host) @staticmethod @@ -122,24 +122,7 @@ def _decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: # In theory we should verify the hmac here too return Padding.unpad(cipher.decrypt(payload[32:]), AES.block_size) - async def query( - self, host: str, request: Union[str, Dict], retry_count: int = 3 - ) -> Dict: - """Request information from a TP-Link SmartHome Device. - - :param str host: host name or ip address of the device - :param request: command to send to the device (can be either dict or - json string) - :param retry_count: ignored, for backwards compatibility only - :return: response dict - """ - if host != self.host: - raise SmartDeviceException("Host %s doesn't match configured host %s") - - if isinstance(request, dict): - request = json.dumps(request) - - _LOGGER.debug("Sending request %s", request) + async def _ask(self, request: str) -> str: try: session = aiohttp.ClientSession(cookie_jar=self.jar) @@ -161,8 +144,6 @@ async def query( % (resp.status, msg_seq) ) response = await resp.read() - plaintext = self._decrypt(response, msg_iv, msg_seq) + return self._decrypt(response, msg_iv, msg_seq).decode("utf-8") finally: await session.close() - - return json.loads(plaintext) diff --git a/kasa/protocol.py b/kasa/protocol.py index 6ee6f72d6..15d66dd84 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -21,18 +21,18 @@ _LOGGER = logging.getLogger(__name__) -class TPLinkSmartHomeProtocol: - """Implementation of the TP-Link Smart Home protocol.""" +class TPLinkProtocol: + """Base class for all TP-Link Smart Home communication.""" - INITIALIZATION_VECTOR = 171 - DEFAULT_PORT = 9999 DEFAULT_TIMEOUT = 5 - @staticmethod - async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> Dict: + def __init__(self, host: str) -> None: + self.host = host + self.timeout = TPLinkProtocol.DEFAULT_TIMEOUT + + async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: """Request information from a TP-Link SmartHome Device. - :param str host: host name or ip address of the device :param request: command to send to the device (can be either dict or json string) :param retry_count: how many retries to do in case of failure @@ -41,32 +41,10 @@ async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> D if isinstance(request, dict): request = json.dumps(request) - timeout = TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT - writer = None for retry in range(retry_count + 1): try: - task = asyncio.open_connection( - host, TPLinkSmartHomeProtocol.DEFAULT_PORT - ) - reader, writer = await asyncio.wait_for(task, timeout=timeout) _LOGGER.debug("> (%i) %s", len(request), request) - writer.write(TPLinkSmartHomeProtocol.encrypt(request)) - await writer.drain() - - buffer = bytes() - # Some devices send responses with a length header of 0 and - # terminate with a zero size chunk. Others send the length and - # will hang if we attempt to read more data. - length = -1 - while True: - chunk = await reader.read(4096) - if length == -1: - length = struct.unpack(">I", chunk[0:4])[0] - buffer += chunk - if (length > 0 and len(buffer) >= length + 4) or not chunk: - break - - response = TPLinkSmartHomeProtocol.decrypt(buffer[4:]) + response = await self._ask(request) json_payload = json.loads(response) _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) @@ -81,10 +59,48 @@ async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> D _LOGGER.debug("Unable to query the device, retrying: %s", ex) - finally: - if writer: - writer.close() - await writer.wait_closed() + raise SmartDeviceException("Not reached") + + async def _ask(self, request: str) -> str: + raise SmartDeviceException("ask should be overridden") + + +class TPLinkSmartHomeProtocol(TPLinkProtocol): + """Implementation of the TP-Link Smart Home protocol.""" + + INITIALIZATION_VECTOR = 171 + DEFAULT_PORT = 9999 + + def __init__(self, host: str): + super().__init__(host=host) + + async def _ask(self, request: str) -> str: + writer = None + try: + task = asyncio.open_connection(self.host, self.DEFAULT_PORT) + reader, writer = await asyncio.wait_for(task, timeout=self.timeout) + writer.write(TPLinkSmartHomeProtocol.encrypt(request)) + await writer.drain() + + buffer = bytes() + # Some devices send responses with a length header of 0 and + # terminate with a zero size chunk. Others send the length and + # will hang if we attempt to read more data. + length = -1 + while True: + chunk = await reader.read(4096) + if length == -1: + length = struct.unpack(">I", chunk[0:4])[0] + buffer += chunk + if (length > 0 and len(buffer) >= length + 4) or not chunk: + break + + return TPLinkSmartHomeProtocol.decrypt(buffer[4:]) + + finally: + if writer: + writer.close() + await writer.wait_closed() # make mypy happy, this should never be reached.. raise SmartDeviceException("Query reached somehow to unreachable") diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index a0ee20d6b..e0a4a8e88 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -225,7 +225,7 @@ def __init__(self, host: str, authentication: Optional[Auth] = None) -> None: self.host = host if authentication is None: - self.protocol = TPLinkSmartHomeProtocol() + self.protocol = TPLinkSmartHomeProtocol(host) else: self.protocol = TPLinkKLAP(host, authentication) @@ -263,7 +263,7 @@ async def _query_helper( request = self._create_request(target, cmd, arg, child_ids) try: - response = await self.protocol.query(host=self.host, request=request) + response = await self.protocol.query(request=request) except Exception as ex: raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex @@ -308,7 +308,7 @@ async def update(self): # Check for emeter if we were never updated, or if the device has emeter if self._last_update is None or self.has_emeter: req.update(self._create_emeter_request()) - self._last_update = await self.protocol.query(self.host, req) + self._last_update = await self.protocol.query(request=req) # TODO: keep accessible for tests self._sys_info = self._last_update["system"]["get_sysinfo"] From 9500d7b6b761e5e71aa1c8fd726e789197fae9ee Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 15:56:05 +0000 Subject: [PATCH 12/16] Force a new handshake if the plug returns a 403 error --- kasa/klapprotocol.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py index 411ea0f39..19ad081a3 100755 --- a/kasa/klapprotocol.py +++ b/kasa/klapprotocol.py @@ -138,6 +138,11 @@ async def _ask(self, request: str) -> str: url = f"http://{self.host}/app/request" resp = await session.post(url, params={"seq": msg_seq}, data=payload) _LOGGER.debug("Got response of %d to request", resp.status) + + # If we failed with a security error, force a new handshake next time + if resp.status == 403: + self.handshake_done = False + if resp.status != 200: raise SmartDeviceException( "Device responded with %d to request with seq %d" From d6ef3cb8cbb9c27f1923e21ff9da8691e75d0980 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 16:06:42 +0000 Subject: [PATCH 13/16] Use the specified timeout for KLAP queries, too --- kasa/klapprotocol.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/klapprotocol.py b/kasa/klapprotocol.py index 19ad081a3..7b901ce4c 100755 --- a/kasa/klapprotocol.py +++ b/kasa/klapprotocol.py @@ -125,7 +125,8 @@ def _decrypt(self, payload: bytes, iv: bytes, seq: int) -> bytes: async def _ask(self, request: str) -> str: try: - session = aiohttp.ClientSession(cookie_jar=self.jar) + timeout = aiohttp.ClientTimeout(total=self.timeout) + session = aiohttp.ClientSession(cookie_jar=self.jar, timeout=timeout) async with self.handshake_lock: if not self.handshake_done: From 2d6ce06e488c8e7c852221269dba6164726b74e7 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 16:08:30 +0000 Subject: [PATCH 14/16] Relax dependencies --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 254f0d65f..7649e53df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ sphinx = { version = "^3", optional = true } m2r = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } -aiohttp = "^3.7.2" -pycryptodome = "^3.9.9" +aiohttp = "^3" +pycryptodome = "^3" [tool.poetry.dev-dependencies] pytest = "^5" From d9a75e93d7360edcdb7e8924324ff6dae425fae7 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 22 Nov 2020 16:42:50 +0000 Subject: [PATCH 15/16] Fix tests --- kasa/tests/newfakes.py | 2 +- kasa/tests/test_protocol.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 55c3e00cb..986bc2d98 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -415,7 +415,7 @@ def light_state(self, x, *args): }, } - async def query(self, host, request, port=9999): + async def query(self, request, port=9999): proto = self.proto # collect child ids from context diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 51c01d49d..3d07507f4 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -21,7 +21,8 @@ def aio_mock_writer(_, __): conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol.query("127.0.0.1", {}, retry_count=retry_count) + protocol = TPLinkSmartHomeProtocol("127.0.0.1") + await protocol.query({}, retry_count=retry_count) assert conn.call_count == retry_count + 1 From b69ca9e68f8f7a9cc4f404e93807c2aa030cffa5 Mon Sep 17 00:00:00 2001 From: Simon Wilkinson Date: Sun, 28 Feb 2021 14:18:27 +0000 Subject: [PATCH 16/16] Don't make emeter requests when they're not supported Making an emeter request against an HS100 plug crashes it. So don't. --- kasa/smartdevice.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index e0a4a8e88..a1b159559 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -305,9 +305,16 @@ async def update(self): req = {} req.update(self._create_request("system", "get_sysinfo")) - # Check for emeter if we were never updated, or if the device has emeter - if self._last_update is None or self.has_emeter: + # Newer HS100 firmware doesn't like emeter requests + if self._last_update is None: + self._last_update = await self.protocol.query(request = req) + self._sys_info = self._last_update["system"]["get_sysinfo"] + if not self.has_emeter: + return + + if self.has_emeter: req.update(self._create_emeter_request()) + self._last_update = await self.protocol.query(request=req) # TODO: keep accessible for tests self._sys_info = self._last_update["system"]["get_sysinfo"]