Коротко. Подпись DKIM охватывает две вещи: хеш тела письма (bh=) и подпись, вычисленную по выбранным заголовкам вместе с этим хешем (b=). Ошибка body hash did not verify означает, что тело изменилось после подписания: например, в него добавили приписку, дисклеймер или баннер антивируса либо изменили его кодировку. Ошибки bad signature / signature verification failed означают, что изменился подписанный заголовок (например, в тему добавили метку или переписали From) или ключ не подходит. Ошибки no key / key not found и permerror указывают на DNS: неверный селектор, отозванная или некорректная запись. Канонизация relaxed (c=relaxed/relaxed) допускает изменения в пробельных символах и в регистре имён заголовков, и только их. Для диагностики сравните заголовок Authentication-Results с тегами DKIM-Signature, а затем исправьте компонент, который изменяет почту после подписания, или подписывайте письма на более позднем этапе пайплайна.
Что на самом деле подписывает DKIM
DKIM (RFC 6376) добавляет к письму заголовок DKIM-Signature. Заголовок ниже создан с помощью dkimpy 1.1.8 для тестового письма:
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
Значение подписи здесь сокращено. Теги, которые важны для отладки:
| Тег | Значение | Типичный сбой |
|---|---|---|
d=, s= | Домен подписи и селектор. Ключ запрашивается по имени s._domainkey.d | Неверный селектор, запись отсутствует или отозвана |
c= | Канонизация заголовков и тела, simple или relaxed. По умолчанию simple/simple | Изменения пробельных символов ломают simple |
h= | Подписанные поля заголовка, по порядку | Любое изменение одного из них |
bh= | Хеш канонизированного тела в Base64 | Тело изменилось |
b= | Подпись, вычисленная по подписанным заголовкам и самому DKIM-Signature (с пустым b=) | Изменён заголовок или использован не тот ключ |
l= | Необязательный: хешируются только первые l байт тела | Позволяет кому угодно дописать содержимое (см. ниже) |
a= | Алгоритм: rsa-sha256 или ed25519-sha256 | rsa-sha1 больше не допускается (RFC 8301) |
Проверяющая сторона действует в строго заданном порядке. Сначала она получает ключ (s._domainkey.d, TXT). Затем канонизирует тело и сравнивает его хеш с bh=. И только после этого проверяет b= по заголовкам. Этим порядком и объясняются вердикты: об изменении тела сообщается как о проблеме с хешем тела, даже если изменился и заголовок.
Канонизация: что прощает режим relaxed
Канонизация приводит письмо к стандартному виду перед хешированием, чтобы безобидные изменения при передаче не ломали подпись. RFC 6376, § 3.4 определяет два варианта:
- simple для заголовков: никаких изменений. Нельзя менять даже регистр букв в имени заголовка.
- relaxed для заголовков: имена приводятся к нижнему регистру, строки продолжения склеиваются, последовательности пробельных символов сжимаются до одного пробела, а пробельные символы в конце значения и вокруг двоеточия удаляются.
- simple для тела: игнорируются только пустые строки в конце.
- relaxed для тела: вдобавок игнорируются пробельные символы в конце строк, а последовательности пробелов и табуляций внутри строки заменяются одним пробелом.
Ни один из вариантов не допускает изменённых слов, добавленных строк, перекодированных частей MIME или других окончаний строк. relaxed прощает только пробельные символы и регистр имён заголовков. Поэтому обычно и выбирают c=relaxed/relaxed. По умолчанию RFC предусматривает simple/simple, так что DKIM-Signature без c= использует строгий вариант.
Воспроизведённые сбои
Тест подписывает короткое письмо ключом RSA длиной 2048 бит (c=relaxed/relaxed, h=from:to:subject:date:message-id), изменяет его так, как это делают реальные посредники, и проверяет с помощью dkimpy 1.1.8. DNS-запрос заменён функцией внутри процесса, поэтому запись с ключом можно менять между случаями:
--- 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
Разберём каждый случай:
- Письмо без изменений. Хеш тела совпадает, и подпись проходит проверку.
- Добавлена приписка. Список рассылки дописывает в конец письма приписку со ссылкой для отписки. Вычисленный хеш тела (
got) отличается отbh=(expected), поэтому проверка останавливается, не дойдя до подписи. - Метка в теме. Список рассылки добавляет в начало темы
[team]. Хеш тела по-прежнему совпадает, то есть тело не изменилось, но подпись заголовков не проходит проверку. Сочетание «хеш тела в порядке, подпись — нет» указывает на изменённый заголовок или не тот ключ. - Пробельные символы. Тест удваивает пробел в теме, заменяет двойной пробел в теле одинарным и добавляет пробел в конце строки. С
relaxed/relaxedподпись по-прежнему проходит проверку. Сsimple/simpleизменение тела ломает хеш тела (4b), а одного изменения заголовка достаточно, чтобы не прошла подпись (4c).
Принимающие серверы записывают результат в Authentication-Results. Ниже те же случаи в том формате, который вы увидите в полученном письме; их проверил mailauth 8.0.1 (Node.js 24.22):
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;
Проверяющие стороны формулируют результат по-разному. mailauth по умолчанию сообщает о несовпадении хеша тела как о neutral, а в строгом режиме — как о dkim=fail (body hash did not verify). OpenDKIM пишет dkim=fail reason="signature verification failed" и при изменении тела, и при изменении заголовка. Причина при этом одна и та же. Варианты формулировок перечислены на страницах ошибок body hash did not verify, signature verification failed, key not found in DNS и permerror.
Кто изменяет письма по пути
Как сопоставить симптом с причиной:
| Симптом | Обычная причина |
|---|---|
| Несовпадение хеша тела во всех письмах одного отправителя | Работающие после подписанта DKIM фильтр содержимого, дисклеймер или инструмент, который добавляет в письма блок подписи с контактами, часто на стороне самого отправителя |
| Несовпадение хеша тела только у писем, прошедших через список рассылки | Программа списка рассылки добавляет приписку в конце письма или переписывает части MIME |
| Несовпадение хеша тела только у писем с символами вне ASCII | 8-битное содержимое, которое ретранслятор преобразовал в quoted-printable. RFC 6376, § 5.3 рекомендует подписантам преобразовывать письмо в 7-битный MIME до подписания |
| Несовпадение хеша тела у писем, которые формирует скрипт | При подписании окончания строк были LF без CR, а по сети передаются CRLF, или наоборот |
| Неверная подпись, хеш тела в порядке | Метка в теме, переписанный From (так списки рассылки обходят проблемы с DMARC), шлюз, который заново переносит строки заголовка или перекодирует его, Reply-To или To, изменённые при пересылке |
| Неверная подпись у всех писем | Закрытый ключ не соответствует DNS-записи: неверный селектор, ключ после ротации или ключ, вставленный с ошибкой |
Простая пересылка через .forward или псевдоним обычно сохраняет тело и подписанные заголовки, поэтому DKIM её переживает, тогда как SPF при пересылке ломается. Списки рассылки — классический источник сбоев, потому что они хотят изменять письмо. Современные программы для списков рассылки могут этого избежать, если не изменяют тело и тему или переписывают From на домен списка и подписывают письмо ключом самого списка. Принимающие серверы с поддержкой ARC (RFC 8617, экспериментальный) могут также доверять цепочке ARC списка рассылки: она фиксирует исходный результат DKIM до изменения письма.
Тег l=: устойчивость, которая открывает путь к подделке
l= предписывает проверяющим сторонам хешировать только первые l байт канонизированного тела. Задумывалось это для того, чтобы подписи переживали приписки списков рассылки. Тест показывает обратную сторону. То же письмо, подписанное с l=35, проходит проверку и после того, как к нему дописали содержимое, будь то приписка или целый новый абзац от кого-то другого:
--- 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
В случае 5b был дописан текст Please pay invoice 4711 to IBAN XX00 0000 0000., а подпись по-прежнему даёт pass. RFC 6376 в § 8.2 предупреждает, что l= «делает возможными атаки, при которых посредник со злым умыслом может изменить письмо», что «подписантам следует крайне настороженно относиться к использованию этого тега» и что «оценивающие стороны могут предпочесть игнорировать подписи, в которых используется этот тег». В письмах с MIME и HTML дописанное содержимое может полностью заменить то, что видит читатель. Не подписывайте с l=. Если обнаружите этот тег в своей исходящей почте, уберите его из конфигурации подписанта. В OpenDKIM l= задаётся настройками BodyLengthDB и MaximumSignedBytes.
Ключи и DNS
Проверяющая сторона запрашивает TXT-запись selector._domainkey.domain. Сначала случаи из демонстрационного теста, затем реальный DNS:
--- 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
Пустой p= — это способ, которым домен отзывает селектор. Случай 8 показывает, что с точки зрения арифметики RSA 512-битная подпись сходится, но проверяющая сторона всё равно отклоняет ключ. RFC 8301 требует: проверяющие стороны «не должны (MUST NOT) считать действительными подписи, созданные ключами RSA длиной менее 1024 бит». Подписанты «должны (MUST) использовать ключи RSA длиной не менее 1024 бит», и им «следует (SHOULD) использовать ключи RSA длиной не менее 2048 бит», а rsa-sha1 «не должен (MUST NOT) использоваться ни для подписи, ни для проверки». Определён и Ed25519 (a=ed25519-sha256, RFC 8463). Пример письма в самом RFC несёт и подпись RSA, и подпись Ed25519, и именно так Ed25519 внедряют на практике: подпись RSA остаётся для проверяющих сторон, которые не поддерживают Ed25519.
В реальном DNS утилита opendkim-testkey из OpenDKIM проверяет селектор так же, как это сделала бы проверяющая сторона:
$ 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» означает лишь, что подлинность ответа не подтверждена через DNSSEC. За селектором 20230601 у gmail.com стоит реальный ключ, выведенный из употребления: его p= пуст. Подписанные им письма теперь не проходят проверку, поэтому после ротации ключей старые письма могут «потерять» успешный результат проверки DKIM.
Ключ длиной 2048 бит не помещается в одну строку TXT длиной 255 байт, поэтому его публикуют несколькими строками, которые проверяющие стороны склеивают без разделителей:
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj6T5sl/RwdSqGoYWaWaFbS2UAeyPrEmd0gogocmRfS441qwR8/0KB81Hw89P0l4YiFRrXYk7NVIGfyCRHAYYZUzCkGeOysI2EjgzLFhd/NEsbRzOEc/kWkK/RO6JFq/5lOn6M9AZw/ap9tds4JG9ApgNNdSpPxp9DmvpsOSgNMVflRxQFrk3kdS4RNAPKu/" "OPoA7dlR/A/pECryjRoYgENtDXzdnK70HgCekems6UDzxDj61cjyoKoXtEMF/QsaHEQ1Gjfv014rDJBsubk/kT5VqHkWHa/ia68Z5r228Ety/wFfQNjXTx/J7KGZ9GkZlKED659eiJcLnWcKDSiQlhwIDAQAB"
Типичные ошибки в DNS: кавычки или пробел, попавшие внутрь ключа при ручной разбивке на строки; DNS-провайдер, который превращает одну длинную строку в две записи вместо одной записи с двумя строками; ключ, опубликованный под неверным именем, например sel._domainkey.example.com.example.com, когда зона дописывает к имени свой origin; и новый селектор, который начали использовать до того, как запись разошлась по DNS. Генератор записей DKIM строит из открытого ключа правильно разбитую запись.
Пошаговая диагностика
- Получите исходное письмо в том виде, в каком оно пришло, со всеми заголовками: пункт «Показать оригинал» в Gmail, просмотр исходного кода сообщения в Outlook или файл
.eml. Если переслать письмо самому себе, оно изменится. - Прочитайте
Authentication-Resultsот вашего принимающего сервера, то есть самый верхний такой заголовок. Обратите внимание на вердикт DKIM и его причину: хеш тела, неверная подпись, нет ключа, permerror. - Прочитайте
DKIM-Signature:d=иs=(какой ключ),c=(насколько строго),h=(какие заголовки),l=(его быть не должно). - Проверьте ключ:
dig +short TXT s._domainkey.dилиopendkim-testkey -d d -s s -vvv. Ключ на месте, не отозван, его длина не меньше 1024 бит, и он опубликован целиком? - Несовпадение хеша тела: ищите, что было добавлено или перекодировано: приписки, баннеры, изменения
Content-Transfer-Encodingмежду отправленной и полученной версиями. Если отправитель под вашим контролем, сравните копию из папки «Отправленные» или журнала MTA с полученной копией. - Неверная подпись при верном хеше тела: сравните в отправленной и полученной копиях каждый заголовок, перечисленный в
h=. Если ни один не изменился, ключ в DNS не соответствует закрытому ключу, которым подписываются письма. Проверьте это на письме, которое вы сами подписываете и проверяете локально. - Нерегулярные сбои: ищите несколько путей исходящей почты (два MTA, облачный ретранслятор), из которых правильно подписывает только один, или селектор, выведенный при ротации, но всё ещё используемый одним из них.
Чтобы не делать всё это вручную, вставьте письмо в отладчик DKIM: он покажет каждый шаг канонизации, вычисленный bh= рядом со значением из заголовка, ключ из DNS и наиболее вероятное изменение. Письмо остаётся в вашем браузере. Чтобы увидеть полную картину, включая SPF, DMARC и ARC, воспользуйтесь анализатором аутентификации почты.
Надёжные исправления
- Подписывайте последним шагом. На собственном пути исходящей почты запускайте дисклеймеры, приписки, переписывание ссылок и антивирус до подписанта DKIM. В Postfix это определяется порядком в
smtpd_miltersилиcontent_filter. - Используйте
c=relaxed/relaxed. Этот режим ничего не стоит и выдерживает повторный перенос строк в заголовках и изменения пробельных символов. - Преобразуйте письмо в 7-битный MIME до подписания (quoted-printable или base64), как советует RFC 6376, § 5.3, чтобы ретрансляторы без поддержки 8BITMIME не перекодировали тело.
- Не используйте
l=и не включайте вh=заголовки, которые посредники меняют на законных основаниях, если только вам не нужно, чтобы они были подписаны. - Меняйте ключи с перекрытием. Опубликуйте новый селектор, дождитесь, пока истечёт срок хранения записей в кэшах DNS, переключите подписание на новый ключ, оставьте старую запись на несколько дней, чтобы письма в пути ещё проходили проверку, а затем отзовите её пустым
p=. - Ключи RSA длиной 2048 бит. Минимум — 1024 бита. Если ваш подписант поддерживает Ed25519, добавьте рядом подпись Ed25519.
- Для собственных списков рассылки: не изменяйте тело и тему письма либо переписывайте
Fromи подписывайте письма от имени домена списка, а также добавляйте подписи ARC.
Источники
- RFC 6376: подписи DKIM, § 3.4 (канонизация), § 3.5 (теги), § 5.3 (преобразования при передаче), § 8.2 (
l=). - RFC 8301: обновление требований к криптографическим алгоритмам и использованию ключа (без SHA-1, минимум 1024 бита).
- RFC 8463: Ed25519 для DKIM.
- RFC 8617: Authenticated Received Chain (экспериментальный RFC).
- dkimpy 1.1.8, mailauth 8.0.1,
opendkim-testkeyиз OpenDKIM.
Случаи dkimpy запускались в node:24.22.0-trixie (Python 3.13.5 из Debian в виртуальном окружении venv), mailauth — в node:24.22.0-bookworm-slim, opendkim-testkey — в debian:trixie-20261005-slim, а dig — в nicolaka/netshoot:v0.14. Все запуски выполнены 20 октября 2026 года.