❮  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
0000UUID 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, exactement 26 octets
0004 WIFIWRITESSID et mot de passe pour NTP (facultatif)
0005 RESULTREAD+NOTIFYUn code de résultat d'un octet après chaque écriture (§9)
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_secretvalid_until par rapport à son horloge → dérive guest_keyguest_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 ]

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)

CodeValeurCodeValeur
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — OUVERT0x11TIME OK (guest)
0x31FIRE OK — FERMÉ0x12TIME a une longueur incorrecte
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

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.