DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Quando o telefone não encontra o dispositivo: uma caçada ao bug de pareamento WhatsApp em Elixir

Depois de semanas de trabalho, o QR era gerado corretamente, o telefone escaneava... e nada. O WhatsApp dizia que não conseguia encontrar o dispositivo. Sem pair-success. Sem erro óbvio.

Aqui está a história completa de como chegamos à causa raiz. Não foi no handshake criptográfico, não foi no registro, não foi no QR. Foi em uma função de 4 linhas que retornava a tupla errada.


O contexto

Estamos portando o Baileys (cliente WhatsApp Web em TypeScript) para um plugin Elixir chamado JusrisOsCore.Plugins.Baileys, dentro de um monólito modular (um projeto JusrisOS Core). O princípio-guia que adotamos desde o início:

"Baileys é o spec."

Nada de inventar comportamento de protocolo: se o Baileys faz X, a gente faz X. A regra é clara porque o WhatsApp Web é um protocolo proprietário, fechado e que não perdoa desvios — um byte errado no handshake e o servidor te descarta, às vezes silenciosamente.

A arquitetura segue camadas Support → Kernel → Plugins, com dependências puras no runtime (sem Rust/NIF no plugin). A criptografia é toda Elixir/:crypto nativo (X25519, AES-256-GCM, Ed25519) — não usamos os NIFs snow/curve25519-dalek que o baileys_ex usa.

Tínhamos três implementações de referência que funcionam:

Repo Stack Papel
Baileys TypeScript Fonte da verdade (o spec)
baileys_ex Elixir + Rust NIFs Port funcional, funciona
wa-go / whatsapp-rust Go / Rust Outras reimplementações, funcionam

O objetivo era que o nosso port fosse byte-idêntico ao baileys_ex no que importa.


O sintoma

O pareamento QR do WhatsApp Web funciona assim (simplificado):

  1. Cliente conecta em wss://web.whatsapp.com/ws/chat.
  2. Executa o handshake Noise XX (clientHello → serverHello → clientFinish).
  3. No clientFinish, envia o ClientPayload (registro) cifrado.
  4. O servidor responde com um desafio iq[xmlns=md] pair-device contendo 6 <ref>.
  5. O cliente responde iq{to, type:result, id} (ack).
  6. O cliente gera o QR:
   https://wa.me/settings/linked_devices#<ref>,<noiseB64>,<identityB64>,<advB64>,<platformId=1>
Enter fullscreen mode Exit fullscreen mode
  1. O telefone escaneia → servidor verifica → envia pair-success ao cliente.

Nosso cliente chegava até o passo 6. QR gerado, PNG bonito na tela. O telefone escaneava, girava o ícone... e o WhatsApp mostrava que não conseguia encontrar o dispositivo. Nunca recebíamos o pair-success.

O dado mais frustrante: nada no log parecia errado. O handshake "funcionava" (o pair-device chegava), o ack "era enviado", o QR "era gerado". Mas o telefone não achava.


A estratégia: oráculo comparativo

Como estávamos seguindo o Baileys à risca, a tática foi importar o baileys_ex para o nosso mix.exs e comparar função a função. O baileys_ex funciona, então ele é o padrão-ouro de comportamento.

Primeiro passo: gerar um QR com o baileys_ex e escanear. Se o baileys_ex pareasse, o problema era nosso. Se não pareasse, o problema era a conta/rede/ambiente.

Rodamos o baileys_ex com um estado de auth fresco:

Application.ensure_all_started(:baileys_ex)
alias BaileysEx.Auth.NativeFilePersistence
{:ok, persisted_auth} = NativeFilePersistence.use_native_file_auth_state(path)

BaileysEx.connect(persisted_auth.state, ..., on_qr: fn qr -> ... end)
Enter fullscreen mode Exit fullscreen mode

O baileys_ex PAREOU. O log mostrou:

[Wire] RECV tag=iq id=375824115 children=[pair-device] state=authenticating
[Wire] SEND tag=iq id=375824115 ... type=result to=s.whatsapp.net
... QR gerado ...
[Wire] RECV tag=iq id=311336498 children=[pair-success] state=authenticating
[Wire] SEND tag=iq id=311336498 ... type=result to=s.whatsapp.net
[PushNameDiag] creds_update changed push name previous=nil next="Matheus de Camargo Marques"
Enter fullscreen mode Exit fullscreen mode

pair-success recebido, e o push name veio com o nome real da conta. O telefone achou o dispositivo do baileys_ex.

Isso foi o divisor de águas: telefone, conta e rede estavam OK. O bug era 100% no nosso cliente. Como o registro é byte-idêntico e as chaves são as mesmas, a diferença tinha que estar no handshake ou na conexão.


As pistas falsas (e o que aprendemos com cada uma)

1. O registration_id fora do intervalo

