Перейти к основному содержимому

mTLS в Rust на rustls: клиент и сервер

Netgap написан на Rust, и его TLS-стек — это rustls: трафик между шлюзами шифруется TLS 1.3, а компоненты аутентифицируют друг друга по mTLS. Эта статья показывает, как поднять обе стороны mTLS-соединения на rustls 0.23: клиент, который предъявляет свой сертификат, и сервер, который его строго требует и проверяет. В конце — паттерн горячей ротации сертификатов в памяти без перезапуска процесса.

Предполагается, что у вас уже есть файлы ca.crt, client.crt/client.key и server.crt/server.key. Источник сертификатов может быть любым — публичный CA, корпоративный УЦ заказчика или тестовый CA для экспериментов; как быстро выпустить такой комплект самостоятельно, описано в статьях Цепочка доверия и Собственная PKI.

1. Чем mTLS отличается от TLS

В обычном TLS сертификат предъявляет только сервер: клиент проверяет, что говорит с настоящим сервером, а сервер о клиенте не знает ничего. В mutual TLS проверка взаимная: сервер отправляет клиенту сообщение CertificateRequest, клиент присылает свой сертификат и доказывает владение приватным ключом сообщением CertificateVerify (подпись хеша всего рукопожатия).

Без шага CertificateRequest рукопожатие остается обычным TLS. Именно поэтому включение mTLS — это всегда настройка серверной стороны: клиент лишь отвечает на запрос.

2. Зависимости Cargo.toml

[dependencies]
tokio = { version = "1", features = ["full"] }
rustls = "0.23"
rustls-pki-types = "1"
rustls-pemfile = "2.0" # парсинг PEM-файлов (сертификаты и ключи)
tokio-rustls = "0.26" # асинхронная обертка rustls для tokio

# Для горячей ротации конфигурации (раздел 7)
arc-swap = "1"
примечание

rustls-pki-types дает общие типы CertificateDer, PrivateKeyDer, ServerName — они реэкспортируются как rustls::pki_types, поэтому в коде можно импортировать их прямо из rustls.

3. Клиент: доверяем CA и предъявляем свой сертификат

Клиенту для mTLS нужны две вещи:

  1. корневой сертификат ca.crt — чтобы проверить сервер (он загружается в RootCertStore);
  2. своя пара client.crt + client.key — чтобы доказать серверу свою идентичность.
use std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use tokio::net::TcpStream;

use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::ClientConfig;
use tokio_rustls::TlsConnector;

fn load_certs_and_key() -> Result<
(rustls::RootCertStore, Vec<CertificateDer<'static>>, PrivateKeyDer<'static>),
Box<dyn std::error::Error>,
> {
// 1. Доверенный корневой сертификат — им проверяем сервер
let mut root_store = rustls::RootCertStore::empty();
let mut ca_reader = BufReader::new(File::open("certs/ca.crt")?);
for cert in rustls_pemfile::certs(&mut ca_reader) {
root_store.add(cert?)?;
}

// 2. Цепочка клиентских сертификатов (leaf + промежуточные, если есть)
let mut cert_reader = BufReader::new(File::open("certs/client.crt")?);
let client_certs: Vec<CertificateDer> =
rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;

// 3. Приватный ключ клиента — формат (PKCS#8 / RSA / SEC1) определяется автоматически
let mut key_reader = BufReader::new(File::open("certs/client.key")?);
let client_key = rustls_pemfile::private_key(&mut key_reader)?
.ok_or("Не удалось найти приватный ключ в файле client.key")?;

Ok((root_store, client_certs, client_key))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (root_store, client_certs, client_key) = load_certs_and_key()?;

// 4. Конфигурация клиента для mTLS:
// with_root_certificates — кому доверяем (проверка сервера),
// with_client_auth_cert — чем представляемся (ответ на CertificateRequest)
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_client_auth_cert(client_certs, client_key)?;

let connector = TlsConnector::from(Arc::new(config));

// 5. Устанавливаем соединение. ServerName должно совпадать
// с SAN в сертификате сервера — иначе rustls разорвет рукопожатие
let server_name: ServerName = "gateway.netgap.local".try_into()?;
let tcp_stream = TcpStream::connect("127.0.0.1:443").await?;
let mut tls_stream = connector.connect(server_name, tcp_stream).await?;

println!("mTLS-соединение установлено");
// tls_stream реализует AsyncRead/AsyncWrite — дальше обычная работа с потоком
Ok(())
}

