Janela anti-replay em webhook: o timestamp assinado e o relógio errado
Por que assinar só o corpo do webhook deixa a porta aberta para reenvio, como funciona a janela de tolerância e o que acontece quando o relógio do servidor desanda.
Uma assinatura HMAC no corpo do webhook responde a uma pergunta: esse conteúdo veio mesmo de quem diz ter enviado e chegou sem alteração no caminho. Ela não responde à segunda pergunta, que é quando aquilo foi enviado. Se o material assinado for apenas o corpo, a assinatura vale para sempre, e quem capturou uma entrega legítima uma única vez pode reenviá-la amanhã, semana que vem, todo dia, sempre com a assinatura correta.
É por isso que os formatos de assinatura que amadureceram carregam um carimbo de tempo dentro do próprio material assinado. O desenho mais copiado do mercado é o cabeçalho com t igual ao timestamp seguido de v1 igual ao HMAC, popularizado pela documentação de webhooks da Stripe: o t é o instante em que o remetente assinou, e o v1 é o HMAC calculado não sobre o corpo isolado, mas sobre a concatenação do timestamp com o corpo, separados por um ponto.
Este texto é sobre a parte que costuma quebrar em produção: a janela de tolerância, o relógio de quem verifica e a confusão entre anti-replay e idempotência. Se a verificação de assinatura ainda está sendo montada, vale ler antes como receber webhooks em verificação de identidade.
O que exatamente vai dentro da assinatura
O detalhe que faz diferença: o HMAC cobre a string formada pelo timestamp, um ponto e o corpo cru, não o corpo sozinho. Amarrar o timestamp ao conteúdo assinado significa que um atacante não pode pegar uma entrega antiga, trocar o t por um valor recente e reaproveitar o v1, porque mudar o timestamp muda o material autenticado e derruba o HMAC. Sem essa amarração, o carimbo vira enfeite: viaja fora do que foi assinado e pode ser reescrito por qualquer um.
A segunda armadilha está na palavra "cru". A verificação precisa usar exatamente os bytes recebidos, antes de qualquer interpretação. Reserializar o JSON quebra a assinatura porque a serialização não é canônica: a ordem das chaves muda, espaços somem, números ganham outra representação textual, acentos saem como escapes Unicode. O remetente assinou uma sequência de bytes específica, e qualquer volta por um interpretador produz outra sequência e outro HMAC.
Na prática isso é um problema de framework, não de criptografia: muitos já converteram o corpo antes do seu código rodar, e a saída é configurar a rota de webhook para preservar os bytes originais. A regra de bolso é curta: leia os bytes, verifique a assinatura, só depois converta para objeto. O algoritmo está descrito na RFC 2104.
A janela de tolerância e o número de cinco minutos
Com o timestamp dentro da assinatura, o receptor ganha uma regra nova: recusar entregas cujo carimbo esteja fora de uma janela em torno da hora atual. Uma entrega com quarenta minutos de idade é rejeitada mesmo com HMAC perfeito, porque não é plausível que uma notificação legítima demore tanto.
A documentação de webhooks da Stripe usa tolerância padrão de cinco minutos, e esse valor virou convenção a ponto de aparecer replicado em integrações que nada têm a ver com pagamentos. Não é uma constante da natureza, é um equilíbrio entre dois erros de sinal contrário.
- Janela curta demais recusa entrega legítima atrasada. Rede lenta, reenvio do fornecedor depois de uma falha, pausa do coletor de lixo, fila interna congestionada: tudo isso acrescenta segundos entre assinar e verificar. Com trinta segundos de tolerância, um pico de tráfego vira uma chuva de assinaturas "inválidas" que não têm nada de inválidas.
- Janela longa demais devolve exatamente a janela de replay que você tentava fechar. Tolerar vinte e quatro horas é quase o mesmo que não ter timestamp: o adversário que capturou uma entrega tem um dia inteiro para reenviá-la.
Cinco minutos cobre a maior parte dos atrasos reais sem deixar a porta escancarada. E existe um ajuste que dissolve a tentação de esticar a janela: verifique a assinatura na borda, assim que os bytes chegam, e enfileire o evento já validado. A tolerância passa a cobrir só o trecho de rede.
Quando o relógio desanda, o sintoma parece rede
Aqui está o incidente que ninguém prevê. A janela compara o timestamp do remetente com o relógio de quem verifica. Se o relógio da sua instância anda dez minutos adiantado ou atrasado, todas as entregas passam a cair fora da janela. Todas. O log enche de assinatura expirada, e a leitura instintiva do time é que o fornecedor está entregando com atraso ou que a rede engasgou.
O sintoma engana porque é total e súbito: parece queda de integração, não desvio de relógio. Duas pistas separam os casos. Se fosse latência, a rejeição seria intermitente e proporcional ao atraso, nunca uniforme; e a diferença entre o carimbo recebido e a hora local seria variável, não constante. Para confirmar sem instrumentação nova, faça uma chamada qualquer à API do fornecedor e leia o cabeçalho Date da RESPOSTA dele, que a RFC 9110 define como o instante em que a mensagem foi originada. Na direção do webhook você recebe uma requisição, e nela esse cabeçalho é opcional, então não dá para contar com ele.
A conclusão prática é que sincronizar relógio deixa de ser higiene de infraestrutura e vira pré-requisito funcional de quem verifica assinatura. O protocolo para isso é o NTP, na versão 4 especificada pela RFC 5905. Container sem fonte de tempo, máquina virtual que ficou suspensa e voltou com o relógio parado, host que perdeu o servidor de referência: qualquer um desses cenários derruba a integração sem que uma linha de código tenha mudado. Vale colocar o desvio de relógio entre os sinais de saúde acompanhados na integração.
Anti-replay e idempotência resolvem problemas diferentes
Os dois mecanismos se parecem porque ambos lidam com "a mesma mensagem chegou de novo", mas o remetente imaginado é outro.
Anti-replay trata de adversário. Alguém capturou uma entrega válida e a reenvia para provocar efeito, por exemplo reprocessar uma aprovação. A defesa é criptográfica e temporal: timestamp dentro da assinatura e janela estreita.
Idempotência trata de duplicata honesta. O worker do fornecedor não recebeu a sua resposta a tempo e tenta de novo, como deveria. A entrega é legítima, recente, bem assinada, e ainda assim é a segunda cópia do mesmo evento. A defesa é de aplicação: registrar o identificador do evento e tratar a repetição como operação sem efeito, tema tratado em idempotência em APIs.
Nenhum dos dois substitui o outro: a janela de cinco minutos não impede a duplicata honesta, que chega dentro da janela, e a chave de idempotência não impede o replay se o evento nunca esteve na sua tabela ou se o registro já expirou. Quem cobre os dois casos ainda precisa de um terceiro mecanismo, para os eventos que simplesmente não chegaram, que é o assunto da reconciliação de webhook.
Rotação de segredo e comparação em tempo constante
Duas escolhas de implementação fecham o desenho. A primeira é aceitar mais de um v1 na mesma assinatura. Quando o segredo é rotacionado, existe uma janela em que o remetente pode assinar com a chave antiga ou com a nova. Um cabeçalho que carrega o mesmo timestamp e duas assinaturas permite ao receptor aceitar a entrega se qualquer um dos valores casar com algum dos segredos ativos. Sem isso, a rotação vira corte instantâneo, e todo corte instantâneo derruba as entregas que estavam em voo.
A segunda é comparar assinaturas em tempo constante. Comparação de texto comum sai no primeiro byte diferente, e o tempo de resposta vaza quantos bytes iniciais estavam certos. Não é conselho exótico: praticamente toda linguagem já traz a primitiva pronta na biblioteca de criptografia, exatamente porque comparar segredo com o operador de igualdade é um erro conhecido.
E uma ordem que evita trabalho inútil: verifique a assinatura antes de qualquer processamento e trate corpo não verificado como dado hostil. Evento que falhou na assinatura não deve virar objeto, tocar o banco nem gerar log com o conteúdo inteiro. Deve virar uma resposta de erro e um contador que alguém observa.
Fontes citadas
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication, IETF.
- RFC 5905: Network Time Protocol Version 4, IETF.
- RFC 9110: HTTP Semantics, IETF.
- Documentação de webhooks da Stripe, origem do formato de cabeçalho com timestamp assinado e da tolerância padrão de cinco minutos, citada por nome no texto.