O
zapo é uma implementação independente e feita do zero do protocolo do WhatsApp e não é afiliado, endossado ou conectado ao WhatsApp ou à Meta. Tudo aqui é pesquisa de interoperabilidade derivada de observar clients publicamente distribuídos na rede (on the wire). Todos os identificadores, chaves, assinaturas e dados de conta reais da captura original foram redigidos e substituídos por placeholders.Visão geral dos fluxos de passkey
Passkeys tocam várias superfícies distintas. A que importa para uma biblioteca companion é o linking; o resto está listado por completude.
A assimetria é o modelo mental central:
- O telefone (primary) é dono do passkey — ele cria, gerencia e usa (login, backup, linking), através do Credential Manager do Android com a extensão PRF do WebAuthn.
- A web (companion) apenas usa um passkey — para linking e para o integrity checkpoint.
Shortcake: vinculando com um passkey
O gatilho
O prólogo de passkey é server-driven. Não é um botão, e nem uma flag A/B client-side — o companion não consegue iniciá-lo. O servidor empurra uma notification para o lado web:type="passkey_prologue_request"é o discriminador literal;fromtem como default o domain JIDs.whatsapp.net;teidsão obrigatórios.<passkey_request_options>é conteúdo inline opcional (1–4096 bytes) — as request options do WebAuthn como JSON, comchallengee cadaallowCredentials[].idem base64url. Se ausente, o companion as busca com um IQ.- A web responde com um ack de transporte, e então roda o prólogo.
pair-device do primary com um retry-with-method listando shortcake-with-passkeys (e, quando não quer fallback, omite qr-code). O passkey não substitui o passo do QR na UI — ele é acoplado depois dele.
O prólogo viaja em qualquer um dos carriers de linking. Ele pode seguir o
pair-device do QR, ou chegar logo após um link por código de pareamento — o handshake companion_hello → primary_hello → companion_finish (tratado na web através da notification link_code_companion_reg). Em logs capturados o servidor enviou passkey_prologue_request cerca de 3 segundos após o companion_finish.A stanza de request do prólogo
Opasskey_request_options inline decodifica para um request get() padrão do WebAuthn:
allowCredentials: [] significa uma credencial discoverable — a conta já tem um passkey, então nenhum credential id precisa ser nomeado. O rpId é whatsapp.com.
O prólogo do companion
Ao receber o request, o lado web roda uma sequência fixa:1
Obter as request options
Do conteúdo inline, ou via um IQ se o servidor não as embutiu.
2
Executar a assertion WebAuthn
Decodifica
challenge / allowCredentials[].id de base64url e chama navigator.credentials.get({ publicKey }). Este é o prompt do passkey — a user verification acontece no dispositivo do dono. Re-encoda clientDataJSON / authenticatorData / signature / userHandle de volta para base64url.3
Buscar um ref de linking
Um IQ
GetRef retorna a string ref que dá escopo a essa sessão de linking.4
Inicializar a sessão Shortcake
Gera o keypair efêmero, o nonce e o commitment, e monta o
prologue_payload (veja criptografia).5
Opcionalmente calcular um handoff proof
Se uma handoff key estiver disponível (derivada do ADV secret), calcula a prova — ela deixa o telefone pular sua UX de confirmação.
6
Enviar o prólogo
Um IQ carrega
credential_id, webauthn_assertion, prologue_payload e (opcionalmente) pairing_handoff_proof. A web então espera a identidade do primary.A state machine de linking
O companion avança por uma pequena state machine, protegida por um timeout de 120 segundos:- No
primary_ephemeral_identityo companion revela seu nonce (via um IQcompanion_nonce), deriva o código de verificação e — a menos que um handoff proof válido tenha deixado os dois lados pularem — mostra o código para conferência. - A confirmação criptografa o pairing request (AES-GCM) e o envia como
encrypted_pairing_request.
A criptografia
Todo o handshake é um ECDH commit-reveal carregado inteiramente em stanzas (porque, ao contrário do QR, não há canal out-of-band carregando as chaves):
O commitment prende o nonce do companion antes do primary responder, então o primary não consegue forçar (grind) um código de verificação escolhido.
O lado do primary (telefone)
No telefone, o CRSC é a imagem espelhada. Ele recebe o prólogo (repassado pelo servidor), gera sua própriaPrimaryEphemeralIdentity e deriva o mesmo código. Dois comportamentos valem destaque porque são frequentemente mal interpretados:
- Auto-confirmação via handoff proof. Se o companion incluiu um
pairing_handoff_proofválido — um HMAC sobre o prólogo com chave derivada do ADV secret, condicionado a uma flag A/B do lado telefone, ao app estar em foreground e a uma chave guardada não expirada — o telefone pula suas próprias telas de confirmar-dispositivo e de conferência de código. - O handoff proof é um atalho do lado primary, não um bypass do companion. Ele remove fricção para o dono do telefone. Ele não remove a obrigação do companion de produzir uma
webauthn_assertionreal. No mesmo linking, a web ainda rodanavigator.credentials.gete envia a assertion; os dois artefatos servem propósitos diferentes.
O servidor valida a
webauthn_assertion e não a repassa ao telefone. O primary recebe apenas prologue_payload + pairing_handoff_proof — nunca a assertion. Ele confia no handoff proof; o servidor é a parte que checou o passkey.Passkey vs QR: a sequência de stanzas
Os dois fluxos convergem para o mesmo registro ADV do companion, mas a coreografia difere bastante. Passkey (na ótica do web/companion — ◄ recebido / ► enviado):
QR (para contraste):
A diferença: o QR são dois IQs para cada lado, o companion só responde, e a troca de chave viaja no QR out of band. O passkey tem o companion iniciando os IQs e carrega todo o handshake ECDH em stanzas — porque não há QR para carregar as chaves.
Captura de wire correlacionada
Para sair de “reconstruído dos bundles” para “confirmado”, o fluxo foi capturado nas duas pontas de um linking real ao mesmo tempo — instrumentação ao vivo no telefone (primary) e uma captura de console no WhatsApp Web (companion) — e então correlacionado byte a byte. Todos os valores abaixo estão redigidos.Timeline unificada
O relay CRSC
O servidors.whatsapp.net é um relay. Cada lado envia um <iq to="s.whatsapp.net">; o outro recebe como um <notification from="s.whatsapp.net"> (crsc_continuation / passkey_prologue). A prova de que é genuinamente uma sessão: os cinco payloads CRSC são byte-idênticos nas duas pontas (a chave X25519 efêmera é aleatória por sessão, então não é coincidência).
O roteamento funciona porque o IQ
primary_ephemeral_identity do primary carrega companion_ref igual ao ref dentro do prologue_payload, então o servidor consegue entregá-lo à sessão web correta.
O
passkey_prologue que o telefone recebe contém apenas prologue_payload + pairing_handoff_proof — não a webauthn_assertion. O servidor valida a assertion e não a encaminha. A assertion nunca chega ao telefone.Payloads decodificados
A assertion WebAuthn é uma cerimônia genuína — ochallenge dentro do clientDataJSON é igual ao challenge das request options:
companion_nonce é o reveal de 32 bytes do commitment. O encrypted_pairing_request é AES-GCM sobre o pairing request (chave pública do companion, identity key, ADV secret) sob a chave derivada do ECDH — não decodável sem essa chave.
Detalhe a nível de node
Por completude, as duas stanzas de registro ADV que emolduram a troca CRSC — opair-device do mobile (a tentativa de registro que puxa o retry-with-method) e o pair-success que o companion recebe assim que o CRSC completa — com seus nodes filhos (valores redigidos):
<challenges><supported> (friction / passkey-create) é ortogonal ao retry-with-method: o primeiro declara quais desafios de criação o primary suporta, o segundo é o método de linking escolhido pelo servidor. Anunciar ou omitir o passkey-create não muda a decisão de método.
O integrity checkpoint
Uma segunda superfície de passkey, não relacionada, na web é o integrity checkpoint anti-abuso — o irmão do challenge de captcha. Ele não é aleatório: é uma decisão server-side baseada em risco.- O servidor empurra uma notification MEX (GraphQL sobre WA) cujo
challenge_typeéPASSKEYouCAPTCHA. Um challengePASSKEYcarrega umchallenge_base64; umCAPTCHAcarrega umsite_key+challenge_url. O challenge escolhido é persistido para sobreviver a um reload da página. - Para
PASSKEY, a web abre um modal que não pode ser fechado oferecendo: completar o challenge, ou fazer logout. - Completá-lo roda
credentials.get({ rpId: "whatsapp.com", userVerification: "preferred", extensions: { prf: { eval: { first: "whatsapp-challenge" } } } })e submete a assertion (mais seu PRF output) via uma mutation MEX.
Fricção de criação de passkey
Uma terceira superfície — facilmente confundida com linking — é o bottom sheet de criação de passkey que aparece durante um link normal de QR / código de pareamento. É um gate anti-abuso no meio do pareamento de QR, não um fluxo de link-por-passkey.- “Não vincular” cancela o pareamento de vez (ele não cai no fallback de QR).
- “Criar” roda o fluxo de criação de passkey, reporta
created=trueao servidor e retoma o mesmo pareamento de QR. No erro reportacreated=falsee também retoma — o servidor decide.
Quem decide: passkey ou QR
- O usuário não escolhe o método — nem na web, nem na tela de dispositivos vinculados do telefone. No telefone o usuário só aprova (biometria + confirma código) ou cancela.
- O servidor decide, com base numa alocação A/B (uma flag por conta, id
29205no overlay de config empurrado pelo servidor), na conta ter um passkey, e num sinal de risco/integridade (a família do captcha). - Gates client-side duros ainda se aplicam: WebAuthn precisa estar disponível, o PRF precisa ser suportado, e (para o atalho de handoff) o telefone precisa estar em foreground.
- O critério exato de risco do servidor não está nos bundles do client — é server-side por design.
Por que o passkey é central: PRF
Os passkeys do WhatsApp usam a extensão PRF do WebAuthn para derivar um segredo estável a partir da credencial (extensions.prf com um input de eval como "whatsapp-challenge"). Esse segredo derivado do PRF sustenta login, backup criptografado e superfícies relacionadas — e é por isso que o passkey é estrutural (load-bearing), e não um mero segundo fator.
Dá pra fazer bypass do passkey
Todo lever client-side foi testado em contas ao vivo. Todos falham; o muro é imposto server-side, por conta.Os levers client-side
A capability que o telefone anuncia e a flag do lado telefone são ambas reativas — elas só decidem se o telefone obedece um
retry-with-method do servidor que já foi enviado. Nenhuma é um gatilho. A decisão é 100% o bucket server-side da conta.
O servidor verifica a assertion
A última incerteza — o servidor de fato verifica a assinatura da assertion, ou aceita qualquer estrutura bem-formada mais um handoff proof válido? — foi testada repassando uma assertion forjada: o credential id discoverable real, mas assinado com uma chave P-256 diferente sobre o challenge fresco do servidor, junto com um handoff proof legítimo. O resultado: o servidor deu ack no IQ de transporte e então ficou em silêncio — ele não repassou o prólogo ao telefone, nenhumprimary_ephemeral_identity voltou, e as duas pontas travaram até o timeout. O servidor achou a credencial, checou a assinatura contra a chave pública registrada, falhou, e rejeitou silenciosamente (sem IQ de erro — um provável design anti-enumeração). Um handoff proof válido não substituiu uma assertion válida.
Confirmado empiricamente: o servidor verifica a assertion WebAuthn contra a chave pública registrada da conta, com um challenge fresco (sem replay). Uma assertion forjada é rejeitada. O muro do passkey é real e imposto ponta a ponta.
Gates de plataforma e versão
Numa conta travada o servidor também faz gate na plataforma do companion e no build:- Plataforma. Qualquer companion web (Chrome, Firefox, Safari, Electron desktop, UWP — todos
platform_type = 2) é roteado para o passkey. Um companion android (platform_type = 10) é rejeitado de cara com<error code="463" text="account_reachout_restricted"/>— a cerimônia Shortcake é web-only, então o servidor recusa em vez de rotear para um fluxo que o telefone nunca completaria. Trocar para android não desvia do passkey; só troca um muro por outro. - Versão. Nenhuma versão de client o evita. Antiga demais é rejeitada no handshake Noise (
<failure reason='405'/>, client_too_old); um build fake-mas-recente falha nopair-devicecom<error code="400" text="bad-request"/>(o servidor valida o build hash contra builds reais); só o build atual genuíno passa nos dois — para o passkey.
Consequência prática
Para qualquer client headless ou companion, o limite é concreto:- Uma conta travada por passkey (bucket do servidor
29205=true) oferece o Shortcake como único caminho de conclusão — sem fallback de QR. Vincular tal conta exige uma assertion real do próprio authenticator do dono, obtida no dispositivo/navegador do dono e repassada para o fluxo. Isso é usar o passkey real, não fazer bypass. - Uma conta não travada vincula normalmente com QR / código de pareamento; a maquinaria de passkey nunca é acionada.
get(), e a chave privada é não-exportável. Nenhum desses cede a um chamador remoto e sem credencial.
Perguntas em aberto
As partes que continuam desconhecidas são, por design, server-side:- O critério exato de risco que coloca uma conta no bucket de passkey (além de “tem um passkey” + a alocação A/B).
- O que, além disso, faz o servidor emitir
passkey_prologue_request. - O schema completo da mutation MEX de integrity-challenge e seu lado de recebimento.
