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.
1. Kryptografische Primitive
- HMAC-SHA256 — Signaturen und Ableitung. Beim Kürzen auf eine bestimmte Länge werden die ersten N Bytes des Ergebnisses genommen.
- HKDF-SHA256 (RFC 5869) — Schlüsselableitung. Wenn
salt = null, wird ein Nullvektor von 32 Byte verwendet. - X25519 (RFC 7748) — ECDH bei der Kopplung. Der private Schlüssel wird geklemmt:
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
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-Suffix | Eigenschaft | Zweck |
|---|---|---|
0000 | — | Service-UUID (vollständig: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | eine 8-Byte-Nonce (veraltet; ein moderner Client nimmt die Nonce aus dem Advertising, §3) |
0002 FIRE | WRITE | Der Öffnungsbefehl: 24 Byte (Besitzer) oder 47 Byte (Gast) |
0003 TIME | WRITE | Uhrkorrektur: 32 Bytes vom Eigentümer, 55 Bytes von einem Gast |
0004 WIFI | WRITE | SSID und Passwort für NTP (optional) |
0005 RESULT | READ+NOTIFY | Ein Ein-Byte-Ergebniscode nach jedem Schreibvorgang (§9) |
0006 REVOKE | WRITE | Die signierte Entscheidung des Eigentümers über einen Gast, 23 Bytes |
00f0 PAIR_PUB | READ | Nur im Kopplungsmodus: der 32 Byte lange öffentliche X25519-Schlüssel des Controllers (LE) |
00f1 PAIR_DONE | WRITE | Nur im Kopplungsmodus: 36 Byte [phone_pub 32B][device_id 4B LE] |
"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]
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:
- Das Telefon liest
PAIR_PUB(00f0) — den 32 Byte langen öffentlichen Schlüssel des Controllers. - Das Telefon erzeugt sein eigenes X25519-Paar,
shared = X25519(phone_priv, esp_pub). - Beide Seiten leiten dasselbe Geheimnis ab:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- Das Telefon schreibt
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]. das Telefon erzeugt die device_id, und der Controller speichert sie.
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].
| Feld | Größe | Formel |
|---|---|---|
token | 7B | [bleId 2B LE][valid_until 4B LE unix-sec][flags 1B] |
owner_sig | 16B | HMAC-SHA256(owner_secret, token)[0..15] |
nonce | 8B | eine frische Nonce aus dem Advertising |
guest_sig | 16B | HMAC-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)
| Code | Wert | Code | Wert |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — OFFEN | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — GESCHLOSSEN | 0x12 | TIME hat die falsche Länge |
0x32 | FIRE OK — ÖFFNET | ||
0x33 | FIRE OK — SCHLIESST | ||
0x01 | die Nonce ist veraltet oder nicht im Ring | 0x13 | der HMAC von TIME stimmte nicht |
0x02 | die TTL ist abgelaufen | 0x15 | TIME lief rückwärts (Ratsche) |
0x03 | owner_sig ist ungültig | 0x16 | TIME überschreitet die Grenze von +24 h |
0x04 | guest_sig ist ungültig | 0x17 | TIME ohne kürzliches Auslösen |
0x05 | falsche Länge | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI hat das falsche Format |
0x07 | der HMAC des Besitzers ist ungültig | 0x22 | der HMAC von WIFI stimmte nicht |
0x08 | der Gast ist auf diesem Controller widerrufen | 0x19 | TIME-Nonce ist veraltet oder nicht im Ring |
0x40 | REVOKE gespeichert | 0x1a | TIME von einem Gast ohne gültigen Pass |
0x41 | REVOKE-Version ist nicht neuer | 0x42 | REVOKE 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.