Управління секретами поштового сервера: SMTP-релеї, DKIM-ключі й API-токени поза конфігами
Вкрадений SMTP-логін релея або DKIM-приватник — це не витік рядка з конфіга, а захоплення репутації вашого домену за години. Розбираємо, де насправді течуть секрети MTA, і будуємо робочу схему зберігання без вендор-локіну: systemd LoadCredential, sops+age і ротація DKIM з подвійним селектором без розриву підписів.
EvilMail Team17 липня 2026 р.12 хв читання
Найшвидший спосіб спалити репутацію відправника — не помилка в SPF і не забутий PTR. Це вкрадений секрет. Один SMTP-логін вашого релея або один DKIM-приватник у чужих руках означає, що спам виходить із валідним підписом вашого домену, DMARC його пропускає, а через кілька годин p=reject перетворюється на суцільний bounce, бо приймальники вже занесли домен у чорні списки. І майже завжди це не «злом сервера», а файл із правами 0644, .env у git-історії або бекап, що ковтнув sasl_passwd разом із рештою /etc.
Секрети пошти живуть довше й коштують дорожче за середній прикладний токен. DKIM-ключ ви публікуєте в DNS і не чіпаєте роками. SMTP-креди релея бачать усі ваші листи. Тому підхід «покладемо пароль у конфіг, там же права root» — це технічний борг, який неминуче виллється в інцидент. Розберемо, де саме течуть ці секрети і як побудувати зберігання, яке переживе і бекап, і git, і ps aux.
Де насправді течуть секрети поштового сервера
Секрети поштового сервера: безпечне зберігання SMTP, DKIM і API-токенів — EvilMail Blog
Поверхня витоку набагато ширша за «хтось отримав root». Ось типова карта для MTA-стека:
Конкретика по MTA. Postfix тримає креди релея у /etc/postfix/sasl_passwd форматом [smtp.provider.com]:587 user:pass. Після postmap з'являється бінарний sasl_passwd.db, але plaintext-джерело нікуди не дівається — його треба видалити або як мінімум закрити на 0600. Обидва файли їдуть у щоденний бекап /etc, який зазвичай лежить без шифрування на тому ж диску або в S3-бакеті з надто широким доступом.
OpenDKIM зберігає приватники в /etc/opendkim/keys/<domain>/<selector>.private. Дефолтні права після opendkim-genkey бувають 0644 — тобто будь-який локальний користувач, включно зі скомпрометованим веб-процесом, читає ваш підписувальний ключ. Прикладні застосунки додають своє: SMTP_PASSWORD у .env, який хтось одного разу закомітив, а потім «видалив» — але git log -p дістає його з історії за секунду.
Теза проста: більшість інцидентів із поштовими секретами — це не пробитий периметр, а забутий файл із неправильними правами. Тож почнемо з класифікації, бо різні секрети течуть по-різному й ротуються по-різному.
Класифікуйте секрети — вони поводяться неоднаково
Тримати всі секрети пошти в одному сховищі з єдиним TTL — помилка проєктування. Їх щонайменше три класи:
SMTP-креди релеїв (Amazon SES, SendGrid, Postmark). Легко ротуються на боці провайдера, живуть у релея, компрометація локалізована. Тут пріоритет — швидка zero-downtime заміна.
DKIM-приватники. Довгоживучі, публічна половина в DNS. Ротація вимагає координації з DNS і перекриття селекторів у часі — не можна просто «замінити файл». Найтонше місце всього стека.
API-токени (панель, вебхуки, RapidAPI-шлюз). Мають бути короткоживучими й scope-обмеженими. Окремий токен на кожну інтеграцію, миттєве відкликання скомпрометованого.
Різний TTL, різна процедура ротації, різний радіус ураження. Ось чому «покладемо все у Vault і забудемо» не працює: DKIM-ключ із TTL, як у API-токена, зламає deliverability при кожній ротації, а API-токен із довговічністю DKIM-ключа — це подарунок зловмиснику.
Базова гігієна: права, git, оточення
До будь-яких інструментів наведіть лад у файлах. DKIM-приватник має бути 0600 і належати процесу підписувача:
bash
chown opendkim:opendkim /etc/opendkim/keys/evilmail.pro/s2.private
chmod 0600 /etc/opendkim/keys/evilmail.pro/s2.private
# знайти всі світлочитані ключі одразу
find /etc/opendkim/keys -type f -perm -o+r -ls
Postfix-креди: після postmap видаліть або захистіть джерело.
bash
postmap /etc/postfix/sasl_passwd
chmod 0600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
# ще краще — тримати plaintext-джерело поза сервером, а на хост класти лише .db
Git — окрема дисципліна. .gitignore недостатньо, бо він не рятує від уже закомічених файлів. Ставте gitleaks у pre-commit і в CI:
bash
gitleaks protect --staged --redact # pre-commit
gitleaks detect --source . --redact # CI на весь репо
Головне правило: секрет, що хоч раз потрапив у git, вважається спаленим. Не «видаленим наступним комітом», а скомпрометованим. git filter-repo прибирає його з історії, але паралельно ви зобов'язані ротувати сам секрет — ви не знаєте, у скільки клонів і CI-кешів він уже розтікся.
Оточення теж не безпечне за замовчуванням. Секрет у CLI-аргументі (--password=...) видно всій системі через ps aux і /proc/<pid>/cmdline. У docker ENV SMTP_PASS=... осідає в шарах образу — docker history покаже. Тому монтований .env-файл (не запечений в образ) кращий за ENV, а --env-file кращий за інлайн -e у скриптах, що логуються.
Видача секрета процесу без plaintext на диску: systemd LoadCredential
Для одно- й кількасерверних сетапів без окремої команди безпеки systemd вирішує задачу повністю й без нових залежностей. Потрібен systemd ≥ 250.
Шифруємо секрет ключем, прив'язаним до хоста або TPM2. Читаємо секрет зі stdin, щоб plaintext не лягав на диск навіть тимчасово:
У юніті застосунку віддаємо зашифрований credential — systemd розшифрує його в пам'ять і змонтує в tmpfs лише для цього процесу:
ini
[Service]
LoadCredentialEncrypted=smtp:/etc/creds/smtp.cred
ExecStart=/usr/bin/node /var/www/evilmail/server.js
# секрет доступний у $CREDENTIALS_DIRECTORY/smtp
У Node читаємо просто з файлу — жодних env-змінних, жодного plaintext на диску:
js
import { readFileSync } from 'node:fs';
const dir = process.env.CREDENTIALS_DIRECTORY; // /run/credentials/<unit>
const smtpPass = readFileSync(`${dir}/smtp`, 'utf8').trim();
Каталог /run/credentials/<unit> — це tmpfs (0700, власник — користувач юніта), а самі файли-креди мають права 0400 і видимі тільки процесу юніта; після зупинки сервісу каталог зникає. Порівняйте з EnvironmentFile=: його вміст світиться в systemctl show <unit> і успадковується дочірніми процесами. LoadCredentialEncrypted цих проблем не має — це базовий рівень, з якого варто починати будь-який сервер.
Шифрування секретів у репозиторії: sops + age
Якщо команда практикує GitOps і хоче версіонувати конфіги разом із секретами, потрібне шифрування «на місці». sops шифрує лише значення, лишаючи структуру YAML читабельною й diff-able, а age замінює важкий GPG простим ключем.
bash
age-keygen -o /root/.config/sops/age/keys.txt
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8
.sops.yaml у корені репо визначає, що і кому шифрувати:
Приватний age-ключ живе поза git — у systemd credential або в KMS. sops+age виграє у Vault для малих команд: немає окремого сервісу, який треба тримати живим і самому охороняти. Щойно з'являється потреба в аудиті доступу «хто і коли читав секрет» або в динамічних кредах — sops перестає покривати задачу.
Коли справді потрібен Vault
Чесна межа: Vault (або OpenBao — його форк із відкритим керуванням) виправданий, коли є динамічні креди — короткоживучі паролі БД, що генеруються під запит; потрібен аудит-лог доступу; серверів багато, а команда переросла кількох людей. Для MTA автентифікація йде через AppRole (role_id + secret_id), а vault agent рендерить template у tmpfs, звідки postfix чи застосунок читають готовий конфіг.
Застереження, яке часто ігнорують: Vault сам стає single point of failure і сам є критичним секретом. Unseal-ключі й root-token треба десь зберігати з тією ж параноєю, що й усе інше, а якщо Vault ляже — станеться і пошта. Для одного релея й пари DKIM-ключів це overkill, який додає крихкості замість того, щоб її прибрати. Не ставте Vault, щоб «було по-дорослому» — ставте, коли sops+age чи systemd creds перестали масштабуватися.
Ротація DKIM без розриву підписів
Тут ламають deliverability найчастіше — видаляючи старий ключ зарано. Правильна ротація має вікно перекриття, де два селектори співіснують.
Покроково:
bash
# T0: генеруємо новий селектор s2 поруч зі старим s1
opendkim-genkey -b 2048 -s s2 -d evilmail.pro -D /etc/opendkim/keys/evilmail.pro/
chown opendkim:opendkim /etc/opendkim/keys/evilmail.pro/s2.private
chmod 0600 /etc/opendkim/keys/evilmail.pro/s2.private
Публікуємо публічну половину в DNS (вміст s2.txt):
dns
s2._domainkey.evilmail.pro. 3600 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
Чекаємо max(TTL записів, 24–48 год), доки новий ключ гарантовано розійдеться по резолверах, і перевіряємо:
bash
dig +short TXT s2._domainkey.evilmail.pro
Аж тепер перемикаємо opendkim на новий селектор (Selector s2 у opendkim.conf, KeyTable/SigningTable на s2.private) і робимо systemctl reload opendkim. Старий селектор `s1` лишаємо в DNS ще приблизно тиждень — листи, що зараз у чергах і на ретраях у приймальників, досі підписані ним, і видалення s1._domainkey одразу зробить їх невалідними за DKIM. Тільки коли черги точно вичерпані, прибираємо s1 із DNS і видаляємо s1.private.
Пара зауважень щодо 2026-го: RSA-2048 — розумний мінімум, 1024 вважайте legacy. Частина провайдерів уже приймає Ed25519 (k=ed25519) — його варто публікувати другим селектором із RSA-fallback, бо не всі приймальники його верифікують.
Ротація SMTP-релеїв і API-токенів
Релейний пароль ротується без простою за тим самим принципом перекриття: провайдер зазвичай дозволяє мати два активних набори кредів одночасно. Створюєте новий SMTP-логін, оновлюєте sasl_passwd, postmap, systemctl reload postfix — і лише переконавшись, що листи йдуть новим кредом, відкликаєте старий на боці провайдера. Порядок «спочатку відкликати, потім оновити» гарантує вам вікно повного bounce.
API-токени тримайте короткими за TTL і роздавайте по одному на інтеграцію зі своїм scope — тоді компрометація одного не тягне за собою решту, а відкликання не ламає інші сервіси. Вебхук-підписи — окремий клас секретів: звіряйте їх через crypto.timingSafeEqual, а не ===, інакше витік іде через тайминг порівняння.
Чек-лист впровадження
Усі DKIM .private — 0600 і chown opendkim; перевірено find /etc/opendkim/keys -type f -perm -o+r.
sasl_passwd — 0600, plaintext-джерело видалено після postmap.
gitleaks у pre-commit і в CI; будь-який секрет із git-історії ротовано, не просто видалено.
Жодного секрета в docker ENV — тільки монтований .env або credential.
Секрети процесам — через systemd LoadCredentialEncrypted або sops+age, не EnvironmentFile.
Два DKIM-селектори напоготові; процедура ротації відрепетирувана на стейджі.
TTL-політика на API-токени; окремий токен зі scope на кожну інтеграцію.
Ротація релейного пароля перевірена в порядку «новий → reload → відкликати старий».
Бекапи /etc шифруються окремим ключем, який не лежить поруч із бекапом.
Регулярний аудит find / -perm -o+r по відомих шляхах секретів.
Готовий план відкликання: що ротуємо і в якому порядку при компрометації кожного класу.
Мінімальна планка, нижче якої не варто опускатися: жоден секрет пошти не живе в git, і жоден DKIM-приватник не має прав, ширших за 0600. Усе інше — це вже питання масштабу, а не принципу.