Спецификация socket-протокола
Всё необходимое, чтобы встроить поддержку онлайн-режима Entrixy в свой контроллер: описание WebSocket-сообщений, E2EE и тест-векторы. Наши собственные платы, говорящие на нём, — линейка NET; те, что работают только по Bluetooth, — линейка BLE.
device_key для серийного производства — §10. Референс: esp32-ws-example (MIT).
1. Транспорт и конверт сообщений
- Endpoint:
wss://entrixy.com/ws(hostentrixy.com, порт 443, path/ws). В открытой модели хост берётся из ключа/настройки — см. /open. - TLS: обычный WSS. Subprotocol не задаётся.
- Кадры: текстовые (UTF-8) JSON. Дискриминатор — поле
type. - Реконнект: фиксированный интервал 2 с в эталонной программе для ESP32. RX-watchdog: нет входящих кадров три периода пинга (3 × ping_s, 30 с при настройках по умолчанию) → рестарт.
2. Подключение и аутентификация
Первым кадром контроллер представляется:
→ device_hello
{ "type":"device_hello",
"device_key": "<32 hex>",
"device_secret": "<32 hex>",
"e2ee": true|false } // есть ли валидный ownerSecret (§5)
device_key/device_secret — две 32-символьные hex-половины "кода устройства" (опц. префикс ENTX-DEV:). Сервер сверяет sha256(device_secret) с хэшем по device_key.
❮ device_ok { "type":"device_ok", "hb_interval":300, "ping_s":10 } // success; hb_interval in s (10..3600), ping_s — the ping period
← error { "type":"error", "reason":"auth" } // failure → the server closes
Контроллер, работающий через интернет, сам держит соединение с сервером, поэтому сервер видит IP-адрес контроллера и время каждой команды. Подпись каждой команды проверяет только сетевой контроллер на ESP32, привязанный к телефону владельца (§5–6). Контроллеры на ESP8266, Raspberry Pi и Node.js доверяют серверу: он может сам отправить им открытие.
3. Открытие (базовый режим, без E2EE)
❮ device_command
{ "type":"device_command", "action":"open", "command_id":"<id>", "number_id":<n> }
// action:"close" — для бистабильного привода
Контроллер даёт импульс на реле (по умолчанию 1000 мс) и отвечает:
→ device_status
{ "type":"device_status", "command_id":"<id>",
"level":"success"|"warning"|"danger"|"info",
"message":"...", "final":true
[, "position":"open"|"closed"|"opening"|"closing"|"unknown" ] }
level вне белого списка → трактуется как info. Приложение показывает message дословно и пишет его в журнал рядом с нажатием.
Контроллер также сообщает, что происходит на нём самом. У такого device_status пустой command_id и есть поле event — приложение пишет его в журнал хозяина.
→ { "type":"device_status", "command_id":"", "event":"button"|"limit"|"auto",
"level":"success", "message":"Opened", "final":true, "position":"open" }
| event | Когда |
|---|---|
button | нажали кнопку выхода внутри; у бистабильного привода она чередует открыть / закрыть |
limit | концевик: дверь двинули руками, или привод не доехал туда, куда его отправило нажатие, за время хода, заданное в контроллере (level danger, message "Did not close" или "Did not open"). Когда концевик подтверждает нажатие, событие несёт "confirm":true: приложение обновляет строку этого нажатия в журнале, а не добавляет новую |
auto | контроллер закрылся сам через заданное число секунд |
С концевиком контроллер сообщает opening или closing от импульса до подтверждения положения концевиком. Без концевика он сразу сообщает целевое положение.
"hold":true просит приложение держать сообщение на карточке до следующего сообщения контроллера; без него сообщение исчезает через несколько секунд. Контроллеры ставят его для Открытие и Closing (уровень info, ворота в пути) и для сбоя.
Ответ на команду, которая действительно запустила привод, несёт "started":true. Конца движения от концевика сервер ждёт только для такого нажатия: «Занято» или «уже открывается» на второе нажатие не перехватывают чужое движение.
4. Heartbeat и живость
Сервер не пингует устройства (fan-out не масштабируется). Живость держит устройство:
→ { "type":"ping" } ← { "type":"pong" } // the device sends this every ping_s seconds
Если устройство молчит дольше, чем разрешает сервер, сервер отключает его и показывает «не в сети». Период сервер сообщает в device_ok.ping_s: треть этого времени (DEVICE_OFFLINE_S, по умолчанию 30 с). Устройство, приславшее "ping_ctl":true в device_hello , следует ему; остальные шлют ping раз в 30 с и считаются «не в сети» после 90 с молчания. На ping от сервера → устройство отвечает pong. Для бистабильного привода — object-state heartbeat: спонтанный device_status без command_id с position каждые hb_interval сек.
5. E2EE: провизионинг ownerSecret
E2EE делает так, что сервер не может подделать открытие — команду подписывает приложение. ownerSecret (32 байта) в прошивку не зашивается: приложение владельца передаёт его по USB-serial:
PROVISION <64 hex> → сохранить ownerSecret в NVS, вернуть fingerprint WIPE → стереть (базовый режим) STATUS → показать fingerprint fingerprint = HMAC-SHA256(ownerSecret, "fp")[0..3] (hex)
После PROVISION/WIPE контроллер переподключается с обновлённым e2ee. Провизенный контроллер отвергает обычный device_command — только §6.
6. E2EE: challenge-response открытие
При e2ee=true вместо device_command — challenge-response. Свежесть даёт одноразовый nonce, не время:
1. ← sock_challenge_req { "type":"sock_challenge_req", "command_id":"<id>" }
2. → sock_challenge { "type":"sock_challenge", "command_id":"<id>", "nonce":"<b64 16B>" }
3. ← sock_fire { "type":"sock_fire", "command_id","number_id",
"token":"<b64>", "owner_sig":"<b64>", "guest_id":<n>,
"nonce":"<b64>", "proof":"<b64>" [, "action":"close"] }
4. → device_status success
| Кто | token | Проверка |
|---|---|---|
| Владелец | пустой | proof == HMAC-SHA256(ownerSecret, nonce)[0..15] |
| Гость | 3 байта | сверить owner_sig, вывести guest_key, сверить proof |
token (3B) = [guest_did 2B LE][perms 1B] // capability, без TTL owner_sig = HMAC-SHA256(ownerSecret, token)[0..15] guest_key = HKDF-SHA256(salt=null, ikm=ownerSecret, info="guest"||guest_did(2B LE), L=32) proof = HMAC-SHA256(guest_key, nonce)[0..15]
Закрытие бистабильного привода несёт "action":"close" и подписывается над nonce с дописанными пятью ASCII-байтами close в конце: proof = HMAC-SHA256(key, nonce || "close")[0..15]. Здесь key — это ownerSecret хозяина или guest_key гостя. Открытие подписывается над голым nonce. Поэтому сервер не может превратить открытие в закрытие и наоборот: подпись не сойдётся. Решает, что делать, тот, кто подписывает, — по положению, которое сообщил контроллер.
Контроллер принимает nonce 30 секунд после выдачи и только в том соединении, где его выдал: подписанная команда, придержанная сервером, позже не сработает.
Бинарные поля — base64. nonce 16 байт, подписи усечены до 16. HKDF salt=null = 32 нулевых байта.
7. Отзыв гостя
❮ sock_revoke { "type":"sock_revoke", "guest_id","version","revoked", "sig":"<b64>" }
→ sock_revoke_ack
sig = HMAC-SHA256(ownerSecret, "rev" || guest_id(2B LE) || version(4B LE) || revoked(1B))[0..15]
Контроллер применяет только при version больше сохранённого (монотонно — сервер не подделает и не воскресит доступ).
Сетевой контроллер на ESP32 хранит до 96 решений по гостям. Когда список заполнен, место для нового отзыва освобождает запись «разрешено»; если все 96 — отзывы, удаляется самый старый.
8. Крипто-примитивы
- HMAC-SHA256 — подписи; усечение = первые 16 байт.
- HKDF-SHA256 (RFC 5869),
salt=null→ 32 нулевых байта. - В socket-открытии AES/GCM нет — только HMAC/HKDF. (AES-256-GCM — в отдельной подсистеме шифрования гостевых бандлов, не здесь.)
Деривация guest_key и подписи идентичны гостевой ветке BLE — крипто переиспользуется.
9. Тест-векторы (E2EE)
Known-answer векторы. Бинарные поля даны и hex, и base64 (в кадрах — base64).
ownerSecret (32B) = a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf fingerprint = b358977a = HMAC(ownerSecret,"fp")[0..3] nonce (16B) = 000102030405060708090a0b0c0d0e0f b64 = AAECAwQFBgcICQoLDA0ODw==
Owner fire (token пустой)
proof (16B) = 3fc0619c684a8261d06c1501ae4e726a = HMAC(ownerSecret, nonce)[0..15] proof b64 = P8BhnGhKgmHQbBUBrk5yag==
Guest fire (guest_did=0x0042, perms=0x01)
token (3B) = 420001 b64 = QgAB owner_sig (16B) = 850a9f31fb708b176f4ed62a43acf0ad = HMAC(ownerSecret, token)[0..15] b64 = hQqfMftwixdvTtYqQ6zwrQ== guest_key (32B) = 614da70a34890a807a25f7c5f271e530434c478f144370a2b9efaf0c58cd85c1 = HKDF(salt=null, ownerSecret, info="guest"||did_LE, L=32) proof (16B) = a8a383ae56ac8a0182df2a275e220e83 = HMAC(guest_key, nonce)[0..15] b64 = qKODrlasigGC3yonXiIOgw==
Закрытие (бистабильный привод)
signed input = nonce || "close" = 000102030405060708090a0b0c0d0e0f636c6f7365 owner proof = ef15ca41823f126de0815f84c7dc8275 = HMAC(ownerSecret, input)[0..15] b64 = 7xXKQYI/Em3ggV+Ex9yCdQ== guest proof = 54f23d61766a274d8996c26e67925c32 = HMAC(guest_key, input)[0..15] b64 = VPI9YXZqJ02JlsJuZ5JcMg==
Revoke (guest_id=0x0042, version=3, revoked=1)
sig input = "rev"||guest_id_LE(2)||version_LE(4)||revoked(1) = 72657642000300000001 sig (16B) = 9433b96ddbb599f6c72bf17c0c9825fe b64 = lDO5bdu1mfbHK/F8DJgl/g==
10. Получение device_key (для OEM)
Чтобы сервер узнавал устройство и знал, какому владельцу оно принадлежит, нужна пара device_key/device_secret, известная серверу и привязанная к аккаунту владельца.
- Прототип / штучно — сегодня: владелец создаёт объект "Устройство" в приложении, получает пару, конфигуратор запекает её в прошивку. Достаточно, чтобы поднять и проверить весь протокол.
- Серийное производство: модель "claim" — устройство с завода "ничьё", покупатель привязывает его к своему аккаунту по коду/QR. Партии кодов и наклейки выпускаются в кабинет производителя: подаёте заявку, после одобрения генерируете партию
device_key, получаете CSV с кодами и готовые QR-наклейки. Вопросы по условиям — hello@entrixy.com.
Рабочая реализация обеих сторон — esp32-ws-example (MIT). Собрать .bin — в браузерном конфигураторе. Как это в открытой архитектуре — /open.