❮  Контроллеры Entrixy

Спецификация BLE-протокола

Всё необходимое, чтобы встроить поддержку Entrixy BLE в свой контроллер — своя плата, свой микроконтроллер. Референс-прошивка реализует ровно этот протокол; ниже — формальное описание и тест-векторы.

BLE-контроллеру не нужен сервер Entrixy. Он не имеет выхода в интернет: телефон общается с ним напрямую по Bluetooth. Привязка к владельцу — локальная (X25519 ECDH). Регистрировать устройство на нашем сервере, получать ключи или заводить аккаунт производителю не требуется — можно отгружать «пустые» контроллеры. Референс: esp32-example/entrixy-ble (MIT).

1. Крипто-примитивы

Референс: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) на ESP, BouncyCastle на Android — обе стороны совпадают.

2. GATT: сервис и характеристики

Все UUID 128-битные, база 656e7472-7869-7900-XXXX-426c45000001 («entrixy» в ASCII).

UUID суффиксСвойствоНазначение
0000Service UUID (полный: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREAD8 байт nonce (legacy; современный клиент берёт nonce из advertise, §3)
0002 FIREWRITEКоманда открытия: 24 байта (владелец) или 47 байт (гость)
0003 TIMEWRITEКоррекция часов, ровно 26 байт
0004 WIFIWRITESSID+пароль для NTP (опционально)
0005 RESULTREAD+NOTIFY1-байтный код результата после каждой записи (§9)
00f0 PAIR_PUBREADТолько в pair-режиме: 32 байта X25519-pubkey контроллера (LE)
00f1 PAIR_DONEWRITEТолько в pair-режиме: 36 байт [phone_pub 32B][device_id 4B LE]
Имя "entrixy" и Service UUID уходят в scan-response, а не в primary: 128-битный UUID + manufacturer data не влезают в 31-байтный primary. Android passive-scan видит только primary, поэтому фильтр — по manufacturer data (company id 0x00E0).

3. GAP-реклама (advertisement)

Контроллер раз в sleep_interval секунд рекламирует присутствие manufacturer-specific data. Company ID — 0x00E0 (на проводе E0 00). Блок — 23 байта = 2 байта company ID + 21 байт value:

offset  size  поле
[0..1]   2    Company ID = E0 00
[2..5]   4    device_id            (LE)
[6..9]   4    counter              (LE, монотонный, anti-replay, переживает deep-sleep)
[10..17] 8    auth_hmac            = HMAC(owner_secret, mac_in)[0..7]
[18]     1    sleep_interval_s     (plaintext)
[19..22] 4    opts_cipher          (battery, status, fw, hw — зашифрованы)

Подпись покрывает 13 байт (Company ID не входит):

mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4)
auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
Эти 8 байт auth_hmac служат nonce для немедленного open без round-trip: наблюдатель без owner_secret видит их как случайные, а владелец/гость вычисляет подпись и сразу шлёт fire. Контроллер держит кольцо из 16 последних выданных nonce.

Опции (opts) — XOR-keystream

ks = HMAC-SHA256(owner_secret, "ks" || device_id(4 LE) || counter(4 LE))[0..3]
opts_cipher[i] = opts_plain[i] XOR ks[i]
opts_plain[0] = battery %   [1] = status   [2] = fw (major=(b>>4)&0xF, minor=b&0xF)   [3] = hw

Бистабильный режим

Для бистабильного привода к value добавляются 3 байта — итого 26 байт (см. §8).

4. Сопряжение (pairing)

Свежий контроллер сам входит в pair-режим на 30–90 с (иначе — по кнопке; factory-reset = удержание 5 с). Обмен локальный, без сервера:

  1. Телефон читает PAIR_PUB (00f0) — 32 байта pubkey контроллера.
  2. Телефон генерит свою X25519-пару, shared = X25519(phone_priv, esp_pub).
  3. Обе стороны выводят одинаковый секрет:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. Телефон пишет PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]. device_id генерирует телефон, контроллер запоминает.
Ключи X25519 — little-endian (RFC 7748). owner_secret в эфир не уходит. Сервер не участвует: приложение само сгенерировало device_id, поэтому и знает его.

5. Открытие владельцем (owner fire)

Запись в FIRE (0002), 24 байта:

[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]

nonce — 8 байт auth_hmac из свежего advertise. Контроллер проверяет nonce в кольце, сверяет подпись, даёт импульс на реле, инвалидирует nonce.

6. Открытие гостем (guest fire)

Гость не знает owner_secret. Владелец выдаёт bundle (через сервер): token, owner_sig, guest_key. Запись в FIRE, 47 байт: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].

ПолеРазмерФормула
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Bсвежий nonce из advertise
guest_sig16BHMAC-SHA256(guest_key, nonce)[0..15]
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)

