Criptografia ponta a ponta realmente funciona?

•Michael•22 min de leitura

Diffie-Hellman não apaga nada

Image description

Dia 18 de novembro de 2025, a Polícia Federal apreendeu um iPhone 17 Pro na Operação Compliance Zero, o dono era Daniel Vorcaro, do Banco Master, e o aparelho tinha um esquema de comunicação desenhado pra não deixar rastro, nota escrita no Bloco de Notas, print da tela, envio da imagem pelo WhatsApp em visualização única, conversa configurada pra se autodestruir em 24 horas, ninguém quebrou o WhatsApp, ninguém quebrou o iOS, a perícia, segundo o próprio relatório da PF (IPJ-A nº 3298613/2026, seção 4, págs. 6-7 de 218), rodou o Cellebrite pra extrair o sistema de arquivos e o IPED versão 4.2.2 pra indexar o que saiu de lá, e achou uma pasta tmp cheia de PDF que ninguém tinha mandado gerar.

Toda vez que o Bloco de Notas era capturado em screenshot, o iOS gerava sozinho um PDF idêntico ao conteúdo da tela, guardado numa área interna do sistema, inacessível ao usuário comum, não é feature, é efeito colateral de como o sistema de captura de tela funciona por baixo, o relatório é direto sobre isso: "tais arquivos não são criados deliberadamente pelo usuário" e "o arquivo permanece por algum tempo no dispositivo como vestígio residual daquela operação", a perícia cruzou o horário de criação desse PDF com o log de abertura do Notas e do WhatsApp e reconstruiu 52 mensagens que seguiam esse padrão, sem nunca precisar decifrar nada.

E tem um detalhe que a própria PF nem comenta, só deixa passar cru no caminho do arquivo de extração:

00008150-00127D9C1408401C_files_partial-afu.zip/private/var/mobile/Containers/Data/
Application/0A980B2F-0503-40DD-956D-69CD2C7E5F91/tmp/com.apple.coherence/[...]

afu.zip, sigla de After First Unlock, um estado de extração forense que a própria Cellebrite documenta: em BFU, o aparelho não foi desbloqueado desde que ligou, e a extração "should theoretically contain only system data", em AFU, já foi desbloqueado ao menos uma vez desde o boot, e "tools are able to collect a lot of information from the device", e o relatório da PF não discute esse detalhe, só deixa a sigla crua dentro do caminho do arquivo, mas ela já denuncia que o aparelho estava em AFU, não em BFU, chegar numa extração completa desse tipo normalmente também depende de uma vulnerabilidade específica da versão do iOS que ferramentas como o Cellebrite exploram, isso o relatório não detalha e eu não tenho fonte pra confirmar qual foi usada neste caso, o que dá pra afirmar com o que tenho é que a condição do aparelho, não uma falha na criptografia do WhatsApp ou do iOS, foi o que abriu espaço pro Cellebrite chegar fundo o suficiente no sistema de arquivos pra achar a pasta tmp.

Image description

A razão técnica por trás dessa diferença de estado está documentada pela própria Apple, não só pela Cellebrite: dados de aplicativo de terceiro usam por padrão a classe de proteção NSFileProtectionCompleteUntilFirstUserAuthentication, que a documentação oficial de segurança da Apple descreve como "the default class for all third-party app data not otherwise assigned to a Data Protection class".

A diferença dela pra classe mais forte está em o que sobrevive ao bloqueio de tela: nessa classe padrão, "the decrypted class key isn't removed from memory when the device is locked", enquanto a classe mais forte, NSFileProtectionComplete, descarta a chave "shortly after the user locks a device", matando o acesso no primeiro toque na tela de bloqueio, sem depender de reboot nem do estado AFU ou BFU.

O relatório da PF não diz em qual classe de proteção o PDF temporário do screenshot estava, mas dado que é artefato gerado pelo próprio sistema, não um arquivo que alguém marcou manualmente como mais sensível, a explicação mais simples e mais consistente com o que a Cellebrite documenta é que ele vivia na classe que sobrevive ao bloqueio, e é por isso que o estado AFU, não alguma falha pontual de criptografia, foi suficiente.

