From 4d5bbfe910cb3c055a3f5c58b3a5e31619754dbc Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Wed, 3 Jul 2024 18:43:35 -0500 Subject: [PATCH 01/13] Decrypt KLAP data from PCAP files A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. --- devtools/klap-decrypt-requirements.txt | 61 ++++++ devtools/klap-decrypt.py | 272 +++++++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 devtools/klap-decrypt-requirements.txt create mode 100644 devtools/klap-decrypt.py diff --git a/devtools/klap-decrypt-requirements.txt b/devtools/klap-decrypt-requirements.txt new file mode 100644 index 000000000..dfafa5f05 --- /dev/null +++ b/devtools/klap-decrypt-requirements.txt @@ -0,0 +1,61 @@ +aiohttp==3.9.5 +aiosignal==1.3.1 +annotated-types==0.7.0 +anyio==4.4.0 +appdirs==1.4.4 +async-timeout==4.0.3 +asyncclick==8.1.7.2 +attrs==23.2.0 +cachetools==5.3.3 +certifi==2024.6.2 +cffi==1.16.0 +cfgv==3.4.0 +chardet==5.2.0 +charset-normalizer==3.3.2 +codecov==2.1.13 +colorama==0.4.6 +coverage==7.5.4 +cryptography==42.0.8 +distlib==0.3.8 +filelock==3.15.4 +freezegun==1.5.1 +frozenlist==1.4.1 +identify==2.5.36 +idna==3.7 +iniconfig==2.0.0 +lxml==5.2.2 +multidict==6.0.5 +mypy==1.10.1 +mypy-extensions==1.0.0 +nodeenv==1.9.1 +packaging==24.1 +platformdirs==4.2.2 +pluggy==1.5.0 +pre-commit==3.7.1 +pycparser==2.22 +pydantic==2.7.4 +pydantic_core==2.18.4 +pyproject-api==1.7.1 +pyshark==0.6 +pytest==8.2.2 +pytest-asyncio==0.23.7 +pytest-cov==5.0.0 +pytest-mock==3.14.0 +pytest-sugar==1.0.0 +pytest-timeout==2.3.1 +pytest_freezer==0.4.8 +python-dateutil==2.9.0.post0 +-e git+https://github.com/python-kasa/python-kasa.git@master#egg=python_kasa +PyYAML==6.0.1 +requests==2.32.3 +six==1.16.0 +sniffio==1.3.1 +termcolor==2.4.0 +toml==0.10.2 +tox==4.15.1 +typing_extensions==4.12.2 +urllib3==2.2.2 +virtualenv==20.26.3 +voluptuous==0.15.1 +xdoctest==1.1.5 +yarl==1.9.4 diff --git a/devtools/klap-decrypt.py b/devtools/klap-decrypt.py new file mode 100644 index 000000000..fae911fd2 --- /dev/null +++ b/devtools/klap-decrypt.py @@ -0,0 +1,272 @@ +""" +This code allow for the decryption of KlapV2 data from a pcap file. + +It will output the decrypted data to a file. +This was designed and tested with a Tapo light strip setup using a cloud account. +""" + +import codecs +import json +import re + +import pyshark +from cryptography.hazmat.primitives import padding + +from kasa.credentials import Credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 + +# These are the only variables that need to be changed in order to run this code. +# ********** CHANGE THESE ********** +username = "hello@world.com" # cloud account username (likely an email) +password = "hunter2" # cloud account password # noqa: S105 +# the ip of the smart device as it appears in the pcap file +device_ip = "192.168.1.100" +pcap_file_path = "/path/to/my.pcap" # the path to the pcap file +output_json_name = ( + "output.json" # the name of the output file, relative to the current directory +) +# ********************************** + +capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + +# In an effort to keep this code tied into the original code +# (so that this can hopefully leverage any future codebase updates inheriently), +# some weird initialization is done here +myCreds = Credentials(username, password) + +fakeConnection = DeviceConnectionParameters( + DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap +) +fakeDevice = DeviceConfig( + device_ip, connection_type=fakeConnection, credentials=myCreds +) + + +# In case any modifications need to be made in the future, +# this class is created to allow for easy modification +class MyKlapTransport(KlapTransportV2): + """A custom KlapTransportV2 class that allows for easy modification.""" + + pass + + +# This is a custom error handler that replaces bad characters with '*', +# in case something goes wrong in decryption. +# Without this, the decryption could yield an error. +def bad_chars_replacement(exception): + """Replace bad characters with '*'.""" + return ("*", exception.start + 1) + + +codecs.register_error("bad_chars_replacement", bad_chars_replacement) + + +class MyEncryptionSession(KlapEncryptionSession): + """A custom KlapEncryptionSession class that allows for decryption.""" + + def decrypt(self, msg): + """Decrypt the data.""" + decryptor = self._cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode("utf-8", "bad_chars_replacement") + + +class Operator: + """A class that handles the data decryption, and the encryption session updating.""" + + def __init__(self, klap, creds): + self.__local_seed: bytes = None + self.__remote_seed: bytes = None + self.__session: MyEncryptionSession = None + self.__creds = creds + self.__klap: MyKlapTransport = klap + self.__auth_hash = self.__klap.generate_auth_hash(self.__creds) + self.__local_auth_hash = None + self.__remote_auth_hash = None + self.__seq = 0 + pass + + def update_encryption_session(self): + """Update the encryption session used for decrypting data. + + It is called whenever the local_seed, remote_seed, + or remote_auth_hash is updated. + + It checks if the seeds are set and, if they are, creates a new session. + + Raises: + ValueError: If the auth hashes do not match. + """ + if self.__local_seed is None or self.__remote_seed is None: + self.__session = None + else: + self.__local_auth_hash = self.__klap.handshake1_seed_auth_hash( + self.__local_seed, self.__remote_seed, self.__auth_hash + ) + if (self.__remote_auth_hash is not None) and ( + self.__local_auth_hash != self.__remote_auth_hash + ): + raise ValueError( + "Local and remote auth hashes do not match.\ +This could mean an incorrect username and/or password." + ) + self.__session = MyEncryptionSession( + self.__local_seed, self.__remote_seed, self.__auth_hash + ) + self.__session._seq = self.__seq + self.__session._generate_cipher() + + @property + def seq(self) -> int: + """Get the sequence number.""" + return self.__seq + + @seq.setter + def seq(self, value: int): + if not isinstance(value, int): + raise ValueError("seq must be an integer") + self.__seq = value + self.update_encryption_session() + + @property + def local_seed(self) -> bytes: + """Get the local seed.""" + return self.__local_seed + + @local_seed.setter + def local_seed(self, value: bytes): + if not isinstance(value, bytes): + raise ValueError("local_seed must be bytes") + elif len(value) != 16: + raise ValueError("local_seed must be 16 bytes") + else: + self.__local_seed = value + self.update_encryption_session() + + @property + def remote_auth_hash(self) -> bytes: + """Get the remote auth hash.""" + return self.__remote_auth_hash + + @remote_auth_hash.setter + def remote_auth_hash(self, value: bytes): + print("setting remote_auth_hash") + if not isinstance(value, bytes): + raise ValueError("remote_auth_hash must be bytes") + elif len(value) != 32: + raise ValueError("remote_auth_hash must be 32 bytes") + else: + self.__remote_auth_hash = value + self.update_encryption_session() + + @property + def remote_seed(self) -> bytes: + """Get the remote seed.""" + return self.__remote_seed + + @remote_seed.setter + def remote_seed(self, value: bytes): + print("setting remote_seed") + if not isinstance(value, bytes): + raise ValueError("remote_seed must be bytes") + elif len(value) != 16: + raise ValueError("remote_seed must be 16 bytes") + else: + self.__remote_seed = value + self.update_encryption_session() + + # This function decrypts the data using the encryption session. + def decrypt(self, *args, **kwargs): + """Decrypt the data using the encryption session.""" + if self.__session is None: + raise ValueError("No session available") + return self.__session.decrypt(*args, **kwargs) + + +operator = Operator(MyKlapTransport(config=fakeDevice), myCreds) + +finalArray = [] + +# pyshark is a little weird in how it handles iteration, +# so this is a workaround to allow for (advanced) iteration over the packets. +while True: + try: + packet = capture.next() + packet_number = capture._current_packet + # we only care about http packets + if hasattr( + packet, "http" + ): # this is redundant, as pyshark is set to only load http packets + if hasattr(packet.http, "request_uri_path"): + uri = packet.http.get("request_uri_path") + elif hasattr(packet.http, "request_uri"): + uri = packet.http.get("request_uri") + else: + uri = None + if hasattr(packet.http, "request_uri_query"): + query = packet.http.get("request_uri_query") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + operator.seq = int( + seq.group(1) + ) # grab the sequence number from the query + data = packet.http.file_data if hasattr(packet.http, "file_data") else None + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data.replace(":", "")) + try: + plaintext = operator.decrypt(message) + myDict = json.loads(plaintext) + print(json.dumps(myDict, indent=2)) + finalArray.append(myDict) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data.replace(":", "")) + operator.local_seed = message + response = None + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if ( + hasattr(response, "http") + and hasattr(response.http, "response_for_uri") + and ( + response.http.response_for_uri + == packet.http.request_full_uri + ) + ): + break + data = response.http.file_data + message = bytes.fromhex(data.replace(":", "")) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: + continue + except StopIteration: + break + +# save the final array to a file +with open(output_json_name, "w") as f: + f.write(json.dumps(finalArray, indent=2)) + f.write("\n" * 1) + f.close() From 83e512fb65df123f42be3e39557d12cb9ccc338c Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Fri, 12 Jul 2024 12:21:36 -0500 Subject: [PATCH 02/13] Removed Old Class and Changed Underscore Convention --- devtools/klap-decrypt.py | 69 +++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/devtools/klap-decrypt.py b/devtools/klap-decrypt.py index fae911fd2..3dacc58ee 100644 --- a/devtools/klap-decrypt.py +++ b/devtools/klap-decrypt.py @@ -47,15 +47,6 @@ device_ip, connection_type=fakeConnection, credentials=myCreds ) - -# In case any modifications need to be made in the future, -# this class is created to allow for easy modification -class MyKlapTransport(KlapTransportV2): - """A custom KlapTransportV2 class that allows for easy modification.""" - - pass - - # This is a custom error handler that replaces bad characters with '*', # in case something goes wrong in decryption. # Without this, the decryption could yield an error. @@ -84,15 +75,15 @@ class Operator: """A class that handles the data decryption, and the encryption session updating.""" def __init__(self, klap, creds): - self.__local_seed: bytes = None - self.__remote_seed: bytes = None - self.__session: MyEncryptionSession = None - self.__creds = creds - self.__klap: MyKlapTransport = klap - self.__auth_hash = self.__klap.generate_auth_hash(self.__creds) - self.__local_auth_hash = None - self.__remote_auth_hash = None - self.__seq = 0 + self._local_seed: bytes = None + self._remote_seed: bytes = None + self._session: MyEncryptionSession = None + self._creds = creds + self._klap: KlapTransportV2 = klap + self._auth_hash = self._klap.generate_auth_hash(self._creds) + self._local_auth_hash = None + self._remote_auth_hash = None + self._seq = 0 pass def update_encryption_session(self): @@ -106,41 +97,41 @@ def update_encryption_session(self): Raises: ValueError: If the auth hashes do not match. """ - if self.__local_seed is None or self.__remote_seed is None: - self.__session = None + if self._local_seed is None or self._remote_seed is None: + self._session = None else: - self.__local_auth_hash = self.__klap.handshake1_seed_auth_hash( - self.__local_seed, self.__remote_seed, self.__auth_hash + self._local_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, self._remote_seed, self._auth_hash ) - if (self.__remote_auth_hash is not None) and ( - self.__local_auth_hash != self.__remote_auth_hash + if (self._remote_auth_hash is not None) and ( + self._local_auth_hash != self._remote_auth_hash ): raise ValueError( "Local and remote auth hashes do not match.\ This could mean an incorrect username and/or password." ) - self.__session = MyEncryptionSession( - self.__local_seed, self.__remote_seed, self.__auth_hash + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, self._auth_hash ) - self.__session._seq = self.__seq - self.__session._generate_cipher() + self._session._seq = self._seq + self._session._generate_cipher() @property def seq(self) -> int: """Get the sequence number.""" - return self.__seq + return self._seq @seq.setter def seq(self, value: int): if not isinstance(value, int): raise ValueError("seq must be an integer") - self.__seq = value + self._seq = value self.update_encryption_session() @property def local_seed(self) -> bytes: """Get the local seed.""" - return self.__local_seed + return self._local_seed @local_seed.setter def local_seed(self, value: bytes): @@ -149,13 +140,13 @@ def local_seed(self, value: bytes): elif len(value) != 16: raise ValueError("local_seed must be 16 bytes") else: - self.__local_seed = value + self._local_seed = value self.update_encryption_session() @property def remote_auth_hash(self) -> bytes: """Get the remote auth hash.""" - return self.__remote_auth_hash + return self._remote_auth_hash @remote_auth_hash.setter def remote_auth_hash(self, value: bytes): @@ -165,13 +156,13 @@ def remote_auth_hash(self, value: bytes): elif len(value) != 32: raise ValueError("remote_auth_hash must be 32 bytes") else: - self.__remote_auth_hash = value + self._remote_auth_hash = value self.update_encryption_session() @property def remote_seed(self) -> bytes: """Get the remote seed.""" - return self.__remote_seed + return self._remote_seed @remote_seed.setter def remote_seed(self, value: bytes): @@ -181,18 +172,18 @@ def remote_seed(self, value: bytes): elif len(value) != 16: raise ValueError("remote_seed must be 16 bytes") else: - self.__remote_seed = value + self._remote_seed = value self.update_encryption_session() # This function decrypts the data using the encryption session. def decrypt(self, *args, **kwargs): """Decrypt the data using the encryption session.""" - if self.__session is None: + if self._session is None: raise ValueError("No session available") - return self.__session.decrypt(*args, **kwargs) + return self._session.decrypt(*args, **kwargs) -operator = Operator(MyKlapTransport(config=fakeDevice), myCreds) +operator = Operator(KlapTransportV2(config=fakeDevice), myCreds) finalArray = [] From 4f56c35655e2800e849a2637f551af75b15540ed Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Fri, 12 Jul 2024 12:27:25 -0500 Subject: [PATCH 03/13] Renamed Script --- devtools/{klap-decrypt.py => parse_pcap_klap.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename devtools/{klap-decrypt.py => parse_pcap_klap.py} (100%) diff --git a/devtools/klap-decrypt.py b/devtools/parse_pcap_klap.py similarity index 100% rename from devtools/klap-decrypt.py rename to devtools/parse_pcap_klap.py From 4ed43e113b0a488c845c90f8068946512719d4e6 Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Fri, 12 Jul 2024 21:52:34 -0500 Subject: [PATCH 04/13] Move to Click CLI --- devtools/parse_pcap_klap.py | 263 ++++++++++++++++++++---------------- 1 file changed, 150 insertions(+), 113 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 3dacc58ee..ce87e4233 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -9,6 +9,7 @@ import json import re +import click import pyshark from cryptography.hazmat.primitives import padding @@ -21,42 +22,6 @@ ) from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 -# These are the only variables that need to be changed in order to run this code. -# ********** CHANGE THESE ********** -username = "hello@world.com" # cloud account username (likely an email) -password = "hunter2" # cloud account password # noqa: S105 -# the ip of the smart device as it appears in the pcap file -device_ip = "192.168.1.100" -pcap_file_path = "/path/to/my.pcap" # the path to the pcap file -output_json_name = ( - "output.json" # the name of the output file, relative to the current directory -) -# ********************************** - -capture = pyshark.FileCapture(pcap_file_path, display_filter="http") - -# In an effort to keep this code tied into the original code -# (so that this can hopefully leverage any future codebase updates inheriently), -# some weird initialization is done here -myCreds = Credentials(username, password) - -fakeConnection = DeviceConnectionParameters( - DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap -) -fakeDevice = DeviceConfig( - device_ip, connection_type=fakeConnection, credentials=myCreds -) - -# This is a custom error handler that replaces bad characters with '*', -# in case something goes wrong in decryption. -# Without this, the decryption could yield an error. -def bad_chars_replacement(exception): - """Replace bad characters with '*'.""" - return ("*", exception.start + 1) - - -codecs.register_error("bad_chars_replacement", bad_chars_replacement) - class MyEncryptionSession(KlapEncryptionSession): """A custom KlapEncryptionSession class that allows for decryption.""" @@ -183,81 +148,153 @@ def decrypt(self, *args, **kwargs): return self._session.decrypt(*args, **kwargs) -operator = Operator(KlapTransportV2(config=fakeDevice), myCreds) - -finalArray = [] - -# pyshark is a little weird in how it handles iteration, -# so this is a workaround to allow for (advanced) iteration over the packets. -while True: - try: - packet = capture.next() - packet_number = capture._current_packet - # we only care about http packets - if hasattr( - packet, "http" - ): # this is redundant, as pyshark is set to only load http packets - if hasattr(packet.http, "request_uri_path"): - uri = packet.http.get("request_uri_path") - elif hasattr(packet.http, "request_uri"): - uri = packet.http.get("request_uri") - else: - uri = None - if hasattr(packet.http, "request_uri_query"): - query = packet.http.get("request_uri_query") - # use regex to get: seq=(\d+) - seq = re.search(r"seq=(\d+)", query) - if seq is not None: - operator.seq = int( - seq.group(1) - ) # grab the sequence number from the query - data = packet.http.file_data if hasattr(packet.http, "file_data") else None - match uri: - case "/app/request": - if packet.ip.dst != device_ip: - continue - message = bytes.fromhex(data.replace(":", "")) - try: - plaintext = operator.decrypt(message) - myDict = json.loads(plaintext) - print(json.dumps(myDict, indent=2)) - finalArray.append(myDict) - except ValueError: - print("Insufficient data to decrypt thus far") - - case "/app/handshake1": - if packet.ip.dst != device_ip: + +# This is a custom error handler that replaces bad characters with '*', +# in case something goes wrong in decryption. +# Without this, the decryption could yield an error. +def bad_chars_replacement(exception): + """Replace bad characters with '*'.""" + return ("*", exception.start + 1) + + +codecs.register_error("bad_chars_replacement", bad_chars_replacement) + + + + + +def main(username, password, device_ip, pcap_file_path, output_json_name=None): + """Run the main function.""" + capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + + # In an effort to keep this code tied into the original code + # (so that this can hopefully leverage any future codebase updates inheriently), + # some weird initialization is done here + myCreds = Credentials(username, password) + + fakeConnection = DeviceConnectionParameters( + DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap + ) + fakeDevice = DeviceConfig( + device_ip, connection_type=fakeConnection, credentials=myCreds + ) + + + + operator = Operator(KlapTransportV2(config=fakeDevice), myCreds) + + finalArray = [] + + # pyshark is a little weird in how it handles iteration, + # so this is a workaround to allow for (advanced) iteration over the packets. + while True: + try: + packet = capture.next() + # packet_number = capture._current_packet + # we only care about http packets + if hasattr( + packet, "http" + ): # this is redundant, as pyshark is set to only load http packets + if hasattr(packet.http, "request_uri_path"): + uri = packet.http.get("request_uri_path") + elif hasattr(packet.http, "request_uri"): + uri = packet.http.get("request_uri") + else: + uri = None + if hasattr(packet.http, "request_uri_query"): + query = packet.http.get("request_uri_query") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + operator.seq = int( + seq.group(1) + ) # grab the sequence number from the query + data = packet.http.file_data if hasattr(packet.http, + "file_data") else None + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data.replace(":", "")) + try: + plaintext = operator.decrypt(message) + myDict = json.loads(plaintext) + print(json.dumps(myDict, indent=2)) + finalArray.append(myDict) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data.replace(":", "")) + operator.local_seed = message + response = None + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if ( + hasattr(response, "http") + and hasattr(response.http, "response_for_uri") + and ( + response.http.response_for_uri + == packet.http.request_full_uri + ) + ): + break + data = response.http.file_data + message = bytes.fromhex(data.replace(":", "")) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: continue - message = bytes.fromhex(data.replace(":", "")) - operator.local_seed = message - response = None - while ( - True - ): # we are going to now look for the response to this request - response = capture.next() - if ( - hasattr(response, "http") - and hasattr(response.http, "response_for_uri") - and ( - response.http.response_for_uri - == packet.http.request_full_uri - ) - ): - break - data = response.http.file_data - message = bytes.fromhex(data.replace(":", "")) - operator.remote_seed = message[0:16] - operator.remote_auth_hash = message[16:] - - case "/app/handshake2": - continue # we don't care about this - case _: - continue - except StopIteration: - break - -# save the final array to a file -with open(output_json_name, "w") as f: - f.write(json.dumps(finalArray, indent=2)) - f.write("\n" * 1) - f.close() + except StopIteration: + break + + # save the final array to a file + if output_json_name is not None: + with open(output_json_name, "w") as f: + f.write(json.dumps(finalArray, indent=2)) + f.write("\n" * 1) + f.close() + + + +@click.command() +@click.option("--host", + required=True, + help="the IP of the smart device as it appears in the pcap file." +) +@click.option( + "--username", + required=True, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + required=True, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--pcap-file-path", + required=True, + help="The path to the pcap file to parse.", +) +@click.option( + "--output-json-name", + required=False, + help="The name of the output file, relative to the current directory.", +) +def cli(username, password, host, pcap_file_path, output_json_name): + """Run the main function.""" + main(username, password, host, pcap_file_path, output_json_name) + + +if __name__ == "__main__": + cli() \ No newline at end of file From fd2dfa12a8552623cf4bba86be6ddef1d5c8fe28 Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Fri, 12 Jul 2024 21:59:00 -0500 Subject: [PATCH 05/13] Changed CLI output switch --- devtools/parse_pcap_klap.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index ce87e4233..0bdb283da 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -287,13 +287,13 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): help="The path to the pcap file to parse.", ) @click.option( - "--output-json-name", + "-o", "--output", required=False, help="The name of the output file, relative to the current directory.", ) -def cli(username, password, host, pcap_file_path, output_json_name): +def cli(username, password, host, pcap_file_path, output): """Run the main function.""" - main(username, password, host, pcap_file_path, output_json_name) + main(username, password, host, pcap_file_path, output) if __name__ == "__main__": From a787fef4067a3c3c9ee411638844fdd85fa36f06 Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Fri, 12 Jul 2024 22:10:22 -0500 Subject: [PATCH 06/13] Update Documentation --- devtools/README.md | 25 +++++++++++++++++++++++++ devtools/parse_pcap_klap.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/devtools/README.md b/devtools/README.md index 99d5ec5a0..40e34dcbb 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -99,3 +99,28 @@ id New parser, parsing 100000 messages took 0.6339647499989951 seconds Old parser, parsing 100000 messages took 9.473990250000497 seconds ``` + + +## parse_pcap_klap + +* A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. + +* NOTE: must install pyshark (`pip install pyshark`) + +```shell +Usage: parse_pcap_klap.py [OPTIONS] + + Export KLAP data in JSON format from a PCAP file. + +Options: + --host TEXT the IP of the smart device as it appears in the pcap + file. [required] + --username TEXT Username/email address to authenticate to device. + [required] + --password TEXT Password to use to authenticate to device. + [required] + --pcap-file-path TEXT The path to the pcap file to parse. [required] + -o, --output TEXT The name of the output file, relative to the current + directory. + --help Show this message and exit. +``` diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 0bdb283da..edda59c9a 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -292,7 +292,7 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): help="The name of the output file, relative to the current directory.", ) def cli(username, password, host, pcap_file_path, output): - """Run the main function.""" + """Export KLAP data in JSON format from a PCAP file.""" main(username, password, host, pcap_file_path, output) From 1faef0d9882dc773918b53f8b0c330af54c36b57 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Sun, 14 Jul 2024 19:09:34 -0500 Subject: [PATCH 07/13] Update devtools/parse_pcap_klap.py Co-authored-by: Teemu R. --- devtools/parse_pcap_klap.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index edda59c9a..d1c8f96a9 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -159,10 +159,6 @@ def bad_chars_replacement(exception): codecs.register_error("bad_chars_replacement", bad_chars_replacement) - - - - def main(username, password, device_ip, pcap_file_path, output_json_name=None): """Run the main function.""" capture = pyshark.FileCapture(pcap_file_path, display_filter="http") From 90e2c1aac534ca411c254c301c8d68d230311a65 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Sun, 14 Jul 2024 19:09:42 -0500 Subject: [PATCH 08/13] Update devtools/parse_pcap_klap.py Co-authored-by: Teemu R. --- devtools/parse_pcap_klap.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index d1c8f96a9..92e30fe7f 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -147,8 +147,6 @@ def decrypt(self, *args, **kwargs): raise ValueError("No session available") return self._session.decrypt(*args, **kwargs) - - # This is a custom error handler that replaces bad characters with '*', # in case something goes wrong in decryption. # Without this, the decryption could yield an error. From 8c876118837b896166eeaf6bf5eb36eacc09a639 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Sun, 14 Jul 2024 19:09:53 -0500 Subject: [PATCH 09/13] Update devtools/parse_pcap_klap.py Co-authored-by: Teemu R. --- devtools/parse_pcap_klap.py | 1 - 1 file changed, 1 deletion(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 92e30fe7f..ca14c8f4d 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -49,7 +49,6 @@ def __init__(self, klap, creds): self._local_auth_hash = None self._remote_auth_hash = None self._seq = 0 - pass def update_encryption_session(self): """Update the encryption session used for decrypting data. From a5eaacbf0bafdd04eb343a94d46ae7581d5c5f78 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Sun, 14 Jul 2024 19:10:02 -0500 Subject: [PATCH 10/13] Update devtools/parse_pcap_klap.py Co-authored-by: Teemu R. --- devtools/parse_pcap_klap.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index ca14c8f4d..1fa924668 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -172,8 +172,6 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): device_ip, connection_type=fakeConnection, credentials=myCreds ) - - operator = Operator(KlapTransportV2(config=fakeDevice), myCreds) finalArray = [] From ab8c400d96b1f1b51d1e77e6b40420e7ba43469b Mon Sep 17 00:00:00 2001 From: Carter Strickland Date: Sun, 14 Jul 2024 19:26:59 -0500 Subject: [PATCH 11/13] Symbol Names and Formatting --- devtools/parse_pcap_klap.py | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index 1fa924668..14caa8f4d 100644 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -146,6 +146,7 @@ def decrypt(self, *args, **kwargs): raise ValueError("No session available") return self._session.decrypt(*args, **kwargs) + # This is a custom error handler that replaces bad characters with '*', # in case something goes wrong in decryption. # Without this, the decryption could yield an error. @@ -156,6 +157,7 @@ def bad_chars_replacement(exception): codecs.register_error("bad_chars_replacement", bad_chars_replacement) + def main(username, password, device_ip, pcap_file_path, output_json_name=None): """Run the main function.""" capture = pyshark.FileCapture(pcap_file_path, display_filter="http") @@ -163,18 +165,18 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): # In an effort to keep this code tied into the original code # (so that this can hopefully leverage any future codebase updates inheriently), # some weird initialization is done here - myCreds = Credentials(username, password) + creds = Credentials(username, password) - fakeConnection = DeviceConnectionParameters( + fake_connection = DeviceConnectionParameters( DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap ) - fakeDevice = DeviceConfig( - device_ip, connection_type=fakeConnection, credentials=myCreds + fake_device = DeviceConfig( + device_ip, connection_type=fake_connection, credentials=creds ) - operator = Operator(KlapTransportV2(config=fakeDevice), myCreds) + operator = Operator(KlapTransportV2(config=fake_device), creds) - finalArray = [] + packets = [] # pyshark is a little weird in how it handles iteration, # so this is a workaround to allow for (advanced) iteration over the packets. @@ -200,8 +202,9 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): operator.seq = int( seq.group(1) ) # grab the sequence number from the query - data = packet.http.file_data if hasattr(packet.http, - "file_data") else None + data = ( + packet.http.file_data if hasattr(packet.http, "file_data") else None + ) match uri: case "/app/request": if packet.ip.dst != device_ip: @@ -209,9 +212,9 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): message = bytes.fromhex(data.replace(":", "")) try: plaintext = operator.decrypt(message) - myDict = json.loads(plaintext) - print(json.dumps(myDict, indent=2)) - finalArray.append(myDict) + payload = json.loads(plaintext) + print(json.dumps(payload, indent=2)) + packets.append(payload) except ValueError: print("Insufficient data to decrypt thus far") @@ -249,16 +252,16 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): # save the final array to a file if output_json_name is not None: with open(output_json_name, "w") as f: - f.write(json.dumps(finalArray, indent=2)) + f.write(json.dumps(packets, indent=2)) f.write("\n" * 1) f.close() - @click.command() -@click.option("--host", +@click.option( + "--host", required=True, - help="the IP of the smart device as it appears in the pcap file." + help="the IP of the smart device as it appears in the pcap file.", ) @click.option( "--username", @@ -278,7 +281,8 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): help="The path to the pcap file to parse.", ) @click.option( - "-o", "--output", + "-o", + "--output", required=False, help="The name of the output file, relative to the current directory.", ) @@ -288,4 +292,4 @@ def cli(username, password, host, pcap_file_path, output): if __name__ == "__main__": - cli() \ No newline at end of file + cli() From d511336bd92592b3c652f4d303288966c6de047a Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Sun, 14 Jul 2024 19:28:18 -0500 Subject: [PATCH 12/13] Delete devtools/klap-decrypt-requirements.txt --- devtools/klap-decrypt-requirements.txt | 61 -------------------------- 1 file changed, 61 deletions(-) delete mode 100644 devtools/klap-decrypt-requirements.txt diff --git a/devtools/klap-decrypt-requirements.txt b/devtools/klap-decrypt-requirements.txt deleted file mode 100644 index dfafa5f05..000000000 --- a/devtools/klap-decrypt-requirements.txt +++ /dev/null @@ -1,61 +0,0 @@ -aiohttp==3.9.5 -aiosignal==1.3.1 -annotated-types==0.7.0 -anyio==4.4.0 -appdirs==1.4.4 -async-timeout==4.0.3 -asyncclick==8.1.7.2 -attrs==23.2.0 -cachetools==5.3.3 -certifi==2024.6.2 -cffi==1.16.0 -cfgv==3.4.0 -chardet==5.2.0 -charset-normalizer==3.3.2 -codecov==2.1.13 -colorama==0.4.6 -coverage==7.5.4 -cryptography==42.0.8 -distlib==0.3.8 -filelock==3.15.4 -freezegun==1.5.1 -frozenlist==1.4.1 -identify==2.5.36 -idna==3.7 -iniconfig==2.0.0 -lxml==5.2.2 -multidict==6.0.5 -mypy==1.10.1 -mypy-extensions==1.0.0 -nodeenv==1.9.1 -packaging==24.1 -platformdirs==4.2.2 -pluggy==1.5.0 -pre-commit==3.7.1 -pycparser==2.22 -pydantic==2.7.4 -pydantic_core==2.18.4 -pyproject-api==1.7.1 -pyshark==0.6 -pytest==8.2.2 -pytest-asyncio==0.23.7 -pytest-cov==5.0.0 -pytest-mock==3.14.0 -pytest-sugar==1.0.0 -pytest-timeout==2.3.1 -pytest_freezer==0.4.8 -python-dateutil==2.9.0.post0 --e git+https://github.com/python-kasa/python-kasa.git@master#egg=python_kasa -PyYAML==6.0.1 -requests==2.32.3 -six==1.16.0 -sniffio==1.3.1 -termcolor==2.4.0 -toml==0.10.2 -tox==4.15.1 -typing_extensions==4.12.2 -urllib3==2.2.2 -virtualenv==20.26.3 -voluptuous==0.15.1 -xdoctest==1.1.5 -yarl==1.9.4 From dfd776dee2368cc6f2ef70c246f88ab16101b522 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Mon, 15 Jul 2024 11:16:16 +0100 Subject: [PATCH 13/13] Fix CI, make work with asynclick and linux --- devtools/README.md | 4 +++- devtools/parse_pcap_klap.py | 26 +++++++++++++++++++------- pyproject.toml | 1 + 3 files changed, 23 insertions(+), 8 deletions(-) mode change 100644 => 100755 devtools/parse_pcap_klap.py diff --git a/devtools/README.md b/devtools/README.md index 40e34dcbb..f59ea374c 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -105,7 +105,9 @@ Old parser, parsing 100000 messages took 9.473990250000497 seconds * A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. -* NOTE: must install pyshark (`pip install pyshark`) +* NOTE: must install pyshark (`pip install pyshark`). +* pyshark requires Wireshark or tshark to be installed on windows and tshark to be installed +on linux (`apt get tshark`) ```shell Usage: parse_pcap_klap.py [OPTIONS] diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py old mode 100644 new mode 100755 index 14caa8f4d..d8be6573c --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python """ This code allow for the decryption of KlapV2 data from a pcap file. @@ -8,8 +9,9 @@ import codecs import json import re +from threading import Thread -import click +import asyncclick as click import pyshark from cryptography.hazmat.primitives import padding @@ -203,13 +205,17 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): seq.group(1) ) # grab the sequence number from the query data = ( - packet.http.file_data if hasattr(packet.http, "file_data") else None + # Windows and linux file_data attribute returns different + # pretty format so get the raw field value. + packet.http.get_field_value("file_data", raw=True) + if hasattr(packet.http, "file_data") + else None ) match uri: case "/app/request": if packet.ip.dst != device_ip: continue - message = bytes.fromhex(data.replace(":", "")) + message = bytes.fromhex(data) try: plaintext = operator.decrypt(message) payload = json.loads(plaintext) @@ -221,7 +227,7 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): case "/app/handshake1": if packet.ip.dst != device_ip: continue - message = bytes.fromhex(data.replace(":", "")) + message = bytes.fromhex(data) operator.local_seed = message response = None while ( @@ -237,8 +243,8 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): ) ): break - data = response.http.file_data - message = bytes.fromhex(data.replace(":", "")) + data = response.http.get_field_value("file_data", raw=True) + message = bytes.fromhex(data) operator.remote_seed = message[0:16] operator.remote_auth_hash = message[16:] @@ -288,7 +294,13 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): ) def cli(username, password, host, pcap_file_path, output): """Export KLAP data in JSON format from a PCAP file.""" - main(username, password, host, pcap_file_path, output) + # pyshark does not work within a running event loop and we don't want to + # install click as well as asyncclick so run in a new thread. + thread = Thread( + target=main, args=[username, password, host, pcap_file_path, output] + ) + thread.start() + thread.join() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 45350aefd..05d1fd061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ disable_error_code = "annotation-unchecked" module = [ "devtools.bench.benchmark", "devtools.parse_pcap", + "devtools.parse_pcap_klap", "devtools.perftest", "devtools.create_module_fixtures" ]