Идентичность клиента: CN и SPIFFE ID
Успешное mTLS-рукопожатие дает аутентификацию: сервер убедился, что клиент владеет сертификатом, подписанным доверенным CA. Но для авторизации этого мало — компонентам Netgap нужно знать, кто именно пришел: одному сервису разрешен доступ к управляющему API, другому — только к телеметрии. Ответ зашит в самом клиентском сертификате, и эта статья показывает два способа его извлечь в Rust: классическое поле CN (Common Name) и современный SPIFFE ID из расширения SAN.
Что такое Subject, SAN и расширения сертификата — см. обзор и теорию; чтение этих полей глазами через OpenSSL — в статье Инспекция и проверка.
1. Извлечение CN из клиентского сертификата
После рукопожатия rustls хранит сертификаты, присланные клиентом, внутри TLS-сессии. Метод peer_certificates() возвращает их в DER-формате, причем первый элемент — всегда конечный (leaf) сертификат самого клиента. Дальше DER разбирается крейтом x509-parser: в блоке subject ищется атрибут с OID 2.5.4.3 — это и есть Common Name (в коде — константа OID_ATR_COMMON_NAME из oid-registry).
Зависимости в дополнение к базовому набору mTLS:
[dependencies]
x509-parser = "0.16"
oid-registry = "0.7"
use x509_parser::prelude::*;
/// Извлекает Common Name (CN) из клиентского сертификата mTLS-сессии
fn extract_client_cn(
tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
) -> Option<String> {
// 1. Сертификаты, предъявленные клиентом во время рукопожатия
let (_, connection) = tls_stream.get_ref();
let peer_certs = connection.peer_certificates()?;
// Первый элемент — leaf-сертификат клиента (дальше идет цепочка CA)
let client_cert_der = peer_certs.first()?;
// 2. Парсим бинарное DER-представление (ASN.1)
let (_, x509) = X509Certificate::from_der(client_cert_der.as_ref()).ok()?;
// 3. Ищем атрибут Common Name (OID 2.5.4.3) в Subject
for rdn in x509.subject().iter() {
for attr in rdn.iter() {
if attr.attr_type() == &oid_registry::OID_ATR_COMMON_NAME {
if let Ok(cn_value) = attr.as_str() {
return Some(cn_value.to_string());
}
}
}
}
None
}
Использование в серверном цикле из статьи про mTLS — простейшая авторизация в стиле RBAC:
match acceptor.accept(stream).await {
Ok(tls_stream) => {
match extract_client_cn(&tls_stream) {
Some(cn) if cn == "billing-service" => {
println!("Клиент CN={cn}: доступ к биллингу разрешен");
// работаем с tls_stream
}
Some(cn) => println!("Клиент CN={cn}: доступ запрещен"),
None => println!("Сертификат валиден, но CN отсутствует"),
}
}
Err(e) => eprintln!("Ошибка TLS-рукопожатия: {e:?}"),
}
peer_certificates() возвращает Some только если сервер сконфигурирован со строгим WebPkiClientVerifier (или клиент добровольно прислал сертификат в опциональном режиме). Сертификат к этому моменту уже проверен rustls по подписи, сроку и EKU — приложению остается только прочитать из него имя.
2. Ограничения CN
CN — это плоская строка без структуры. Из значения billing-service невозможно понять, из какого окружения, кластера или пространства имен пришел запрос; договоренности вида prod-billing-service держатся только на дисциплине команды, никак не валидируются и неизбежно расползаются. Вдобавок CN — устаревающее поле: публичный Web-PKI давно перешел на SAN, и rustls при проверке серверных имен поле CN игнорирует. Для машинной идентичности нужен формат со структурой и гарантиями выдачи.
3. SPIFFE ID: структурная идентичность
SPIFFE (Secure Production Identity Framework for Everyone) — открытый стандарт идентификации рабочих нагрузок в cloud-native средах. Идентификатор упаковывается в расширение SAN как URI:
spiffe://trust-domain.local/ns/prod/sa/billing-service-sa
└─ домен доверия ─┘ └ ns ┘ └─ service account ─┘
Почему это лучше CN:
| Критерий | CN | SPIFFE ID |
|---|---|---|
| Структура | Плоская строка | Иерархический URI: домен доверия / namespace / service account |
| Где хранится | Поле Subject | Расширение SAN (тип URI) |
| Выдача | Ручные договоренности об именах | Автоматически по ServiceAccount пода (SPIRE, Vault, cert-manager) |
| Защита от подмены | Никакой: любой, кто уговорит CA, получит любой CN | Платформа проверяет метаданные рабочей нагрузки перед выдачей |
| Экосистема | — | Istio, Linkerd, Envoy, Kong используют SPIFFE ID в политиках авторизации |
В Kubernetes со SPIRE или Vault SPIFFE-сертификаты выписываются автоматически на основе ServiceAccount пода: злоумышленник из соседнего namespace физически не получит сертификат с чужим SPIFFE ID. Как устроена такая автоматическая выдача — см. Автоматизация.
4. Извлечение SPIFFE ID из SAN URI
Расширение SAN живет в блоке extensions сертификата. Перебираем расширения, находим SubjectAlternativeName, фильтруем значения типа URI с префиксом spiffe://:
use x509_parser::extensions::{GeneralName, ParsedExtension};
use x509_parser::prelude::*;
/// Извлекает SPIFFE ID из SAN URI клиентского leaf-сертификата
fn extract_client_spiffe_id(
tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
) -> Option<String> {
// 1. Leaf-сертификат клиента из TLS-сессии
let (_, connection) = tls_stream.get_ref();
let peer_certs = connection.peer_certificates()?;
let client_cert_der = peer_certs.first()?;
// 2. Парсим DER
let (_, x509) = X509Certificate::from_der(client_cert_der.as_ref()).ok()?;
// 3. Перебираем расширения в поисках SAN
for ext in x509.extensions() {
if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() {
// В SAN могут лежать DNS, IP, Email, URI — нужен только URI
for name in &san.general_names {
if let GeneralName::URI(uri) = name {
if uri.starts_with("spiffe://") {
return Some(uri.to_string());
}
}
}
}
}
None
}
5. Структурный парсер и Zero-Trust авторизация
Сравнивать SPIFFE ID как целую строку можно, но вся сила — в разборе на компоненты: тогда политики формулируются на уровне «любой сервис из namespace finance домена cluster.local»:
#[derive(Debug, PartialEq)]
pub struct SpiffeId {
pub trust_domain: String,
pub namespace: String,
pub service_account: String,
}
impl SpiffeId {
/// Разбирает spiffe://trust-domain/ns/namespace/sa/service-account
pub fn parse(id: &str) -> Option<Self> {
// Отрезаем схему; если ее нет — это не SPIFFE ID
let path = id.strip_prefix("spiffe://")?;
// Ожидаем: ["cluster.local", "ns", "finance", "sa", "billing-app"]
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 5 && parts[1] == "ns" && parts[3] == "sa" {
Some(Self {
trust_domain: parts[0].to_string(),
namespace: parts[2].to_string(),
service_account: parts[4].to_string(),
})
} else {
None // неверный формат
}
}
}
Пример авторизации в обработчике соединения:
if let Some(raw_id) = extract_client_spiffe_id(&tls_stream) {
match SpiffeId::parse(&raw_id) {
Some(id) if id.trust_domain == "cluster.local"
&& id.namespace == "finance" => {
println!("Доступ разрешен: sa={}", id.service_account);
}
Some(id) => println!("Доступ запрещен для {id:?}"),
None => println!("Невалидный формат SPIFFE ID: {raw_id}"),
}
} else {
// Zero-Trust: валидный сертификат без SPIFFE ID — тоже отказ
println!("В сертификате клиента нет SPIFFE ID — соединение отклонено");
}
Это и есть Zero-Trust на практике: недостаточно «валидного сертификата вообще» — решение о доступе принимается по конкретной, структурно проверенной идентичности каждого соединения.
6. Выпуск сертификата со SPIFFE ID через OpenSSL
Чтобы протестировать извлечение на реальном сертификате, «запечем» SPIFFE ID в SAN при выпуске клиентского сертификата нашим тестовым CA (сам CA создается по статье Собственная PKI). OpenSSL про SPIFFE не знает, поэтому URI задается явно в файле расширений client_spiffe.ext:
authorityKeyIdentifier = keyid,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
# Зашиваем SPIFFE ID в Subject Alternative Name как URI:
subjectAltName = URI:spiffe://cluster.local/ns/finance/sa/billing-app
Генерация ключа, запроса и подпись сертификата с файлом расширений:
# Приватный ключ клиента
openssl genrsa -out client_spiffe.key 2048
# Запрос на подпись (CSR); CN оставляем человекочитаемым
openssl req -new -key client_spiffe.key -out client_spiffe.csr \
-subj "/CN=billing-app-pod"
# Подписываем нашим CA, передавая расширения со SPIFFE ID
openssl x509 -req -in client_spiffe.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out client_spiffe.crt -days 365 -sha256 \
-extfile client_spiffe.ext
Проверяем, что URI попал в структуру сертификата:
openssl x509 -in client_spiffe.crt -text -noout
В выводе должен появиться блок:
X509v3 Subject Alternative Name:
URI:spiffe://cluster.local/ns/finance/sa/billing-app
SPIFFE ID ставится только в leaf-сертификаты рабочих нагрузок. В корневом CA-сертификате SAN URI со SPIFFE ID быть не должно: домен доверия отражается в CN корня (например, CN=cluster.local), а идентичности конкретных сервисов выдаются только при подписании leaf-сертификатов. Смешение этих уровней ломает модель доверия — см. Цепочка доверия и Собственная PKI.
Итоговые правила: в новом коде идентичность извлекается из SAN URI (SPIFFE ID), а не из CN; SPIFFE ID разбирается структурно (SpiffeId::parse), а не сравнением подстрок; соединения с валидным сертификатом, но без ожидаемой идентичности, отклоняются (Zero-Trust). Выпущенные тестовые сертификаты стоит проверить командами из статьи Инспекция и проверка, а реакция на компрометацию клиента описана в статье Отзыв сертификатов.