Todo serviço de webhooks mostra um log de entregas. A parte desconfortável é que esse log é uma linha no Postgres de alguém, e qualquer pessoa com acesso ao banco de dados, incluindo o fornecedor, pode alterá-lo com UPDATE. Altere um payload, retroceda um timestamp ou exclua uma linha inteira, e não sobra rastro. Quando uma disputa depende de "o que exatamente a Stripe enviou para você às 14:32", um log mutável não é prova. É uma alegação, sustentada apenas pela confiança no fornecedor.
Os frameworks de auditoria dizem isso com todas as letras: logs que podem ter sido adulterados têm valor probatório reduzido. Por isso, tornamos o log de eventos do AnyHook um log com adulteração detectável. Este post cobre o design inteiro, incluindo as partes que foram mais difíceis do que esperávamos e o limite que aceitamos de propósito.
TL;DR
- Cada evento registra, no momento da escrita, um SHA-256 do seu corpo em texto puro
- Um job diário encadeia os eventos de cada app: altere ou exclua qualquer evento passado e todas as raízes seguintes mudam
- As raízes dos apps de cada dia se combinam numa única âncora pública, encadeada na do dia anterior
- As âncoras são puxadas para um repositório público no GitHub por um workflow para o qual o serviço do AnyHook não tem nenhuma credencial. Um servidor invadido não consegue mexer no registro, e reescrever o histórico dele ficaria visível para qualquer pessoa com um clone
- O limite, dito sem rodeios: uma adulteração dentro do dia atual, ainda não selado, não é detectável. O caminho de evolução existe; ninguém precisou dele ainda
A cadeia
Três camadas, todas SHA-256 puro.
Uma folha por evento:
leaf = sha256("{id}|{created_at}|{body_hash}")
body_hash é calculado a partir do corpo em texto puro, antes da criptografia. Isso é proposital: os corpos armazenados são criptografados com AES-256-GCM e um IV aleatório, então o texto cifrado não é reproduzível. Calcular o hash do texto puro significa que qualquer pessoa com o payload que recebeu consegue recalcular a folha. A verificação nunca precisa da nossa chave de criptografia.
A raiz do app é um fold sobre o dia do app, ordenado por (created_at, id), com a raiz anterior desse app como semente:
h = prev_root # "" at genesis
for leaf in leaves:
h = sha256(h + "|" + leaf)
Editar o evento #5 muda todos os hashes depois dele; excluir um encurta o fold; inserir um com data retroativa desloca tudo. Os três produzem uma raiz diferente.
A âncora do dia faz o fold de cada par (app_id, root), ordenado pelo id do app, com esta semente:
h = sha256(prev_anchor + "|" + day)
Esse day na semente importa mais do que parece. Nossa primeira versão usava só prev_anchor como semente, e isso fazia um dia com zero eventos produzir uma âncora idêntica à do dia anterior, e "dois dias tranquilos" ficava indistinguível de "um dia removido em silêncio da linha do tempo". Incluir a data no fold torna a âncora de cada dia distinta, então uma lacuna é, ela mesma, uma prova.
Por que cadeias por app, e não uma cadeia global
Uma cadeia global seria mais simples. Também significaria que verificar seu evento exige as folhas de todos os outros clientes, o que vaza para você os IDs de evento e os timestamps deles. Cadeias por app permitem que um cliente verifique o próprio histórico de forma independente, e a âncora do dia só precisa dos pares (app_id, root), que não dão acesso a nada.
Por que o selo roda uma vez por dia, fora do caminho de escrita
O design óbvio calcula a cadeia no insert. Não fizemos assim, porque uma cadeia de hashes global serializa as escritas (todo insert precisa esperar o hash do anterior), e o caminho de ingestão é exatamente onde um relay de webhooks (um serviço que recebe, guarda e encaminha seus webhooks) não pode ter um gargalo.
Em vez disso, um cron sela o dia UTC anterior uma única vez. Ele nunca é recalculado: quando a retenção apaga eventos antigos, refazer o fold produziria uma raiz diferente, e trocar em silêncio uma âncora publicada é exatamente a adulteração que este sistema existe para pegar.
O custo é uma janela. Edições feitas dentro do dia atual, antes do selo, não são detectáveis. Para provas de disputas medidas em semanas ou meses, uma janela de selagem de 24 horas é irrelevante. Se algum cliente precisar de garantias na mesma hora, a evolução são cadeias ao vivo por app: cada app serializa de forma independente, então os apps continuam escrevendo em paralelo. Escrevemos o comentário que nomeia esse limite e lançamos a versão simples.
A âncora vem por pull, não por push
A cadeia sozinha não prova nada, porque quem controla o banco de dados pode recalcular tudo. A âncora precisa ficar num lugar onde nossos servidores não conseguem escrever e onde uma edição retroativa ficaria visível. Nosso primeiro rascunho fazia o job de selagem enviar cada âncora para o GitHub com um token armazenado. Depois, invertemos isso.
O repositório de âncoras roda uma GitHub Action agendada própria, que consulta nossa API pública e faz commit do que vê. O serviço do AnyHook não tem nenhuma credencial para esse repositório. O workflow também se recusa a reescrever o histórico: ele busca de novo a janela inteira de âncoras a cada execução e, se uma âncora que já está em um commit divergir do que a API devolve agora, o job falha de forma ruidosa em vez de fazer commit por cima.
Uma âncora que o serviço não consegue escrever vale estritamente mais do que uma que ele consegue. Inverter o fluxo também eliminou trinta linhas de código de manipulação de token, que é o tipo de simplificação que dá para manter.
Como verificar com nada além de um shell
Tudo acima é reproduzível a partir da API pública. O dia da gênese, conferido à mão:
$ printf '%s' "|2026-07-27" | shasum -a 256
f27f9546bc0e021a051d8c469539406b50dded3d21a36a9331095632852413d3
Essa é a âncora publicada para 2026-07-27 (zero eventos e uma gênese vazia, então o fold inteiro é um único hash da semente). O dia dois incorpora esse valor no fold, e assim por diante. GET /api/v1/anchors?day=YYYY-MM-DD devolve os pares (app_id, root) de que qualquer auditor precisa para recalcular um dia sem conta, sem chave e sem a nossa cooperação.
Limites, com honestidade
- O dia atual não está selado, como explicado acima. Esse é o preço de um caminho de escrita não serializado
- A retenção limita a prova individual. Quando um evento é apagado pela retenção do plano, a folha dele não pode mais ser recalculada. A raiz do dia continua registrando a contagem e a ordem, mas provar um payload específico exige que o evento ainda exista. Uma retenção mais longa compra, literalmente, mais tempo de prova
- Os registros começam no dia em que o recurso foi lançado, com um buraco: o cron do selo só conseguiu se autenticar em 2026-08-01, então os dias de 07-29 a 07-31 nunca foram selados. A cadeia pública vai de 07-28 direto para 08-01, e selar esses dias agora registraria o que o banco de dados diz hoje, que é exatamente o que um selo existe para impedir. Eventos gravados antes de existir o hash do corpo são registrados só pelo id e pelo timestamp. Uma cadeia não consegue cobrir retroativamente um histórico que ela não estava medindo
- O repositório de âncoras fica na conta do GitHub do fundador. O serviço não consegue escrever nele; o dono consegue. O que torna cara uma reescrita silenciosa é que um histórico alterado com force push aparece para qualquer pessoa com um clone, e o workflow falha quando uma âncora já registrada em commit muda
Nada disso exige blockchain. O Git já é uma cadeia de hashes, o GitHub já registra o horário dos commits, e o Certificate Transparency provou o padrão anos atrás. A única decisão nova aqui é manter fora das mãos do serviço a caneta que escreve o registro público.