Nosso Auth gerava o ID de registro com 24 bits:

<<r::24>> = :crypto.strong_rand_bytes(3)   # [0, 16.7M] — FORA DO RANGE!
Enter fullscreen mode Exit fullscreen mode

Mas o Baileys TS faz:

const registrationId = randomInt(0, 16384)   // 14 bits
Enter fullscreen mode Exit fullscreen mode

E o wa-go também: k[0] &= 248; ... e NewRegistrationID = & 16383. A referência usa 14 bits, [0, 16383]. Corrigimos:

<<r::16>> = :crypto.strong_rand_bytes(2)
Bitwise.band(r, 16_383)
Enter fullscreen mode Exit fullscreen mode

Depois disso o registro ficou byte-idêntico ao baileys_ex (286 bytes). Mas o pareamento continuava falhando. *pista falsa*

2. A geração de chave X25519 bruta

Usávamos Curve25519.generate_key_pair() que retorna uma chave sem clamp. Passamos a usar o :crypto nativo:

{pub, priv} = :crypto.generate_key(:ecdh, :x25519)   # clamped (RFC 7748)
Enter fullscreen mode Exit fullscreen mode

O efeito é o mesmo (ambas clamptam na derivação), então foi mais cosmético que a causa. *pista falsa* — e foi bom ter descartado.

3. O hash inicial do handshake

Nos debruçamos no noise-handler.ts do Baileys:

authenticate(NOISE_HEADER)      // <<87, 65, 6, 3>>
authenticate(publicKey)         // ← chave efêmera do CLIENTE
Enter fullscreen mode Exit fullscreen mode

Ficamos convencidos de que o nosso new/2 não autenticava a chave efêmera do cliente no hash inicial. Mas, ao reler, já estava lá:

|> authenticate(@noise_wa_header)
|> authenticate(public_key)
Enter fullscreen mode Exit fullscreen mode

*pista falsa*. O hash estava correto.

4. A ordem do authenticate no encrypt/decrypt

O noise decrypt do nosso código fazia authenticate(ciphertext). O resumo anterior dizia que o Baileys TS autentica o plaintext. Ao olhar no fonte:

105: authenticate(result)        // encrypt → cifra
115: authenticate(ciphertext)     // decrypt → cifra
Enter fullscreen mode Exit fullscreen mode

Na verdade o Baileys autentica a cifra nos dois. A nossa implementação batia. *pista falsa*.


O caminho que faltava: o transporte

Depois de descartar handshake, registro e QR (todos idênticos ao baileys_ex), a única diferença restante era o cliente WebSocket. O baileys_ex usa Mint.WebSocket; nós usávamos WebSockex.

A pergunta do usuário foi a chave: "usa o mint na nossa implementação também."

Migrei o transport.ex de um GenServer WebSockex para um GenServer usando Mint.HTTP + Mint.WebSocket, mantendo toda a lógica do handshake Noise intacta:

Mint.HTTP.connect(http_scheme, host, port,
  timeout: 20_000, mode: :active, protocols: [:http1],
  transport_opts: [cacerts: :public_key.cacerts_get()])

Mint.WebSocket.upgrade(ws_scheme, conn, path, [], mode: :active)
Enter fullscreen mode Exit fullscreen mode

O socket do Mint é active: :once por padrão, então o GenServer recebe {:tcp, socket, data} / {:ssl, socket, data} e alimenta Mint.WebSocket.stream(conn, msg) — o mesmo modelo do baileys_ex.

Rodamos contra o servidor real: clientHello → serverHello → clientFinish → pair-device → ack → QR. Tudo "funcionou"... menos um detalhe. No log apareceu:

[error] GenServer terminating
** (KeyError) key :websocket not found in:
    %JusrisOsCore.Plugins.Baileys.Protocol.Noise.State{...}
Enter fullscreen mode Exit fullscreen mode

Um crash assim que o ack era processado. E o ack tinha "enviado" — só que não tinha sido realmente enviado, porque o GenServer morreu antes.


A causa raiz: encode_node retornava a tupla errada

O ack passa por encode_node, que fazia:

defp encode_node(node, ctx) do
  buff = WABinary.encode_binary_node(node)
  Noise.encode_frame(ctx.noise, buff)    # ← retorna {frame, noise_state}
end
Enter fullscreen mode Exit fullscreen mode

Repare. Noise.encode_frame retorna dois elementos: o frame e o estado Noise atualizado. O encode_node repassava isso sem transformar. Mas os chamadores faziam:

{frame, ctx} = encode_node(node, ctx)   # ctx vira o Noise.State!
Enter fullscreen mode Exit fullscreen mode

Aí estava o bug. Em Elixir, o {:ok, ctx} de um map é reatribuível — mas aqui o segundo elemento não é o nosso ctx (que carrega conn, websocket, on_frame, etc.), é o estado interno do Noise. Ao fazer {frame, ctx} = encode_node(...), a variável ctx passa a apontar para o %Noise.State{}.