Если сервер не запрашивает клиентский сертификат, тот же код продолжит работать как обычный TLS-клиент: пара client.crt/client.key просто не будет отправлена.

4. Сервер: требуем и проверяем сертификат клиента

Логика сервера зеркальна, но с одной важной особенностью: серверу недостаточно «иметь список доверенных CA» — он должен явно включить mTLS, передав в конфигурацию верификатор клиентских сертификатов. Именно верификатор заставляет rustls отправить клиенту CertificateRequest.

use std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use tokio::net::TcpListener;

use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::WebPkiClientVerifier;
use rustls::ServerConfig;
use tokio_rustls::TlsAcceptor;

fn load_server_certs_and_ca() -> Result<
(rustls::RootCertStore, Vec<CertificateDer<'static>>, PrivateKeyDer<'static>),
Box<dyn std::error::Error>,
> {
// 1. Доверенный корневой сертификат — им проверяем входящих КЛИЕНТОВ
let mut root_store = rustls::RootCertStore::empty();
let mut ca_reader = BufReader::new(File::open("certs/ca.crt")?);
for cert in rustls_pemfile::certs(&mut ca_reader) {
root_store.add(cert?)?;
}

// 2. Сертификат и приватный ключ самого СЕРВЕРА
let mut cert_reader = BufReader::new(File::open("certs/server.crt")?);
let server_certs: Vec<CertificateDer> =
rustls_pemfile::certs(&mut cert_reader).collect::<Result<Vec<_>, _>>()?;

let mut key_reader = BufReader::new(File::open("certs/server.key")?);
let server_key = rustls_pemfile::private_key(&mut key_reader)?
.ok_or("Не удалось найти приватный ключ сервера")?;

Ok((root_store, server_certs, server_key))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (root_store, server_certs, server_key) = load_server_certs_and_ca()?;

// 3. Верификатор клиентов на основе доверенного CA.
// По умолчанию он СТРОГИЙ: клиент без валидного сертификата
// не пройдет рукопожатие
let client_verifier = WebPkiClientVerifier::builder(Arc::new(root_store)).build()?;

// 4. Конфигурация сервера:
// with_client_cert_verifier — включаем mTLS,
// with_single_cert — чем сервер представляется клиентам
let config = ServerConfig::builder()
.with_client_cert_verifier(client_verifier)
.with_single_cert(server_certs, server_key)?;

let acceptor = TlsAcceptor::from(Arc::new(config));

// 5. Основной цикл приема соединений
let listener = TcpListener::bind("0.0.0.0:443").await?;
println!("mTLS-сервер запущен на порту 443");

loop {
let (stream, peer_addr) = listener.accept().await?;
let acceptor = acceptor.clone();

tokio::spawn(async move {
match acceptor.accept(stream).await {
Ok(mut tls_stream) => {
println!("Входящее mTLS-соединение от {peer_addr}");
// Чтение/запись через tls_stream; идентичность клиента —
// см. статью «Идентичность клиента: CN и SPIFFE ID»
}
Err(e) => eprintln!("Ошибка TLS-рукопожатия: {e:?}"),
}
});
}
}
подсказка

Если нужен опциональный mTLS (например, на переходный период миграции), используйте WebPkiClientVerifier::builder(roots).allow_unauthenticated().build() — сервер по-прежнему отправит CertificateRequest, но пустит и клиентов без сертификата. Для Netgap боевой режим — только строгий: анонимные соединения между компонентами недопустимы.

Как именно после рукопожатия узнать, кто подключился (CN или SPIFFE ID из клиентского сертификата), разобрано в статье Идентичность клиента.

5. Клиент и сервер: что загружает и что проверяет каждый

