❮  Entrixy-Controller

Spezifikation des BLE-Protokolls

Alles, was Sie brauchen, um Ihrem eigenen Controller — Ihrer Platine, Ihrem Mikrocontroller — Entrixy-BLE-Unterstützung zu geben. Unsere fertige Firmware macht genau das; unten stehen die genaue Protokollbeschreibung und Testvektoren, damit Sie sich Byte für Byte prüfen können.

Ein BLE-Controller braucht den Entrixy-Server nicht. Er geht überhaupt nie online: Das Telefon spricht direkt über Bluetooth mit ihm. Die Bindung an einen Besitzer erfolgt lokal (X25519 ECDH). Das Gerät bei unserem Server anzumelden, Schlüssel zu beziehen oder ein Herstellerkonto zu eröffnen, ist nicht erforderlich — Sie können leere Controller ausliefern. Referenz: esp32-example/entrixy-ble (MIT).

1. Kryptografische Primitive

Referenz: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) auf dem ESP, BouncyCastle auf Android — beide Seiten stimmen überein.

2. GATT: Dienst und Merkmale

Alle UUIDs sind 128 Bit lang und basieren auf 656e7472-7869-7900-XXXX-426c45000001 („entrixy“ in ASCII).

UUID-SuffixEigenschaftZweck
0000—Service-UUID (vollständig: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADeine 8-Byte-Nonce (veraltet; ein moderner Client nimmt die Nonce aus dem Advertising, §3)
0002 FIREWRITEDer Öffnungsbefehl: 24 Byte (Besitzer) oder 47 Byte (Gast)
0003 TIMEWRITEUhrkorrektur: 32 Bytes vom Eigentümer, 55 Bytes von einem Gast
0004 WIFIWRITESSID und Passwort für NTP (optional)
0005 RESULTREAD+NOTIFYEin Ein-Byte-Ergebniscode nach jedem Schreibvorgang (§9)
0006 REVOKEWRITEDie signierte Entscheidung des Eigentümers über einen Gast, 23 Bytes
00f0 PAIR_PUBREADNur im Kopplungsmodus: der 32 Byte lange öffentliche X25519-Schlüssel des Controllers (LE)
00f1 PAIR_DONEWRITENur im Kopplungsmodus: 36 Byte [phone_pub 32B][device_id 4B LE]
Der Name "entrixy" und die Service-UUID wandern in das scan-responsestatt in das Primärpaket: Eine 128-Bit-UUID samt Herstellerdaten passt nicht in die 31 Byte des Primärpakets. Passives Scannen unter Android sieht nur das Primärpaket, gefiltert wird deshalb nach den Herstellerdaten (Company ID 0x00E0).

3. GAP-Advertising

Alle sleep_interval Sekunden meldet der Controller seine Anwesenheit mit herstellerspezifischen Daten. Die Company ID lautet 0x00E0 (auf der Leitung E0 00). Der Block ist 23 Byte = 2 Byte Company ID plus 21 Byte Nutzdaten:

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)

Die Signatur erstreckt sich über 13 Byte (die Company ID zählt nicht mit):

mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4)
auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
Diese 8 Byte auth_hmac dienen als nonce für ein sofortiges Öffnen ohne Rückfrage: Ein Beobachter ohne owner_secret sieht darin nur Zufall, während Besitzer oder Gast die Signatur berechnen und sofort auslösen. Der Controller führt einen Ring der letzten 16 von ihm ausgegebenen Nonces.

Optionen (opts) — XOR-Schlüsselstrom

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

Bistabiler Modus

Bei einem bistabilen Antrieb werden drei Byte an die Nutzdaten angehängt, sodass sich 26 Byte ergeben (siehe §8).

4. Kopplung