Eu trabalho com sistemas financeiros, ledger de partidas dobradas, liquidação, API de banco, e a palavra que aparece direto nessas conversas é "efêmero", chave de sessão efêmera, mensagem efêmera, token efêmero, a promessa por trás da palavra é sempre a mesma: depois que isso acabar, não vai ter como reconstruir, o caso Vorcaro é o exemplo mais nítido que eu já vi de por que essa promessa tem um limite exato, e o limite nunca é onde a maioria acha que é.

A maioria acha que efêmero é sobre o dado sumir, é sobre o escopo exato do que a garantia promete cobrir, e esse escopo nunca inclui o efeito colateral que o próprio sistema gera pra funcionar.

O mecanismo que a matemática garante

Image description

Vale separar dois termos que este texto vai tratar como a mesma conversa, porque é assim que quem implementa fala no dia a dia: Diffie-Hellman é o algoritmo de troca de chave, forward secrecy é a propriedade que você ganha quando usa esse algoritmo de forma efêmera, descartando o segredo a cada sessão, o título fala em Diffie-Hellman porque é o nome que aparece na configuração do TLS, o que o título nega é a propriedade, não o algoritmo sozinho.

Diffie-Hellman resolve um problema específico: duas partes que nunca se falaram antes, conversando num canal que um terceiro está ouvindo, chegam num segredo compartilhado sem nunca transmitir esse segredo, a matemática é simples o suficiente pra rodar em qualquer coisa.

p = 23
g = 5

a = 6   # segredo de Alice, nunca sai da maquina dela
b = 15  # segredo de Bob, nunca sai da maquina dele

pow_mod = fn base, exp, mod ->
  :crypto.mod_pow(base, exp, mod) |> :binary.decode_unsigned()
end

a_pub = pow_mod.(g, a, p)  # Alice manda a_pub pro Bob, em aberto
b_pub = pow_mod.(g, b, p)  # Bob manda b_pub pra Alice, em aberto

s_alice = pow_mod.(b_pub, a, p)
s_bob = pow_mod.(a_pub, b, p)

Rodando isso: a_pub = 8, b_pub = 19, os dois lados chegam em 2 como chave compartilhada, s_alice == s_bob dá true, quem está ouvindo o canal viu p=23, g=5, a_pub=8 e b_pub=19 passarem, nunca viu a=6 nem b=15, e mesmo assim os dois lados combinaram uma chave que o espião não tem.

Só que com p=23 isso não protege nada, recuperar a a partir de a_pub=8 é forçar bruto de 6 tentativas:

tentativa_certa = Enum.find(1..(p - 1), fn tentativa -> pow_mod.(g, tentativa, p) == a_pub end)
IO.puts("a=#{tentativa_certa}")
# a=6, achado testando de 1 a 6

Essa é a primeira conta que precisa fechar antes de qualquer coisa: segurança de Diffie-Hellman não vem da forma da equação, vem do tamanho do primo, implementação real usa primo de 2048 bits pra cima (RFC 7919 define grupos padronizados de 2048 a 8192 bits) ou curva elíptica equivalente, porque o problema do logaritmo discreto só é caro de resolver quando o espaço de busca é astronomicamente grande. A matemática do quadro-negro e a matemática que protege tráfego real diferem só em quantos dígitos o primo tem, e é nesse detalhe que mora toda a segurança.

Isso em código de produção, não em quadro-negro

O p=23 do exemplo acima existe pra caber numa tela, a diferença entre ele e o que protege um handshake TLS de verdade não é a forma da conta, é só o tamanho do espaço de busca: com p=23 sobram 22 valores possíveis pro segredo de cada lado, com uma curva de 256 bits, a mesma família que TLS 1.3 costuma negociar, sobram 2^256, ou seja 115792089237316195423570985008687907853269984665640564039457584007913129639936 valores possíveis, um número que não cabe numa comparação boa, cabe só na constatação de que testar um por um não é estratégia nenhuma.

Essa diferença de tamanho também explica por que o RFC 7919 lista ffdhe3072, o grupo de 3072 bits, como o equivalente prático mais comum a uma curva de 256 bits: segundo a tabela de equivalência de força do NIST (SP 800-57 Parte 1 Rev. 5, Tabela 2), um DH clássico de L=3072 bits entrega a mesma força de segurança, 128 bits, que uma curva elíptica de f=256-383 bits, a diferença de tamanho existe porque o melhor ataque conhecido contra o logaritmo discreto em curva elíptica cresce exponencialmente com o tamanho da chave, enquanto o melhor ataque contra o DH clássico, o general number field sieve, cresce só sub-exponencialmente, precisa de bem mais bits pra chegar na mesma dificuldade.

