Appearance
ADR-012: Сетевая изоляция админки — mTLS + TOTP
Дата: 2026-04-04
Статус: Принято
Авторы: Backend Team
Связан с: ADR-010
Контекст
ADR-010 предлагает admin.partizap.ru как публичный домен с app-level аутентификацией. Security review (пп. 5, 10, 13) покрывает brute-force protection, MFA, rate limiting на уровне приложения.
Однако реальные угрозы для маленькой команды — не SQLi и не brute-force auth endpoints:
- DNS discovery —
admin.partizap.ruвиден черезdig, Certificate Transparency logs (crt.sh), subdomain enumeration. Любой узнает, где админка - Социальная инженерия — фишинг, credential reuse, компрометация устройства. Пароль один — украл и зашёл
- Brute-force инфраструктуры — прямой подбор паролей к PostgreSQL, Redis, SSH
App-level защиты (rate limiting, progressive delays, account lockout) работают после того, как HTTP-запрос дошёл до PHP. Нужен слой, который отсекает атакующего до приложения.
Решение: mTLS (клиентские сертификаты) + TOTP
Почему mTLS, а не VPN
Сотрудники используют коммерческий VPN (Red Shield). Добавление WireGuard создает VPN-over-VPN:
| Проблема | Описание |
|---|---|
| Double NAT | WireGuard handshake (UDP 51820) идёт через Red Shield tunnel |
| Блокировка протоколов | Некоторые VPN-провайдеры режут нестандартный UDP |
| Каскадные разрывы | Переподключение Red Shield рвёт WireGuard |
| MTU issues | Double encapsulation уменьшает MTU, вызывает фрагментацию |
Tailscale (userspace WireGuard) решает часть проблем, но добавляет зависимость от внешнего SaaS (координационный сервер Tailscale).
mTLS не конфликтует с VPN — это обычный HTTPS-трафик. Работает через Red Shield, через корпоративный proxy, через мобильный интернет. Ноль внешних зависимостей, Nginx-нативное решение.
Как mTLS закрывает угрозы
Угроза 1: DNS discovery
Атакующий находит admin.partizap.ru → пытается открыть в браузере:
Атакующий → TLS handshake → Nginx требует client certificate →
→ нет сертификата → SSL alert → connection resetЗапрос не доходит до PHP. Атакующий не видит login-страницу, HTTP-заголовки, framework fingerprint. Для сканера/бота сервер неотличим от закрытого порта:
bash
# Без клиентского сертификата:
$ curl https://admin.partizap.ru/admin/auth/login
curl: (56) OpenSSL SSL_read: ssl3_read_bytes:sslv3 alert bad certificate
# Nmap видит открытый 443, но любой HTTP-запрос → connection reset
# Нет информации о приложении, фреймворке, версии PHPУгроза 2: Социальная инженерия (украденный пароль)
С mTLS + TOTP атакующему нужны три фактора одновременно:
| Фактор | Тип | Как украсть | Как защитить |
|---|---|---|---|
| Клиентский сертификат (.p12) | Устройство | Физический доступ к ноутбуку, малварь | Пароль на .p12 файл, отзыв через CRL |
| Пароль admin_users | Знание | Фишинг, credential reuse, подсмотрел | Уникальный пароль 16+ символов |
| TOTP-код | Устройство (телефон) | Физический доступ к телефону | PIN/биометрия на телефоне |
Компрометация одного фактора бесполезна:
- Украл пароль → нет сертификата → TLS handshake fail, запрос не дошёл до сервера
- Украл сертификат → не знает пароль → 3 попытки → lockout → alert email
- Украл сертификат + пароль → нет TOTP → 401
Угроза 3: Brute-force
Без сертификата: brute-force невозможен. TLS handshake отклоняется до HTTP.
С украденным сертификатом: все app-level защиты работают как последний рубеж:
| Слой | Защита | Где описано |
|---|---|---|
| TLS (Nginx) | mTLS — только владельцы сертификатов (2-5 человек) | Этот ADR |
| HTTP (Nginx) | Rate limit: 3 req/1800s на login | Security review п. 13 |
| App (PHP) | IP+email lockout: 3 попытки/30 мин | Security review п. 5 |
| App (PHP) | Progressive delays: 2s → 5s | Security review п. 5 |
| App (PHP) | Alert email на каждую неудачную попытку | Security review п. 17 |
| App (PHP) | TOTP — угаданный пароль бесполезен без кода | Security review п. 10 |
| App (PHP) | admin_audit_log: auth.failed с IP, User-Agent, CN сертификата | Security review п. 20 |
Из Nginx access log видно, чей сертификат используется:
10.0.0.1 - yura@partizap.ru [04/Apr/2026:15:32:01] "POST /admin/auth/login" 401
10.0.0.1 - yura@partizap.ru [04/Apr/2026:15:32:05] "POST /admin/auth/login" 401Три failed login от yura@partizap.ru с незнакомого IP → alert → отозвать сертификат → выдать новый.
Итоговая модель угроз
| Атака | Результат |
|---|---|
| Сканирование/discovery admin.partizap.ru | TLS handshake fail, нет информации о приложении |
| Brute-force login без сертификата | Connection reset на уровне TLS |
| Brute-force login с украденным сертификатом | 3 попытки → lockout → alert → отзыв сертификата |
| Украденный cert + угаданный пароль | TOTP блокирует |
| Cert + пароль + TOTP (все три фактора) | Целенаправленная атака — за пределами threat model |
Реализация
1. Создание CA и клиентских сертификатов
bash
# === Один раз: создать Certificate Authority ===
mkdir -p /etc/nginx/ssl/admin-mtls
cd /etc/nginx/ssl/admin-mtls
# Приватный ключ CA (хранить в безопасном месте, НЕ на сервере постоянно)
openssl genrsa -aes256 -out ca.key 4096
# Пароль на ключ CA — записать и сохранить offline
# Корневой сертификат CA (10 лет)
openssl req -new -x509 -days 3650 -key ca.key \
-out ca.crt \
-subj "/CN=Partizap Admin CA/O=Partizap"
# === Для каждого админа: выпустить клиентский сертификат ===
ADMIN_EMAIL="yura@partizap.ru"
ADMIN_NAME="yura"
# Ключ админа
openssl genrsa -out ${ADMIN_NAME}.key 2048
# Запрос на сертификат
openssl req -new -key ${ADMIN_NAME}.key \
-out ${ADMIN_NAME}.csr \
-subj "/CN=${ADMIN_EMAIL}/O=Partizap"
# Подписать CA (1 год)
openssl x509 -req -days 365 \
-in ${ADMIN_NAME}.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out ${ADMIN_NAME}.crt
# Упаковать в .p12 для импорта в браузер
# Пароль для .p12 — сообщить админу лично, не через мессенджер
openssl pkcs12 -export \
-out ${ADMIN_NAME}.p12 \
-inkey ${ADMIN_NAME}.key \
-in ${ADMIN_NAME}.crt \
-certfile ca.crt \
-name "Partizap Admin (${ADMIN_EMAIL})"
# Удалить промежуточные файлы
rm ${ADMIN_NAME}.key ${ADMIN_NAME}.csr
# CA key — убрать с сервера после выпуска сертификатов
# Хранить offline (USB, password manager)2. Nginx конфигурация
nginx
server {
listen 443 ssl;
server_name admin.partizap.ru;
# --- Серверный сертификат (Let's Encrypt) ---
ssl_certificate /etc/letsencrypt/live/admin.partizap.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.partizap.ru/privkey.pem;
# --- Клиентский сертификат (mTLS) ---
ssl_client_certificate /etc/nginx/ssl/admin-mtls/ca.crt;
ssl_verify_client on;
# ssl_crl /etc/nginx/ssl/admin-mtls/ca.crl; # Раскомментировать после первого отзыва
# --- Security headers ---
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# --- Передать CN сертификата в приложение ---
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
# --- Логирование с CN ---
log_format admin_access '$remote_addr - $ssl_client_s_dn_cn [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/admin.access.log admin_access;
root /var/www/partizap/admin-production/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_X_CLIENT_CN $ssl_client_s_dn_cn;
include fastcgi_params;
}
}
# HTTP → редирект на HTTPS (без mTLS проверки на этом этапе)
server {
listen 80;
server_name admin.partizap.ru;
return 301 https://$host$request_uri;
}3. dev-admin — та же схема
nginx
server {
listen 443 ssl;
server_name dev-admin.partizap.ru;
ssl_certificate /etc/letsencrypt/live/dev-admin.partizap.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev-admin.partizap.ru/privkey.pem;
# Тот же CA — одни сертификаты для prod и dev
ssl_client_certificate /etc/nginx/ssl/admin-mtls/ca.crt;
ssl_verify_client on;
add_header X-Robots-Tag "noindex, nofollow" always;
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
# Reverse proxy на VPS 2
location / {
proxy_pass http://192.168.0.5:8084;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
}
}auth_basic больше не нужен — mTLS строже Basic Auth.
4. Импорт сертификата в браузер
Chrome (Windows/Mac):
- Settings → Privacy and Security → Security → Manage certificates
- Import → выбрать
yura.p12→ ввести пароль - Перезапустить Chrome
- При заходе на
admin.partizap.ru— браузер предложит выбрать сертификат
Firefox:
- Settings → Privacy & Security → Certificates → View Certificates
- Your Certificates → Import → выбрать
yura.p12
Safari (Mac):
- Двойной клик на
yura.p12→ откроется Keychain Access - Ввести пароль → сертификат в keychain
5. Отзыв сертификата
При компрометации устройства или увольнении сотрудника:
bash
cd /etc/nginx/ssl/admin-mtls
# Создать/обновить CRL (Certificate Revocation List)
# Первый раз — создать index:
touch index.txt
echo 01 > crlnumber
# Отозвать конкретный сертификат
openssl ca -revoke yura.crt \
-keyfile ca.key -cert ca.crt \
-config <(echo -e "[ca]\ndefault_ca=CA_default\n[CA_default]\ndatabase=index.txt\ncrlnumber=crlnumber")
# Сгенерировать CRL
openssl ca -gencrl \
-keyfile ca.key -cert ca.crt \
-out ca.crl \
-config <(echo -e "[ca]\ndefault_ca=CA_default\n[CA_default]\ndatabase=index.txt\ncrlnumber=crlnumber\ndefault_crl_days=3650")
# Раскомментировать в nginx config:
# ssl_crl /etc/nginx/ssl/admin-mtls/ca.crl;
nginx -t && systemctl reload nginxПосле reload Nginx — сертификат yura.p12 мгновенно перестаёт работать.
Экстренный отзыв (без CRL): заменить ca.crt на новый CA и перевыпустить сертификаты только для действующих админов. Быстрее, но требует раздать новые .p12 всем.
6. CN сертификата в audit log
PHP получает CN через заголовок X-Client-CN. Логировать в admin_audit_log:
php
// AdminAuditLogger — добавить CN
$clientCN = $request->getHeaderLine('X-Client-CN');
$log->setDetails(array_merge($details ?? [], [
'client_cn' => $clientCN,
]));Это позволяет отследить: "запрос пришёл с сертификата yura@partizap.ru, но залогинился как admin@partizap.ru" — аномалия.
Порядок внедрения
В контексте плана реализации из ADR-010:
Phase B (инфраструктура) — обновлённый чеклист
1. [ ] DNS: A-записи для admin.partizap.ru и dev-admin.partizap.ru
2. [ ] VPS 1: Nginx vhost для admin (listen 80, server_name — без mTLS)
3. [ ] VPS 1: certbot — получить SSL-сертификаты
4. [ ] VPS 1: Создать CA и клиентские сертификаты для каждого админа
5. [ ] VPS 1: Включить mTLS в Nginx (ssl_verify_client on)
6. [ ] Раздать .p12 файлы админам, проверить доступ
7. [ ] VPS 2: Nginx vhost для dev-admin (порт 8084)
8. [ ] VPS 1: Reverse proxy для dev-admin с mTLS
9. [ ] PostgreSQL: создать роли с column-level grants
10. [ ] Клонировать admin-деплои, настроить .env
11. [ ] Создать admin users, проверить login flowЗависимости
SSL сертификат (certbot)
→ CA + клиентские сертификаты
→ mTLS в Nginx
→ раздать .p12 админам
→ проверить доступ
→ настроить приложениеmTLS настраивается до деплоя приложения. Если сертификаты не работают — лучше узнать до того, как PHP-код поедет в production.
Ежегодное обслуживание
| Действие | Периодичность | Кто |
|---|---|---|
| Перевыпуск клиентских сертификатов | 1 раз в год (365 дней) | DevOps |
| Проверка срока CA | 1 раз в год (CA выдан на 10 лет) | DevOps |
| Ротация .p12 паролей | При перевыпуске сертификатов | DevOps |
| Аудит выданных сертификатов | 1 раз в квартал | DevOps |
| Отзыв сертификатов уволенных | В день увольнения | DevOps |
Альтернативы, рассмотренные и отклонённые
| Альтернатива | Почему отклонена |
|---|---|
| WireGuard VPN | Конфликт с Red Shield VPN (VPN-over-VPN). Double NAT, блокировка UDP, каскадные разрывы |
| Tailscale | Решает VPN-over-VPN, но добавляет зависимость от SaaS (координационный сервер). Self-hosted Headscale — лишняя инфраструктура |
| IP whitelist | Динамические IP у админов (мобильный интернет, Red Shield меняет exit nodes). Постоянная правка whitelist |
| Nginx auth_basic | Один фактор (пароль). Credentials в Base64 — перехватываются при MITM. Не логирует, кто подключился |
| Cloudflare Access/Tunnel | Зависимость от Cloudflare. Трафик через третью сторону. Overkill для 2-5 админов |
| VPN + mTLS | Избыточно. mTLS уже даёт аутентификацию на уровне TLS. VPN добавил бы сетевую изоляцию, но при 2-5 пользователях mTLS достаточен |
Последствия
- Админка недоступна без клиентского сертификата — боты, сканеры, случайные посетители не видят даже login-страницу
- Каждый админ получает
.p12файл и импортирует в браузер — одноразовая операция - При компрометации устройства — отзыв сертификата через CRL и reload Nginx (минуты)
auth_basicдля dev-admin больше не нужен — mTLS строже- CN сертификата логируется в audit log — видно, чей сертификат использован для каждого запроса
- Три фактора аутентификации: сертификат (устройство) + пароль (знание) + TOTP (телефон)