Ein neuer Controller geht von selbst für 30–90 s in den Kopplungsmodus (sonst per Taste; ein Zurücksetzen auf Werkseinstellungen ist ein 5-Sekunden-Druck). Der Austausch läuft lokal, ohne Server:

  1. Das Telefon liest PAIR_PUB (00f0) — den 32 Byte langen öffentlichen Schlüssel des Controllers.
  2. Das Telefon erzeugt sein eigenes X25519-Paar, shared = X25519(phone_priv, esp_pub).
  3. Beide Seiten leiten dasselbe Geheimnis ab:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. Das Telefon schreibt PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]. das Telefon erzeugt die device_id, und der Controller speichert sie.
X25519-Schlüssel sind Little-Endian (RFC 7748). owner_secret geht nie über die Luft. Kein Server ist beteiligt: Die App hat die device_id selbst erzeugt und kennt sie deshalb.

5. Öffnen durch den Besitzer (owner fire)

Ein Schreibvorgang auf FIRE (0002), 24 Byte:

[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]

nonce — die 8 Byte auth_hmac aus einem frischen Advertising. Der Controller prüft die Nonce gegen seinen Ring, verifiziert die Signatur, gibt einen Impuls auf das Relais und entwertet die Nonce.

6. Öffnen durch einen Gast (guest fire)

Ein Gast kennt nicht owner_secret. Der Besitzer stellt über den Server ein Bündel aus: token, owner_sig, guest_key. Ein Schreibvorgang auf FIRE, 47 Byte: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].

FeldGrößeFormel
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Beine frische Nonce aus dem Advertising
guest_sig16BHMAC-SHA256(guest_key, nonce)[0..15]
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)

Der Controller: Nonce im Ring → owner_sig mit seinem eigenen owner_secret → der Gast steht nicht auf der Widerrufsliste → valid_until gegen seine Uhr → leitet guest_key → guest_sig.

Die einzige Liste, die der Controller führt, sind die Widerrufe des Eigentümers (§7).

7. Zeitsynchronisation

Eine Gast-TTL braucht eine Uhr. Stellen können sie der Eigentümer und ein Gast mit gültigem Pass. Jeder Schreibvorgang trägt eine vom Controller ausgegebene Nonce, und der Controller verbraucht sie: Ein über Funk mitgeschnittener Schreibvorgang lässt sich später nicht wiederholen, um die Uhr zurückzudrehen.

Die Nonce stammt aus einem Lesen von NONCE (0001) oder aus einem frischen Advertisement. Ein Schreibvorgang auf 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] ]

Ein Gast braucht denselben Pass wie für ein Öffnen (§6): vom Eigentümer signiert, nicht widerrufen, jetzt gültig und zu der vorgeschlagenen Zeit gültig. Ein widerrufener Gast kann die Uhr nicht über die Pässe aller anderen hinausschieben.

Die Gastzeit ist außerdem durch eine Ratsche (kein Zurück), eine Obergrenze nach vorn (höchstens +24 h pro Schreibvorgang) und eine Fire-first-Sperre geschützt. Der Eigentümer ist nicht eingeschränkt. Eine externe RTC und NTP sind optional.

Einen Gast entziehen

Die Entscheidung des Eigentümers über einen Gast, signiert mit dem eigenen owner_secret. Ein Schreibvorgang auf REVOKE (0006), 23 Bytes:

[guest_id 2B LE][version 4B LE][revoked 1B: 1 revoked, 0 allowed]
[ HMAC-SHA256(owner_secret, "rev" || first 7 bytes)[0..15] ]

Der Controller speichert pro Gast die neueste Version und übernimmt einen Eintrag nur mit höherer Version, sodass das Wiederholen eines alten „erlaubt“ einen widerrufenen Gast nicht zurückholt. Einem widerrufenen Gast werden sowohl das Öffnen (0x08) als auch das Schreiben der Zeit verweigert.

Jedes Telefon darf den Eintrag schreiben: das des Eigentümers oder das eines Gastes, der gerade an der Tür ist. Der Controller vertraut der Signatur, nicht dem Schreibenden, daher erreicht ein Widerruf das Schloss mit der ersten Person in der Nähe, statt auf das Ablaufen des Gastpasses zu warten. Ein erneutes Koppeln leert die Liste.

