Spécification du protocole BLE
Tout ce qu'il faut pour ajouter la prise en charge du BLE Entrixy à votre propre contrôleur — votre carte, votre microcontrôleur. Notre micrologiciel tout prêt fait exactement cela ; ci-dessous, la description précise du protocole et les vecteurs de test pour vous vérifier octet par octet.
1. Primitives cryptographiques
- HMAC-SHA256 — signatures et dérivation. La troncature à une longueur donnée prend les N premiers octets du résultat.
- HKDF-SHA256 (RFC 5869) — dérivation de clés. Si
salt = null, un vecteur nul de 32 octets est utilisé. - X25519 (RFC 7748) — ECDH lors de l'appairage. La clé privée est "clampée" :
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
Référence : mbedTLS (MBEDTLS_ECP_DP_CURVE25519) sur l'ESP, BouncyCastle sur Android — les deux côtés concordent.
2. GATT : service et caractéristiques
Tous les UUID sont sur 128 bits et reposent sur 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" en ASCII).
| Suffixe d'UUID | Propriété | Rôle |
|---|---|---|
0000 | — | UUID de service (complet : 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | un nonce de 8 octets (hérité ; un client moderne prend le nonce dans l'annonce, §3) |
0002 FIRE | WRITE | La commande d'ouverture : 24 octets (propriétaire) ou 47 octets (invité) |
0003 TIME | WRITE | Correction de l'horloge, exactement 26 octets |
0004 WIFI | WRITE | SSID et mot de passe pour NTP (facultatif) |
0005 RESULT | READ+NOTIFY | Un code de résultat d'un octet après chaque écriture (§9) |
00f0 PAIR_PUB | READ | Uniquement en mode appairage : la clé publique X25519 de 32 octets du contrôleur (LE) |
00f1 PAIR_DONE | WRITE | Uniquement en mode appairage : 36 octets [phone_pub 32B][device_id 4B LE] |
"entrixy" et l'UUID de service vont dans le scan-responseplutôt que dans le paquet primaire : un UUID 128 bits plus les données fabricant ne tiennent pas dans les 31 octets du primaire. Le balayage passif d'Android ne voit que le primaire, le filtrage se fait donc sur les données fabricant (company id 0x00E0).3. Annonce GAP
Toutes les sleep_interval secondes, le contrôleur signale sa présence avec des données spécifiques au fabricant. Le company id vaut 0x00E0 (sur le fil E0 00). Le bloc fait 23 octets = 2 octets de company id plus 21 octets de valeur :
offset size field [0..1] 2 Company ID = E0 00 [2..5] 4 device_id (LE) [6..9] 4 counter (LE, monotonic, anti-replay, survives 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 — encrypted)
La signature porte sur 13 octets (le company id n'est pas inclus) :
mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4) auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
auth_hmac servent de nonce pour une ouverture immédiate sans aller-retour : un observateur sans owner_secret n'y voit que du hasard, tandis que le propriétaire ou l'invité calcule la signature et déclenche aussitôt. Le contrôleur conserve un anneau des 16 derniers nonces qu'il a émis.Options (opts) — flux de clé XOR
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
Mode bistable
Pour une motorisation bistable, trois octets sont ajoutés à la valeur, ce qui donne 26 octets (voir §8).
4. Appairage
Un contrôleur neuf passe de lui-même en mode appairage pendant 30–90 s (sinon par bouton ; la réinitialisation d'usine est un appui de 5 secondes). L'échange est local, sans serveur :
- Le téléphone lit
PAIR_PUB(00f0) — la clé publique de 32 octets du contrôleur. - Le téléphone génère sa propre paire X25519,
shared = X25519(phone_priv, esp_pub). - Les deux côtés dérivent le même secret :
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- Le téléphone écrit
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]. le téléphone génère le device_id, et le contrôleur l'enregistre.
owner_secret ne circule jamais sur les ondes. Aucun serveur n'intervient : l'application a généré elle-même le device_id, c'est ainsi qu'elle le connaît.5. Ouverture par le propriétaire (owner fire)
Une écriture dans FIRE (0002), 24 octets:
[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]
nonce — les 8 octets auth_hmac d'une annonce fraîche. Le contrôleur vérifie le nonce dans son anneau, contrôle la signature, envoie une impulsion au relais et invalide le nonce.
6. Ouverture par un invité (guest fire)
Un invité ne connaît pas owner_secret. Le propriétaire délivre un lot via le serveur : token, owner_sig, guest_key. Une écriture dans FIRE, 47 octets: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].
| Champ | Taille | Formule |
|---|---|---|
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 | un nonce frais issu de l'annonce |
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)
Le contrôleur : nonce dans l'anneau → owner_sig avec sa propre owner_secret → valid_until par rapport à son horloge → dérive guest_key → guest_sig. Il ne tient aucune liste d'invités — il vérifie seulement la signature.
7. Synchronisation de l'heure
Le TTL d'un invité exige une horloge. Tout client de confiance peut régler l'heure avec une signature. Une écriture dans TIME (0003), 26 octets:
[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]
bleId = 0→ propriétaire,key = owner_secret.bleId ≠ 0→ invité,key = guest_key(comme au §6).
L'heure des invités est protégée par un cliquet (pas de retour en arrière), un plafond vers l'avant (pas plus de +24 h par écriture) et un verrou fire-first. Une RTC externe et NTP sont facultatifs.
8. État bistable (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] // bit0: 1=open, 0=closed [22..23] state_mac = HMAC-SHA256(K_state, counter(4B LE) || state_enc)[0..1] // 2 bytes
9. Codes de réponse (RESULT, caractéristique 0005)
| Code | Valeur | Code | Valeur |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — OUVERT | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — FERMÉ | 0x12 | TIME a une longueur incorrecte |
0x01 | le nonce est périmé ou absent de l'anneau | 0x13 | le HMAC de TIME ne correspond pas |
0x02 | le TTL a expiré | 0x15 | TIME est revenu en arrière (cliquet) |
0x03 | owner_sig est invalide | 0x16 | TIME dépasse le plafond de +24 h |
0x04 | guest_sig est invalide | 0x17 | TIME sans déclenchement récent |
0x05 | longueur incorrecte | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI a un format incorrect |
0x07 | le HMAC du propriétaire est invalide | 0x22 | le HMAC de WIFI ne correspond pas |
10. Vecteurs de test
Vecteurs déterministes à réponse connue : exécutez votre implémentation et comparez les octets. Toutes les valeurs sont en hexadécimal et l'ordre des octets est celui du fil. La chaîne se déroule de bout en bout : appairage → owner_secret → tout le reste.
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)
Code de référence et contact
Une implémentation fonctionnelle des deux côtés est esp32-example/entrixy-ble (MIT). Pour compiler .bin pour votre propre carte, utilisez le configurateur dans le navigateur. Le transport en ligne, c'est protocole socket. Comment cela s'inscrit dans l'architecture ouverte : /open. Demandes OEM : hello@entrixy.com.