❮  Contrôleurs Entrixy

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.

Un contrôleur BLE n'a pas besoin du serveur Entrixy. Il ne va jamais en ligne : le téléphone lui parle directement par Bluetooth. La liaison à un propriétaire est locale (X25519 ECDH). Enregistrer l'appareil sur notre serveur, obtenir des clés ou ouvrir un compte fabricant n'est pas nécessaire — vous pouvez expédier des contrôleurs vides. Référence : esp32-example/entrixy-ble (MIT).

1. Primitives cryptographiques

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'UUIDPropriétéRôle
0000—UUID de service (complet : 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADun nonce de 8 octets (hérité ; un client moderne prend le nonce dans l'annonce, §3)
0002 FIREWRITELa commande d'ouverture : 24 octets (propriétaire) ou 47 octets (invité)
0003 TIMEWRITECorrection de l’horloge : 32 octets du propriétaire, 55 octets d’un invité
0004 WIFIWRITESSID et mot de passe pour NTP (facultatif)
0005 RESULTREAD+NOTIFYUn code de résultat d'un octet après chaque écriture (§9)
0006 REVOKEWRITELa décision signée du propriétaire concernant un invité, 23 octets
00f0 PAIR_PUBREADUniquement en mode appairage : la clé publique X25519 de 32 octets du contrôleur (LE)
00f1 PAIR_DONEWRITEUniquement en mode appairage : 36 octets [phone_pub 32B][device_id 4B LE]
Le nom "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]
Ces 8 octets 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 :

  1. Le téléphone lit PAIR_PUB (00f0) — la clé publique de 32 octets du contrôleur.
  2. Le téléphone génère sa propre paire X25519, shared = X25519(phone_priv, esp_pub).
  3. 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)
  4. 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.
Les clés X25519 sont en little-endian (RFC 7748). 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].

ChampTailleFormule
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Bun nonce frais issu de l'annonce
guest_sig16BHMAC-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)

CodeValeurCodeValeur
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — OUVERT0x11TIME OK (guest)
0x31FIRE OK — FERMÉ0x12TIME a une longueur incorrecte
0x32FIRE OK — OUVERTURE
0x33FIRE OK — FERMETURE
0x01le nonce est périmé ou absent de l'anneau0x13le HMAC de TIME ne correspond pas
0x02le TTL a expiré0x15TIME est revenu en arrière (cliquet)
0x03owner_sig est invalide0x16TIME dépasse le plafond de +24 h
0x04guest_sig est invalide0x17TIME sans déclenchement récent
0x05longueur incorrecte0x20WIFI OK
0x06HKDF failed0x21WIFI a un format incorrect
0x07le HMAC du propriétaire est invalide0x22le HMAC de WIFI ne correspond pas
0x08l’invité est révoqué sur ce contrôleur0x19le nonce TIME est périmé ou absent de l’anneau
0x40REVOKE enregistré0x1aTIME d’un invité sans pass valide
0x41la version de REVOKE n’est pas plus récente0x42REVOKE 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.