Ротація DKIM-ключів без простою: схема з кількома селекторами
Верифікатор тягне публічний ключ у момент перевірки листа, а не підпису. Тому ротація DKIM — це не заміна TXT-запису, а операція з перекриттям у часі. Розбираємо схему з кількома селекторами, реальні команди OpenDKIM і Rspamd, розбивку TXT на 255 символів і єдиний правильний критерій, за яким старий ключ можна прибирати.
EvilMail Team13 липня 2026 р.11 хв читання
Найпоширеніший спосіб зламати собі DKIM — згенерувати новий ключ, замінити TXT-запис на місці й піти пити каву. За годину частина листів починає провалювати перевірку, а якщо у DMARC стоїть p=reject, ці листи просто відбиваються. Проблема не в ключі й не в криптографії. Проблема в тому, що підпис і верифікація рознесені в часі, а заміна запису «на місці» цю відстань ігнорує.
Розберемо, чому підміна ключа ламає пошту, як влаштувати ротацію з перекриттям через кілька селекторів, і за яким критерієм старий ключ дійсно безпечно прибирати — не «за кілька днів навмання», а за фактами з логів і DMARC-звітів.
Чому підміна ключа «на місці» ламає пошту
Заголовок DKIM-Signature несе кілька тегів, з яких для нас критичні два: d= (домен) і s= (селектор). Виглядає підпис приблизно так:
Коли лист приходить на приймаючий сервер, верифікатор бере d= і s=, склеює їх у s202601._domainkey.example.com і робить TXT-запит у цей момент — у момент прийому, а не в момент підпису. Це вся суть проблеми.
Між підписом і перевіркою може минути багато часу:
Лист застряг у черзі відправника. На тимчасову помилку 4xx MTA робить retry годинами й днями — у Postfix за замовчуванням до maximal_queue_lifetime = 5d.
Запис сидить у кешах резолверів рівно стільки, скільки дозволяє TTL. Приберете TXT — а резолвер отримувача ще віддаватиме старе значення, або навпаки, вже не знайде запис.
Лист пройшов через форвардер, який тримає його у власній черзі.
Поки старий TXT існує в DNS, усі ці «хвостові» листи верифікуються нормально. Щойно ви видаляєте старий запис — кожен лист, підписаний старим селектором і ще не доставлений, отримує dkim=fail. З p=reject це bounce, зі скаргами на спам і просілим доменним reputation.
Висновок один: підпис новим ключем і публікація старого мусять перекриватися в часі. Ротація — це операція з вікном співіснування, а не атомарна заміна.
Схема з кількома селекторами
Робоча модель проста: у домені завжди живуть щонайменше два селектори одночасно. Один активний — ним зараз підписуються листи. Решта — «у виведенні»: підпис ними вже не ставиться, але TXT-запис ще опублікований, щоб верифікувався хвіст черги.
Іменування має нести версію. Використовуйте дату (s202601, s202607) або інкремент (k1, k2), але ніколи `default`, `mail` чи `dkim` — за такими іменами неможливо відстежити покоління ключа, і через рік ви не згадаєте, який із них ще потрібен.
Домен d= лишається сталим завжди. Змінюється тільки селектор s=. Це важливо для DMARC-вирівнювання (alignment): доки d= збігається з доменом у From:, зміна селектора нічого не ламає.
Генерація нового ключа
Базовий стандарт 2026 року — RSA-2048. Ключі на 1024 біти давно deprecated, і низка провайдерів трактує їх як слабкі. За бажання паралельно тримайте Ed25519 (дуальний підпис), але RSA лишайте як fallback для верифікаторів без підтримки Ed25519 — таких ще достатньо.
Отримуєте два файли: s202607.private (приватний, права строго 600, власник opendkim) і s202607.txt із готовим TXT-записом. Права не на 600 — типова причина того, що milter мовчки не підписує.
Тут спливає нюанс, який ламає більше ротацій, ніж будь-що інше. Один TXT-рядок у DNS обмежений 255 символами, а base64 публічного ключа RSA-2048 — це близько 392 символів у полі p= (294 байти DER), тож разом із рештою тегів запис фізично не влазить в один рядок. opendkim-genkey уже розбиває його на конкатеновані рядки в лапках:
text
s202607._domainkey IN TXT ( "v=DKIM1; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1x...Q7fL"
"9mKpR2...aVbc3dEf0gHiJkLmNoPqRsTuVwXyZ...IDAQAB" )
Ці шматки склеюються резолвером у один логічний рядок без пробілів між ними. Якщо ви вставляєте ключ через веб-панель DNS чи API вручну і випадково додаєте пробіл або переносите не там — ключ не спарситься, а opendkim-testkey поскаржиться. І окремо: не лишайте в записі тег t=y — це тестовий режим, за якого верифікатор не має карати fail; у продакшені його бути не повинно. (Тег t=s, якщо колись його додаєте, означає інше — заборону підпису від імені субдоменів, тобто i= мусить точно збігатися з d=.)
За добу і більше до ротації знизьте TTL на _domainkey-записах до 300 секунд. Це робиться заздалегідь свідомо: TTL керує тим, як довго старе значення живе в кешах, і знижувати його в момент ротації вже пізно — стара, висока TTL-версія все одно висітиме.
Далі публікуєте новий TXT v=DKIM1; k=rsa; p=..., доки підпис ще стоїть на старому селекторі. Новий запис існує в DNS, але ним поки ніхто не підписує — нічого не ламається.
Перш ніж чіпати підпис, переконайтеся, що ключ бачать усі. Різні резолвери мають різні кеші, тому питайте кількох:
Доки opendkim-testkey не каже key OK і доки ключ не віддається з усіх перевірених резолверів — підпис не перемикаємо.
Перемикання підпису на новий селектор
У OpenDKIM селектор задається через три місця. KeyTable мапить ім'я на приватний ключ, SigningTable вирішує, чим підписувати конкретний домен, а Selector у конфізі — це дефолт.
Одразу після reload надішліть тестовий лист і подивіться сирі заголовки. У DKIM-Signature має бути s=s202607, а в Authentication-Results приймаючого боку — dkim=pass:
Вікно співіснування і безпечне виведення старого ключа
Головне число всієї ротації — як довго тримати старий селектор опублікованим після флипу підпису. Відповідь: не менше максимального часу життя черги вашого MTA плюс запас. У Postfix maximal_queue_lifetime і bounce_queue_lifetime за замовчуванням по 5 днів. Плюс TTL, плюс кеші резолверів, плюс форвардери — округляємо мінімум до 7 днів.
Але 7 днів — це нижня межа, а не сигнал «видаляй». Видаляти старий TXT потрібно за фактами, а не за календарем. Критерій із двох умов, обидві мають виконуватися:
Нуль звернень у логах. Grep логів milter/MTA на старий селектор дає порожньо кілька днів поспіль. Ніхто більше не підписує і не має «хвоста» з цим s=.
Стабільний `dkim=pass` у DMARC. В агрегатних RUA-звітах новий селектор дає стабільний pass без сплеску fail. Це підтверджує, що зовнішній світ бачить новий ключ і верифікує ним.
bash
# нуль підписів старим селектором за останні дні?
grep -h 's=s202601' /var/log/mail.log /var/log/rspamd/rspamd.log | wc -l
Тільки коли обидві умови зійшлися — прибираєте старий TXT, повертаєте TTL до 3600 і знищуєте приватний ключ:
Типова, дорога помилка — видалити старий запис наступного дня після флипу, «бо новий уже працює». Новий працює для свіжих листів; ламається саме хвіст черги, який ви не бачите, поки не почнуть сипатися bounce і скарги.
Автоматизація квартальної ротації
Ротацію варто робити раз на квартал і скриптувати. Скелет логіки:
селектор = поточна дата у форматі sYYYYMM;
згенерувати ключ, опублікувати TXT через API DNS (у нашому стеку — PowerDNS);
цикл dig до збігу з очікуваним p= на кількох резолверах — не рухатися далі, поки не поширилося;
флип конфігу підпису + reload;
поставити відкладене завдання на видалення старого селектора через N днів.
Два застереження, без яких автоматизація стає небезпечною. Скрипт має бути ідемпотентним — повторний запуск не повинен плодити селектори чи ламати конфіг. І перед фінальним видаленням старого ключа має спрацювати алерт на будь-який `dkim=fail` у свіжих DMARC-звітах: якщо fail росте — видалення блокується, розбираємося вручну.
Чеклист ротації без простою
T-1: знизити TTL на _domainkey-записах до 300 с.
T0: згенерувати новий ключ (opendkim-genkey -b 2048 -s sYYYYMM), права 600, власник opendkim.
T0: опублікувати новий TXT, доки підпис ще на старому селекторі; перевірити розбивку на 255-символьні рядки.
T0:dig +short TXT з @8.8.8.8 і @1.1.1.1 + opendkim-testkey ... -vvv → key OK.
T+1: флип підпису (KeyTable/SigningTable/Selector або dkim_signing) + reload.
T+1: тестовий лист → у сирих заголовках s= новий і dkim=pass.
T+1…T+8: тримати старий селектор опублікованим ≥ 7 днів.
Перед видаленням:grep логів на старий s= → нуль; DMARC RUA → стабільний dkim=pass.
Видалення: прибрати старий TXT, повернути TTL до 3600, shred -u старого .private.
Правильна ротація нудна й непомітна: жоден лист не провалює перевірку, DMARC-звіти рівні, а reputation домену не смикається. Уся хитрість — тримати обидва ключі живими рівно стільки, скільки живе ваша черга, і прибирати старий за логами, а не за настроєм.