Контроллер: nonce в кольце → owner_sig своим owner_secretvalid_until против часов → выводит guest_keyguest_sig. Гостей не хранит, только верифицирует.

7. Синхронизация времени

Гостевой TTL требует часов. Любой доверенный клиент подписью ставит время. Запись в TIME (0003), 26 байт:

[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]

Защиты гостевого времени: ratchet (нельзя назад), forward-cap (не более +24 ч/запись), fire-first gate. Опционально — внешний RTC и NTP.

8. Бистабильное состояние (K_state)

K_state = HKDF-SHA256(salt=null, ikm=owner_secret, info="ble-state-v1", L=16)
[..21] state_enc = state_byte XOR K_state[counter & 15]     // бит0: 1=открыто,0=закрыто
[22..23] state_mac = HMAC-SHA256(K_state, counter(4B LE) || state_enc)[0..1]   // 2 байта

9. Коды ответа (RESULT, характеристика 0005)

КодЗначениеКодЗначение
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — ОТКРЫТО0x11TIME OK (guest)
0x31FIRE OK — ЗАКРЫТО0x12TIME неверная длина
0x01nonce устарел / не в кольце0x13TIME HMAC не сошёлся
0x02TTL истёк0x15TIME откат (ratchet)
0x03owner_sig неверна0x16TIME превышен +24ч
0x04guest_sig неверна0x17TIME без свежего fire
0x05неверная длина0x20WIFI OK
0x06HKDF failed0x21WIFI неверный формат
0x07owner HMAC неверна0x22WIFI HMAC не сошёлся

10. Тест-векторы

Детерминированные known-answer векторы — прогоните свою реализацию и сверьте байты. Все значения hex, порядок байт — как на проводе. Цепочка сквозная: pairing → owner_secret → всё остальное.

Pairing (X25519 → owner_secret)

esp_priv   (clamped) = 0002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f60
phone_priv (clamped) = 201f1e1d1c1b1a191817161514131211100f0e0d0c0b0a090807060504030241
esp_pub              = 07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c
phone_pub            = 0d799600f6ffaee2e121e6b8f7a05dc66874b51db3102d0d71f799a09cb4c461
shared_z             = 53126e95ac6e407e8a412fdf82c87f1be45a2251edf9422ad00df2e83aaebd19
owner_secret         = 3609fa67bd15cf2bbaecdd5305feea0d48e1f21d714d01b29082a5fec459c64b
  = HKDF(salt="entrixy-pair-v1", ikm=shared_z, info="owner-secret", L=32)

Advertise (did=0x0A0B0C0D, counter=7, sleep=1, opts=batt100/status1/fw v2.3/hw5)

ks[0..3]     = ee0a8499
opts_cipher  = 8a0ba79c
auth_hmac    = 1f2d8012178a6e06   (= nonce)
mfg    (23B) = e0000d0c0b0a070000001f2d8012178a6e06018a0ba79c

Owner fire (24B)

fire = 1f2d8012178a6e06eaafaae40500580a774b62be6e9b6aa2
  = nonce || HMAC(owner_secret, nonce)[0..15]

Guest fire (47B) — bleId=0x0042, valid_until=0x6890ABCD, flags=0x01

token     (7B)  = 4200cdab906801
owner_sig (16B) = faa3a99a76dfba0521fb4eb4bd508559   = HMAC(owner_secret, token)[0..15]
guest_key (32B) = 248b57d94ccafeb3cba598c6b81fc18617c1b54da1386ca1ed99bcd5dd012ff5
  = HKDF(salt=null, ikm=owner_secret, info="guest"||bleId_LE, L=32)
guest_sig (16B) = 7a6e02ae269827b78b7c49840c40476e   = HMAC(guest_key, nonce)[0..15]
fire      (47B) = 4200cdab906801faa3a99a76dfba0521fb4eb4bd5085591f2d8012178a6e067a6e02ae269827b78b7c49840c40476e

Time-sync (26B) — owner (bleId=0), epoch_ms=1750000000000

time = 000000dc2074970100005e1120cdb0bb4c8f5ee22f2b1920cb99
  = [bleId 2B LE][epoch_ms 8B LE][ HMAC(owner_secret, payload)[0..15] ]

K_state (16B)

K_state = 19bcfc41f61a103d79e9967c21403a93
  = HKDF(salt=null, ikm=owner_secret, info="ble-state-v1", L=16)

Референс и контакт

Рабочая реализация обеих сторон — esp32-example/entrixy-ble (MIT). Собрать .bin под свою плату — в браузерном конфигураторе. Онлайн-вариант связи — socket-протокол. Как это вписано в открытую архитектуру — /open. Вопросы OEM — hello@entrixy.com.