❮  Controladores Entrixy

Especificação do protocolo BLE

Tudo o que é preciso para acrescentar suporte a BLE do Entrixy ao seu próprio controlador — a sua placa, o seu microcontrolador. O nosso firmware já pronto faz exatamente isto; abaixo está a descrição precisa do protocolo e os vetores de teste para se verificar byte a byte.

Um controlador BLE não precisa do servidor Entrixy. Nunca vai à internet: o telemóvel fala com ele diretamente por Bluetooth. A ligação a um proprietário é local (X25519 ECDH). Registar o aparelho no nosso servidor, obter chaves ou abrir uma conta de fabricante não é necessário — pode enviar controladores vazios. Referência: esp32-example/entrixy-ble (MIT).

1. Primitivas criptográficas

Referência: mbedTLS (MBEDTLS_ECP_DP_CURVE25519) no ESP, BouncyCastle no Android — os dois lados coincidem.

2. GATT: serviço e características

Todos os UUID são de 128 bits e baseiam-se em 656e7472-7869-7900-XXXX-426c45000001 ("entrixy" em ASCII).

Sufixo do UUIDPropriedadeFinalidade
0000—UUID do serviço (completo: 656e7472-7869-7900-0000-426c45000001)
0001 NONCEREADum nonce de 8 bytes (legado; um cliente moderno tira o nonce do anúncio, §3)
0002 FIREWRITEO comando de abertura: 24 bytes (proprietário) ou 47 bytes (convidado)
0003 TIMEWRITEAcerto do relógio: 32 bytes do proprietário, 55 bytes de um convidado
0004 WIFIWRITESSID e palavra-passe para NTP (opcional)
0005 RESULTREAD+NOTIFYUm código de resultado de um byte após cada escrita (§9)
0006 REVOKEWRITEA decisão assinada do proprietário sobre um convidado, 23 bytes
00f0 PAIR_PUBREADSó em modo de emparelhamento: a chave pública X25519 de 32 bytes do controlador (LE)
00f1 PAIR_DONEWRITESó em modo de emparelhamento: 36 bytes [phone_pub 32B][device_id 4B LE]
O nome "entrixy" e o UUID do serviço vão para o scan-responseem vez do pacote primário: um UUID de 128 bits mais os dados de fabricante não cabem nos 31 bytes do primário. A pesquisa passiva do Android só vê o primário, por isso a filtragem faz-se pelos dados de fabricante (company id 0x00E0).

3. Anúncio GAP

A cada sleep_interval segundos, o controlador anuncia a sua presença com dados específicos do fabricante. O company id é 0x00E0 (no ar E0 00). O bloco tem 23 bytes = 2 bytes de company id mais 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)

A assinatura cobre 13 bytes (o company id não é incluído):

mac_in = device_id(4) || counter(4) || sleep(1) || opts_cipher(4)
auth_hmac = HMAC-SHA256(owner_secret, mac_in)[0..7]
Estes 8 bytes auth_hmac servem de nonce para uma abertura imediata sem ida e volta: um observador sem owner_secret vê-os como aleatórios, ao passo que o proprietário ou o convidado calcula a assinatura e dispara de imediato. O controlador mantém um anel com os últimos 16 nonces que emitiu.

Opções (opts) — fluxo de chave 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 biestável

Para um motor biestável acrescentam-se três bytes ao valor, ficando 26 bytes (ver §8).

4. Emparelhamento

Um controlador novo entra sozinho em modo de emparelhamento durante 30–90 s (caso contrário, pelo botão; a reposição de fábrica é uma pressão de 5 segundos). A troca é local, sem servidor:

  1. O telemóvel lê PAIR_PUB (00f0) — a chave pública de 32 bytes do controlador.
  2. O telemóvel gera o seu próprio par X25519, shared = X25519(phone_priv, esp_pub).
  3. Ambos os lados derivam o mesmo segredo:
    owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
  4. O telemóvel escreve PAIR_DONE (00f1): [phone_pub 32B][device_id 4B LE]. o telemóvel gera o device_id, e o controlador guarda-o.
As chaves X25519 são little-endian (RFC 7748). owner_secret nunca passa pelo ar. Nenhum servidor intervém: a aplicação gerou ela própria o device_id, e é por isso que o conhece.

5. Abertura pelo proprietário (owner fire)

Uma escrita em FIRE (0002), 24 bytes:

[nonce 8B][ HMAC-SHA256(owner_secret, nonce)[0..15] 16B ]

nonce — os 8 bytes auth_hmac de um anúncio recente. O controlador verifica o nonce contra o seu anel, valida a assinatura, dá um impulso ao relé e invalida o nonce.

6. Abertura por um convidado (guest fire)

Um convidado não conhece owner_secret. O proprietário emite um pacote através do servidor: token, owner_sig, guest_key. Uma escrita em FIRE, 47 bytes: [token 7B][owner_sig 16B][nonce 8B][guest_sig 16B].

CampoTamanhoFórmula
token7B[bleId 2B LE][valid_until 4B LE unix-sec][flags 1B]
owner_sig16BHMAC-SHA256(owner_secret, token)[0..15]
nonce8Bum nonce recente do anúncio
guest_sig16BHMAC-SHA256(guest_key, nonce)[0..15]
guest_key = HKDF-SHA256(salt=null, ikm=owner_secret, info="guest" || bleId(2B LE), L=32)

