❮  Controladores Entrixy

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.

Un controlador BLE no necesita el servidor de Entrixy. No se conecta nunca a internet: el teléfono habla con él directamente por Bluetooth. La vinculación con un propietario es local (X25519 ECDH). Registrar el dispositivo en nuestro servidor, obtener claves o abrir una cuenta de fabricante no es necesario — puedes enviar controladores vacíos. Referencia: esp32-example/entrixy-ble (MIT).

1. Primitivas criptográficas

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 UUIDPropiedadPara qué sirve
0000—UUID del servicio (completo: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADun nonce de 8 bytes (heredado; un cliente moderno toma el nonce del anuncio, §3)
0002 FIREWRITELa orden de apertura: 24 bytes (propietario) o 47 bytes (invitado)
0003 TIMEWRITEAjuste del reloj: 32 bytes del propietario, 55 bytes de un invitado
0004 WIFIWRITESSID y contraseña para NTP (opcional)
0005 RESULTREAD+NOTIFYUn código de resultado de un byte tras cada escritura (§9)
0006 REVOKEWRITELa decisión firmada del propietario sobre un invitado, 23 bytes
00f0 PAIR_PUBREADSolo en modo de emparejamiento: la clave pública X25519 de 32 bytes del controlador (LE)
00f1 PAIR_DONEWRITESolo en modo de emparejamiento: 36 bytes [phone_pub 32B][device_id 4B LE]
El nombre "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]
Estos 8 bytes 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:

  1. El teléfono lee PAIR_PUB (00f0): la clave pública de 32 bytes del controlador.
  2. El teléfono genera su propio par X25519, shared = X25519(phone_priv, esp_pub).
  3. Ambos lados derivan el mismo secreto:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. 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.
Las claves X25519 son little-endian (RFC 7748). 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].

CampoTamañoFórmula
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Bun nonce reciente del anuncio
guest_sig16BHMAC-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ódigoValorCódigoValor
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — ABIERTO0x11TIME OK (guest)
0x31FIRE OK — CERRADO0x12TIME tiene una longitud incorrecta
0x32FIRE OK — ABRIENDO
0x33FIRE OK — CERRANDO
0x01el nonce está caducado o no está en el anillo0x13el HMAC de TIME no coincidió
0x02el TTL ha caducado0x15TIME ha retrocedido (trinquete)
0x03owner_sig no es válida0x16TIME supera el tope de +24 h
0x04guest_sig no es válida0x17TIME sin una apertura reciente
0x05longitud incorrecta0x20WIFI OK
0x06HKDF failed0x21WIFI tiene un formato incorrecto
0x07el HMAC del propietario no es válido0x22el HMAC de WIFI no coincidió
0x08el invitado está revocado en este controlador0x19el nonce de TIME está caducado o no está en el anillo
0x40REVOKE guardado0x1aTIME de un invitado sin pase válido
0x41la versión de REVOKE no es más nueva0x42REVOKE 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.