Saltar al contenido
ecdsa.com
← Todos los artículos

Por qué falla DKIM: hash del cuerpo, firma, claves y DNS

DKIM se rompe cuando un mensaje cambia después de firmarse o cuando el verificador no consigue una clave utilizable. Cada tipo de fallo que sigue se reprodujo con dkimpy y mailauth, con el veredicto exacto que produce, para que puedas compararlo con lo que ves en Authentication-Results.

Respuesta breve. Una firma DKIM cubre dos cosas: un hash del cuerpo del mensaje (bh=) y una firma sobre los encabezados seleccionados más ese hash (b=). body hash did not verify significa que el cuerpo cambió después de la firma, por ejemplo por un pie de página, un aviso legal, un banner del antivirus o un cambio de codificación. bad signature / signature verification failed significa que cambió un encabezado firmado, como una etiqueta en el Subject o un From reescrito, o que la clave no coincide. no key / key not found y permerror apuntan al DNS: selector incorrecto, registro revocado o mal formado. La canonicalización relaxed (c=relaxed/relaxed) tolera los cambios en los espacios en blanco y en las mayúsculas y minúsculas de los nombres de los encabezados, y nada más. Para diagnosticar, compara el encabezado Authentication-Results con las etiquetas de la DKIM-Signature y después corrige el componente que modifica el correo tras la firma, o firma más adelante en el pipeline.

Qué firma DKIM en realidad

DKIM (RFC 6376) añade un encabezado DKIM-Signature. Este lo generó dkimpy 1.1.8 para un mensaje de prueba:

Encabezado DKIM-Signature (dkimpy 1.1.8, plegado tal como se generó)
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

Aquí el valor de la firma está acortado. Las etiquetas que importan para depurar:

EtiquetaSignificadoFallo típico
d=, s=Dominio de firma y selector. La clave se obtiene de s._domainkey.dSelector incorrecto, registro ausente o revocado
c=Canonicalización de encabezados/cuerpo, simple o relaxed. Por defecto, simple/simpleLos cambios de espacios en blanco rompen simple
h=Campos de encabezado firmados, en ordenCualquier cambio en uno de ellos
bh=Hash en Base64 del cuerpo canonicalizadoEl cuerpo cambió
b=Firma sobre los encabezados firmados y la propia DKIM-Signature (con b= vacío)Un encabezado cambió, o la clave es incorrecta
l=Opcional: solo se calcula el hash de los primeros l bytes del cuerpoPermite que cualquiera añada contenido (ver más abajo)
a=Algoritmo: rsa-sha256 o ed25519-sha256rsa-sha1 ya no es válido (RFC 8301)

El verificador comprueba en un orden fijo. Primero obtiene la clave (s._domainkey.d, TXT). Después canonicaliza el cuerpo y compara su hash con bh=. Solo entonces verifica b= sobre los encabezados. Ese orden explica los veredictos: un cambio en el cuerpo se notifica como un problema del hash del cuerpo aunque también haya cambiado un encabezado.

Canonicalización: qué perdona «relaxed»

La canonicalización lleva el mensaje a una forma estándar antes de calcular el hash, para que los cambios inofensivos en tránsito no rompan la firma. El RFC 6376 §3.4 define dos formas:

  • simple en los encabezados: ningún cambio en absoluto. Ni siquiera pueden cambiar las mayúsculas y minúsculas del nombre del encabezado.
  • relaxed en los encabezados: pasa los nombres a minúsculas, despliega las líneas de continuación, reduce cada secuencia de espacios en blanco a un solo espacio y elimina los espacios en blanco al final del valor y alrededor de los dos puntos.
  • simple en el cuerpo: solo se ignoran las líneas vacías del final.
  • relaxed en el cuerpo: además, se ignoran los espacios en blanco al final de las líneas, y cada secuencia de espacios y tabulaciones dentro de una línea pasa a ser un solo espacio.

Ninguna de las dos formas tolera palabras cambiadas, líneas añadidas, partes MIME recodificadas ni finales de línea distintos. relaxed solo perdona los espacios en blanco y las mayúsculas y minúsculas de los nombres de los encabezados. Por eso c=relaxed/relaxed es la elección habitual. El valor por defecto del RFC es simple/simple, así que una DKIM-Signature sin c= usa la forma estricta.

