IoT системы не безопасны и разрознены

от автора

Ни для кого не секрет, что IoT‑устройства собирают огромное количество информации, в том числе личные данные пользователей и фиксируют их местоположение. А также они жёстко привязаны к конкретному производителю, т.е. пользователь зависим от конкретного производителя или бренда. Гаджеты совместимы с парой основных систем, но кроссплатформенность отсутствует. Устройства разных производителей часто не могут взаимодействовать друг с другом без дополнительных шлюзов. Универсального протокола связи устройств тоже нет. Из стандартов: Zigbee, Matter, Z‑Wave, Bluetooth, Wi-Fi — как следствие умные устройства плохо дружат между собой.

Есть ли выход из подобной ситуации?

Конечно да.

В прошлой статье я говорил о экосистеме MixRech с поисковиком и мессенджером. Ссылка тут. И там есть мессенджер MixChat, который управляет умными устройствами с помощью системного чат бота SmarMix в самом же MixChat.

SmartMix объединяет умные устройства в один стандарт MQTT

Мессенджер MixChat и микроконтроллер ESP32 взаимодействуют друг с другом по протоколу MQTT.

Вам потребуется собственный MQTT сервер (например, на Raspberry Pi или в облаке), но после разового подключения, вам не нужно будет настраивать SmartMix.

Как это работает?

Зайдя в чат со SmartMix, вы вводите параметры MQTT сервера по кнопке вверху и сохраняете. Потом вы подключаетесь к своему серверу и можете отправлять команды, для управления ESP32: включать/выключать светодиод, выводить температуру на экран.

Введите параметры сервера

Введите параметры сервера

Поздравляем! Теперь вы можете управлять устройством.

SmartMix не привязан к конкретному производителю. А это означает, что вы можете управлять разными устройствами без дополнительных настроек.

ESP32 не собирает данные пользователей

Когда вы отправляете сообщение SmartMix, ваши личные данные: пароли, номера телефонов, электронные почты или цифровые ID людей не используются для передачи.

Когда вы вводите параметры своего MQTT сервера, в.т.ч. и топики для отправки и получения, они шифруются и сохраняются в кэш по API запросу:

class SaveMqttView(generics.GenericAPIView):    serializer_class = SaveMqttSerializer    authentication_classes = [JWTAuthentication, CsrfExemptSessionAuthentication]    permission_classes = [IsAuthenticated]    def post(self, request, *args, **kwargs):        serializer = self.get_serializer(data=request.data)        serializer.is_valid(raise_exception=True)        user = request.user.id        server = serializer.validated_data['server']        port = serializer.validated_data['port']        client_id = serializer.validated_data['client_id']        mqtt_username = serializer.validated_data['mqtt_username']        mqtt_password = serializer.validated_data['mqtt_password']        topic_sent = serializer.validated_data['topic_sent']        topic_subscribe = serializer.validated_data['topic_subscribe']        mqtt_values = list(request.session.values())        for value in mqtt_values:            if value == server or value == mqtt_username:                return Response({"message": "Ошибка. Пользователь не может подключиться"                                 " к другому серверу"}, status=403)        key = gen_key(user, salt)        request.session[f'server_{user}'] = encrypt_text(server, key).decode('utf-8')        request.session[f'port_{user}'] = port        request.session[f'client_id_{user}'] = client_id        request.session[f'mqtt_username_{user}'] = encrypt_text(mqtt_username, key).decode('utf-8')        request.session[f'mqtt_password_{user}'] = encrypt_text(mqtt_password, key).decode('utf-8')        request.session[f'topic_sent_{user}'] = topic_sent        request.session[f'topic_subscribe_{user}'] = topic_subscribe        return Response({"message": "Данные MQTT успешно сохранены"})

Данные MQTT-сервера расшифровываются только на сервере при подключении, после чего вы подключаетесь к серверу.

В боте за подключение к серверу отвечает класс MQTT. Вот его ключевые методы:

connection() — устанавливает связь с сервером.

publish(msg) — отправляет команду в топик topic_sent.

subscribe() — слушает топик topic_subscribe, чтобы получить ответ от ESP32.

Полный код класса выглядит так:

Скрытый текст
import osimport timeimport sysfrom dotenv import load_dotenvfrom paho.mqtt import client as mqtt_clientfrom .cifer import gen_key, decrypt_textload_dotenv()salt = os.getenv('CIFER_SALT')class MQTT:    def __init__(self, mqtt_dict, client, user_id, exit_flag, msg):        key = gen_key(user_id, salt)        self.client = client        self.exit_flag = exit_flag        self.msg = msg        self.server = decrypt_text(mqtt_dict[f'server_{user_id}'], key).decode('utf-8')        self.port = mqtt_dict[f'port_{user_id}']        self.mqtt_username = decrypt_text(mqtt_dict[f'mqtt_username_{user_id}'], key).decode('utf-8')        self.mqtt_password = decrypt_text(mqtt_dict[f'mqtt_password_{user_id}'], key).decode('utf-8')        self.topic_sent = mqtt_dict[f'topic_sent_{user_id}']        self.topic_subscribe = mqtt_dict[f'topic_subscribe_{user_id}']    def connection(self):        self.client.username_pw_set(self.mqtt_username, self.mqtt_password)        # self.client.on_connect = "Connected to MQTT Broker!"        if self.server and self.port:            self.client.connect(self.server, int(self.port))            print("Connect to server")        else:            print("Ошибка отправки. Не верные данные сервера")    def publish(self, msg):        # Отправить сообщение в MQTT        if self.topic_sent:            result = self.client.publish(self.topic_sent, msg)            status = result[0]            if status == 0:                print(f"Отправлено `{msg}` в топик `{self.topic_sent}`")            else:                print(f"Не удалось отправить сообщение в топик {self.topic_sent}")        else:            print("Ошибка. Неверный топик для отправки")    def on_connect(self, client, userdata, flags, rc):        if rc == 0:            self.client.subscribe(self.topic_subscribe)        else:            print("Failed to connect, return code %d\n", rc)    def subscribe(self):        try:            if self.server and self.port:                self.client.username_pw_set(self.mqtt_username, self.mqtt_password)                self.client.on_connect = self.on_connect                self.client.on_message = self.on_message                self.client.connect(self.server, int(self.port))                self.client.loop_start()  # Запускаем цикл в фоне                start = time.time()                while not self.exit_flag:                    time.sleep(0.1)                self.exit_flag = False                print(time.time() - start)                self.client.loop_stop()  # Останавливаем цикл                print(self.client.is_connected)                self.client.disconnect()            else:                print("Ошибка получения. Не верные данные сервера")        except KeyboardInterrupt:            print("Соединение прервано")            sys.exit()    def on_message(self, client, userdata, message):        self.msg = message.payload.decode()        print('MQTT MESSAGE:', message.payload.decode())        self.exit_flag = True    def print_message(self):        print(self.msg)        return self.msgdef return_mqtt(mqtt_dict, user_id):    mqtt = MQTT(mqtt_dict, mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION1,                mqtt_dict[f'client_id_{user_id}']), user_id,False, '')    return mqtt

Полный код всего проекта с поисковиком и мессенджером здесь.

После подключения к MQTT-серверу вы отправляете сообщение по MQTT:

mqtt_dict = dict(request.session.items())send_to_mqtt(mqtt_dict, sender_id, content) 
def send_to_mqtt(mqtt_dict, user_id, msg):    mqtt = return_mqtt(mqtt_dict, user_id)    mqtt.connection()    mqtt.publish(msg)

Сообщение доходит до ESP32, срабатывает внутренняя логика на ESP32. С ESP32 вы получаете только технические показатели (например, значение температуры).

ESP32 также подключается к MQTT и использует следующие методы и функции:

connect — устанавливает связь с сервером.

set_callback — обрабатывает полученное сообщение и отправляет ответ в топик topic_subscribe.

on_message — ловит полученную команду от бота SmartMix

subscribe — слушает топик topic_sent для получения сообщения от бота SmartMix.

check_msg — проверяет полученные сообщения из топика topic_sent в бесконечном цикле.

Полный код работы ESP32 с MQTT:

Скрытый текст
import osimport networkimport timefrom umqtt.simple import MQTTClientfrom micro import flashing_led, led_off, show_temperatureprint(os.getcwd())print(os.listdir())server = 'MQTT_SERVER'port = 'MQTT_PORT'topic = 'MQTT_TOPIC'topic_sub = 'MQTT_TOPIC_ANSWER'client_id = 'MQTT_CLIENT_ID'username = 'MQTT_USERNAME'password = 'MQTT_PASSWORD'sta_if = network.WLAN(network.STA_IF)if not sta_if.isconnected():    # Подключение к Wi-Fi    print("Connect to  Wi-Fi...")    sta_if.active(True)    print('Wi-Fi Interface states:')    print('Active:', sta_if.active())    print('Connected:', sta_if.isconnected())    timeout = 20    start = time.time()    sta_if.connect('WIFI_SSID', 'WIFI_PASSWORD')    while not sta_if.isconnected():        print("Wating...")        time.sleep(1)        if time.time() - start > timeout:            print("Connection is timeout")            break    print("Connected. IP:", sta_if.ifconfig())else:     # Подключение к Wi-Fi установлено    print("Already connected, IP:", sta_if.ifconfig())def on_message(topic, msg):    answer = ''    # Обработка сообщений    print(msg)    if msg == b'led_on':        flashing_led()        answer = 'Светодиод включён'        client.publish(topic_sub, bytes(answer, 'utf-8'))        print(answer)    elif msg == b'led_off':        led_off()        answer = 'Светодиод выключен'        client.publish(topic_sub, bytes(answer, 'utf-8'))        print(answer)    elif msg == b'temp':        show_temperature()        answer = show_temperature()        print(answer)        client.publish(topic_sub, bytes(answer, 'utf-8'))client = MQTTClient(client_id, server, port, username, password)client.connect()client.set_callback(on_message)client.subscribe(topic)last_ping = time.time()ping_interval = 30while True:    client.check_msg()    now = time.time()    if now - last_ping > ping_interval:        client.ping()        last_ping = now

