Resposta curta. Uma assinatura DKIM cobre duas coisas: um hash do corpo da mensagem (bh=) e uma assinatura sobre cabeçalhos selecionados mais esse hash (b=). body hash did not verify significa que o corpo mudou depois da assinatura, por exemplo por causa de um rodapé, um aviso legal, um banner de antivírus ou uma mudança de codificação. bad signature / signature verification failed significa que um cabeçalho assinado mudou, como uma tag no Subject ou um From reescrito, ou que a chave não corresponde. no key / key not found e permerror apontam para o DNS: seletor errado, registro revogado ou malformado. A canonicalização relaxed (c=relaxed/relaxed) tolera mudanças nos espaços em branco e nas maiúsculas e minúsculas dos nomes de cabeçalho, e nada mais. Para diagnosticar, compare o cabeçalho Authentication-Results com as tags do DKIM-Signature e então corrija o componente que modifica o e-mail depois da assinatura, ou assine mais adiante no pipeline.
O que o DKIM realmente assina
O DKIM (RFC 6376) adiciona um cabeçalho DKIM-Signature. Este foi gerado pelo dkimpy 1.1.8 para uma mensagem de teste:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
i=@example.com; q=dns/txt; s=sel; t=1792500885; h=from : to : subject
: date : message-id; bh=eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=;
b=FVDVEZSc7Whs7waF1W5ImDSxpa/czkhdpeUBefQ3/daFclzCdP0x9EQh6NOxBCa6oWph4
WtntuKENSuqQGtjzcVlLdT+itgDCj5PaKMHI6S6JmQPBo2yy2EzPpdcDPeuiORtNbJMRHdZ
0MhMgg7k11sPD9ts0pE6i+nzWmUXyJ2jEKWw68oG59kSO3B9jns
O valor da assinatura foi encurtado aqui. As tags que importam para a depuração:
| Tag | Significado | Falha típica |
|---|---|---|
d=, s= | Domínio de assinatura e seletor. A chave é buscada em s._domainkey.d | Seletor errado, registro ausente ou revogado |
c= | Canonicalização do cabeçalho/corpo, simple ou relaxed. Padrão: simple/simple | Mudanças de espaços em branco quebram simple |
h= | Campos de cabeçalho assinados, em ordem | Qualquer mudança em um deles |
bh= | Hash em Base64 do corpo canonicalizado | O corpo mudou |
b= | Assinatura sobre os cabeçalhos assinados e o próprio DKIM-Signature (com b= vazio) | Um cabeçalho mudou, ou a chave está errada |
l= | Opcional: só os primeiros l bytes do corpo entram no hash | Permite que qualquer pessoa acrescente conteúdo (veja abaixo) |
a= | Algoritmo: rsa-sha256 ou ed25519-sha256 | rsa-sha1 não é mais válido (RFC 8301) |
O verificador segue uma ordem fixa. Primeiro, busca a chave (s._domainkey.d, TXT). Depois, canonicaliza o corpo e compara o hash dele com bh=. Só então verifica b= sobre os cabeçalhos. Essa ordem explica os veredictos: uma mudança no corpo é relatada como problema no hash do corpo, mesmo que um cabeçalho também tenha mudado.
Canonicalização: o que o “relaxed” perdoa
A canonicalização converte a mensagem em uma forma padrão antes do cálculo do hash, para que mudanças inofensivas no caminho não quebrem a assinatura. A RFC 6376 §3.4 define duas formas:
- simple nos cabeçalhos: nenhuma alteração. Nem a caixa (maiúsculas e minúsculas) do nome do cabeçalho pode mudar.
- relaxed nos cabeçalhos: os nomes passam para minúsculas, as linhas de continuação são juntadas, cada sequência de espaços em branco vira um único espaço e os espaços em branco no fim do valor e em volta dos dois-pontos são removidos.
- simple no corpo: só as linhas vazias no final são ignoradas.
- relaxed no corpo: além disso, os espaços em branco no fim das linhas são ignorados, e cada sequência de espaços e tabulações dentro de uma linha vira um único espaço.
Nenhuma das duas formas tolera palavras alteradas, linhas adicionadas, partes MIME recodificadas ou terminações de linha diferentes. O relaxed só perdoa espaços em branco e a caixa dos nomes de cabeçalho. É por isso que c=relaxed/relaxed é a escolha habitual. O padrão da RFC é
simple/simple, então um DKIM-Signature sem c= usa a forma estrita.
Reprodução das falhas
O teste assina uma mensagem curta com uma chave RSA de 2048 bits (c=relaxed/relaxed, h=from:to:subject:date:message-id), altera a mensagem como fazem os intermediários reais e a verifica com o dkimpy 1.1.8. A consulta ao DNS é substituída por uma função no próprio processo, para que o registro da chave possa ser alterado entre um caso e outro:
--- 1. unchanged message, relaxed/relaxed
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 2. list appends a footer to the body
log: bh: b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8='
exception: ValidationError: body hash mismatch (got b'cL30gL2flFNCvVcW5T6gPIkNZAhq6yYRaXnavNmMAJ8=', expected b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE=')
--- 3. list adds a [team] tag to the Subject
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: False
verify: False
--- 4a. whitespace changed in transit, relaxed/relaxed
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 4b. same whitespace change, simple/simple
log: bh: b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk='
exception: ValidationError: body hash mismatch (got b'CdTP1x6zZ/fF6DwHq354s/aG/L7f72HAMBorT94XVmk=', expected b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY=')
--- 4c. simple/simple, only the Subject whitespace changed
log: bh: b'bxvAnpxX30DaKPhGagkY2hEaFkHH+Q8kAXms32JaSNY='
log: b'DKIM-Signature' valid: False
verify: False
Caso a caso:
- Mensagem inalterada. O hash do corpo confere, então a assinatura passa na verificação.
- Rodapé acrescentado. Uma lista de discussão adiciona um rodapé para cancelar a inscrição. O hash do corpo calculado (
got) difere debh=(expected), então a verificação para antes de chegar à assinatura. - Tag no Subject. A lista coloca
[team]no início do Subject. O hash do corpo continua conferindo, então o corpo está intacto, mas a assinatura dos cabeçalhos falha. “Hash do corpo OK, assinatura não OK” aponta para um cabeçalho alterado ou para uma chave errada. - Espaços em branco. O teste duplica um espaço no Subject, reduz um espaço duplo do corpo a um só e acrescenta um espaço no fim de uma linha. Com
relaxed/relaxed, a assinatura continua passando na verificação. Comsimple/simple, a mudança no corpo faz o hash do corpo divergir (4b), e a mudança no cabeçalho, sozinha, já faz a assinatura falhar (4c).
Os servidores receptores gravam o resultado em Authentication-Results. Aqui estão os mesmos casos verificados pelo mailauth 8.0.1 (Node.js 24.22), no formato que você verá em uma mensagem recebida:
unchanged dkim=pass header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
footer appended dkim=neutral (body hash did not verify) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
[team] added to Subject dkim=fail (bad signature) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
key revoked (empty p=) dkim=neutral (invalid public key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
no key in DNS dkim=neutral (no key) header.i=@example.com header.s=sel header.a=rsa-sha256 header.b=P1HuE+RF;
Cada verificador descreve os resultados de um jeito. O mailauth relata a divergência do hash do corpo como neutral por padrão e como dkim=fail (body hash did not verify) no modo estrito. O OpenDKIM escreve
dkim=fail reason="signature verification failed" tanto para mudanças no corpo quanto para mudanças nos cabeçalhos. A causa é a mesma. As páginas de erro de
body hash did not verify,
signature verification failed,
key not found in DNS e permerror
listam as variantes.
Quem altera as mensagens no caminho
Como relacionar o sintoma à causa:
| Sintoma | Culpado habitual |
|---|---|
| Hash do corpo divergente em todas as mensagens de um remetente | Um filtro de conteúdo, um aviso legal ou uma ferramenta que adiciona assinaturas de e-mail rodando depois do signatário DKIM, muitas vezes no próprio lado do remetente |
| Hash do corpo divergente só em mensagens que passam por uma lista de discussão | O software da lista adiciona um rodapé ou reescreve partes MIME |
| Hash do corpo divergente só em mensagens com texto não ASCII | Conteúdo de 8 bits convertido em quoted-printable por um servidor de retransmissão. A RFC 6376 §5.3 orienta os signatários a converter para MIME de 7 bits antes de assinar |
| Hash do corpo divergente em mensagens geradas por um script | Terminações de linha LF isoladas na assinatura e CRLF na transmissão, ou o contrário |
| Assinatura inválida, hash do corpo OK | Tag no Subject, reescrita do From (a mitigação de DMARC de uma lista), um gateway que quebra de novo as linhas de um cabeçalho ou o recodifica, Reply-To ou To alterado pelo encaminhamento |
| Assinatura inválida em todas as mensagens | A chave privada e o registro DNS não correspondem: seletor errado, chave rotacionada ou chave colada com erro |
O encaminhamento simples, por um .forward ou por um alias, costuma preservar o corpo e os cabeçalhos assinados, então o DKIM sobrevive onde o SPF quebra. As listas de discussão são as clássicas culpadas, porque querem alterar a mensagem. Um software de lista moderno pode evitar isso não modificando o corpo nem o Subject, ou reescrevendo o From com o domínio da lista e assinando com a própria chave. Os receptores que oferecem suporte ao ARC (RFC 8617, experimental) também podem confiar na cadeia ARC da lista, que registra o resultado DKIM original antes da modificação.
A tag l=: uma robustez que convida à falsificação
l= instrui os verificadores a calcular o hash só dos primeiros l bytes do corpo canonicalizado. A ideia era permitir que as assinaturas sobrevivessem aos rodapés das listas de discussão. O teste mostra o lado ruim. A mesma mensagem assinada com l=35 continua passando na verificação depois que se acrescenta conteúdo, seja um rodapé, seja um parágrafo inteiro novo escrito por outra pessoa:
--- 5. signed with l= (body length limit)
l=35
--- 5a. footer appended to an l= signature
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
--- 5b. a whole new part appended to an l= signature
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
verify: True
O texto acrescentado em 5b foi Please pay invoice 4711 to IBAN XX00 0000 0000., e a assinatura continua dizendo pass. A RFC 6376, na §8.2, alerta que l= “permite ataques em que um intermediário mal-intencionado pode modificar uma mensagem”, que “os signatários deveriam ser extremamente cautelosos ao usar essa tag” e que “os avaliadores podem querer ignorar as assinaturas que usam essa tag”. Com MIME e HTML, o conteúdo acrescentado pode substituir por completo o que o leitor vê. Não assine com l=. Se encontrar essa tag nos seus e-mails de saída, remova-a da configuração do signatário. No OpenDKIM, a tag l= vem das configurações BodyLengthDB e MaximumSignedBytes.
Chaves e DNS
O verificador consulta selector._domainkey.domain como registro TXT. Primeiro os casos da demonstração, depois o DNS real:
--- 6. key revoked in DNS (empty p=)
log: could not parse RSA public key (b''): Unparsable public key; SubjectPublicKeyInfo: Data truncated at byte 0; RSAPublicKey: Data truncated at byte 0
verify: False
--- 7. no key record in DNS
log: missing public key: b'sel._domainkey.example.com.'
verify: False
--- 8. 512-bit RSA key
log: bh: b'eMSXagBrSE6c3/J0njUAW7nGMOlyd4roWBYqgR3grrE='
log: b'DKIM-Signature' valid: True
exception: KeyFormatError: public key too small: 512
Um p= vazio é a forma de um domínio revogar um seletor. O caso 8 mostra que a aritmética RSA de uma assinatura de 512 bits confere, mas o verificador rejeita a chave mesmo assim. A RFC 8301
estabelece que os verificadores “NÃO DEVEM considerar válidas as assinaturas que usam chaves RSA de menos de 1024 bits”. Os signatários “DEVEM usar chaves RSA de pelo menos 1024 bits” e “DEVERIAM usar chaves RSA de pelo menos 2048 bits”, e o rsa-sha1 “NÃO DEVE ser usado para assinar nem para verificar”. O Ed25519 (a=ed25519-sha256,
RFC 8463) também está definido. A própria mensagem de exemplo da RFC traz uma assinatura RSA e uma Ed25519, e é assim que ele é implantado: com a assinatura RSA para os verificadores que não oferecem suporte a Ed25519.
No DNS real, o opendkim-testkey do OpenDKIM verifica um seletor como um verificador faria:
$ opendkim-testkey -d github.com -s google -vvv
opendkim-testkey: checking key 'google._domainkey.github.com'
opendkim-testkey: key not secure
opendkim-testkey: key OK
$ opendkim-testkey -d gmail.com -s 20230601 -vvv
opendkim-testkey: checking key '20230601._domainkey.gmail.com'
opendkim-testkey: Revoked key
$ opendkim-testkey -d gmail.com -s nosuchselector -vvv
opendkim-testkey: checking key 'nosuchselector._domainkey.gmail.com'
opendkim-testkey: 'nosuchselector._domainkey.gmail.com' record not found
“key not secure” só significa que a resposta não foi validada com DNSSEC. O seletor 20230601 em gmail.com é uma chave real aposentada, com p= vazio. As mensagens assinadas com ela falham agora, e é por isso que mensagens antigas podem “perder” o pass do DKIM depois de uma rotação de chaves.
Uma chave de 2048 bits não cabe em uma única string TXT de 255 bytes, então ela é publicada como várias strings, que os verificadores juntam sem separadores:
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSqGoYWaWaFbS2UAeyPrEmd0gogocmRfS441qwR8/0KB81Hw89P0l4YiFRrXYk7NVIGfyCRHAYYZUzCkGeOysI2EjgzLFhd/NEsbRzOEc/kWkK/RO6JFq/5lOn6M9AZw/ap9tds4JG9ApgNNdSpPxp9DmvpsOSgNMVflRxQFrk3kdS4RNAPKu/" "OPoA7dlR/A/pECryjRoYgENtDXzdnK70HgCekems6UDzxDj61cjyoKoXtEMF/QsaHEQ1Gjfv014rDJBsubk/kT5VqHkWHa/ia68Z5r228Ety/wFfQNjXTx/J7KGZ9GkZlKED659eiJcLnWcKDSiQlhwIDAQAB"
Erros típicos de DNS: aspas ou um espaço colados dentro da chave ao dividi-la à mão; um provedor de DNS que transforma uma string longa em dois registros, em vez de um registro com duas strings; a chave publicada com o nome errado, como sel._domainkey.example.com.example.com quando a zona acrescenta a origem; e um seletor novo usado antes de o registro se propagar. O
gerador de registros DKIM produz, a partir de uma chave pública, um registro dividido corretamente.
Diagnóstico passo a passo
- Obtenha a mensagem bruta como foi recebida, com todos os cabeçalhos: “Mostrar original” no Gmail, “Exibir código-fonte” no Outlook ou o arquivo
.eml. Encaminhar a mensagem para você mesmo a altera. - Leia o
Authentication-Resultsdo seu servidor receptor, o que está mais acima. Anote o veredicto do DKIM e o motivo: hash do corpo, assinatura inválida, chave ausente, permerror. - Leia o
DKIM-Signature:d=es=(qual chave),c=(qual o rigor),h=(quais cabeçalhos),l=(deveria estar ausente). - Verifique a chave:
dig +short TXT s._domainkey.douopendkim-testkey -d d -s s -vvv. Ela existe, não foi revogada, tem pelo menos 1024 bits e está completa? - Hash do corpo divergente: procure o que foi adicionado ou recodificado: rodapés, banners, mudanças de
Content-Transfer-Encodingentre o que foi enviado e o que foi recebido. Se você controla o remetente, compare a cópia da pasta Enviados ou do log do MTA com a cópia recebida. - Assinatura inválida com hash do corpo correto: compare cada cabeçalho listado em
h=entre a cópia enviada e a recebida. Se nenhum mudou, a chave do DNS não corresponde à chave privada em uso. Teste com uma mensagem que você assina e verifica localmente. - Falhas intermitentes: procure vários caminhos de saída (dois MTAs, um servidor de retransmissão na nuvem) em que só um assina corretamente, ou um seletor já rotacionado que um deles ainda usa.
Para fazer isso com uma mensagem colada, em vez de à mão, o depurador de DKIM
mostra cada etapa da canonicalização, o bh= calculado comparado ao valor do cabeçalho, a chave do DNS e a modificação mais provável. A mensagem fica no seu navegador. Para o quadro completo, incluindo SPF, DMARC e ARC, use o analisador de autenticação de e-mail.
Correções duradouras
- Assine por último. No seu próprio caminho de saída, rode avisos legais, rodapés, reescrita de links e antivírus antes do signatário DKIM. No Postfix, isso é definido pela ordem em
smtpd_miltersoucontent_filter. - Use
c=relaxed/relaxed. Não custa nada e resiste a novas quebras de linha nos cabeçalhos e a mudanças de espaços em branco. - Converta para MIME de 7 bits antes de assinar (quoted-printable ou base64), como recomenda a RFC 6376 §5.3, para que servidores de retransmissão sem suporte a 8BITMIME não recodifiquem o corpo.
- Não use
l=e não coloque emh=cabeçalhos que os intermediários alteram legitimamente, a menos que você precise deles assinados. - Faça a rotação de chaves com sobreposição. Publique o novo seletor, espere os caches de DNS expirarem, passe a assinar com ele, mantenha o registro antigo por alguns dias para que os e-mails em trânsito ainda sejam verificados e depois revogue-o com um
p=vazio. - Chaves RSA de 2048 bits. O mínimo é 1024. Acrescente também uma assinatura Ed25519 se o seu signatário oferecer suporte.
- Nas listas de discussão que você mantém: não modifique o corpo nem o Subject, ou reescreva o
Frome assine com o domínio da lista; e adicione assinaturas ARC.
Fontes
- RFC 6376: assinaturas DKIM, §3.4 (canonicalização), §3.5 (tags),
§5.3 (conversões de transporte), §8.2 (
l=). - RFC 8301: atualização dos algoritmos criptográficos e do uso da chave (sem SHA-1, mínimo de 1024 bits).
- RFC 8463: Ed25519 para DKIM.
- RFC 8617: protocolo Authenticated Received Chain (experimental).
- dkimpy 1.1.8, mailauth 8.0.1,
opendkim-testkeydo OpenDKIM.
Os casos do dkimpy rodaram em node:24.22.0-trixie (o Python 3.13.5 do Debian com um venv), o mailauth em
node:24.22.0-bookworm-slim, o opendkim-testkey em debian:trixie-20261005-slim e o dig em
nicolaka/netshoot:v0.14, todos em 20 de outubro de 2026.