Los fallos, reproducidos

La prueba firma un mensaje corto con una clave RSA de 2048 bits (c=relaxed/relaxed, h=from:to:subject:date:message-id), lo modifica como lo hacen los intermediarios reales y lo verifica con dkimpy 1.1.8. La consulta DNS se sustituye por una función dentro del propio proceso, de modo que el registro de la clave puede cambiarse entre un caso y otro:

dkimpy 1.1.8, Python 3.13.5 (líneas de log de dkimpy, valores base64 completos)
--- 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 por caso:

  1. Mensaje sin cambios. El hash del cuerpo coincide, así que la firma se verifica.
  2. Pie de página añadido. Una lista de correo añade un pie de página para darse de baja. El hash del cuerpo calculado (got) difiere de bh= (expected), así que la verificación se detiene antes de llegar a la firma.
  3. Etiqueta en el Subject. La lista antepone [team]. El hash del cuerpo sigue coincidiendo, así que el cuerpo está intacto, pero la firma de los encabezados falla. «Hash del cuerpo correcto, firma incorrecta» apunta a un encabezado cambiado o a una clave equivocada.
  4. Espacios en blanco. La prueba duplica un espacio en el Subject, reduce un espacio doble del cuerpo a uno y añade un espacio al final de una línea. Con relaxed/relaxed la firma sigue verificándose. Con simple/simple, el cambio en el cuerpo hace fallar el hash del cuerpo (4b), y el cambio en el encabezado por sí solo hace fallar la firma (4c).

Los servidores receptores escriben el resultado en Authentication-Results. Estos son los mismos casos verificados con mailauth 8.0.1 (Node.js 24.22), en el formato que verás en un mensaje recibido:

mailauth 8.0.1: Authentication-Results, parte dkim
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 redacta los resultados a su manera. mailauth notifica una discrepancia del hash del cuerpo como neutral por defecto y como dkim=fail (body hash did not verify) en modo estricto. OpenDKIM escribe dkim=fail reason="signature verification failed" tanto para los cambios en el cuerpo como para los cambios en los encabezados. La causa es la misma. Las páginas de error de body hash did not verify, signature verification failed, key not found in DNS y permerror recogen las variantes.

Quién modifica los mensajes en tránsito

Del síntoma a la causa:

SíntomaCulpable habitual
Discrepancia del hash del cuerpo en todos los mensajes de un remitenteUn filtro de contenido, un aviso legal o una herramienta que añade firmas de texto al correo, que se ejecutan después del firmante DKIM, a menudo en el propio lado del remitente
Discrepancia del hash del cuerpo solo en los mensajes que pasan por una lista de correoEl software de la lista añade un pie de página o reescribe partes MIME
Discrepancia del hash del cuerpo solo en los mensajes con texto no ASCIIContenido de 8 bits convertido a quoted-printable por un servidor de retransmisión (relay). El RFC 6376 §5.3 indica a los firmantes que conviertan a MIME de 7 bits antes de firmar
Discrepancia del hash del cuerpo en los mensajes generados por un scriptSe firmaron finales de línea LF sin CR y por la red viajan como CRLF, o al revés
Firma incorrecta, hash del cuerpo correctoEtiqueta en el Subject, reescritura del From (la mitigación de DMARC de una lista), una pasarela que vuelve a plegar o a codificar un encabezado, Reply-To o To cambiados por el reenvío
Firma incorrecta en todos los mensajesLa clave privada y el registro DNS no coinciden: selector incorrecto, una clave rotada o una clave pegada con un error

El reenvío mediante un simple .forward o un alias suele conservar el cuerpo y los encabezados firmados, así que DKIM sobrevive donde SPF se rompe. Las listas de correo son las que rompen DKIM por excelencia, porque quieren cambiar el mensaje. El software de listas moderno puede evitarlo si no modifica el cuerpo ni el Subject, o si reescribe From con el dominio de la lista y firma con su propia clave. Los receptores que admiten ARC (RFC 8617, experimental) también pueden confiar en la cadena ARC de la lista, que registra el resultado DKIM original antes de la modificación.

