BLE protocol specification
Everything you need to add Entrixy BLE support to your own controller — your board, your microcontroller. Our ready firmware does exactly this; below is the precise protocol description and test vectors so you can check yourself byte for byte.
1. Cryptographic primitives
- HMAC-SHA256 — signatures and derivation. Truncation to a given length takes the first N bytes of the result.
- HKDF-SHA256 (RFC 5869) — key derivation. If
salt = null, a 32-byte zero vector is used. - X25519 (RFC 7748) — ECDH during pairing. The private key is clamped:
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
Reference: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) on the ESP, BouncyCastle on Android — both sides agree.
2. GATT: service and characteristics
All UUIDs are 128-bit, based on 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" in ASCII).
| UUID suffix | Property | Purpose |
|---|---|---|
0000 | — | Service UUID (full: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | an 8-byte nonce (legacy; a modern client takes the nonce from the advertisement, §3) |
0002 FIRE | WRITE | The open command: 24 bytes (owner) or 47 bytes (guest) |
0003 TIME | WRITE | Clock correction: 32 bytes from the owner, 55 bytes from a guest |
0004 WIFI | WRITE | SSID and password for NTP (optional) |
0005 RESULT | READ+NOTIFY | A one-byte result code after every write (§9) |
0006 REVOKE | WRITE | The owner's signed decision about one guest, 23 bytes |
00f0 PAIR_PUB | READ | Pairing mode only: the controller's 32-byte X25519 public key (LE) |
00f1 PAIR_DONE | WRITE | Pairing mode only: 36 bytes [phone_pub 32B][device_id 4B LE] |
"entrixy" and the Service UUID go into the scan-responserather than the primary packet: a 128-bit UUID plus manufacturer data will not fit into the 31-byte primary. Android passive scanning sees only the primary, so filtering is done on the manufacturer data (company id 0x00E0).3. GAP advertisement
Once every sleep_interval seconds the controller advertises its presence with manufacturer-specific data. The company ID is 0x00E0 (on the wire E0 00). The block is 23 bytes = 2 bytes of company ID plus 21 bytes of value:
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)
The signature covers 13 bytes (the company ID is not included):
mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4) auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
auth_hmac serve as the nonce for an immediate open with no round trip: an observer without owner_secret sees them as random, while the owner or guest computes the signature and fires straight away. The controller keeps a ring of the last 16 nonces it issued.Options (opts) — XOR keystream
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
Bistable mode
For a bistable drive three bytes are appended to the value, giving 26 bytes (see §8).
4. Pairing
A new controller enters pairing mode by itself for 30–90 s (otherwise by button; a factory reset is a 5-second hold). The exchange is local, with no server:
- The phone reads
PAIR_PUB(00f0) — the controller's 32-byte public key. - The phone generates its own X25519 pair,
shared = X25519(phone_priv, esp_pub). - Both sides derive the same secret:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- The phone writes
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]. the phone generates the device_id, and the controller stores it.
owner_secret never goes over the air. No server is involved: the app generated the device_id itself, which is how it knows it.5. Opening by the owner (owner fire)
A write to FIRE (0002), 24 bytes:
[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]
nonce — the 8-byte auth_hmac from a fresh advertisement. The controller checks the nonce against its ring, verifies the signature, pulses the relay and invalidates the nonce.
6. Opening by a guest (guest fire)
A guest does not know owner_secret. The owner issues a bundle through the server: token, owner_sig, guest_key. A write to FIRE, 47 bytes: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].
| Field | Size | Formula |
|---|---|---|
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 | a fresh nonce from the advertisement |
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)
The controller: nonce in the ring → owner_sig with its own owner_secret → the guest is not on the revocation list → valid_until against its clock → derives guest_key → guest_sig.
The only list the controller keeps is the owner's revocations (§7).
7. Time synchronisation
A guest TTL needs a clock. The owner and a guest with a valid pass can set it. Every write carries a nonce the controller issued, and the controller spends it: a write recorded over the air cannot be played back later to wind the clock back.
The nonce comes from a read of NONCE (0001) or from a fresh advertisement. A write to 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] ]
A guest needs the same pass as for a fire (§6): signed by the owner, not revoked, valid now and valid at the time it proposes. A revoked guest cannot move the clock past everyone else's passes.
Guest time is also protected by a ratchet (no going back), a forward cap (no more than +24 h per write) and a fire-first gate. The owner is not limited. An external RTC and NTP are optional.
Revoking a guest
The owner's decision about one guest, signed with the controller's own owner_secret. A write to 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] ]
The controller keeps the latest version per guest and applies a record only with a higher version, so replaying an old "allowed" cannot bring a revoked guest back. A revoked guest is refused both a fire (0x08) and a time write.
Any phone may write the record: the owner's or a guest's that happens to be at the door. The controller trusts the signature, not the writer, so a revocation reaches the lock with the first person nearby instead of waiting for the guest's pass to expire. Pairing again clears the list.
8. State and events (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
Two bytes of tag are deliberate: the state byte only drives the open/closed indicator and grants no authority, while every extra byte of advertisement costs battery on every wake-up. The tag guards against corruption on the air, not against a forger — 16 bits fall to brute force in seconds. Nothing that decides access may travel this way.
The controller's own events
The other bits of the state byte carry what happened on the controller by itself, so a phone nearby can write it into the owner's log. The controller has no internet: an event reaches the log only when a phone hears the advertisement.
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)
A phone keeps the last number it saw for each controller. A new number with a non-zero type is an event; the difference between the numbers tells how many were missed while nobody was near. The first number a phone ever sees is a starting point, not an event. The controller keeps the number across sleep and power loss. A momentary lock sends the block only when it has an exit button.
With a limit switch a press puts the lock on its way (bit 6) until the switch confirms it. If the switch does not confirm within the travel time set in the controller, the controller believes it and sends a new event number with type 0: a failure, the drive did not get there. Bit 0 then holds where the lock really is, so open means it did not close.
The reply to such a press is 0x32 or 0x33: the lock is on its way to open or to closed. The phone that pressed then follows the advertisement until the position settles or a failure comes.
9. Response codes (RESULT, characteristic 0005)
| Code | Value | Code | Value |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — OPEN | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — CLOSED | 0x12 | TIME has the wrong length |
0x32 | FIRE OK — OPENING | ||
0x33 | FIRE OK — CLOSING | ||
0x01 | the nonce is stale or not in the ring | 0x13 | the TIME HMAC did not match |
0x02 | the TTL has expired | 0x15 | TIME went backwards (ratchet) |
0x03 | owner_sig is invalid | 0x16 | TIME exceeds the +24 h cap |
0x04 | guest_sig is invalid | 0x17 | TIME without a recent fire |
0x05 | wrong length | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI has the wrong format |
0x07 | the owner HMAC is invalid | 0x22 | the WIFI HMAC did not match |
0x08 | the guest is revoked on this controller | 0x19 | TIME nonce is stale or not in the ring |
0x40 | REVOKE stored | 0x1a | TIME from a guest without a valid pass |
0x41 | REVOKE version is not newer | 0x42 | REVOKE has the wrong length or signature |
10. Test vectors
Deterministic known-answer vectors: run your implementation and compare the bytes. All values are hex and the byte order is as on the wire. The chain runs end to end: pairing → owner_secret → everything else.
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)
Reference code and contact
A working implementation of both sides is esp32-example/entrixy-ble (MIT). To build .bin for your own board, use the browser configurator. The online transport is socket protocol. How this fits into the open architecture: /open. OEM enquiries: hello@entrixy.com.