Skip to content

Commit

Permalink
Added support for encryption over LAN communication
Browse files Browse the repository at this point in the history
  • Loading branch information
albertogeniola committed Mar 27, 2024
1 parent c5762c6 commit 78706f4
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 3 deletions.
39 changes: 39 additions & 0 deletions meross_iot/controller/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,45 @@ def lookup_channel(self, channel_id_or_name: Union[int, str]):
return res[0]
raise ValueError(f"Could not find channel by id or name = {channel_id_or_name}")

def support_encryption(self) -> bool:
"""
Returns true if encryption is supported by this device
:return:
"""
return False

def is_encryption_key_set(self) -> bool:
"""
Returns whether an encryption key has been already set
:return:
"""
return False

def set_encryption_key(self, *args, **kwargs):
"""
Sets the encryption key to be used for encryption and decryption
:param args:
:param kwargs:
:return:
"""
pass

def encrypt(self, message_data_bytes: bytes)->str:
"""
Encrypts the message into a base64 string
:param message_data_bytes:
:return:
"""
raise NotImplementedError("Encryption not supported by this device")

def decrypt(self, encrypted_message_bytes: bytes) -> bytes:
"""
Decrypt the message and returns the war decrypted bytes
:param encrypted_message_bytes:
:return:
"""
raise NotImplementedError("Encryption not supported by this device")


class HubDevice(BaseDevice):
# TODO: provide meaningful comment here describing what this class does
Expand Down
90 changes: 90 additions & 0 deletions meross_iot/controller/mixins/encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import logging
from hashlib import md5
import base64
from Crypto.Cipher import AES
from enum import Enum

from meross_iot.model.enums import Namespace

_LOGGER = logging.getLogger(__name__)


class EncryptionAlg(Enum):
ECDHE256 = 0


class EncryptionSuiteMixin(object):
_execute_command: callable
_DEFAULT_IV="0000000000000000".encode("utf8")
_abilities: dict[str, dict]

def __init__(self, device_uuid: str,
manager,
**kwargs):
super().__init__(device_uuid=device_uuid, manager=manager, **kwargs)
self._encryption_key = None

if Namespace.SYSTEM_ENCRYPTION_ECDHE.value in self._abilities:
self._encryption_alg = EncryptionAlg.ECDHE256
else:
raise ValueError("Unsupported/undetected encryption method")

def _pad_to_16_bytes(self, data):
block_size = 16
pad_length = block_size - (len(data) % block_size)
padding = bytes([0] * pad_length)
return data + padding

def _ecdhe256_encrypt(self, message_data_bytes, iv=_DEFAULT_IV) -> str:
# Returns encrypted message in base64 encoded string.
padded_data = self._pad_to_16_bytes(message_data_bytes)
cipher = AES.new(self._encryption_key, AES.MODE_CBC, iv)
cipher.padding = 0
encrypted = cipher.encrypt(padded_data)
return base64.b64encode(encrypted).decode('utf-8')

def _ecdhe256_decrypt(self, message_data_bytes: bytes, iv=_DEFAULT_IV) -> bytes:
# Returns decrypted message bytes.
cipher = AES.new(self._encryption_key, AES.MODE_CBC, iv)
cipher.padding = 0
enc_bytes = base64.b64decode(message_data_bytes)
decrypted = cipher.decrypt(enc_bytes)
return decrypted

def support_encryption(self) -> bool:
return True

def is_encryption_key_set(self) -> bool:
return self._encryption_key is not None

def set_encryption_key(self, uuid:str, mrskey: str, mac: str, *args, **kwargs):
strtohash = uuid[3:22] + mrskey[1:9] + mac + mrskey[10:28]
self._encryption_key = md5(strtohash.encode("utf8")).hexdigest().encode("utf8")

def encrypt(self, message_data_bytes: bytes)->str:
"""
Encrypts the message into a base64 string
:param message_data_bytes:
:return:
"""
if not self.is_encryption_key_set():
raise ValueError("Encryption key is not set! Please invoke set_encryption_key first.")

if self._encryption_alg == EncryptionAlg.ECDHE256:
return self._ecdhe256_encrypt(message_data_bytes)