La etiqueta l=: una robustez que invita a la falsificación

l= indica a los verificadores que calculen el hash solo de los primeros l bytes del cuerpo canonicalizado. Se pensó para que las firmas sobrevivieran a los pies de página de las listas de correo. La prueba muestra el inconveniente. El mismo mensaje firmado con l=35 sigue verificándose después de añadirle contenido, ya sea un pie de página o un párrafo nuevo entero escrito por otra persona:

dkimpy 1.1.8: una firma con l=
--- 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

El texto añadido en 5b era Please pay invoice 4711 to IBAN XX00 0000 0000., y la firma sigue diciendo pass. El RFC 6376 §8.2 advierte que l= «permite ataques en los que un intermediario con intenciones maliciosas puede modificar un mensaje», que «los firmantes deberían ser extremadamente cautelosos al usar esta etiqueta» y que «los evaluadores podrían querer ignorar las firmas que usan la etiqueta». Con MIME y HTML, el contenido añadido puede reemplazar por completo lo que ve el lector. No firmes con l=. Si lo encuentras en tu correo saliente, elimínalo de la configuración del firmante. En OpenDKIM, l= procede de las opciones de configuración BodyLengthDB y MaximumSignedBytes.

Claves y DNS

El verificador busca selector._domainkey.domain como registro TXT. Primero los casos de la demostración y después el DNS real:

dkimpy 1.1.8: problemas con la clave
--- 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

Un p= vacío es la forma en que un dominio revoca un selector. El caso 8 muestra que la aritmética RSA de una firma de 512 bits cuadra, pero el verificador rechaza la clave de todos modos. El RFC 8301 establece que los verificadores «NO DEBEN considerar válidas las firmas que usan claves RSA de menos de 1024 bits». Los firmantes «DEBEN usar claves RSA de al menos 1024 bits» y «DEBERÍAN usar claves RSA de al menos 2048 bits», y rsa-sha1 «NO DEBE usarse para firmar ni para verificar». También está definido Ed25519 (a=ed25519-sha256, RFC 8463). El propio mensaje de ejemplo del RFC lleva una firma RSA y otra Ed25519, y así es como se despliega: con la firma RSA para los verificadores que no admiten Ed25519.

En el DNS real, opendkim-testkey de OpenDKIM comprueba un selector igual que lo haría un verificador:

opendkim-testkey (OpenDKIM 2.11.0~beta2, Debian 13), 20 de octubre de 2026
$ 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» solo significa que la respuesta no se validó con DNSSEC. El selector 20230601 de gmail.com es una clave real retirada, con un p= vacío. Los mensajes firmados con ella fallan ahora, y por eso los mensajes antiguos pueden «perder» su resultado pass de DKIM después de una rotación de claves.

Una clave de 2048 bits no cabe en una sola cadena TXT de 255 bytes, así que se publica como varias cadenas que los verificadores unen sin separadores:

dig +short TXT google._domainkey.github.com (BIND 9.20.10, 20 de octubre de 2026)
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSqGoYWaWaFbS2UAeyPrEmd0gogocmRfS441qwR8/0KB81Hw89P0l4YiFRrXYk7NVIGfyCRHAYYZUzCkGeOysI2EjgzLFhd/NEsbRzOEc/kWkK/RO6JFq/5lOn6M9AZw/ap9tds4JG9ApgNNdSpPxp9DmvpsOSgNMVflRxQFrk3kdS4RNAPKu/" "OPoA7dlR/A/pECryjRoYgENtDXzdnK70HgCekems6UDzxDj61cjyoKoXtEMF/QsaHEQ1Gjfv014rDJBsubk/kT5VqHkWHa/ia68Z5r228Ety/wFfQNjXTx/J7KGZ9GkZlKED659eiJcLnWcKDSiQlhwIDAQAB"

Errores típicos de DNS: comillas o un espacio pegados dentro de la clave al dividirla a mano. Un proveedor de DNS que convierte una cadena larga en dos registros en lugar de un registro con dos cadenas. La clave publicada con un nombre incorrecto, como sel._domainkey.example.com.example.com cuando la zona añade el origen. Y un selector nuevo que se usa antes de que el registro se haya propagado. El generador de registros DKIM produce un registro correctamente dividido a partir de una clave pública.

