Ротация DKIM без простоя: параллельные селекторы, окно перекрытия и автоматизация в rspamd/OpenDKIM
Наивная замена DKIM-ключа ломает подпись у писем, которые ещё лежат в очереди или застряли на greylisting. Разбираем, почему правильная ротация — это перекрывающийся во времени процесс с двумя валидными селекторами, считаем длину окна перекрытия и полностью автоматизируем цикл для rspamd и OpenDKIM через PowerDNS API.
EvilMail Team16 июля 2026 г.11 мин чтения
Ротация DKIM в большинстве инструкций описана как две строчки: сгенерировать новый ключ и заменить TXT-запись. Так делать нельзя. Подпись письма и публикация паблик-ключа живут в разных временных плоскостях — очередь отправителя и кэши DNS-резолверов, — и переключить их атомарно физически невозможно. Любая наивная замена оставляет окно, в котором почта подписана ключом, которого в DNS уже нет. Ниже — инженерная механика того, как убрать это окно целиком.
Почему «просто поменять ключ» роняет подпись
Разберём конкретный отказ. MTA подписал письмо селектором s2026a и поставил его в очередь. Принимающая сторона ответила 4xx (greylisting, временный дефер, перегруз) — письмо ложится на повторы. Postfix по умолчанию держит его в очереди до пяти суток (maximal_queue_lifetime = 5d). За эти дни администратор «обновил» DKIM: перезаписал TXT s2026a._domainkey новым паблик-ключом или переключил подпись на s2026b и удалил старую запись.
Ротация DKIM без простоя: два селектора и окно перекрытия | evilmail — EvilMail Blog
Через 30 часов очередь делает очередную попытку. Письмо всё ещё несёт заголовок
DKIM-Signature: ...; s=s2026a
, подписанный
старым
приватным ключом. Верификатор на стороне получателя запрашивает
s2026a._domainkey.evilmail.pro
, находит там новый паблик-ключ (или
NXDOMAIN
) и получает
dkim=fail
. Если у домена
DMARC p=reject
и SPF тоже не выравнивается — письмо отбивается. При
p=quarantine
оно молча падает в спам. Вы узнаёте об этом из тикетов, а не из логов.
Второй сценарий тоньше и от очереди не зависит. Вы поменяли DNS и приватник одновременно, очередь пуста. Но резолвер получателя ещё держит в кэше старую TXT-запись с TTL 3600 секунд. MTA уже подписывает новым приватным ключом, а резолвер целый час отдаёт старый паблик — снова dkim=fail, теперь уже на свежих письмах.
Отсюда правило, вокруг которого строится всё остальное: подпись нельзя переключать раньше, чем новый паблик-ключ гарантированно виден всем резолверам, и старый паблик-ключ нельзя убирать раньше, чем из очереди уйдёт последнее письмо, подписанное старым приватником. Между этими двумя моментами в DNS обязаны сосуществовать оба ключа.
Модель двух селекторов и математика окна перекрытия
Ключевой сдвиг мышления: селектор — это версия ключа, а не его имя. Мы никогда не перезаписываем p= у активного селектора. Мы вводим новый селектор рядом: s2026a → s2026b, либо датированные mail-2026q3 → mail-2026q4. В любой момент в DNS опубликованы два валидных паблик-ключа, но MTA подписывает исходящее ровно одним из них.
Длина окна перекрытия — не «на глазок неделя», а вычисляемая величина:
overlap ≥ TTL_dns + max_queue_lifetime + запас
Подставляем реальные значения типичной инфраструктуры: TTL записи 3600s, Postfix maximal_queue_lifetime = 5d. Сумма — чуть больше пяти суток, практический минимум — семь. Рекомендую держать окно 14 дней: оно покрывает и нестандартно длинные очереди, и медленные корпоративные резолверы, которые игнорируют низкий TTL, и оставляет запас на выходные, когда в мониторинг никто не смотрит. Саму ротацию имеет смысл проводить раз в квартал: этого достаточно, чтобы ограничить время жизни скомпрометированного ключа, и не настолько часто, чтобы окна перекрытия начали накладываться друг на друга.
Весь процесс — четыре временные точки на одной оси:
Диаграмма делает наглядным главный запрет: точка T3 не может наступить раньше, чем из очереди уйдёт последнее письмо, подписанное на участке между T1 и T2. Именно поэтому revoke отстоит от cutover на полное окно перекрытия, а не на «пару часов, чтобы наверняка».
Генерация ключей: RSA-2048, Ed25519 и двойная подпись
RSA-1024 в 2026 мёртв. После того как Google и Yahoo в 2024 подняли планку требований к массовым отправителям, ключи короче 2048 бит де-факто не проходят. Базовый минимум — RSA-2048.
Ed25519 (RFC 8463, k=ed25519) даёт короткую подпись и компактную запись: паблик-ключ — всего 44 символа base64 вместо RSA-простыни. Но не каждый верификатор в мире умеет его валидировать, а неизвестный алгоритм для части старых реализаций — это не neutral, а иногда permerror. Рабочая схема на проде — двойная подпись: два селектора, RSA и Ed25519, публикуются и подписывают письмо одновременно. Старые резолверы валидируют RSA, современные предпочитают Ed25519, DMARC проходит по любому из двух.
Генерация под rspamd (сразу выдаёт приватник и готовую TXT-запись):
Под OpenDKIM то же самое делает opendkim-genkey -b 2048 -s s2026b -d evilmail.pro. Приватник обязан лежать с правами 0600 и владельцем подписывающего демона — _rspamd для rspamd или opendkim для OpenDKIM, в каталоге /var/lib/rspamd/dkim/ или /etc/opendkim/keys/ соответственно. Ключ, который может прочитать кто угодно, — это не ключ, а публичный документ.
Публикация в DNS: флаги, чанкинг и PowerDNS API
Формат записи:
s2026b._domainkey.evilmail.pro. IN TXT "v=DKIM1; k=rsa; t=s; h=sha256; p=MIIBIjANBg..."
Разбор флагов — на них ломаются чаще всего:
`k=` — алгоритм: rsa или ed25519.
`t=y` — testing mode. Не ставьте его на проде. Часть верификаторов при t=y игнорирует результат подписи, то есть ваш DKIM просто перестаёт работать, а вы этого не видите.
`t=s` — строгое соответствие: домен в d= обязан точно совпадать с i=, поддомены ключ не наследуют. На проде это желаемое поведение.
`h=sha256` — разрешённый алгоритм хеша.
Паблик-ключ RSA-2048 в base64 длиннее 255 байт, а одна строка внутри TXT-записи ограничена 255 символами. Ключ бьётся на строковые чанки в кавычках внутри одной RRset — DNS склеит их при выдаче:
Руками это копипастить нельзя — на длинном base64 неизбежна опечатка. Публикуем через PowerDNS HTTP API. Сначала, за сутки до ротации, понижаем TTL существующих записей до 300 секунд, чтобы к моменту cutover кэши резолверов обновлялись быстро. Затем PATCH-ом добавляем новый селектор:
opendkim-testkey полезен даже на rspamd-стеке: key OK означает, что опубликованный паблик действительно пара к вашему приватнику, а не остаток от прошлой ротации.
Переключение подписи в rspamd без reload-даунтайма
Главная ошибка на rspamd — прописать selector и path прямо в dkim_signing.conf. Тогда каждый cutover требует правки конфига и rspamadm control reload. Вместо этого переезжаем на карты, которые rspamd перечитывает по mtime файла, не трогая воркеры и не разрывая соединения:
Cutover — замена s2026a на s2026b в dkim_selectors.map и подмена пути на s2026b.key в dkim_paths.map. Приватник к этому моменту уже лежит на месте. Никакого reload: rspamd видит новый mtime и подхватывает карту в течение секунд, milter-сессии не рвутся. Проверяем, чем реально подписывается почта:
bash
rspamadm configdump dkim_signing
# и в тестовом письме — заголовок:
# DKIM-Signature: v=1; a=rsa-sha256; ...; s=s2026b; d=evilmail.pro
То же на OpenDKIM: KeyTable и SigningTable
Кто не на rspamd — механика та же, только через две таблицы. KeyTable держит оба селектора одновременно, SigningTable решает, каким из них подписывать:
Cutover — замена s2026a на s2026b в SigningTable, затем:
bash
systemctl reload opendkim # именно reload, не restart
reload перечитывает таблицы, сохраняя milter-сокет и не обрывая активные сессии, — Postfix не увидит ни одного отвалившегося milter-коннекта. restart пересоздал бы сокет и на доли секунды разорвал бы приём. Держите AutoRestart yes для устойчивости демона, но плановый cutover делайте через reload.
Автоматизация полного цикла: три фазы, разнесённые во времени
Соблазн — написать один скрипт, который делает всё от keygen до revoke. Это ошибка: между публикацией и cutover, и между cutover и revoke обязаны пройти реальные часы и дни, чтобы резолверы и очереди «выдохнули». Поэтому пайплайн — это три идемпотентных фазы, каждая под своим systemd-timer или cron, с логом и алертом:
Фаза 1 (T0→T1): понизить TTL → rspamadm dkim_keygen / opendkim-genkey → положить приватник с правами 0600 → PATCH нового селектора в PowerDNS. На выходе — паблик в DNS, подпись всё ещё старая.
Фаза 2 (T2), спустя часы:dig + opendkim-testkey подтверждают propagation по нескольким резолверам → только при успехе правим map / SigningTable (cutover) → проверяем заголовок s= в тестовом письме. Если testkey или dig не сходятся — фаза не выполняется, летит алерт.
Фаза 3 (T3), спустя полное окно перекрытия: revoke старого селектора.
Каждая фаза при повторном запуске должна быть безопасна: проверила состояние, ничего лишнего не сделала. Любое расхождение между тем, что подписывает MTA, и тем, что лежит в DNS, — повод остановить пайплайн и разбудить дежурного.
Отзыв старого селектора и чеклист
Правильный revoke — неDELETE записи одним движением. Сначала публикуем пустой паблик-ключ, что по RFC 6376 §3.6.1 означает «ключ отозван»:
s2026a._domainkey.evilmail.pro. IN TXT "v=DKIM1; p="
Пустой p= явно говорит верификатору «этот ключ мёртв», тогда как NXDOMAIN неотличим от DNS-глюка или ошибки делегирования. Подержите отозванную запись несколько дней и только потом удаляйте RRset совсем.
Практический чеклист ротации:
За сутки понизить TTL DKIM-записей до 300s.
Ввести новый селектор, а не перезаписывать p= активного.
Держать в DNS два валидных паблик-ключа на всё окно.
Окно перекрытия ≥ TTL_dns + max_queue_lifetime + запас, практически 14 дней.
Перед cutover — opendkim-testkey ... : key OK по нескольким резолверам.
Cutover — правкой map / SigningTable, через reload, а не restart.
Дать очереди полностью дренироваться после cutover, только потом revoke.
Revoke — сначала v=DKIM1; p=, затем удаление RRset.
Ротация раз в квартал; между ротациями мониторить dkim=fail в history rspamd.