No Erlang/OTP, a VM que roda por baixo de qualquer aplicação Elixir, essa troca não é biblioteca de terceiro, é :crypto, parte da distribuição padrão:

{alice_pub, alice_priv} = :crypto.generate_key(:ecdh, :secp256r1)
{bob_pub, bob_priv} = :crypto.generate_key(:ecdh, :secp256r1)

alice_shared = :crypto.compute_key(:ecdh, bob_pub, alice_priv, :secp256r1)
bob_shared = :crypto.compute_key(:ecdh, alice_pub, bob_priv, :secp256r1)

Rodando isso, alice_pub e bob_pub saem com 65 bytes cada, formato de ponto não comprimido de uma curva de 256 bits, alice_shared == bob_shared dá true, os dois lados calcularam o mesmo segredo de 32 bytes sem nunca colocar alice_priv nem bob_priv no fio, exatamente o mesmo mecanismo do exemplo com p=23, só que com um espaço de busca astronomicamente maior.

A parte que importa pra este artigo não é o tamanho da chave, é onde ela mora: alice_priv e bob_priv são valores locais de uma função, existem enquanto a call stack que os criou existe, quando a função retorna, ou quando o processo Elixir que rodou essa chamada morre, o valor some da memória do jeito que a garbage collection do BEAM decide, não porque alguém mandou apagar, um GenServer que negocia uma chave de sessão por requisição e não guarda o segredo em nenhum lugar além do próprio estado de processo já está implementando forward secrecy por construção, não por disciplina, o processo morre, a chave morre junto, ninguém precisa lembrar de rodar um DELETE.

Forward secrecy de verdade não é uma função que você chama, é uma decisão sobre onde o segredo mora e por quanto tempo o processo que o criou continua vivo.

Efêmero por sessão não é efêmero de verdade

Até aqui o artigo tratou "efêmero" como sinônimo de "seguro", os exemplos com p=23 e com a curva de 256 bits geraram um segredo novo a cada execução, mas o Diffie-Hellman clássico tem outro parâmetro que ainda não apareceu, o grupo, o par (g, p) que os dois lados concordam em usar antes mesmo de gerar a e b, esse grupo não precisa ser efêmero, a maioria das implementações reaproveita um conjunto pequeno de grupos padronizados, é exatamente o que o RFC 7919 padroniza, e foi nessa reutilização que o ataque Logjam, publicado em 2015 pela mesma equipe que hoje mantém o weakdh.org, encontrou a fresta.

O problema não era quebrar uma troca específica: "millions of HTTPS, SSH, and VPN servers all use the same prime numbers for Diffie-Hellman key exchange", e o primeiro passo do ataque, o number field sieve, "is dependent only on this prime", não no expoente efêmero de cada sessão.

Isso significa que um atacante pode gastar meses de computação pesada uma única vez contra um primo, e depois quebrar qualquer sessão que use aquele mesmo primo em minutos, os pesquisadores estimaram que "an academic team can break a 768-bit prime and that a nation-state can break a 1024-bit prime", e quebrar o primo de 1024 bits mais comum da época "would allow passive eavesdropping on connections to 18% of the Top 1 Million HTTPS domains".

Cada sessão individual continuava gerando a e b novos, exatamente como o exemplo em Elixir deste artigo faz, a efemeridade da conversa estava correta, o que não era efêmero, e não precisava ser pra manter a teoria da forward secrecy de pé, era o chão sobre o qual ela foi construída.

A garantia de forward secrecy assume implicitamente que quebrar uma troca custa caro a cada vez, quando a infraestrutura reaproveita o mesmo grupo em escala, essa suposição de custo deixa de valer, e a efemeridade da sessão vira teatro.

Forward secrecy é uma cláusula, não um sumiço

