Автоматизация ротации TLS- и DKIM-ключей без простоя
Ротация ключей ломает почту не сама по себе, а порядком действий. Разбираем модель publish → wait → switch → grace → retire, математику TTL и grace-окон, ловушку сброса прав после certbot renew и контроль истечения, который срабатывает до инцидента.
EvilMail Team24 июля 2026 г.12 мин чтения
Сгенерировать новый ключ — одна команда. Опасность не в генерации, а в окне, когда опубликованное в DNS расходится с тем, чем сервис подписывает или шифрует прямо сейчас. Именно этот рассинхрон отправляет исходящую почту в спам и вызывает жёсткий отказ доставки — и почти каждый гайд про «меняйте DKIM раз в квартал» о нём молчит.
Ниже — не про то, как сделать ключ, а про модель ротации, при которой почта не падает ни на секунду: очерёдность фаз, арифметику TTL и grace-окон, ловушку сброса прав после certbot renew и три проверки истечения, которые срабатывают до жалоб на недоставку, а не после.
Почему ротация ломает доставку чаще, чем защищает
Четыре типовых режима отказа, все реальные, все тихие:
Переключил подпись на новый селектор до распространения TXT. Сервер уже подписывает письма ключом s2026b, а запись s2026b._domainkey ещё не разошлась по резолверам. Верификатор на принимающей стороне запрашивает TXT, не находит публичный ключ — dkim=fail
на всей исходящей. Пока TTL негативного кэша не истечёт, чинить нечего.
Удалил старый селектор слишком рано. Письмо ушло в очередь или в отложенную рассылку, подписанное s2026a. Через неделю получатель наконец его разгребает, идёт за ключом s2026a — а его в DNS уже нет. dkim=fail на письмах, которые вы отправили корректно.
certbot обновил сертификат, но под DANE запись TLSA не совпала. Валидирующий MTA считает SHA-256 нового SPKI, сравнивает со старой TLSA 3 1 1, получает mismatch — и это не софт-фейл, а hard-fail доставки. Письмо не уходит вообще.
После renew приватный ключ пересоздан с дефолтными правами, а сервис ждал его в другом месте или под другим владельцем. TLS молча не поднимается на нужном сертификате, соединение падает на STARTTLS.
Общий знаменатель: ротация — не событие в одну точку времени, а перекрывающееся во времени состояние, где старый и новый материал обязаны сосуществовать. Как только вы обращаетесь с ней как с атомарной заменой, что-то ломается.
Модель без простоя: publish → wait → switch → grace → retire
Пять фаз, единый каркас и для DKIM, и для TLS/DANE:
Publish. Положить новый материал в DNS рядом со старым, ничего не переключая на сервере.
Wait. Выждать max(TTL записи, negative-cache TTL) плюс запас на распространение. Если имя селектора запрашивали раньше, связывает именно негативный кэш (SOA minimum) — снижайте его заранее. Для свежего TXT с TTL 300s на практике — час.
Switch. Атомарно переключить подпись или сертификат через reload.
Grace. Держать старый ключ опубликованным столько, сколько может существовать почта, подписанная им: очереди, отложенные рассылки — практически 1–2 недели для DKIM.
Retire. Удалить старый материал.
Деталь, которую путают чаще всего: reload перечитывает конфиг и сертификаты без разрыва активных TCP-сессий, а restart рвёт текущие SMTP-транзакции. Письмо, которое как раз передаётся через DATA, при restart теряется. В скриптах ротации — только reload, restart под запретом.
DKIM: два селектора и скрипт ротации
Схема с двумя селекторами — основа. Пока s2026a работает, вы готовите s2026b; в следующую ротацию роли меняются. Дата в имени (s2026b, 202601) снимает вопрос «какой из них свежий».
Генерация RSA-2048 и параллельного Ed25519 для opendkim:
bash
umask 077
cd /etc/opendkim/keys/evilmail.pro
# RSA-2048 селектор — opendkim-genkey кладёт s2026b.private и s2026b.txt
opendkim-genkey -b 2048 -h sha256 -r -s s2026b -d evilmail.pro
# Ed25519 opendkim-genkey не генерирует — делаем openssl вручную
openssl genpkey -algorithm ed25519 -out ed2026b.private
# публичный ключ для p= : последние 32 байта DER, в base64
openssl pkey -in ed2026b.private -pubout -outform DER | tail -c 32 | base64
chown opendkim:opendkim s2026b.private ed2026b.private
RSA-2048 — минимум на 2026 год (1024 давно недостаточно и уже отбрасывается частью верификаторов). Ed25519 (256 бит, по стойкости ~RSA-3072) кладём вторым, параллельным ключом: он влезает в одну TXT-строку, но валидатор, который его не понимает, должен иметь запасной RSA-ключ, иначе получит dkim=permerror. Два ключа — две записи, обе публикуются.
KeyTable и SigningTable для нового селектора:
# KeyTable
s2026b._domainkey.evilmail.pro evilmail.pro:s2026b:/etc/opendkim/keys/evilmail.pro/s2026b.private
# SigningTable — эту строку меняем ТОЛЬКО после подтверждения DNS
*@evilmail.pro s2026b._domainkey.evilmail.pro
TXT-запись RSA берём из s2026b.txt, Ed25519 собираем из base64 выше — обе с коротким TTL:
s2026b._domainkey.evilmail.pro. 300 IN TXT "v=DKIM1; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEB..."
ed2026b._domainkey.evilmail.pro. 300 IN TXT "v=DKIM1; k=ed25519; p=<base64 32-байта>"
Скелет скрипта ротации. Ключевая строка помечена — подпись переключаем только после того, как dig реально вернул новый ключ:
bash
#!/usr/bin/env bash
set -euo pipefail
umask 077
DOM=evilmail.pro; SEL=s2026b
KEYDIR=/etc/opendkim/keys/$DOM
opendkim-genkey -b 2048 -h sha256 -r -s "$SEL" -d "$DOM" -D "$KEYDIR"
chown opendkim:opendkim "$KEYDIR/$SEL.private"
echo ">> Опубликуй TXT из $KEYDIR/$SEL.txt и нажми Enter"; read -r
# ждём распространения: подпись НЕ трогаем, пока dig пуст
until [ -n "$(dig +short ${SEL}._domainkey.${DOM} TXT)" ]; do
echo "жду публикацию ${SEL}._domainkey..."; sleep 60
done
opendkim-testkey -d "$DOM" -s "$SEL" -vvv # публичный в DNS совпал с приватным
# только теперь — SWITCH
sed -i "s/^\*@${DOM}.*/*@${DOM} ${SEL}._domainkey.${DOM}/" /etc/opendkim/SigningTable
systemctl reload opendkim
Для rspamd вместо KeyTable/SigningTable — файл /var/lib/rspamd/dkim/evilmail.pro.s2026b.key и local.d/dkim_signing.conf с указанием selector и path; логика фаз та же, меняется только selector после подтверждения DNS, затем systemctl reload rspamd.
Единственно правильное место для установки прав и перезагрузки сервисов — --deploy-hook, который certbot вызывает только при фактическом обновлении. Отдельный cron с reload вслепую либо дёргает сервисы зря, либо промахивается мимо renew.
Сам хук не указывает postfix/dovecot на /etc/letsencrypt/live напрямую (это симлинки, которые certbot пересобирает) — он раскладывает копии в стабильный каталог через install, а не cp, с явными владельцем и правами:
Отдельно и подробно — DANE. Нельзя менять ключ и TLSA одновременно: между сменой сертификата и распространением новой TLSA каждый валидирующий резолвер увидит несовпадение и даст hard-fail. Решение — pre-publish: опубликовать вторую TLSA от SPKI нового ключа заранее, дождаться TTL, только потом renew, потом убрать старую.
TLSA 3 1 1 (DANE-EE, SPKI, SHA-256) считаем из будущего сертификата или из отдельно сгенерированного ключа до renew:
_25._tcp.mx.evilmail.pro. 3600 IN TLSA 3 1 1 <sha256-hex SPKI>
Компромисс — --reuse-key: ключ не ротируется, значит SPKI и TLSA стабильны, renew не требует трогать DNS. Для DANE это иногда осознанный выбор — стабильность важнее ротации самого ключа. Контраст с MTA-STS: там ключа нет вообще, только политика и id в TXT, поэтому MTA-STS в ротацию ключей не входит — но именно поэтому у него нет этого класса отказов.
Права, владельцы и ловушка после renew
Кто и как читает ключ:
DKIM.private → opendkim:opendkim 0600. opendkim работает под своим пользователем и без прав на файл просто не подпишет.
TLSprivkey → root:root 0600. И postfix master, и dovecot читают приватный ключ как root до chroot и сброса привилегий, поэтому владелец именно root, а не postfix.
umask 077 в начале каждого скрипта генерации — иначе свежий ключ рискует родиться с 0644.
Главная ловушка: certbot renew пересоздаёт файлы в archive/ с дефолтными владельцем и правами. Если сервис бежит под не-root или ждёт ключ в кастомном каталоге, после renew всё слетает молча — TLS перестаёт подниматься на нужном сертификате, а в логе только невнятный отказ рукопожатия. Поэтому в deploy-хуке — install с явными -o/-g/-m, а не cp, который тащит исходные атрибуты источника.
Контроль истечения: алерт до инцидента, не после жалоб
Три независимые проверки в cron или мониторинге. Каждая проверяет своё — они не заменяют друг друга.
TLS — проверять сертификат, реально отдаваемый по сети, а не файл на диске. Они расходятся после неудачного reload: на диске новый серт, в памяти сервиса — старый.
DANE — активное совпадение TLSA с текущим SPKI. Это самый опасный тихий отказ: сертификат валиден, срок в порядке, но mismatch кладёт доставку целиком. Считаем хеш живого SPKI и сравниваем с опубликованной TLSA — mismatch = critical, событие уровня «разбудить дежурного»:
Есть Prometheus — отдайте это в blackbox_exporter. Но голый cron с письмом на дежурный ящик лучше, чем изящный дашборд, которого нет.
Чеклист до и после ротации
TTL нужных записей (и негативного кэша SOA) снижен заранее, за сутки до ротации.
Новый DKIM-ключ / TLSA опубликован и подтверждён dig +short.
Выждан минимум 2×TTL (для TXT 300s — час) перед переключением.
Подпись / сертификат переключены через reload, никогда restart.
Права проверены stat -c '%U %G %a' на обоих ключах.
Тестовое письмо прошло mail-tester, Authentication-Results показывает dkim=pass.
Grace-окно для старого DKIM-селектора ≥ 1 недели (очереди и отложенные рассылки).
Под DANE: pre-publish второй TLSA сделан за ≥ TTL (3600s) до renew.
Мониторинг истечения зелёный по всем трём проверкам (TLS / DKIM-age / TLSA).
Старый материал удалён только после grace; в runbook записаны дата и селектор.
Ротация без простоя держится на одном принципе: DNS всегда идёт впереди сервиса, а старый ключ уходит последним. Нарушите очерёдность в любую сторону — получите либо dkim=fail на исходящей, либо DANE hard-fail на входящей. Соблюдёте — и ни один получатель не заметит, что вы вообще что-то меняли.