O controlador: nonce no anel → owner_sig com a sua própria owner_secret → o convidado não está na lista de revogação → valid_until contra o seu relógio → deriva guest_key → guest_sig.

A única lista que o controlador guarda são as revogações do proprietário (§7).

7. Sincronização da hora

Um TTL de convidado precisa de um relógio. Podem acertá-lo o proprietário e um convidado com um passe válido. Cada escrita leva um nonce emitido pelo controlador, que o gasta: uma escrita gravada pelo ar não pode ser repetida mais tarde para atrasar o relógio.

O nonce vem de uma leitura de NONCE (0001) ou de um anúncio recente. Uma escrita em 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] ]

Um convidado precisa do mesmo passe que para abrir (§6): assinado pelo proprietário, não revogado, válido agora e válido na hora que propõe. Um convidado revogado não consegue levar o relógio para além dos passes de todos os outros.

A hora de um convidado também está protegida por uma roquete (sem voltar atrás), um limite para a frente (no máximo +24 h por escrita) e a regra de abrir primeiro. O proprietário não tem limites. Um RTC externo e NTP são opcionais.

Revogar um convidado

A decisão do proprietário sobre um convidado, assinada com o próprio owner_secret. Uma escrita em 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] ]

O controlador guarda a versão mais recente de cada convidado e só aplica um registo com uma versão superior, por isso repetir um antigo «permitido» não devolve o acesso a um convidado revogado. A um convidado revogado é recusada tanto a abertura (0x08) como a escrita da hora.

Qualquer telemóvel pode escrever o registo: o do proprietário ou o de um convidado que esteja à porta. O controlador confia na assinatura, não em quem escreve, por isso uma revogação chega à fechadura com a primeira pessoa por perto, em vez de esperar que o passe do convidado expire. Emparelhar de novo limpa a lista.

8. Posição e 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

Dois bytes de etiqueta são de propósito: o byte de estado só governa o indicador aberto/fechado e não concede qualquer direito, enquanto cada byte a mais do anúncio custa bateria em cada despertar. A etiqueta protege da corrupção no ar, não de um falsificador — 16 bits caem em segundos. Nada que decida o acesso deve viajar por aqui.

Os eventos do próprio controlador

Os outros bits do byte de estado trazem o que aconteceu no próprio controlador, para que um telefone próximo o escreva no registo do proprietário. O controlador não tem internet: um evento chega ao registo só quando um telefone ouve o anúncio.

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)

O telefone guarda o último número que viu de cada controlador. Um número novo com tipo diferente de zero é um evento; a diferença entre os números mostra quantos se perderam enquanto não havia ninguém perto. O primeiro número que um telefone vê é um ponto de partida, não um evento. O controlador mantém o número durante o sono e as quedas de energia. Uma fechadura de trinco só envia o bloco se tiver botão de saída.

Com fim de curso, um toque põe a fechadura a caminho (bit 6) até o fim de curso confirmar. Se ele não confirmar dentro do tempo de percurso definido no controlador, o controlador acredita nele e envia um novo número de evento com tipo 0: uma falha, o motor não chegou. O bit 0 mostra então onde a fechadura realmente está: aberta significa que não fechou.

A resposta a esse toque é 0x32 ou 0x33: a fechadura está a caminho de aberta ou de fechada. O telefone que tocou acompanha depois o anúncio até a posição se fixar ou chegar uma falha.

9. Códigos de resposta (RESULT, característica 0005)

CódigoValorCódigoValor
0x00FIRE OK0x10TIME OK (owner)
0x30FIRE OK — ABERTO0x11TIME OK (guest)
0x31FIRE OK — FECHADO0x12TIME tem um comprimento incorreto
0x32FIRE OK — ABRINDO
0x33FIRE OK — FECHANDO
0x01o nonce está velho ou não está no anel0x13o HMAC de TIME não coincidiu
0x02o TTL expirou0x15TIME recuou (roquete)
0x03owner_sig é inválida0x16TIME ultrapassa o teto de +24 h
0x04guest_sig é inválida0x17TIME sem uma abertura recente
0x05comprimento incorreto0x20WIFI OK
0x06HKDF failed0x21WIFI tem um formato incorreto
0x07o HMAC do proprietário é inválido0x22o HMAC de WIFI não coincidiu
0x08o convidado está revogado neste controlador0x19o nonce de TIME está expirado ou não está no anel
0x40REVOKE guardado0x1aTIME de um convidado sem passe válido
0x41a versão de REVOKE não é mais recente0x42REVOKE tem comprimento ou assinatura incorretos

10. Vetores de teste

Vetores determinísticos com resposta conhecida: execute a sua implementação e compare os bytes. Todos os valores são hexadecimais e a ordem dos bytes é a do ar. A cadeia corre de ponta a ponta: emparelhamento → owner_secret → tudo o resto.

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 referência e contacto

Uma implementação funcional dos dois lados é esp32-example/entrixy-ble (MIT). Para compilar .bin para a sua própria placa, use o configurador no navegador. O transporte online é protocolo de socket. Como isto se enquadra na arquitetura aberta: /open. Pedidos OEM: hello@entrixy.com.