Полный код для всего ESP32 здесь.

И в конце вы получаете зашифрованное сообщение от ESP32: «Светодиод включён», «Светодиод выключен» или значение температуры.

salt = os.getenv('CIFER_SALT')key = gen_key(chat_id, salt)message_content = get_mqtt(mqtt_dict, sender_id)message_bot = Message(    sender_bot_id=bot.id,    content=encrypt_text(message_content, key),    chat_id=chat_id,)
def get_mqtt(mqtt_dict, user_id):    mqtt = return_mqtt(mqtt_dict, user_id)    mqtt.subscribe()    return mqtt.print_message()

Для хранения данных сервера в кэше я использовал Redis, который развёрнут в контейнере и защищён паролем. Контейнер с Redis запускается вместе с поисковиком, мессенджером и Nginx.

Полный код:

Скрытый текст
version: '3.8'services:   mixrech:      build: ./mixrech      command: sh -c "python manage.py makemigrations && python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mixrech.wsgi:application --bind 0.0.0.0:8000 --timeout 120"      volumes:      - .:/app      - mixrech_static:/mixrech/staticfiles/            env_file:      - .env      ports:         - "8000:8000"   mixchat:      build: ./mixchat      command: sh -c "python manage.py migrate && python manage.py collectstatic --noinput --verbosity 2 && gunicorn mixchat.wsgi:application --bind 0.0.0.0:8001 --timeout 120"      volumes:      - .:/app      - mixchat_static:/mixchat/staticfiles/      - media_data:/mixchat/main/media_v4/      environment:      - MEDIA_ROOT=/mixchat/main/media_v4/      env_file:      - .env      ports:         - "8001:8001"   redis:     image: redis:7-alpine     container_name: mixchat_redis     restart: unless-stopped     ports:      - "6379:6379"      volumes:      - redis_data:/data     command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes      networks:      - default     environment:      - REDIS_PASSWORD=${REDIS_PASSWORD}            nginx:      image: nginx:1.25.3      volumes:         - ./nginx.conf:/etc/nginx/nginx.conf:ro         - media_data:/app/media/         - mixrech_static:/app/staticfiles/mixrech/         - mixchat_static:/app/staticfiles/mixchat/         - /etc/letsencrypt/live/mixrech.com/fullchain.pem:/etc/letsencrypt/live/mixrech.com/fullchain.pem         - /etc/letsencrypt/live/mixrech.com/privkey.pem:/etc/letsencrypt/live/mixrech.com/privkey.pem               depends_on:        - mixrech        - mixchat      ports:        - "80:80"        - "443:443"volumes:   mixrech_static:   mixchat_static:   media_data:   redis_data:

Архитектура шифрования

Соль (CIFER_SALT) для генерации ключа хранится на сервере в переменных окружениях и недоступна клиенту. Это исключает утечку паролей.

Шифрование симметричное и происходит оно во всех чатах.

import base64from dotenv import load_dotenvfrom cryptography.fernet import Fernetfrom cryptography.hazmat.primitives.kdf.scrypt import Scryptload_dotenv()def gen_key(id, salt):    kdf = Scrypt(salt=salt.encode('utf-8'), length=32, n=2**14, r=8, p=1)    key = kdf.derive(str(id).encode())    return base64.urlsafe_b64encode(key)def encrypt_text(text, key):    fer = Fernet(key)    encrypt_data = fer.encrypt(text.encode('utf-8'))    return encrypt_datadef decrypt_text(text, key):    fer = Fernet(key)    decrypt_data = fer.decrypt(text)    return decrypt_datadef encrypt_file(file_path, key):    fer = Fernet(key)    with open(file_path, 'rb') as f:        data = f.read()    encrypt_data = fer.encrypt(data)    with open(file_path, 'wb') as f:        f.write(encrypt_data)def decrypt_file(file_path, key):    fer = Fernet(key)    with open(file_path, 'rb') as f:        data = f.read()    decrypt_data = fer.decrypt(data)    with open(file_path, 'wb') as f:        f.write(decrypt_data)

Итог

Чат-бот SmartMix управляет устройством без привязки к производителю, а само устройство не собирает данные пользователей.

Буду рад вопросам!

ссылка на оригинал статьи https://habr.com/ru/articles/1058210/