Especificación del protocolo BLE
Todo lo necesario para añadir compatibilidad BLE de Entrixy a tu propio controlador: tu placa, tu microcontrolador. Nuestro firmware ya hecho hace exactamente esto; abajo está la descripción precisa del protocolo y los vectores de prueba para que te compruebes byte a byte.
1. Primitivas criptográficas
- HMAC-SHA256 — firmas y derivación. El truncado a una longitud dada toma los primeros N bytes del resultado.
- HKDF-SHA256 (RFC 5869) — derivación de claves. Si
salt = null, se usa un vector de ceros de 32 bytes. - X25519 (RFC 7748) — ECDH durante el emparejamiento. La clave privada se ajusta (clamping):
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
Referencia: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) en el ESP, BouncyCastle en Android: ambos lados coinciden.
2. GATT: servicio y características
Todos los UUID son de 128 bits y se basan en 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" en ASCII).
| Sufijo del UUID | Propiedad | Para qué sirve |
|---|---|---|
0000 | — | UUID del servicio (completo: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | un nonce de 8 bytes (heredado; un cliente moderno toma el nonce del anuncio, §3) |
0002 FIRE | WRITE | La orden de apertura: 24 bytes (propietario) o 47 bytes (invitado) |
0003 TIME | WRITE | Corrección del reloj, exactamente 26 bytes |
0004 WIFI | WRITE | SSID y contraseña para NTP (opcional) |
0005 RESULT | READ+NOTIFY | Un código de resultado de un byte tras cada escritura (§9) |
00f0 PAIR_PUB | READ | Solo en modo de emparejamiento: la clave pública X25519 de 32 bytes del controlador (LE) |
00f1 PAIR_DONE | WRITE | Solo en modo de emparejamiento: 36 bytes [phone_pub 32B][device_id 4B LE] |
"entrixy" y el UUID del servicio van al scan-responseen lugar del paquete primario: un UUID de 128 bits más los datos de fabricante no caben en los 31 bytes del primario. El escaneo pasivo de Android solo ve el primario, así que el filtrado se hace por los datos de fabricante (id de compañía 0x00E0).3. Anuncio GAP
Cada sleep_interval segundos, el controlador anuncia su presencia con datos específicos de fabricante. El id de compañía es 0x00E0 (en el aire E0 00). El bloque ocupa 23 bytes = 2 bytes de id de compañía más 21 bytes de valor:
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 firma cubre 13 bytes (el id de compañía no se incluye):
mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4) auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
auth_hmac hacen de nonce para una apertura inmediata sin ida y vuelta: un observador sin owner_secret los ve como aleatorios, mientras que el propietario o el invitado calcula la firma y dispara al momento. El controlador mantiene un anillo con los últimos 16 nonces que emitió.Opciones (opts): flujo de clave 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
Modo biestable
Para un motor biestable se añaden tres bytes al valor, con lo que quedan 26 bytes (ver §8).
4. Emparejamiento
Un controlador nuevo entra solo en modo de emparejamiento durante 30–90 s (si no, con el botón; el reinicio de fábrica es una pulsación de 5 segundos). El intercambio es local, sin servidor:
- El teléfono lee
PAIR_PUB(00f0): la clave pública de 32 bytes del controlador. - El teléfono genera su propio par X25519,
shared = X25519(phone_priv, esp_pub). - Ambos lados derivan el mismo secreto:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- El teléfono escribe
PAIR_DONE(00f1):[phone_pub 32B][device_id 4B LE]. el teléfono genera el device_id, y el controlador lo guarda.
owner_secret nunca viaja por el aire. No interviene ningún servidor: la aplicación generó el device_id ella misma, por eso lo conoce.5. Apertura por el propietario (owner fire)
Una escritura en FIRE (0002), 24 bytes:
[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]
nonce — los 8 bytes auth_hmac de un anuncio reciente. El controlador comprueba el nonce contra su anillo, verifica la firma, da un pulso al relé e invalida el nonce.
6. Apertura por un invitado (guest fire)
Un invitado no conoce owner_secret. El propietario emite un paquete a través del servidor: token, owner_sig, guest_key. Una escritura en FIRE, 47 bytes: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].
| Campo | Tamaño | Fórmula |
|---|---|---|
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 | un nonce reciente del anuncio |
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)
El controlador: nonce en el anillo → owner_sig con su propia owner_secret → valid_until contra su reloj → deriva guest_key → guest_sig. No mantiene ninguna lista de invitados: solo verifica la firma.
7. Sincronización de la hora
El TTL de un invitado necesita un reloj. Cualquier cliente de confianza puede fijar la hora con una firma. Una escritura en TIME (0003), 26 bytes:
[bleId 2B LE][epoch_ms 8B LE][ HMAC-SHA256(key, payload[0..9])[0..15] 16B ]
bleId = 0→ propietario,key = owner_secret.bleId ≠ 0→ invitado,key = guest_key(como en §6).
La hora del invitado está protegida por un trinquete (no puede retroceder), un tope hacia delante (no más de +24 h por escritura) y una barrera de fire-first. Un RTC externo y NTP son opcionales.
8. Estado biestable (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. Códigos de respuesta (RESULT, característica 0005)
| Código | Valor | Código | Valor |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — ABIERTO | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — CERRADO | 0x12 | TIME tiene una longitud incorrecta |
0x01 | el nonce está caducado o no está en el anillo | 0x13 | el HMAC de TIME no coincidió |
0x02 | el TTL ha caducado | 0x15 | TIME ha retrocedido (trinquete) |
0x03 | owner_sig no es válida | 0x16 | TIME supera el tope de +24 h |
0x04 | guest_sig no es válida | 0x17 | TIME sin una apertura reciente |
0x05 | longitud incorrecta | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI tiene un formato incorrecto |
0x07 | el HMAC del propietario no es válido | 0x22 | el HMAC de WIFI no coincidió |
10. Vectores de prueba
Vectores deterministas con respuesta conocida: ejecuta tu implementación y compara los bytes. Todos los valores son hexadecimales y el orden de bytes es el del aire. La cadena va de punta a punta: emparejamiento → owner_secret → todo lo demás.
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)
Código de referencia y contacto
Una implementación funcional de ambos lados es esp32-example/entrixy-ble (MIT). Para compilar .bin para tu propia placa, usa el configurador del navegador. El transporte en línea es protocolo de socket. Cómo encaja esto en la arquitectura abierta: /open. Consultas OEM: hello@entrixy.com.