АспектКлиентСервер
Доверенный кореньca.crt в RootCertStore — для проверки сервераca.crt в RootCertStore внутри верификатора — для проверки клиентов
Своя параclient.crt + client.key через with_client_auth_cert()server.crt + server.key через with_single_cert()
Включение mTLSАвтоматически: отвечает на CertificateRequestЯвно: with_client_cert_verifier()
Проверка имениДа: ServerName обязан совпадать с SAN серверного сертификатаНет: у клиента обычно нет доменного имени; проверяются только подпись цепочки, срок действия и EKU (clientAuth)
Тип соединителяTlsConnector (tokio-rustls)TlsAcceptor (tokio-rustls)

Асимметрия в проверке имени — ключевой момент: сервер аутентифицирует клиента как «предъявителя валидного сертификата от доверенного CA», а извлечение и проверка конкретного имени клиента (авторизация) — отдельный шаг уровня приложения (Идентичность клиента).

6. Архитектурные особенности rustls

  1. Автоопределение формата ключа. rustls_pemfile::private_key() (версии 2.0+) сама распознает PKCS#8, «классический» RSA (PKCS#1) и SEC1 (EC) и возвращает готовый PrivateKeyDer. Не нужно угадывать формат файла — про сами форматы см. Ключи и алгоритмы.
  2. Отказ от слабой криптографии by design. rustls не примет сертификат с ключом RSA короче 2048 бит или подписью на SHA-1 — соединение будет разорвано с ошибкой. Используйте сертификаты с ключами минимум RSA 2048 (лучше 4096) или ECDSA P-256/P-384 — это касается и собственной тестовой PKI, и сертификатов от внешнего УЦ.
  3. Проверку имени сервера нельзя отключить «флагом». В rustls нет аналога curl --insecure: строгое соответствие ServerName полю SAN зашито в дизайн библиотеки. Обойти проверку можно только осознанной реализацией собственного верификатора через dangerous() API — само название подсказывает, что в production-коде Netgap этому не место.

7. Горячая ротация сертификатов без рестарта

В автоматизированной PKI сертификаты короткоживущие (часы или дни) и переиздаются постоянно. Перезапускать шлюз при каждой ротации — значит рвать активные соединения. Решение — держать ServerConfig за атомарно заменяемым указателем ArcSwap:

use arc_swap::ArcSwap;
use std::sync::Arc;
use std::time::Duration;

// make_server_config() собирает Arc<ServerConfig> из файлов —
// это код из раздела 4, вынесенный в функцию
let current_config = Arc::new(ArcSwap::from(make_server_config()?));

// Фоновая задача ротации: перечитывает файлы (или опрашивает Vault API)
let config_clone = current_config.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(3600)).await;
match make_server_config() {
// Атомарная замена указателя: новые соединения берут новый
// конфиг, уже открытые доживают на старом Arc и не «падают»
Ok(new_config) => config_clone.store(new_config),
Err(e) => eprintln!("Ошибка обновления конфигурации: {e:?}"),
}
}
});

// Основной цикл: на КАЖДЫЙ accept берем актуальный снимок конфигурации
loop {
let (stream, _) = listener.accept().await?;
let acceptor = TlsAcceptor::from(current_config.load_full());
tokio::spawn(async move {
if let Ok(tls_stream) = acceptor.accept(stream).await {
// обработка соединения
}
});
}

Почему именно ArcSwap, а не Mutex:

  • load_full()lock-free: горячий путь приема соединений никогда не блокируется, даже если в этот момент идет ротация;
  • store() меняет только указатель — старые соединения продолжают удерживать прежний Arc<ServerConfig> со старыми ключами и корректно доживают свой век;
  • с Mutex каждый accept конкурировал бы за блокировку с потоком ротации, и обновление ключей замораживало бы прием трафика.

Триггером ротации вместо таймера может быть подписка на события файловой системы (крейт notify) или уведомление от агента выдачи сертификатов — подробнее в статье Автоматизация.

Следующий шаг после установления mTLS-соединения — извлечение и проверка идентичности клиента для авторизации: Идентичность клиента.