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

Идентичность клиента: 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:

КритерийCNSPIFFE 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). Выпущенные тестовые сертификаты стоит проверить командами из статьи Инспекция и проверка, а реакция на компрометацию клиента описана в статье Отзыв сертификатов.