O motivo de Diffie-Hellman aparecer em praticamente todo handshake TLS moderno não é confidencialidade contra quem está ouvindo agora, RSA estático já resolvia isso, é proteção contra o futuro: se o servidor perder a chave privada de longo prazo daqui a um ano, o tráfego capturado hoje continua ilegível, porque a chave de sessão de hoje nunca dependeu só daquela chave privada, dependeu de um segredo efêmero (a, b no exemplo acima) que foi descartado assim que a sessão terminou.

O RFC 8446, que define TLS 1.3, não deixa isso como recomendação, trocas estáticas foram removidas do protocolo: "Static RSA and Diffie-Hellman cipher suites have been removed; all public-key based key exchange mechanisms now provide forward secrecy" (seção 1.2), e a troca efêmera não é opcional quando não há PSK: "If PSK is not being used, then (EC)DHE and certificate-based authentication are always used" (seção 4.1.1).

Essa frase carrega uma segunda fronteira que passa despercebida na primeira leitura, "(EC)DHE and certificate-based authentication are always used", o "and" ali não é enfeite, Diffie-Hellman sozinho não autentica ninguém, ele garante que as duas pontas que trocaram a_pub e b_pub chegaram na mesma chave, não garante quem são essas pontas, um atacante no meio do caminho pode interceptar a troca e negociar uma chave efêmera com cada lado separadamente, sem que nenhum dos dois perceba, porque a matemática do lado de cada vítima fecha perfeitamente, só que fecha com o atacante, não com quem ela pensa que está falando, e é por isso que TLS nunca manda esses valores nus, manda assinados pela chave do certificado, o MITM não quebra o logaritmo discreto, ele finge ser as duas pontas antes de a matemática começar a rodar.

Autenticação e forward secrecy resolvem dois problemas diferentes, um garante com quem você está falando, o outro garante por quanto tempo essa conversa continua protegida depois, e um Diffie-Hellman sem o primeiro é uma chave perfeita combinada com a pessoa errada.

Aqui está a cláusula que a maioria lê rápido demais: forward secrecy garante que a chave de sessão não pode ser reconstruída depois, a partir da chave de longo prazo, ela não garante nada sobre o que aconteceu com o conteúdo depois que a sessão decifrou ele, o TLS protege o cano, não o que sai do cano na outra ponta.

Forward secrecy é uma cláusula sobre um segredo específico, não uma promessa sobre o destino do dado que passou por ele.

O WhatsApp já tinha forward secrecy, e não importou

Antes de voltar pro caso Vorcaro, fica uma pergunta no ar: será que o WhatsApp, especificamente, já faz essa parte direito? A resposta está documentada no whitepaper de criptografia da própria empresa (WhatsApp Encryption Overview, fevereiro de 2026), e ela se aplica ao caso de um jeito quase cirúrgico.

O WhatsApp usa o Signal Protocol, e o texto oficial é direto sobre o que isso garante: "Due to the ephemeral nature of the cryptographic keys, even in a situation where the current encryption keys from a user's device are physically compromised, they cannot be used to decrypt previously transmitted messages."

Compromisso físico do dispositivo é exatamente o que aconteceu em 18 de novembro de 2025, a PF ficou com o aparelho na mão, rodou Cellebrite e IPED nele, e mesmo assim não precisou, e não teria como, decifrar retroativamente nenhuma mensagem antiga usando o que encontrou no aparelho.

O mecanismo por trás dessa garantia é Diffie-Hellman de novo, o whitepaper chama de Double Ratchet: cada mensagem enviada carrega consigo uma chave pública Curve25519 efêmera nova, "each time a message is transmitted, an ephemeral Curve25519 public key is advertised along with it", e a resposta recalcula a base da criptografia seguinte com uma nova troca DH, ephemeral_secret = ECDH(Ephemeral_sender, Ephemeral_recipient).

Uma troca DH efêmera nova a cada ida e volta de mensagem, não uma por conexão como o TLS faz, uma por mensagem, o documento chama isso de "DH ratchet", combinado com um "hash ratchet" que avança a cada mensagem individual mesmo sem round trip novo, e o resultado declarado no próprio texto é "This provides forward secrecy".

