❮  Contrôleurs Entrixy

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.

Contrairement à BLE, un contrôleur socket garde une connexion permanente au serveur Entrixy par WebSocket et peut être ouvert depuis n'importe où. Le mode de base est entièrement décrit par cette spécification. Pour le chiffrement de bout en bout, où le serveur ne peut pas forger une ouverture, voir §5–7. L'obtention d'un 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

Le firmware de référence pour ESP32 et ESP8266 vérifie le certificat du serveur avec les racines Let’s Encrypt (ISRG Root X1 et X2) et règle son horloge par NTP pour en vérifier les dates. Un serveur dont le certificat vient d’une autre autorité place sa racine dans SERVER_CA du firmware. Si SERVER_CA reste vide, le firmware se connecte sans vérifier le serveur.

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" }
eventQuand
buttonle bouton de sortie intérieur a été pressé ; pour un entraînement bistable il alterne ouvrir / fermer
limitle 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
autole 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
QuitokenVérification
Propriétairevideproof == HMAC-SHA256(ownerSecret, nonce)[0..15]
Invité3 octetsvé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

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.

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.