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 | Ajuste del reloj: 32 bytes del propietario, 55 bytes de un invitado |
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) |
0006 REVOKE | WRITE | La decisión firmada del propietario sobre un invitado, 23 bytes |
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 → el invitado no está en la lista de revocación → valid_until contra su reloj → deriva guest_key → guest_sig.
La única lista que guarda el controlador son las revocaciones del propietario (§7).
7. Sincronización de la hora
Un TTL de invitado necesita un reloj. Pueden ajustarlo el propietario y un invitado con un pase válido. Cada escritura lleva un nonce emitido por el controlador, y el controlador lo gasta: una escritura grabada por radio no puede repetirse después para retrasar el reloj.
El nonce se obtiene de una lectura de NONCE (0001) o de un anuncio reciente. Una escritura en 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] ]
Un invitado necesita el mismo pase que para abrir (§6): firmado por el propietario, no revocado, válido ahora y válido en la hora que propone. Un invitado revocado no puede llevar el reloj más allá de los pases de los demás.
La hora de un invitado también está protegida por un trinquete (sin vuelta atrás), un límite hacia delante (no más de +24 h por escritura) y la regla de abrir primero. El propietario no tiene límites. Un RTC externo y NTP son opcionales.
Revocar a un invitado
La decisión del propietario sobre un invitado, firmada con el propio owner_secret. Una escritura en 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] ]
El controlador guarda la última versión de cada invitado y aplica un registro solo con una versión mayor, así que repetir un antiguo «permitido» no devuelve el acceso a un invitado revocado. A un invitado revocado se le niega tanto abrir (0x08) como escribir la hora.
Cualquier teléfono puede escribir el registro: el del propietario o el de un invitado que esté en la puerta. El controlador confía en la firma, no en quien escribe, así que una revocación llega a la cerradura con la primera persona cercana en lugar de esperar a que caduque el pase del invitado. Volver a emparejar vacía la lista.
8. Posición y eventos (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
Dos bytes de etiqueta son a propósito: el byte de estado solo gobierna el indicador de abierto/cerrado y no concede ningún permiso, mientras que cada byte de más en el anuncio cuesta batería en cada despertar. La etiqueta protege de la corrupción en el aire, no de un falsificador: 16 bits caen en segundos. Nada que decida el acceso debe viajar por aquí.
Eventos propios del controlador
Los demás bits del byte de estado llevan lo que ocurrió en el propio controlador, para que un teléfono cercano lo escriba en el registro del propietario. El controlador no tiene internet: un evento llega al registro solo cuando un teléfono oye el anuncio.
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)
Un teléfono guarda el último número que vio de cada controlador. Un número nuevo con un tipo distinto de cero es un evento; la diferencia entre números indica cuántos se perdieron mientras no había nadie cerca. El primer número que ve un teléfono es un punto de partida, no un evento. El controlador conserva el número tras el sueño y los cortes de energía. Una cerradura de pestillo envía el bloque solo si tiene botón de salida.
Con final de carrera, una pulsación pone la cerradura en camino (bit 6) hasta que el final de carrera lo confirma. Si no lo confirma dentro del tiempo de recorrido fijado en el controlador, el controlador le cree y envía un nuevo número de evento con tipo 0: un fallo, el motor no llegó. El bit 0 indica entonces dónde está realmente la cerradura: abierta significa que no se cerró.
La respuesta a esa pulsación es 0x32 o 0x33: la cerradura va camino de abierta o de cerrada. El teléfono que pulsó sigue después el anuncio hasta que la posición se establece o llega un fallo.
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 |
0x32 | FIRE OK — ABRIENDO | ||
0x33 | FIRE OK — CERRANDO | ||
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ó |
0x08 | el invitado está revocado en este controlador | 0x19 | el nonce de TIME está caducado o no está en el anillo |
0x40 | REVOKE guardado | 0x1a | TIME de un invitado sin pase válido |
0x41 | la versión de REVOKE no es más nueva | 0x42 | REVOKE tiene una longitud o firma incorrecta |
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 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)
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.