Quando o handle_info({:send_wa_node, node}, ctx) chamava send_frame(ctx, frame):

defp send_frame(ctx, binary) do
  Mint.WebSocket.encode(ctx.websocket, ...)   # ctx.websocket → CRASH
end
Enter fullscreen mode Exit fullscreen mode

ctx.websocket sobre %Noise.State{}KeyError. O transporte morria. E, como ele morria antes de realmente gravar o ack no socket, o servidor nunca recebia o ack do pair-device.

E foi isso o tempo todo. Sem o ack do pair-device, o WhatsApp não marca o dispositivo como "pronto pra pareamento". O telefone escaneia, olha o ref, não acha o dispositivo ativo → "não consigo encontrar o dispositivo".

O mais cruel: o log mostrava ENVIADO (iq) 35b porque a linha de log rodava antes do send_frame que crashava. Parecia que tinha enviado. Não tinha.

A correção é uma linha:

defp encode_node(node, ctx) do
  buff = WABinary.encode_binary_node(node)
  {frame, noise} = Noise.encode_frame(ctx.noise, buff)
  {frame, %{ctx | noise: noise}}    # ← preserva o ctx (com conn/websocket/on_frame)
end
Enter fullscreen mode Exit fullscreen mode

Esse bug também existia na versão WebSockex original — o mesmo {frame, ctx} = encode_node(...) reatribuía ctx para o estado do Noise. Ou seja, a migração para Mint não "inventou" o problema; ela o tornou visível (o crash do Mint.WebSocket.encode era mais claro que o comportamento estranho do WebSockex após o estado corrompido).


A verificação

Depois do fix, o fluxo real ficou assim, sem crash:

clientHello enviado (36 bytes)
serverHello recebido (ephemeral: 32b, static: 48b, payload: 257b)
process_handshake OK (keyEnc: 48b)
ENVIADO (clientFinish) 361b
clientFinish enviado → modo transporte ativo
pair-device: 6 refs
node decodificado: tag="iq" attrs=%{"type" => "set", "xmlns" => "md"}
ack pair-device enviado (id=...)          ← AGORA enviado de verdade
ENVIADO (iq) 35b
✅ QR GERADO
Enter fullscreen mode Exit fullscreen mode

O servidor também mandou um ping urn:xmpp:ping antes de fechar a conexão ~30s depois — exatamente o comportamento do baileys_ex.


Lições que eu levaria em conta

  1. "O X é a causa" é uma hipótese, não um fato. Cada vez que achamos a "causa", validamos contra o oráculo (baileys_ex). Se o oráculo pareia e nós não, com registro byte-idêntico, então o bug está fora do que a gente acha que está errado. A validação com o baileys_ex (gerar QR e escanear) foi o que separou "problema de ambiente/conta" de "bug no cliente".

  2. Quando tudo "funciona" mas falha, procure o que está silenciosamente errado na fronteira. Aqui tudo funcionava porque o log era impresso antes do crash. O frame era calculado, o log rodava, e só então o send quebrava. Mentira por omissão de logging.

  3. Tuplas de retorno e reatribuição de variável é um buraco em Elixir. {a, ctx} = foo(ctx) onde foo retorna {a, outro_estado} reatribui ctx silenciosamente. Vale um nome mais explícito ({:ok, frame, %{ctx | noise: noise}}) para não deixar a reatribuição implícita.

  4. Uma implementação de referência que funciona é o melhor debugger. Ter o baileys_ex funcionando, importável no mesmo projeto, tornou cada comparação objetiva: primeiro baileys_ex para ver o comportamento esperado, depois o nosso para achar o desvio.

  5. O protocolo não perdoa. O handshake pode estar "funcionando" (o servidor responde) e ainda assim a associação do dispositivo estar quebrada. O pair-device é enviado de boa vontade; o que faz o telefone achar o dispositivo é o ack e o estado da conexão — se o ack não chega, o servidor jamais "publica" o dispositivo.


O que ficou

  • lib/jusris_os_core/plugins/baileys/protocol/transport.ex — reescrito para Mint (Mint.HTTP + Mint.WebSocket) e com o fix do encode_node.
  • mix.exs{:websockex, ...}{:mint, "~> 1.6"}.
  • auth_test.exs — assert do push_name atualizado (o registro não mais fixa "JusrisOS", para casar com o baileys_ex).

Todos os 443 testes do plugin passam; mix compile --warnings-as-errors compila limpo após mix clean.

O pareamento agora deve completar. A lição maior: quando você portar um protocolo fechado, tenha uma referência que funcione e compare tudo — inclusive as funções que você acha que não podem estar erradas. O bug estava numa função de 4 linhas que o time todo tinha lido várias vezes.

Top comments (0)