Skip to content

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:

  1. DNS discoveryadmin.partizap.ru виден через dig, Certificate Transparency logs (crt.sh), subdomain enumeration. Любой узнает, где админка
  2. Социальная инженерия — фишинг, credential reuse, компрометация устройства. Пароль один — украл и зашёл
  3. 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 NATWireGuard handshake (UDP 51820) идёт через Red Shield tunnel
Блокировка протоколовНекоторые VPN-провайдеры режут нестандартный UDP
Каскадные разрывыПереподключение Red Shield рвёт WireGuard
MTU issuesDouble 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 на loginSecurity review п. 13
App (PHP)IP+email lockout: 3 попытки/30 минSecurity review п. 5
App (PHP)Progressive delays: 2s → 5sSecurity 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.ruTLS 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):

  1. Settings → Privacy and Security → Security → Manage certificates
  2. Import → выбрать yura.p12 → ввести пароль
  3. Перезапустить Chrome
  4. При заходе на admin.partizap.ru — браузер предложит выбрать сертификат

Firefox:

  1. Settings → Privacy & Security → Certificates → View Certificates
  2. Your Certificates → Import → выбрать yura.p12

Safari (Mac):

  1. Двойной клик на yura.p12 → откроется Keychain Access
  2. Ввести пароль → сертификат в 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
Проверка срока CA1 раз в год (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 (телефон)