Diagnóstico paso a paso

  1. Consigue el mensaje en bruto tal como se recibió, con todos los encabezados: «Mostrar original» en Gmail, «Ver código fuente» en Outlook o el archivo .eml. Reenviarte el mensaje a ti mismo lo modifica.
  2. Lee Authentication-Results de tu servidor receptor, el que está más arriba. Anota el veredicto de DKIM y su motivo: hash del cuerpo, firma incorrecta, sin clave, permerror.
  3. Lee la DKIM-Signature: d= y s= (qué clave), c= (con qué rigor), h= (qué encabezados), l= (no debería estar).
  4. Comprueba la clave: dig +short TXT s._domainkey.d o opendkim-testkey -d d -s s -vvv. ¿Existe, no está revocada, tiene al menos 1024 bits y está completa?
  5. Discrepancia del hash del cuerpo: busca lo que se añadió o se recodificó: pies de página, banners, cambios de Content-Transfer-Encoding entre lo que se envió y lo que se recibió. Si controlas el lado del remitente, compara la copia de la carpeta Enviados o del log del MTA con la copia recibida.
  6. Firma incorrecta con un hash del cuerpo correcto: compara cada encabezado listado en h= entre la copia enviada y la recibida. Si ninguno cambió, la clave no corresponde a la clave privada que se está usando. Haz una prueba con un mensaje que firmes y verifiques localmente.
  7. Fallos intermitentes: busca varias rutas de salida (dos MTA, un relay en la nube) en las que solo una firma correctamente, o un selector rotado que una de ellas todavía usa.

Para hacerlo con un mensaje pegado en lugar de a mano, el depurador de DKIM muestra cada paso de la canonicalización, el bh= calculado frente al valor del encabezado, la clave del DNS y la modificación más probable. El mensaje no sale de tu navegador. Para tener el panorama completo, incluidos SPF, DMARC y ARC, usa el analizador de autenticación de correo.

Soluciones duraderas

  • Firma en último lugar. En tu propia ruta de salida, ejecuta los avisos legales, los pies de página, la reescritura de enlaces y el antivirus antes del firmante DKIM. En Postfix, eso lo decide el orden de smtpd_milters o content_filter.
  • Usa c=relaxed/relaxed. No cuesta nada y tolera que se vuelvan a plegar los encabezados y que cambien los espacios en blanco.
  • Convierte a MIME de 7 bits antes de firmar (quoted-printable o base64), como aconseja el RFC 6376 §5.3, para que los relays que no admiten 8BITMIME no recodifiquen el cuerpo.
  • No uses l=, y no incluyas en h= encabezados que los intermediarios cambian legítimamente, salvo que necesites firmarlos.
  • Rota las claves con solapamiento. Publica el nuevo selector, espera a que caduquen las cachés de DNS, pasa a firmar con él, conserva el registro antiguo unos días para que el correo en tránsito todavía se verifique y después revócalo con un p= vacío.
  • Claves RSA de 2048 bits. 1024 es el mínimo. Añade además una firma Ed25519 si tu firmante lo admite.
  • En las listas de correo que gestionas: no modifiques el cuerpo ni el Subject, o reescribe From y firma con el dominio de la lista, y añade firmas ARC.

Fuentes

  • RFC 6376: firmas DKIM, §3.4 (canonicalización), §3.5 (etiquetas), §5.3 (conversiones de transporte), §8.2 (l=).
  • RFC 8301: actualización de algoritmo criptográfico y uso de clave (sin SHA-1, mínimo de 1024 bits).
  • RFC 8463: Ed25519 para DKIM.
  • RFC 8617: cadena de recepción autenticada (Authenticated Received Chain), experimental.
  • dkimpy 1.1.8, mailauth 8.0.1, opendkim-testkey de OpenDKIM.

Los casos de dkimpy se ejecutaron en node:24.22.0-trixie (el Python 3.13.5 de Debian con un venv), mailauth en node:24.22.0-bookworm-slim, opendkim-testkey en debian:trixie-20261005-slim y dig en nicolaka/netshoot:v0.14, todos el 20 de octubre de 2026.