O próprio TLS também permite renovar a chave de tráfego no meio de uma conexão, o handshake KeyUpdate existe exatamente pra isso, "the KeyUpdate handshake message is used to indicate that the sender is updating its sending cryptographic keys" (RFC 8446, seção 4.6.3), só que o gatilho recomendado pela mesma especificação é bem mais folgado que "toda mensagem": até 2^24.5 registros, cerca de 24 milhões, podem ser cifrados com o mesmo conjunto de chaves antes de a seção 5.5 recomendar a renovação, o Double Ratchet faz o equivalente uma vez por mensagem, não uma vez a cada 24 milhões delas, a garantia de fundo é a mesma, forward secrecy via DH efêmero, a janela de exposição entre as duas implementações difere em ordens de grandeza.

A perícia nunca discutiu quebrar o Signal Protocol porque não precisou, a garantia que ele oferece, o conteúdo da mensagem não pode ser reconstruído depois, nunca foi o que vazou.

O que vazou foi um PDF que o subsistema de screenshot do iOS gerou antes mesmo de a mensagem existir como mensagem, fora do escopo de qualquer coisa que o Signal Protocol, o Double Ratchet, ou qualquer Diffie-Hellman efêmero tenha prometido cobrir.

O efeito colateral que ninguém desenhou pra existir

É exatamente essa cláusula que o esquema do Vorcaro tentou replicar na camada de aplicativo, mensagem de visualização única mais auto-apagamento em 24h é, na prática, uma tentativa caseira de forward secrecy: depois que a mensagem for vista ou o prazo vencer, a promessa é que não sobra nada pra reconstruir a conversa.

O WhatsApp cumpriu a parte dele, a mensagem, dentro do armazenamento do próprio WhatsApp, se comportou como prometido, o que a perícia usou não foi o conteúdo do WhatsApp, foi um artefato gerado por um sistema adjacente que nunca fez parte do contrato: o subsistema de captura de tela do iOS, que precisa converter o que está na tela num formato intermediário pra funcionar (o relatório da PF descreve isso como parte da rotina de manipulação de imagem do sistema), e que grava esse intermediário num diretório temporário antes de descartar.

O relatório documenta um desses padrões com granularidade de segundo, na tabela que resume uma das notas reconstruídas (Figura 6 da IPJ-A, págs. 12-13 de 218):

EventoHorário (UTC)Intervalo
Bloco de Notas aberto16:38:04-
Captura de tela16:38:1713s depois
PDF temporário criado16:38:17mesmo segundo
Bloco de Notas fechado16:38:2811s depois
WhatsApp aberto16:38:28mesmo segundo
Mensagem enviada16:38:4012s depois
WhatsApp fechado16:38:433s depois

Do primeiro segundo com o Bloco de Notas aberto até o WhatsApp fechado, 39 segundos, e o detalhe que interessa pra este artigo está numa única linha: o PDF nasceu no mesmo segundo do screenshot, não um segundo depois, não um minuto depois, no mesmo segundo, porque ele não é um arquivo que alguém decidiu criar, é a consequência mecânica de como o iOS processa uma captura de tela, e é essa correlação, replicada 52 vezes com o mesmo padrão, que substituiu decifrar qualquer coisa.

Ninguém desenhou esse PDF pra ser um registro, ele existe porque a engenharia de um recurso completamente diferente, a própria função de screenshot, precisa de um estado intermediário em disco pra funcionar, a garantia de "visualização única" nunca previu esse vizinho, porque o vizinho não é parte do WhatsApp, é parte do sistema operacional embaixo dele.

Isso é o mesmo formato de furo que qualquer engenheiro de API financeira reconhece na hora, TLS com ECDHE garante forward secrecy pro que trafega entre o seu serviço e o do parceiro, ele não sabe, e não tem como saber, que o middleware de observabilidade grava o corpo da requisição inteiro no APM depois que o TLS já decifrou, que o webhook cai numa fila de retry que persiste o payload em disco por 72 horas, ou que o log de erro captura a exceção com o request completo dentro [VERIFICAR: autor, troque por um caso real seu de payload logado em claro depois do TLS, ou remova], o canal foi protegido do jeito que o RFC promete, o que aconteceu depois do TLS decifrar nunca foi parte da promessa.

A garantia de forward secrecy termina exatamente onde o segredo efêmero é descartado, e tudo que o sistema grava como efeito colateral de processar o conteúdo já decifrado está do lado de fora dessa fronteira.

Tabela de decisão: o que cada camada realmente cobre