8. Stellung und Ereignisse (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

Zwei Byte Tag sind Absicht: Das Zustandsbyte steuert nur die Anzeige „offen/geschlossen“ und gewährt keine Rechte, während jedes zusätzliche Byte des Advertisements bei jedem Aufwachen Akku kostet. Das Tag schützt vor Verfälschung im Funk, nicht vor einem Fälscher — 16 Bit fallen in Sekunden. Nichts, was über Zugang entscheidet, darf diesen Weg nehmen.

Eigene Ereignisse des Controllers

Die übrigen Bits des Zustandsbytes tragen, was am Controller selbst passiert ist, damit ein Telefon in der Nähe es ins Protokoll des Besitzers schreiben kann. Der Controller hat kein Internet: Ein Ereignis gelangt erst ins Protokoll, wenn ein Telefon die Werbung empfängt.

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)

Ein Telefon merkt sich für jeden Controller die zuletzt gesehene Nummer. Eine neue Nummer mit einem Typ ungleich null ist ein Ereignis; die Differenz der Nummern zeigt, wie viele verpasst wurden, während niemand in der Nähe war. Die erste Nummer, die ein Telefon sieht, ist ein Ausgangspunkt, kein Ereignis. Der Controller behält die Nummer über Schlaf und Stromausfall. Ein Schnapperschloss sendet den Block nur, wenn es eine Ausgangstaste hat.

Mit Endschalter versetzt ein Druck das Schloss in Bewegung (Bit 6), bis der Endschalter sie bestätigt. Bestätigt er nicht innerhalb der im Controller eingestellten Fahrzeit, glaubt der Controller ihm und sendet eine neue Ereignisnummer mit Typ 0: ein Fehler, der Antrieb ist nicht angekommen. Bit 0 zeigt dann, wo das Schloss wirklich ist: offen heißt, es hat nicht geschlossen.

Die Antwort auf einen solchen Druck ist 0x32 oder 0x33: Das Schloss ist auf dem Weg zu offen oder geschlossen. Das Telefon, das gedrückt hat, verfolgt danach die Werbung, bis die Position feststeht oder ein Fehler kommt.

9. Antwortcodes (RESULT, Merkmal 0005)

CodeWertCodeWert
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — OFFEN0x11TIME OK (guest)
0x31FIRE OK — GESCHLOSSEN0x12TIME hat die falsche Länge
0x32FIRE OK — ÖFFNET
0x33FIRE OK — SCHLIESST
0x01die Nonce ist veraltet oder nicht im Ring0x13der HMAC von TIME stimmte nicht
0x02die TTL ist abgelaufen0x15TIME lief rückwärts (Ratsche)
0x03owner_sig ist ungültig0x16TIME überschreitet die Grenze von +24 h
0x04guest_sig ist ungültig0x17TIME ohne kürzliches Auslösen
0x05falsche Länge0x20WIFI OK
0x06HKDF failed0x21WIFI hat das falsche Format
0x07der HMAC des Besitzers ist ungültig0x22der HMAC von WIFI stimmte nicht
0x08der Gast ist auf diesem Controller widerrufen0x19TIME-Nonce ist veraltet oder nicht im Ring
0x40REVOKE gespeichert0x1aTIME von einem Gast ohne gültigen Pass
0x41REVOKE-Version ist nicht neuer0x42REVOKE hat die falsche Länge oder Signatur

10. Testvektoren

Deterministische Vektoren mit bekannten Ergebnissen: Lassen Sie Ihre Umsetzung laufen und vergleichen Sie die Bytes. Alle Werte sind hexadezimal, die Byte-Reihenfolge ist wie auf der Leitung. Die Kette läuft durchgehend: Kopplung → owner_secret → alles Weitere.

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)

Referenzcode und Kontakt

Eine funktionierende Umsetzung beider Seiten ist esp32-example/entrixy-ble (MIT). Um .bin für Ihre eigene Platine zu bauen, nutzen Sie den Konfigurator im Browser. Der Online-Transport ist Socket-Protokoll. Wie sich das in die offene Architektur einfügt: /open. OEM-Anfragen: hello@entrixy.com.