Спецификация BLE-протокола
Всё необходимое, чтобы встроить поддержку Entrixy BLE в свой контроллер — своя плата, свой микроконтроллер. Наша готовая прошивка делает ровно это; ниже — точное описание протокола и тест-векторы, чтобы свериться байт в байт.
1. Крипто-примитивы
- HMAC-SHA256 — подписи и деривация. Усечение до нужной длины — первые N байт результата.
- HKDF-SHA256 (RFC 5869) — вывод ключей. Если
salt = null, берётся нулевой вектор длиной 32 байта. - X25519 (RFC 7748) — ECDH при сопряжении. Приватный ключ clamp'ится:
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
Референс: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) на ESP, BouncyCastle на Android — обе стороны совпадают.
2. GATT: сервис и характеристики
Все UUID 128-битные, база 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" в ASCII).
| UUID суффикс | Свойство | Назначение |
|---|---|---|
0000 | — | Service UUID (полный: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | 8 байт nonce (legacy; современный клиент берёт nonce из advertise, §3) |
0002 FIRE | WRITE | Команда открытия: 24 байта (владелец) или 47 байт (гость) |
0003 TIME | WRITE | Подстройка часов: 32 байта от владельца, 55 байт от гостя |
0004 WIFI | WRITE | SSID+пароль для NTP (опционально) |
0005 RESULT | READ+NOTIFY | 1-байтный код результата после каждой записи (§9) |
0006 REVOKE | WRITE | Подписанное решение владельца об одном госте, 23 байта |
00f0 PAIR_PUB | READ | Только в pair-режиме: 32 байта X25519-pubkey контроллера (LE) |
00f1 PAIR_DONE | WRITE | Только в 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]
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 с). Обмен локальный, без сервера:
- Телефон читает
PAIR_PUB(00f0) — 32 байта pubkey контроллера. - Телефон генерит свою X25519-пару,
shared = X25519(phone_priv, esp_pub). - Обе стороны выводят одинаковый секрет:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- Телефон пишет
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]. device_id генерирует телефон, контроллер запоминает.
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].
| Поле | Размер | Формула |
|---|---|---|
token | 7B | [bleId 2B LE][valid_until 4B LE unix-sec][flags 1B] |
owner_sig | 16B | HMAC-SHA256(owner_secret, token)[0..15] |
nonce | 8B | свежий nonce из advertise |
guest_sig | 16B | HMAC-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_secret → гостя нет в списке отзыва → valid_until против часов → выводит guest_key → guest_sig.
Единственный список, который хранит контроллер, — отзывы владельца (§7).
7. Синхронизация времени
Для срока гостя нужны часы. Подвести их может владелец и гость с действующим пропуском. Каждая запись несёт одноразовый номер, выданный контроллером, и контроллер его гасит: запись, перехваченную по радио, нельзя повторить позже и отмотать часы назад.
Номер берётся из чтения NONCE (0001) или из свежей рекламы. Запись в TIME (0003):
owner, 32 bytes: [epoch_ms 8B LE][nonce 8B][ HMAC-SHA256(owner_secret, "T" || epoch_ms || nonce)[0..15] ] guest, 55 bytes: [token 7B][owner_sig 16B][epoch_ms 8B LE][nonce 8B] [ HMAC-SHA256(guest_key, "T" || token || epoch_ms || nonce)[0..15] ]
Гостю нужен тот же пропуск, что и для открытия (§6): подписанный владельцем, не отозванный, действующий сейчас и в то время, которое он предлагает. Отозванный гость не может увести часы за сроки пропусков остальных.
Время от гостя защищено ещё и храповиком (назад нельзя), потолком сдвига вперёд (не больше +24 ч за запись) и правилом «сначала открытие». Владелец не ограничен. Внешние часы и NTP необязательны.
Отзыв гостя
Решение владельца об одном госте, подписанное собственным owner_secret. Запись в REVOKE (0006), 23 байта:
[guest_id 2B LE][version 4B LE][revoked 1B: 1 revoked, 0 allowed] [ HMAC-SHA256(owner_secret, "rev" || first 7 bytes)[0..15] ]
Контроллер хранит последнюю версию по каждому гостю и принимает запись только с версией выше, поэтому повтор старого «разрешён» не вернёт отозванного гостя. Отозванному гостю отказано и в открытии (0x08), и в записи времени.
Записать её может любой телефон: владельца или гостя, оказавшегося у двери. Контроллер верит подписи, а не тому, кто пишет, поэтому отзыв доходит до замка с первым человеком рядом, а не ждёт, пока истечёт пропуск гостя. Повторная привязка очищает список.
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 байта
Два байта тега — намеренно: байт состояния управляет только индикатором открыто/закрыто и никаких прав не даёт, а каждый лишний байт объявления стоит батареи на каждом пробуждении. Тег защищает от искажения в эфире, а не от подделывателя — 16 бит перебираются за секунды. Ничто, решающее доступ, этим путём ходить не должно.
События самого контроллера
Остальные биты байта положения несут то, что произошло на самом контроллере, чтобы телефон рядом мог записать это в журнал хозяина. Интернета у контроллера нет: событие попадает в журнал, только когда телефон услышит рекламный пакет.
state_byte: bit 0 1 = open, 0 = closed bits 1..3 number of the last event, 0..7, wrapping bits 4..5 its type: 0 none, 1 exit button, 2 limit switch, 3 auto-close bit 6 1 = on its way: the limit switch has not confirmed the press yet, bit 0 is where it goes bit 7 1 = no position (a momentary lock that sends the byte only for its events)
Телефон помнит последний увиденный номер для каждого контроллера. Новый номер с ненулевым типом — событие; разница номеров показывает, сколько событий пропущено, пока рядом никого не было. Первый номер, который телефон видит, — точка отсчёта, а не событие. Контроллер хранит номер через сон и пропадание питания. Импульсный замок передаёт этот блок, только если у него есть кнопка выхода.
С концевиком нажатие отправляет замок в путь (бит 6), пока концевик его не подтвердит. Если концевик не подтвердил за время хода, заданное в контроллере, контроллер верит ему и отправляет новый номер события с типом 0: сбой, привод не доехал. Бит 0 тогда показывает, где замок на самом деле: открыт — значит не закрылся.
Ответ на такое нажатие — 0x32 или 0x33: замок в пути к «открыто» или к «закрыто». Нажавший телефон дальше следит за рекламой, пока положение не установится или не придёт сбой.
9. Коды ответа (RESULT, характеристика 0005)
| Код | Значение | Код | Значение |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — ОТКРЫТО | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — ЗАКРЫТО | 0x12 | TIME неверная длина |
0x32 | FIRE OK — ОТКРЫВАЕТСЯ | ||
0x33 | FIRE OK — ЗАКРЫВАЕТСЯ | ||
0x01 | nonce устарел / не в кольце | 0x13 | TIME HMAC не сошёлся |
0x02 | TTL истёк | 0x15 | TIME откат (ratchet) |
0x03 | owner_sig неверна | 0x16 | TIME превышен +24ч |
0x04 | guest_sig неверна | 0x17 | TIME без свежего fire |
0x05 | неверная длина | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI неверный формат |
0x07 | owner HMAC неверна | 0x22 | WIFI HMAC не сошёлся |
0x08 | гость отозван на этом контроллере | 0x19 | одноразовый номер TIME устарел или не из кольца |
0x40 | REVOKE сохранён | 0x1a | TIME от гостя без действующего пропуска |
0x41 | версия REVOKE не новее | 0x42 | у REVOKE неверная длина или подпись |
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 from the owner (32B), epoch_ms=1750000000000, nonce as above
time = 00dc2074970100001f2d8012178a6e06bfb4645605d42a5287cd56ce9a61e6df
= [epoch_ms 8B LE][nonce 8B][ HMAC(owner_secret, "T" || epoch_ms || nonce)[0..15] ]
Time from a guest (55B), the same pass, epoch_ms and nonce
time = 4200cdab906801faa3a99a76dfba0521fb4eb4bd50855900dc2074970100001f2d8012178a6e06d92cc82b145083302bc8e3f97b2080cb
= token || owner_sig || epoch_ms || nonce || HMAC(guest_key, "T" || token || epoch_ms || nonce)[0..15]
Revoke (23B), guest_id=0x0042, version=1750000000, revoked=1
revoke = 420080e14e6801a1a54f560a1cc74d91cb985e9f62b2a6
= guest_id || version || revoked || HMAC(owner_secret, "rev" || guest_id || version || revoked)[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.