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.
1. Primitivas criptográficas
- HMAC-SHA256 — assinaturas e derivação. O truncamento a um dado comprimento toma os primeiros N bytes do resultado.
- HKDF-SHA256 (RFC 5869) — derivação de chaves. Se
salt = null, é usado um vetor de zeros de 32 bytes. - X25519 (RFC 7748) — ECDH durante o emparelhamento. A chave privada é ajustada (clamping):
k[0] &= 0xF8; k[31] = (k[31] & 0x7F) | 0x40.
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 UUID | Propriedade | Finalidade |
|---|---|---|
0000 | — | UUID do serviço (completo: 656e7472-7869-7900-0000-426c45000001) |
0001 NONCE | READ | um nonce de 8 bytes (legado; um cliente moderno tira o nonce do anúncio, §3) |
0002 FIRE | WRITE | O comando de abertura: 24 bytes (proprietário) ou 47 bytes (convidado) |
0003 TIME | WRITE | Acerto do relógio: 32 bytes do proprietário, 55 bytes de um convidado |
0004 WIFI | WRITE | SSID e palavra-passe para NTP (opcional) |
0005 RESULT | READ+NOTIFY | Um código de resultado de um byte após cada escrita (§9) |
0006 REVOKE | WRITE | A decisão assinada do proprietário sobre um convidado, 23 bytes |
00f0 PAIR_PUB | READ | Só em modo de emparelhamento: a chave pública X25519 de 32 bytes do controlador (LE) |
00f1 PAIR_DONE | WRITE | Só em modo de emparelhamento: 36 bytes [phone_pub 32B][device_id 4B LE] |
"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]
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:
- O telemóvel lê
PAIR_PUB(00f0) — a chave pública de 32 bytes do controlador. - O telemóvel gera o seu próprio par X25519,
shared = X25519(phone_priv, esp_pub). - Ambos os lados derivam o mesmo segredo:
owner_secret = HKDF-SHA256(salt="entrixy-pair-v1", ikm=shared, info="owner-secret", L=32)
- 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.
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].
| Campo | Tamanho | 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 | um nonce recente do anúncio |
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)
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ódigo | Valor | Código | Valor |
|---|---|---|---|
0x00 | FIRE OK | 0x10 | TIME OK (owner) |
0x30 | FIRE OK — ABERTO | 0x11 | TIME OK (guest) |
0x31 | FIRE OK — FECHADO | 0x12 | TIME tem um comprimento incorreto |
0x32 | FIRE OK — ABRINDO | ||
0x33 | FIRE OK — FECHANDO | ||
0x01 | o nonce está velho ou não está no anel | 0x13 | o HMAC de TIME não coincidiu |
0x02 | o TTL expirou | 0x15 | TIME recuou (roquete) |
0x03 | owner_sig é inválida | 0x16 | TIME ultrapassa o teto de +24 h |
0x04 | guest_sig é inválida | 0x17 | TIME sem uma abertura recente |
0x05 | comprimento incorreto | 0x20 | WIFI OK |
0x06 | HKDF failed | 0x21 | WIFI tem um formato incorreto |
0x07 | o HMAC do proprietário é inválido | 0x22 | o HMAC de WIFI não coincidiu |
0x08 | o convidado está revogado neste controlador | 0x19 | o nonce de TIME está expirado ou não está no anel |
0x40 | REVOKE guardado | 0x1a | TIME de um convidado sem passe válido |
0x41 | a versão de REVOKE não é mais recente | 0x42 | REVOKE 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.