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, exactly 26 bytes |
0004 WIFI | WRITE | SSID and password for NTP (optional) |
0005 RESULT | READ+NOTIFY | A one-byte result code after every write (§9) |
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 → valid_until against its clock → derives guest_key → guest_sig. It keeps no list of guests — it only verifies the signature.
7. Time synchronisation
A guest TTL needs a clock. Any trusted client can set the time with a signature. A write to TIME (0003), 26 bytes:
[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]
bleId = 0→ owner,key = owner_secret.bleId ≠ 0→ guest,key = guest_key(as in §6).
Guest time is protected by a ratchet (no going back), a forward cap (no more than +24 h per write) and a fire-first gate. An external RTC and NTP are optional.
8. Bistable state (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.
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 |
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 |
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-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)
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.