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 : 32 octets du propriétaire, 55 octets d’un invité |
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) |
0006 REVOKE | WRITE | La décision signée du propriétaire concernant un invité, 23 octets |
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 → l’invité n’est pas sur la liste de révocation → valid_until par rapport à son horloge → dérive guest_key → guest_sig.
La seule liste que conserve le contrôleur est celle des révocations du propriétaire (§7).
7. Synchronisation de l'heure
Un TTL d’invité a besoin d’une horloge. Le propriétaire et un invité muni d’un pass valide peuvent la régler. Chaque écriture porte un nonce émis par le contrôleur, qui le consomme : une écriture enregistrée sur les ondes ne peut pas être rejouée plus tard pour reculer l’horloge.
Le nonce provient d’une lecture de NONCE (0001) ou d’une annonce récente. Une écriture dans 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] ]
Un invité a besoin du même pass que pour ouvrir (§6) : signé par le propriétaire, non révoqué, valide maintenant et valide à l’heure qu’il propose. Un invité révoqué ne peut pas pousser l’horloge au-delà des pass de tous les autres.
L’heure d’un invité est aussi protégée par un cliquet (pas de retour en arrière), un plafond vers l’avant (au plus +24 h par écriture) et la règle « ouvrir d’abord ». Le propriétaire n’est pas limité. Une RTC externe et NTP sont facultatifs.
Révoquer un invité
La décision du propriétaire concernant un invité, signée avec le propre owner_secret. Une écriture dans REVOKE (0006), 23 octets :
[guest_id 2B LE][version 4B LE][revoked 1B: 1 revoked, 0 allowed] [ HMAC-SHA256(owner_secret, "rev" || first 7 bytes)[0..15] ]
Le contrôleur garde la dernière version pour chaque invité et n’applique un enregistrement qu’avec une version supérieure : rejouer un ancien « autorisé » ne fait pas revenir un invité révoqué. Un invité révoqué se voit refuser à la fois l’ouverture (0x08) et l’écriture de l’heure.
N’importe quel téléphone peut écrire l’enregistrement : celui du propriétaire ou celui d’un invité qui se trouve à la porte. Le contrôleur fait confiance à la signature, pas à celui qui écrit : une révocation atteint la serrure avec la première personne à proximité au lieu d’attendre l’expiration du pass de l’invité. Un nouvel appairage vide la liste.
8. Position et événements (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
Deux octets d’étiquette, c’est délibéré : l’octet d’état ne pilote que l’indicateur ouvert/fermé et n’accorde aucun droit, tandis que chaque octet supplémentaire de l’annonce coûte de la batterie à chaque réveil. L’étiquette protège d’une altération dans les ondes, pas d’un faussaire — 16 bits tombent en quelques secondes. Rien de ce qui décide de l’accès ne doit passer par là.
Les événements propres du contrôleur
Les autres bits de l'octet d'état portent ce qui s'est passé sur le contrôleur lui-même, pour qu'un téléphone à proximité l'écrive dans le journal du propriétaire. Le contrôleur n'a pas internet : un événement n'arrive au journal que lorsqu'un téléphone capte l'annonce.
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)
Un téléphone garde le dernier numéro vu pour chaque contrôleur. Un nouveau numéro avec un type non nul est un événement ; l'écart entre les numéros indique combien ont été manqués pendant que personne n'était à proximité. Le premier numéro qu'un téléphone voit est un point de départ, pas un événement. Le contrôleur conserve le numéro malgré la veille et les coupures de courant. Une serrure à loquet n'envoie le bloc que si elle a un bouton de sortie.
Avec un fin de course, un appui met la serrure en route (bit 6) jusqu’à ce que le fin de course le confirme. S’il ne confirme pas dans le temps de course réglé dans le contrôleur, le contrôleur le croit et envoie un nouveau numéro d’événement de type 0 : un échec, la motorisation n’est pas arrivée. Le bit 0 indique alors où est vraiment la serrure : ouverte signifie qu’elle ne s’est pas fermée.
La réponse à un tel appui est 0x32 ou 0x33 : la serrure est en route vers ouvert ou fermé. Le téléphone qui a appuyé suit ensuite l'annonce jusqu'à ce que la position se fixe ou qu'un échec arrive.
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 |
0x32 | FIRE OK — OUVERTURE | ||
0x33 | FIRE OK — FERMETURE | ||
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 |
0x08 | l’invité est révoqué sur ce contrôleur | 0x19 | le nonce TIME est périmé ou absent de l’anneau |
0x40 | REVOKE enregistré | 0x1a | TIME d’un invité sans pass valide |
0x41 | la version de REVOKE n’est pas plus récente | 0x42 | REVOKE a une longueur ou une signature incorrecte |
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 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)
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.