Spécification du protocole socket
Tout ce qu'il faut pour ajouter le mode en ligne d'Entrixy à votre propre contrôleur : les messages WebSocket, le chiffrement de bout en bout et les vecteurs de test. Nos propres cartes qui le parlent forment la gamme NET ; celles qui fonctionnent uniquement par Bluetooth, la gamme BLE.
device_key pour la production en série est traitée au §10. Référence : esp32-ws-example (MIT).
1. Transport et enveloppe des messages
- Endpoint:
wss://entrixy.com/ws(hostentrixy.com, port 443, chemin/ws). Dans le modèle ouvert, l'hôte provient de la clé ou d'un réglage — voir /open. - TLS: du WSS simple. Aucun sous-protocole n'est négocié.
- Trames : JSON texte (UTF-8). Le discriminant est le champ
type. - Reconnexion : intervalle fixe de 2 s dans le firmware de référence pour ESP32. Watchdog RX : trois périodes de ping sans trame entrante (3 × ping_s, 30 s avec les valeurs par défaut) → redémarrage.
2. Connexion et authentification
Le contrôleur se présente dans la première trame :
→ device_hello
{ "type":"device_hello",
"device_key": "<32 hex>",
"device_secret": "<32 hex>",
"e2ee": true|false } // whether a valid ownerSecret exists (§5)
device_key/device_secret — les deux moitiés hexadécimales de 32 caractères du code de l'appareil (avec un ENTX-DEV:préfixe facultatif). Le serveur vérifie sha256(device_secret) par rapport au hachage par device_key.
❮ device_ok { "type":"device_ok", "hb_interval":300, "ping_s":10 } // success; hb_interval in s (10..3600), ping_s — the ping period
← error { "type":"error", "reason":"auth" } // failure → the server closes
Un contrôleur qui fonctionne via internet maintient lui-même sa connexion au serveur : le serveur voit donc l’adresse IP du contrôleur et l’heure de chaque commande qui lui est envoyée. Seul le contrôleur réseau ESP32 lié au téléphone du propriétaire vérifie la signature de chaque commande (§5–6). Les contrôleurs sur ESP8266, Raspberry Pi et Node.js font confiance au serveur : il peut leur envoyer lui-même une ouverture.
3. Ouverture (mode de base, sans E2EE)
❮ device_command
{ "type":"device_command", "action":"open", "command_id":"<id>", "number_id":<n> }
// action:"close" — for a bistable drive
Le contrôleur envoie une impulsion au relais (1000 ms par défaut) et répond :
→ device_status
{ "type":"device_status", "command_id":"<id>",
"level":"success"|"warning"|"danger"|"info",
"message":"...", "final":true
[, "position":"open"|"closed"|"opening"|"closing"|"unknown" ] }
level hors de la liste blanche est traité comme info. L'app affiche message mot pour mot et l'écrit dans le journal à côté de l'appui.
Le contrôleur signale aussi ce qui se passe sur lui-même. Un tel device_status a un command_id vide et un champ event — l'app l'écrit dans le journal du propriétaire.
→ { "type":"device_status", "command_id":"", "event":"button"|"limit"|"auto",
"level":"success", "message":"Opened", "final":true, "position":"open" }
| event | Quand |
|---|---|
button | le bouton de sortie intérieur a été pressé ; pour un entraînement bistable il alterne ouvrir / fermer |
limit | le fin de course : la porte a été déplacée à la main, ou la motorisation n’est pas arrivée dans le temps de course réglé dans le contrôleur là où un appui l’a envoyée (level danger, message "Did not close" ou "Did not open"). Quand le fin de course confirme un appui, l’événement porte "confirm":true: l’app met à jour cet appui dans le journal au lieu d’ajouter une ligne |
auto | le contrôleur s'est fermé de lui-même après le nombre de secondes réglé |
Avec un fin de course, le contrôleur signale opening ou closing depuis l’impulsion jusqu’à ce que le fin de course confirme la position. Sans fin de course, il signale tout de suite la position visée.
"hold":true demande à l’app de garder le message sur la carte jusqu’au message suivant du contrôleur ; sans lui, le message disparaît après quelques secondes. Les contrôleurs le mettent pour Ouverture et Closing (niveau info, le portail est en route) et pour un échec.
La réponse à une commande qui a réellement lancé le moteur porte "started":true. Le serveur n’attend la fin donnée par le fin de course que pour un tel appui : « Occupé » ou « déjà en ouverture » sur un second appui ne s’approprie pas le mouvement d’un autre.
4. Heartbeat et détection de vie
Le serveur ne ping pas les appareils — ce fan-out ne passe pas à l'échelle. C'est l'appareil qui maintient la connexion en vie :
→ { "type":"ping" } ← { "type":"pong" } // the device sends this every ping_s seconds
Si un appareil reste silencieux plus longtemps que le serveur ne le permet, le serveur le déconnecte et l’affiche hors ligne. Le serveur indique la période dans device_ok.ping_s: un tiers de ce temps (DEVICE_OFFLINE_S, 30 s par défaut). Un appareil qui envoie "ping_ctl":true dans device_hello la suit ; les autres envoient un ping toutes les 30 s et passent hors ligne après 90 s de silence. À un ping venu du serveur, l'appareil y répond par pong. Pour une motorisation bistable, il existe un heartbeat de l'état de l'objet : un device_status sans command_id avec position toutes les hb_interval secondes.
5. E2EE : provisionnement de l'ownerSecret
Le chiffrement de bout en bout garantit que le serveur ne peut pas forger une ouverture — la commande est signée par l'application. ownerSecret (32 octets) n'est pas inscrit dans le micrologiciel : c'est l'application du propriétaire qui le livre par le port série USB:
PROVISION <64 hex> → store the ownerSecret in NVS, return the fingerprint WIPE → erase it (basic mode) STATUS → show the fingerprint fingerprint = HMAC-SHA256(ownerSecret, "fp")[0..3] (hex)
Après PROVISION ou WIPE, le contrôleur se reconnecte avec un e2ee mis à jour. Un contrôleur provisionné rejette un simple device_command — seul le §6 s'applique.
6. E2EE : ouverture par défi-réponse
Avec e2ee=true au lieu de device_command on procède par défi-réponse. La fraîcheur vient d'un nonce à usage unique, pas du temps :
1. ← sock_challenge_req { "type":"sock_challenge_req", "command_id":"<id>" }
2. → sock_challenge { "type":"sock_challenge", "command_id":"<id>", "nonce":"<b64 16B>" }
3. ← sock_fire { "type":"sock_fire", "command_id","number_id",
"token":"<b64>", "owner_sig":"<b64>", "guest_id":<n>,
"nonce":"<b64>", "proof":"<b64>" [, "action":"close"] }
4. → device_status success
| Qui | token | Vérification |
|---|---|---|
| Propriétaire | vide | proof == HMAC-SHA256(ownerSecret, nonce)[0..15] |
| Invité | 3 octets | vérifier owner_sig, dériver guest_key, vérifier proof |
token (3B) = [guest_did 2B LE][perms 1B] // a capability, no TTL owner_sig = HMAC-SHA256(ownerSecret, token)[0..15] guest_key = HKDF-SHA256(salt=null, ikm=ownerSecret, info="guest"||guest_did(2B LE), L=32) proof = HMAC-SHA256(guest_key, nonce)[0..15]
Une fermeture d'un entraînement bistable porte "action":"close" et se signe sur le nonce suivi des cinq octets ASCII close : proof = HMAC-SHA256(key, nonce || "close")[0..15]. Ici key est l' ownerSecret du propriétaire ou le guest_key de l'invité. Une ouverture se signe sur le nonce seul. Le serveur ne peut donc changer ni une ouverture en fermeture ni l'inverse : la signature ne correspondrait pas. C'est celui qui signe qui décide quoi faire, selon la position signalée par le contrôleur.
Le contrôleur accepte un nonce pendant 30 secondes après l’avoir émis, et seulement sur la connexion qui l’a émis : une commande signée retenue par le serveur ne fonctionne plus ensuite.
Les champs binaires sont en base64. Le nonce fait 16 octets et les signatures sont tronquées à 16. Le salt=null de HKDF vaut 32 octets nuls.
7. Révoquer un invité
❮ sock_revoke { "type":"sock_revoke", "guest_id","version","revoked", "sig":"<b64>" }
→ sock_revoke_ack
sig = HMAC-SHA256(ownerSecret, "rev" || guest_id(2B LE) || version(4B LE) || revoked(1B))[0..15]
Le contrôleur ne l'applique que lorsque version est supérieur à celui qui est stocké — c'est monotone, si bien que le serveur ne peut ni le forger ni rétablir l'accès.
Le contrôleur réseau ESP32 conserve jusqu’à 96 décisions sur les invités. Quand la liste est pleine, une entrée « autorisé » cède sa place à une nouvelle révocation ; si les 96 sont des révocations, la plus ancienne est supprimée.
8. Primitives cryptographiques
- HMAC-SHA256 — signatures ; la troncature prend les 16 premiers octets.
- HKDF-SHA256 (RFC 5869),
salt=null→ 32 octets nuls. - L'ouverture par socket n'utilise pas AES/GCM — seulement HMAC et HKDF. (AES-256-GCM relève du sous-système distinct qui chiffre les lots d'invités, pas d'ici.)
La dérivation de guest_key et les signatures sont identiques à la branche invité de BLE — la cryptographie est réutilisée.
9. Vecteurs de test (E2EE)
Vecteurs à réponse connue. Les champs binaires sont donnés en hexadécimal et en base64 ; les trames transportent du base64.
ownerSecret (32B) = a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf fingerprint = b358977a = HMAC(ownerSecret,"fp")[0..3] nonce (16B) = 000102030405060708090a0b0c0d0e0f b64 = AAECAwQFBgcICQoLDA0ODw==
Ouverture propriétaire (jeton vide)
proof (16B) = 3fc0619c684a8261d06c1501ae4e726a = HMAC(ownerSecret, nonce)[0..15] proof b64 = P8BhnGhKgmHQbBUBrk5yag==
Guest fire (guest_did=0x0042, perms=0x01)
token (3B) = 420001 b64 = QgAB owner_sig (16B) = 850a9f31fb708b176f4ed62a43acf0ad = HMAC(ownerSecret, token)[0..15] b64 = hQqfMftwixdvTtYqQ6zwrQ== guest_key (32B) = 614da70a34890a807a25f7c5f271e530434c478f144370a2b9efaf0c58cd85c1 = HKDF(salt=null, ownerSecret, info="guest"||did_LE, L=32) proof (16B) = a8a383ae56ac8a0182df2a275e220e83 = HMAC(guest_key, nonce)[0..15] b64 = qKODrlasigGC3yonXiIOgw==
Fermeture (entraînement bistable)
signed input = nonce || "close" = 000102030405060708090a0b0c0d0e0f636c6f7365 owner proof = ef15ca41823f126de0815f84c7dc8275 = HMAC(ownerSecret, input)[0..15] b64 = 7xXKQYI/Em3ggV+Ex9yCdQ== guest proof = 54f23d61766a274d8996c26e67925c32 = HMAC(guest_key, input)[0..15] b64 = VPI9YXZqJ02JlsJuZ5JcMg==
Revoke (guest_id=0x0042, version=3, revoked=1)
sig input = "rev"||guest_id_LE(2)||version_LE(4)||revoked(1) = 72657642000300000001 sig (16B) = 9433b96ddbb599f6c72bf17c0c9825fe b64 = lDO5bdu1mfbHK/F8DJgl/g==
10. Obtention d'un device_key (pour les OEM)
Pour que le serveur reconnaisse un appareil et sache à qui il appartient, il faut la paire device_key/device_secret, connue du serveur et liée au compte du propriétaire.
- Pour un prototype ou une pièce unique, aujourd'hui : le propriétaire crée un objet de type Appareil dans l'application, reçoit la paire, et le configurateur l'inscrit dans le micrologiciel. Cela suffit pour monter tout le protocole et le tester.
- Pour la production en série : le modèle "claim" — l'appareil sort d'usine sans appartenir à personne, et l'acheteur le lie à son compte avec un code ou un QR. Les lots de codes et d'étiquettes sont émis dans le compte fabricant : vous déposez une demande et, une fois approuvée, vous générez un lot de
device_key, recevez un CSV des codes et des étiquettes QR toutes prêtes. Pour toute question sur les conditions, écrivez à hello@entrixy.com.
Une implémentation fonctionnelle des deux côtés est esp32-ws-example (MIT). Pour compiler .bin — voir configurateur dans le navigateur. Comment cela s'inscrit dans l'architecture ouverte : /open.