-
-
Notifications
You must be signed in to change notification settings - Fork 239
Add support for the new encryption protocol #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6fc0de
efcbece
cacb996
901b0ce
3c2709d
fbc7335
2bd9976
b8df6ee
a74e60d
75992bf
c3d1fc6
9500d7b
d6ef3cb
2d6ce06
d9a75e9
b69ca9e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,13 @@ | ||
| """Discovery module for TP-Link Smart Home devices.""" | ||
| import asyncio | ||
| import binascii | ||
| import hashlib | ||
rytilahti marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import json | ||
| import logging | ||
| 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 | ||
|
|
@@ -35,15 +38,18 @@ def __init__( | |
| target: str = "255.255.255.255", | ||
| discovery_packets: int = 3, | ||
| interface: Optional[str] = None, | ||
| authentication: Optional[Auth] = None, | ||
| ): | ||
| self.transport = None | ||
| 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 = {} | ||
| self.discovered_devices_raw = {} | ||
| self.authentication = authentication | ||
| self.emptyUser = hashlib.md5().digest() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| def connection_made(self, transport) -> None: | ||
| """Set socket options for broadcasting.""" | ||
|
|
@@ -62,21 +68,49 @@ 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 | ||
| self.transport.sendto(new_req, self.new_target) # type: ignore | ||
|
|
||
| def datagram_received(self, data, addr) -> None: | ||
| """Handle discovery responses.""" | ||
| ip, port = addr | ||
| if ip in self.discovered_devices: | ||
| return | ||
|
|
||
| info = json.loads(self.protocol.decrypt(data)) | ||
| if port == 9999: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would suggest that instead of relying on a hardcoded ports, to either have a command line option, or even better - attempt the old encryption mechanism, and if that fails - to try the new one. In respect to this I've already opened a PR #109 that would extract the port and make it configurable and I think it'd quite handy for this implementation.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Considering that there are only two discovery methods, I think it's fine to hard-code (or rather use a const from the class) for this use case. I'd still separate the klap discovery handling into its own method that gets called only when necessary (so instead of Open question is how to handle the type detection, as the I'm personally for making a sysinfo query, which adds another round-trip but would allow direct reuse of the existing detection logic.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An example discovery payload is: So we could use the device_model as a way of determining the different between a plug (HS100 or HS110) and a strip (KP303) |
||
| info = json.loads(TPLinkSmartHomeProtocol.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) | ||
| owner = Discover._get_new_owner(info) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To simplify the code, maybe something like this: |
||
| 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The owner cannot be none anymore at this point, so checking if the |
||
| _LOGGER.debug("[DISCOVERY] Device %s has no owner", ip) | ||
| device = device_class(ip, Auth()) | ||
| elif ( | ||
| self.authentication is not None | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The self.authentication needs to be already there if we get this far, so checking for the owner should be enough. |
||
| and owner_bin == self.authentication.owner() | ||
| ): | ||
| _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) | ||
|
|
||
| device_class = Discover._get_device_class(info) | ||
| device = device_class(ip) | ||
| asyncio.ensure_future(device.update()) | ||
|
|
||
| self.discovered_devices[ip] = device | ||
|
|
@@ -133,6 +167,8 @@ class Discover: | |
|
|
||
| DISCOVERY_PORT = 9999 | ||
|
|
||
| NEW_DISCOVERY_PORT = 20002 | ||
|
|
||
| DISCOVERY_QUERY = { | ||
| "system": {"get_sysinfo": None}, | ||
| "emeter": {"get_realtime": None}, | ||
|
|
@@ -150,6 +186,7 @@ async def discover( | |
| discovery_packets=3, | ||
| return_raw=False, | ||
| interface=None, | ||
| authentication=None, | ||
| ) -> Mapping[str, Union[SmartDevice, Dict]]: | ||
| """Discover supported devices. | ||
|
|
||
|
|
@@ -176,6 +213,7 @@ async def discover( | |
| on_discovered=on_discovered, | ||
| discovery_packets=discovery_packets, | ||
| interface=interface, | ||
| authentication=authentication, | ||
| ), | ||
| local_addr=("0.0.0.0", 0), | ||
| ) | ||
|
|
@@ -202,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: | ||
|
|
@@ -242,6 +280,33 @@ 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: | ||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would very much prefer to re-use the already existing mechanisms for device type detection, in order to keep the code cleaner & to support both types of devices without maintaining two separate code paths. The reason being that we will likely see devices from other device categories using this new encryption protocol, while we still want to keep on supporting the "legacy" devices. |
||
|
|
||
| raise SmartDeviceException("Unknown device type: %s", dtype) | ||
|
|
||
| @staticmethod | ||
| def _get_new_owner(info: dict) -> Optional[str]: | ||
| """Find owner given new-style discovery payload.""" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this method should be removed. For incorrect payloads there is not much we can do, but we can wrap it inside a try-except block as I wrote above. |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better do the calculations when using the accessors, use case: allow changing the creds without reconstructing the device instance.