CamadaO que protegeO que não cobre
DH efêmero (TLS, ECDHE)chave de sessão não recuperável depoiso texto plano após decifrado no endpoint
Double Ratchet (Signal Protocol)conteúdo da mensagem, mesmo com o dispositivo comprometido depoisartefato que o SO gera antes de a mensagem existir como mensagem
WhatsApp visualização únicaconteúdo dentro do armazenamento do appartefato gerado por sistema adjacente (screenshot)
Auto-apagamento em 24hmensagem no histórico do chatcópia já processada por outro subsistema antes
Full disk encryption (BFU)dados em repouso com telefone desligadotelefone já desbloqueado uma vez (estado AFU)

O buraco que fica, mesmo sabendo disso

Aqui está o desconforto que essa análise não resolve: dá pra listar cada vizinho conhecido, log de APM, fila de retry, cache de screenshot, mas a lista nunca é fechada, porque o vizinho que vaza é sempre um recurso de infraestrutura que ninguém pensou em auditar do ponto de vista de "isso retém segredo", ninguém revisa o subsistema de screenshot pensando em forward secrecy, porque a função dele é tirar print, não guardar prova, é exatamente por isso que ele guardou prova.

Dá pra reduzir a superfície, não fechar ela, um jeito comum num serviço Elixir é redigir campo conhecido antes que ele vire linha de log, não depois:

defmodule Redact do
  @campos ~w(token cpf cartao request_body)a

  def apply(params) when is_map(params) do
    Enum.reduce(@campos, params, fn campo, acc ->
      if Map.has_key?(acc, campo), do: Map.put(acc, campo, "[REDACTED]"), else: acc
    end)
  end
end

Rodando com %{token: "abc123", valor: 42, cartao: "4111111111111111", nome: "ok"}, sai %{cartao: "[REDACTED]", nome: "ok", token: "[REDACTED]", valor: 42}, o campo listado some, o resto passa direto, isso pega o que atravessa essa função antes de virar log, não pega o que uma lib de terceiro escreve direto num IO.puts, não pega o corpo bruto que um Plug de request logging captura antes do seu código rodar, e não pega nada que já saiu pra um agente de APM que intercepta a chamada HTTP na camada de rede, por fora da aplicação inteira.

Redigir log custa disciplina em cada ponto que escreve log, e o primeiro ponto que alguém esquece de aplicar essa disciplina é sempre o middleware que ninguém escreveu pensando em segurança, o de observabilidade.

Auditar toda a superfície de efeito colateral de um sistema operacional inteiro, ou de toda a cadeia de middleware entre o TLS e o disco, custa caro o suficiente pra ninguém fazer isso de verdade, o preço de fechar esse buraco por completo é reescrever a suposição de confiança em cada componente que toca o dado depois de decifrado, não só no que decifra, o que sobra é: forward secrecy garante uma coisa específica muito bem, e qualquer suposição de que ela garante mais do que isso é o próprio erro que expôs o Vorcaro.

A objeção que você vai levantar

Você vai me dizer que isso já tem solução, TLS 1.3 obriga (EC)DHE, ninguém mais assina contrato com PSP nem integra API bancária relevante sem HTTPS, e a rotação de certificado já é rotina, você tem razão, e é exatamente por isso que o furo continua aberto: ninguém está discutindo se o handshake está certo, o handshake está certo, o problema nunca esteve lá.

O teste que fecha essa discussão em dez minutos não é auditar o TLS, é isto, num serviço seu que fala com um parceiro externo por HTTPS:

grep -ri "request.body\|payload\|req\.body" caminho/do/seu/middleware/de/log.*

Se voltar alguma linha gravando o corpo da requisição ou da resposta depois que o TLS já decifrou, você achou o seu PDF na pasta tmp, se voltar zero linha, ainda falta olhar dois lugares que esse grep de código-fonte não pega, porque a gravação acontece em runtime, não no repositório: a fila de retry de webhook que persiste o payload em disco antes de reenviar, e o rastreamento de APM que captura o corpo inteiro da requisição quando uma exceção estoura no meio do processamento.

O número que sai desse grep não mede se você tem forward secrecy, isso o TLS já resolveu, mede quantos lugares diferentes do seu sistema prometeram guardar segredo por tempo nenhum e, na prática, guardam por meses.


Fontes

Loading comments...