Спецификация 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 | Коррекция часов, ровно 26 байт |
0004 WIFI | WRITE | SSID+пароль для NTP (опционально) |
0005 RESULT | READ+NOTIFY | 1-байтный код результата после каждой записи (§9) |
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. Синхронизация времени
Гостевой TTL требует часов. Любой доверенный клиент подписью ставит время. Запись в TIME (0003), 26 байт:
[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]
bleId = 0→ владелец,key = owner_secret.bleId ≠ 0→ гость,key = guest_key(как в §6).
Защиты гостевого времени: 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)
| Код | Значение | Код | Значение |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — ОТКРЫТО | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — ЗАКРЫТО | 0x12 | TIME неверная длина |
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 не сошёлся |
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.