raise ValueError("Unimplemented encryption algorithm")

def decrypt(self, encrypted_message_bytes: bytes) -> bytes:
"""
Decrypt the message and returns the war decrypted bytes
:param encrypted_message_bytes:
:return:
"""
if not self.is_encryption_key_set():
raise ValueError("Encryption key is not set! Please invoke set_encryption_key first.")

if self._encryption_alg == EncryptionAlg.ECDHE256:
return self._ecdhe256_decrypt(encrypted_message_bytes)

raise ValueError("Unimplemented encryption algorithm")
4 changes: 4 additions & 0 deletions meross_iot/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from meross_iot.controller.mixins.diffuser_spray import DiffuserSprayMixin
from meross_iot.controller.mixins.dnd import SystemDndMixin
from meross_iot.controller.mixins.electricity import ElectricityMixin
from meross_iot.controller.mixins.encryption import EncryptionSuiteMixin
from meross_iot.controller.mixins.garage import GarageOpenerMixin
from meross_iot.controller.mixins.hub import HubMts100Mixin, HubMixn, HubMs100Mixin
from meross_iot.controller.mixins.light import LightMixin
Expand Down Expand Up @@ -37,6 +38,9 @@
Namespace.CONTROL_CONSUMPTION.value: ConsumptionMixin,
Namespace.CONTROL_ELECTRICITY.value: ElectricityMixin,

# Encryption
Namespace.SYSTEM_ENCRYPTION.value: EncryptionSuiteMixin,

# Light abilities
Namespace.CONTROL_LIGHT.value: LightMixin,

Expand Down
25 changes: 22 additions & 3 deletions meross_iot/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@

_PENDING_FUTURES = []

_DEFAULT_HEADERS = {"Content-Type": "application/json"}


def _mqtt_key_from_domain_port(domain: str, port: int) -> str:
return f"{domain}:{port}"
Expand Down Expand Up @@ -861,11 +863,28 @@ async def _async_execute_cmd_http(self,
# Send the message over the network
# Build the mqtt message we will send to the broker
message, message_id = self._build_mqtt_message(method, namespace, payload, destination_device_uuid)
device: BaseDevice = self._device_registry.lookup_base_by_uuid(destination_device_uuid)

async with ClientSession() as session:
async with session.post(f"http://{device_ip}/config", json=json.loads(message.decode()), timeout=timeout) as response:
data = await response.json()
return data.get("payload")
message_data = message_id
decrypt_response = False
if device.support_encryption():
# Ensure we have correctly set the encryption key. If not, set it right away
if not device.is_encryption_key_set():
device.set_encryption_key(uuid=device.uuid, mrskey=self._cloud_creds.key, mac=device.mac_address)
# Encrypt the data
message_data = device.encrypt(message)
decrypt_response = True

async with session.post(f"http://{device_ip}/config", data=message_data, timeout=timeout, headers=_DEFAULT_HEADERS) as response:
response_data = await response.text("utf8")

if decrypt_response:
response_data = device.decrypt(response_data.encode("utf8")).decode("utf8")
response_data = response_data.rstrip('\0')

data = json.loads(response_data)
return data.get("payload")

async def async_execute_cmd_client(self,
client: mqtt.Client,
Expand Down
3 changes: 3 additions & 0 deletions meross_iot/model/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ class Namespace(Enum):
SYSTEM_DEBUG = 'Appliance.System.Debug'
SYSTEM_RUNTIME = 'Appliance.System.Runtime'

SYSTEM_ENCRYPTION = 'Appliance.Encrypt.Suite'
SYSTEM_ENCRYPTION_ECDHE = 'Appliance.Encrypt.ECDHE'

CONTROL_BIND = 'Appliance.Control.Bind'
CONTROL_UNBIND = 'Appliance.Control.Unbind'
CONTROL_TRIGGER = 'Appliance.Control.Trigger'
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
paho-mqtt>=1.5.0,<2.0.0
requests>=2.19.1,<3.0.0
aiohttp[speedups]>=3.7.4.post0,<4.0.0
pycryptodome>=3.20.0

0 comments on commit 78706f4

Please sign in to comment.