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 нужны две вещи:
- корневой сертификат
ca.crt— чтобы проверить сервер (он загружается вRootCertStore); - своя пара
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
- Автоопределение формата ключа.
rustls_pemfile::private_key()(версии 2.0+) сама распознает PKCS#8, «классический» RSA (PKCS#1) и SEC1 (EC) и возвращает готовыйPrivateKeyDer. Не нужно угадывать формат файла — про сами форматы см. Ключи и алгоритмы. - Отказ от слабой криптографии by design. rustls не примет сертификат с ключом RSA короче 2048 бит или подписью на SHA-1 — соединение будет разорвано с ошибкой. Используйте сертификаты с ключами минимум RSA 2048 (лучше 4096) или ECDSA P-256/P-384 — это касается и собственной тестовой PKI, и сертификатов от внешнего УЦ.
- Проверку имени сервера нельзя отключить «флагом». В 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-соединения — извлечение и проверка идентичности клиента для авторизации: Идентичность клиента.