From cc46b5907d5ad556d9807b38e0b3dd752d6734b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavol=20Babin=C4=8D=C3=A1k?= Date: Tue, 1 Mar 2022 21:15:39 +0100 Subject: [PATCH] New encryption of WiFi credentials and set network mode station Required since firmware version 2.4.30. Fixes: #70 --- tests/test_security.py | 37 ++++++++++++++++++++++++++++++++ xled/control.py | 37 +++++++++++++++++++++++++++++++- xled/security.py | 48 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/tests/test_security.py b/tests/test_security.py index 93495a6..cc82214 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -64,6 +64,43 @@ def test_valid(self): assert expected_cipher == cipher +class TestEncryptWiFiCredentials(unittest.TestCase): + """Tests for encrypt_wifi_credentials() from `xled.security` module.""" + + def test_valid_legacy_key(self): + expected_cipher = ( + b"e4XXiiUhg4J1FnJEfUQ0BhIji2HGVk1NHU5vGCHfyclF" + b"dX6R8Nd9BSXVKS5nj2FXGU6SWv9CIzztfAvGgTGLUw==" + ) + + str_password = "Twinkly" + cipher = security.encrypt_wifi_credentials( + str_password, MAC_ADDRESS_TEST, security.SHARED_KEY_WIFI + ) + assert expected_cipher == cipher + bytes_password = b"Twinkly" + cipher = security.encrypt_wifi_credentials( + bytes_password, MAC_ADDRESS_TEST, security.SHARED_KEY_WIFI + ) + assert expected_cipher == cipher + + def test_valid_v2_key(self): + expected_cipher = ( + b"R8/Wb0N52RLRU9HAqutebsmJZrNwdMJPOzmXLk4+0cjU" + b"TgXS/J+nZ9icDcTNb5P2Kb6TZP2TCNxpQGtnjetMrg==" + ) + str_password = "Twinkly" + cipher = security.encrypt_wifi_credentials( + str_password, MAC_ADDRESS_TEST, security.SHARED_KEY_WIFI_V2 + ) + assert expected_cipher == cipher + bytes_password = b"Twinkly" + cipher = security.encrypt_wifi_credentials( + bytes_password, MAC_ADDRESS_TEST, security.SHARED_KEY_WIFI_V2 + ) + assert expected_cipher == cipher + + class TestDeriveKey(unittest.TestCase): """Tests for derive_key() from `xled.security` module.""" diff --git a/xled/control.py b/xled/control.py index dbdc8f6..62fab60 100644 --- a/xled/control.py +++ b/xled/control.py @@ -778,7 +778,7 @@ def set_network_mode_ap(self, password=None): def set_network_mode_station(self, ssid=None, password=None): """ - Sets network mode to Station + Sets network mode to Station for firmware up until 2.4.22 The first time you need to provide an ssid and password for the WIFI to connect to. @@ -806,6 +806,41 @@ def set_network_mode_station(self, ssid=None, password=None): assert all(key in app_response.keys() for key in required_keys) return app_response + def set_network_mode_station_v2(self, ssid=None, password=None): + """ + Sets network mode to Station since firmware version 2.4.30 + + The first time you need to provide an ssid and password for + the WIFI to connect to. + + :param str ssid: SSID of the access point to connect to + :param str password: password to use + :raises ApplicationError: on application error + :rtype: :class:`~xled.response.ApplicationResponse` + """ + json_payload = {"mode": 1} + if ssid and password: + assert self.hw_address + encpassword = xled.security.encrypt_wifi_credentials( + password, self.hw_address, xled.security.SHARED_KEY_WIFI_V2 + ) + encssid = xled.security.encrypt_wifi_credentials( + ssid, self.hw_address, xled.security.SHARED_KEY_WIFI_V2 + ) + json_payload["station"] = { + "dhcp": 1, + "encssid": encssid, + "encpassword": encpassword, + } + else: + assert not ssid and not password + url = urljoin(self.base_url, "network/status") + response = self.session.post(url, json=json_payload) + app_response = ApplicationResponse(response) + required_keys = [u"code"] + assert all(key in app_response.keys() for key in required_keys) + return app_response + def set_playlist(self, entries): """ Sets a new playlist diff --git a/xled/security.py b/xled/security.py index 9d8bd49..533e956 100644 --- a/xled/security.py +++ b/xled/security.py @@ -31,6 +31,18 @@ #: Default key to encrypt WiFi password SHARED_KEY_WIFI = b"supersecretkey!!" +#: Default key to encrypt WiFi password and SSID used since firmware v.2.4.25 +SHARED_KEY_WIFI_V2 = ( + b"\x26\x80\xf5\x87\x9f\xee" + b"\x2c\x75\x11\xaa\x08\x15" + b"\x47\x44\x8e\x04\x99\xcd" + b"\x68\x07\x6e\x09\x32\x62" + b"\x5d\xc4\xde\x7c\x38\x98" + b"\x9e\x88\x80\xee\x2a\xb7" + b"\x33\x67\x8f\xa2\x0d\xcc" + b"\x85\xd8\x94\xcd\x94\x4f" +) + #: Read buffer size for sha1sum BUFFER_SIZE = 65536 @@ -118,13 +130,19 @@ def make_challenge_response(challenge_message, mac_address, key=SHARED_KEY_CHALL def encrypt_wifi_password(password, mac_address, key=SHARED_KEY_WIFI): """ - Encrypts password + Encrypts WiFi password This can be used to send password for WiFi in encrypted form over unencrypted channel. Ideally only device that knows shared secret key and has defined MAC address should be able to decrypt the message. + This is backward compatible API which wraps encrypt_wifi_credentials(). + Predefined key was used to encrypt only password up until firmware version + 2.4.22. Since firmware 2.4.25 a different key is used and also the SSID is + encrypted. While this function still can be used, its name and arguments + might be confusing for readers. + :param str password: password to encrypt :param str mac_address: MAC address of the remote device in any format that netaddr.EUI recognizes @@ -132,10 +150,30 @@ def encrypt_wifi_password(password, mac_address, key=SHARED_KEY_WIFI): :return: Base 64 encoded string of ciphertext of input password :rtype: str """ - if not isinstance(password, bytes): - password = bytes(password, "utf-8") - secret_key = derive_key(key, mac_address) - data = password.ljust(64, b"\x00") + return encrypt_wifi_credentials( + credential=password, mac_address=mac_address, shared_key=key + ) + + +def encrypt_wifi_credentials(credential, mac_address, shared_key): + """ + Encrypts WiFi credentials + + Derives a secret key out of mac_address and shared_key which is then used to + encrypt the credential. This can be used to send password or SSID for WiFi + in encrypted form over unencrypted channel. + + :param str credential: secret in clear text to encrypt + :param str mac_address: MAC address of the remote device in AP mode or from + gestalt call in any format that netaddr.EUI recognizes + :param str shared_key: shared key that device has to know + :return: ciphertext encoded as base 64 string + :rtype: str + """ + if not isinstance(credential, bytes): + credential = bytes(credential, "utf-8") + secret_key = derive_key(shared_key, mac_address) + data = credential.ljust(64, b"\x00") rc4_encoded = rc4(data, secret_key) return base64.b64encode(rc4_encoded)