Хранение секретов почтового сервера: права доступа вместо иллюзии секретности
Приватный DKIM-ключ с правами 0644, plaintext-пароль БД в dovecot-sql.conf.ext и API-ключи в базе открытым текстом опаснее любого слабого пароля пользователя. Permission-first аудит реального стека Postfix + Dovecot + rspamd: где лежит каждый секрет, кто его читает и как уйти от plaintext к systemd-creds, sops+age и хешам.
EvilMail Team24 июля 2026 г.12 мин чтения
Начнём с команды, которую стоит выполнить прямо сейчас на своём почтовом сервере:
bash
find /etc/opendkim/keys -type f -name '*.private' -perm /044 -ls
Если она что-то вернула — у вас проблема серьёзнее, чем любой «слабый пароль пользователя». Приватный DKIM-ключ с режимом 0644 и группой mail читается любым процессом в этой группе: скомпрометированным milter, веб-мордой панели управления, PHP-скриптом на том же хосте. А приватный DKIM-ключ — это право подписывать почту от вашего домена так, что она пройдёт DMARC с политикой p=reject. Не «попадёт в спам», а именно пройдёт проверку у Gmail и Outlook как легитимная. Один неверный бит прав доступа превращает ваш домен в площадку для отмывки чужого спама.
В почтовом стеке 90% утечек секретов — это не брутфорс и не 0-day. Это chmod 644 по умолчанию из пакета, пароль БД в конфиге, уехавший в git, и API-ключи, лежащие в таблице открытым текстом. Секрет на диске защищён ровно настолько, насколько корректны его владелец и режим доступа — а не тем, что «его никто не найдёт». Разберём стек так, как его смотрит аудитор: не «10 советов», а карта каждого секрета.
Инвентаризация: какие секреты живут в почтовом стеке
Нельзя защитить то, чего не видишь. Прежде чем что-то шифровать, нужна карта — где физически лежит каждый класс секретов, кто владелец и какой процесс его читает.
Классы секретов, которые нужно нанести на эту карту:
Приватные DKIM-ключи — /etc/opendkim/keys/<domain>/<selector>.private у OpenDKIM или /var/lib/rspamd/dkim/<domain>.<selector>.key у rspamd.
Пароли релея/SASL — /etc/postfix/sasl_passwd и производный .db.
Креды БД аутентификации — connect = … password=… внутри dovecot-sql.conf.ext.
API-ключи приложения и секреты сессий/вебхуков — в БД и env-файлах.
Первые четыре обязаны лежать на диске — их защищают права. Последние два хранить обратимо нельзя вообще: на диске должен быть только хеш.
Модель прав доступа: владелец, режим, umask, ACL
Для каждого класса есть ровно один правильный владелец и режим, и почти всегда пакетный дефолт от него отличается.
DKIM-приватник: opendkim:opendkim 0600 (или _rspamd:_rspamd 0600). Никакой групповой доступ здесь не нужен — ключ читает один процесс.
sasl_passwd и sasl_passwd.db: root:root 0600. Ловушка в том, что postmap создаёт .db с режимом 0644, и большинство про это забывает.
dovecot-sql.conf.ext: root:root 0600. Пакет Debian кладёт его 0644 — то есть пароль БД читается любым локальным пользователем «из коробки».
privkey.pem: root:root 0600.
Групповые права особенно опасны именно в milter-архитектуре: milter — это сетевой сервис, принимающий данные извне, и если он в группе с доступом к DKIM-ключу, любая RCE в фильтре сразу даёт ключ подписи. Аудит одной командой:
Всё, что она показывает, читаемо «другими» либо записываемо группой — то есть кандидат на утечку. Для процессов, генерирующих секреты, выставляйте umask 077, чтобы новые файлы сразу рождались 0600. Когда TLS-ключ реально нужен двум демонам с разными uid, не решайте это через chmod 0644 — раздайте доступ точечно через POSIX ACL: setfacl -m u:dovecot:r privkey.pem. Это даёт ровно одному пользователю ровно чтение, а не всему миру.
Plaintext в конфигах: почему это дефолт и как уйти
Признаем честно: Postfix через smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd, Dovecot через SQL-конфиг и почти любой MTA хранят креды в открытом виде. Это архитектурная данность — демону нужен пароль в рантайме, и он читает его из файла. Значит, единственная защита самого файла — это права, расположение вне git и отсутствие в незашифрованных бэкапах.
Уйти от plaintext можно поэтапно, по нарастанию усилий.
Уровень 1 — вынести секрет в отдельный файл 0600. После правки пересобрать карту и починить права руками, потому что postmap этого не делает:
Уровень 2 — systemd-creds. Секрет шифруется ключом машины (при наличии TPM — аппаратным) и отдаётся процессу через tmpfs, никогда не касаясь диска в открытом виде:
bash
systemd-creds encrypt --name=smtp_pass plain.txt /etc/creds/smtp_pass.cred
# в unit-файле:
# LoadCredentialEncrypted=smtp_pass:/etc/creds/smtp_pass.cred
# процесс читает $CREDENTIALS_DIRECTORY/smtp_pass из tmpfs
Уровень 3 — внешний менеджер.sops + age для git-репозитория конфигов, с расшифровкой при рендере на деплое:
Получаются два файла: mail2026.private (сам ключ, режим сразу 0600) и mail2026.txt с публичной частью для DNS:
mail2026._domainkey.evilmail.pro. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
2048 бит — норма на 2026 год, 1024 — абсолютный минимум: ключи короче 1024 верификаторы обязаны отвергать по RFC 8301. Менять ключ стоит раз в 6–12 месяцев, и делать это надо не заменой на месте, а через два селектора, иначе почта в пути перестанет верифицироваться.
Порядок такой: публикуешь новый селектор mail2027 в DNS, ждёшь max(TTL, 24–48ч) на распространение, переключаешь директиву Selector/SigningTable на новый ключ, а старый TXT держишь в DNS ещё 5–10 дней — ровно чтобы письма, ушедшие до переключения, ещё прошли проверку у получателя. Только потом удаляешь mail2026._domainkey. Убрать старую запись слишком рано — значит своими руками отправить в спам всё, что было подписано за последние сутки.
API-ключи и пароли ящиков: хранить хеш, а не секрет
Здесь ломается сама логика «файла с правами». API-ключ и пароль пользователя нельзя хранить обратимо: если базу выгрузят, хеш бесполезен, а plaintext-ключ работает сразу.
Для API-ключей схема простая: генерируешь ключ с человекочитаемым префиксом, показываешь его пользователю один раз, в БД кладёшь только sha256-хеш плюс короткий видимый префикс для идентификации в UI:
javascript
const raw = 'ek_live_' + crypto.randomBytes(24).toString('base64url');
const hash = crypto.createHash('sha256').update(raw).digest('hex');
const prefix = raw.slice(0, 12); // ek_live_ab12 — для отображения в списке
// в БД: { hash, prefix } ; пользователю показываем raw и больше никогда
Проверка входящего ключа — только constant-time сравнением хешей через crypto.timingSafeEqual, никогда обычным ==: строковое сравнение утекает длину совпадающего префикса через тайминг. Хеши всегда равной длины, так что timingSafeEqual отработает без исключения.
С паролями ящиков всё упирается в схему passdb. Уходим от PLAIN к ARGON2ID (или BLF-CRYPT как приемлемой альтернативе):
Компромисс, который важно понимать: механизмы CRAM-MD5 и DIGEST-MD5 требуют обратимо хранимого пароля на сервере — иначе они математически не работают. Поэтому их надо отключить в auth_mechanisms и оставить только PLAIN/LOGIN поверх обязательного TLS. Тогда пароль передаётся зашифрованно по сети, а в passdb лежит хеш, бесполезный при сливе базы. PLAIN-схема Dovecot и env-файлы 0644 с паролями — это не «легаси, которое работает», а инцидент, который ещё не случился.
Секреты вне сервера: git, бэкапы, логи, CI
Даже правильно лежащий на диске секрет утекает по трём боковым каналам.
Git..gitignore недостаточно — нужен pre-commit хук с gitleaks detect --source . или git-secrets. И помните: удаление секрета из рабочей копии не чистит историю. Если пароль был в коммите, единственные варианты — git filter-repo по всей истории и, обязательно, ротация самого секрета, потому что он уже мог быть склонирован.
Бэкапы. Незашифрованный архив /etc — это plaintext-копия всех ваших ключей, лежащая где-то на бэкап-хосте. Шифруйте конфиги перед выгрузкой: tar … | age -r age1… > etc.tar.age.
Логи и окружение. Debug-режим milter и дампы postconf умеют писать креды в логи — фильтруйте. И отдельная частая дыра: пароль в EnvironmentFile= с правами 0644 виден любому локальному пользователю через /proc/<pid>/environ. Env-секреты требуют 0600 и владельца-сервиса ровно так же, как файлы конфигов.
Чеклист внедрения
Прогнать find-аудит прав по /etc/opendkim /etc/postfix /etc/dovecot /etc/letsencrypt.
Выставить 0600 и корректных владельцев на все приватники, включая забытый sasl_passwd.db.