Продолжение первой и второй частей. В первой мы развернули двухуровневую инфраструктуру: Root CA и три промежуточных центра — Person, Server и Code. Во второй научились отзывать сертификаты, раздавать CRL и подняли OCSP-responder. В самом начале первой части я обещал «беспарольный вход в админку». Пришло время его получить.
Как и предыдущие части, статья задумана как подробное пособие: по каждой директиве и каждой переменной приведён расширенный справочник — синтаксис, допустимые значения, умолчание, контекст, версия появления и подводные камни. Всего таких справочников девять, в них 331 параметр. Не нужен сейчас какой-то ключ — пролистайте таблицу; она здесь, чтобы потом не лезть в документацию. Структура разделов прежняя: сначала рабочий конфиг для типового случая, затем справочник по всем параметрам.
Эта часть — про сервер: как заставить nginx и Apache спрашивать сертификат, проверять его и передавать поля приложению — и что меняется, если веб-сервера перед приложением нет вовсе и разбирать X.509 приходится своим кодом. Про то, откуда сертификаты у людей берутся — заявки, роли, журнал и веб-интерфейс управления удостоверяющим центром, — будет в Части 4.
Проверялось на версиях. Имена директив, значения по умолчанию и синтаксис сверены с официальной документацией: nginx
ngx_http_ssl_module, Apache httpd 2.4mod_ssl, OpenSSL master. Живой стенд, на котором всё это работает, собран на nginx 1.29.8, Apache httpd 2.4 (alpine), OpenSSL 3.3.7 и Go 1.24. Где документация версии не сообщает, в таблицах стоит прочерк — я не угадываю.
В этой части:
Чем вход по сертификату отличается от входа по паролю и что именно проверяет сервер.
Выпускаем клиентский сертификат, пригодный для входа, и упаковываем его в PKCS#12 для браузера.
Включаем mTLS в nginx: полный разбор директив и всех переменных
$ssl_client_*.Передаём поля сертификата в приложение — и разбираемся, почему заголовкам нельзя верить просто так.
Разбираем случай, когда прокси нет вовсе: приложение терминирует TLS и проверяет сертификат само.
То же самое на Apache:
SSLVerifyClient,SSLOptions,SSLRequire, переменныеSSL_CLIENT_*.Проверяем отзыв клиентских сертификатов на стороне сервера: CRL против OCSP.
Делаем вход по кнопке, а не по щелчку браузера: отдельный хост, одноразовый пропуск, защита от login CSRF.
Все пути, имена файлов и секции конфигов — те же, что в Частях 1–2.
Немного теории: что на самом деле проверяет сервер
Вход по паролю — это «я знаю секрет». Вход по сертификату — «я владею ключом, а вот бумага от того, кому ты доверяешь, что этот ключ мой».
Разница не косметическая. При парольном входе секрет передаётся серверу: он проходит по сети, попадает в память приложения, иногда в логи. При входе по сертификату закрытый ключ не покидает клиента никогда. Браузер подписывает им кусок данных рукопожатия, сервер проверяет подпись открытым ключом из сертификата — и всё.
Что при этом происходит на самом деле, по шагам:
Сервер в ходе TLS-рукопожатия шлёт клиенту
CertificateRequest— и вместе с ним список отличительных имён CA, сертификаты которых он готов принять.Браузер смотрит на этот список, находит в своём хранилище подходящие сертификаты и показывает их пользователю. Не подошло ни одного — диалог не появится вовсе.
Клиент отправляет сертификат и
CertificateVerify— подпись, доказывающую владение закрытым ключом.Сервер строит цепочку до доверенного корня, проверяет срок, назначение (
extendedKeyUsage) и — если настроено — отзыв через CRL или OCSP.Только после этого запрос доходит до приложения, и приложение узнаёт, кто пришёл, из полей сертификата.
Из этой механики следуют три вещи, о которые спотыкаются чаще всего.
Первое. Список CA из пункта 1 задаётся директивой ssl_client_certificate (nginx) или SSLCACertificateFile (Apache) — и он же служит списком доверия. Положите туда все три ваших промежуточных центра — и браузер предложит пользователю выбрать серверный сертификат или сертификат подписи кода. Человек растеряется. Поэтому в списке должен быть только тот CA, который выпускает сертификаты людям.
Второе. Аутентификация отвечает на вопрос «кто пришёл». На вопрос «что ему можно» она не отвечает. Роль придётся хранить либо в самом сертификате, либо отдельно — и это развилка, к которой мы вернёмся.
Третье, и самое неприятное. Приложение живёт за обратным прокси и узнаёт о сертификате из HTTP-заголовков, которые прокси ему проставил. Заголовок — это текст. Если до приложения можно достучаться в обход прокси, любой желающий пришлёт X-SSL-Client-Verify: SUCCESS и войдёт кем угодно. Об этом — отдельный раздел.
Шаг 1. Клиентский сертификат, пригодный для входа
В Части 1 мы выпускали User1 секцией [ user_cert ]. Посмотрим на неё ещё раз — теперь с точки зрения «пустит ли с этим сертификатом сервер».
[ user_cert ] basicConstraints = CA:FALSE nsCertType = client, email nsComment = "Client certificates" subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer keyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment extendedKeyUsage = clientAuth, emailProtection
Здесь важны ровно две строки.
extendedKeyUsage = clientAuth — это и есть разрешение «использовать для клиентской аутентификации». Нет его — и сервер отвергнет сертификат при проверке назначения, даже если цепочка идеальна. Проверить можно, не поднимая ничего:
openssl verify -purpose sslclient \ -CAfile /root/ca/PersonIntermediateCA/certs/ca-chain.cert.pem \ /root/ca/PersonIntermediateCA/certs/User1.cert.pem
keyUsage = critical, digitalSignature — без бита digitalSignature клиент не сможет подписать CertificateVerify. Расширение объявлено критичным, то есть проверяющая сторона обязана его понять и соблюсти.
nsCertType — наследие Netscape. Современные клиенты его игнорируют; в конфигах цикла он остался из исторической достоверности, и трогать его незачем.
Если вы прошли Часть 2, то к этой секции добавились ещё две строки — точки проверки отзыва:
crlDistributionPoints = URI:http://pki.certservice.info/crl/PersonIntermediateCA.crl authorityInfoAccess = OCSP;URI:http://ocsp.certservice.info/
Именно из authorityInfoAccess nginx возьмёт адрес, когда мы включим ему проверку отзыва клиентов. Сертификаты, выпущенные до Части 2, этих ссылок не несут — их придётся перевыпустить.
Расширенный справочник: расширения X.509v3 клиентского сертификата
Оговорка о составе: в таблице не только то, что кладут в конечный сертификат. Часть расширений — nameConstraints, policyConstraints, inhibitAnyPolicy, basicConstraints с CA:TRUE — по RFC 5280 предназначена для сертификатов удостоверяющих центров, а issuingDistributionPoint и freshestCRL относятся к CRL. Они здесь потому, что при разборе чужого клиентского сертификата вы всё равно на них наткнётесь и должны понимать, что видите. В колонке «Описание и нюансы» такие строки помечены.
Показать таблицу — 21 расширение (расширения клиентского сертификата)
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
|
| не задано в документации; документированные примеры — 1.2.3.4 = critical, ASN1:UTF8String:Some random data / 1.2.3.4 = critical, DER:01:02:03:04 / 1.2.3.4.1 = DER:01020304 | Если расширение не поддерживается кодом OpenSSL, оно должно кодироваться в формате произвольного расширения; произвольный формат допустимо использовать и для поддерживаемых расширений. Первый способ — слово ASN1, за которым следует содержимое расширения в том же синтаксисе, что у ASN1_generate_nconf(3), с возможностью вынести составные значения в отдельную секцию. Второй способ — слово DER и hex-дамп DER-кодирования расширения; в этой форме можно записать любое расширение, чтобы переопределить поведение по умолчанию, например basicConstraints = critical, DER:00:01:02:03.⚠️ Документация дважды предупреждает: следует крайне тщательно следить за корректностью формата данных для конкретного типа расширения, а опции DER и ASN1 использовать с осторожностью — при неаккуратном обращении легко создать невалидные расширения. Отдельная ловушка формы DER: ею можно переопределить штатное расширение (пример с basicConstraints в документации именно про это), и никакой проверки семантики не произойдёт. Имя записи — сам OID, поэтому правило «повторное имя перекрывает предыдущее» распространяется и сюда. |
| authorityInfoAccess = [critical, ] | не задано в документации; документированные примеры — authorityInfoAccess = OCSP;URI:http://ocsp.example.com/ и связка OCSP;URI:…,caIssuers;URI:… | Сообщает, как получить связанную с сертификатом информацию, которую предоставляет CA. Синтаксис — access_id;location, где access_id это object identifier (общеизвестных значений немного), а location имеет тот же синтаксис, что и subject alternative name, за исключением того, что email:copy не поддерживается.⚠️ Разделитель между access_id и location — точка с запятой, а не двоеточие: двоеточие уже занято внутри location (URI:http://…). Элементы списка разделяются запятой, поэтому location с запятой (например dirName с DN) требует длинной формы. Список известных access_id не закрыт — допустим любой OID, но потребители обычно понимают только OCSP и caIssuers. |
| authorityKeyIdentifier = [critical, ]none | | не указано в документации; документированные примеры — keyid, issuer / keyid:nonss, issuer:nonss / keyid, issuer:always / keyid, issuer:nonss | Идентификатор ключа издателя. Форма none означает, что AKID включаться не будет. Иначе значение — список через запятую из keyid и/или issuer. keyid просит включить в AKID subject key identifier издателя. issuer просит включить серийный номер и издателя (distinguished name) сертификата издателя; без квалификатора always эти поля добавляются только как запасной вариант, если keyid не запрошен или SKID у издателя недоступен. Любое из ключевых слов может иметь необязательный квалификатор always или nonss, отделяемый двоеточием: always делает элемент обязательным (при невозможности включить — ошибка, и запасной вариант с именем/серийником издателя больше не работает), nonss дополнительно обусловливает элемент тем, что сертификат не самоподписанный.⚠️ always и nonss — не синонимы «включить всегда»: always превращает мягкий fallback в жёсткое требование и снимает откат на issuer name + serial, а nonss, наоборот, гасит элемент для самоподписанных сертификатов. Комбинация keyid:always у самоподписанного сертификата без заданного subjectKeyIdentifier даст ошибку — документация прямо предупреждает задавать SKID в этом случае. Квалификатор nonss появился только в OpenSSL 4.0, на более старых сборках конфиг с ним не разберётся. |
| basicConstraints = [critical, ]CA:TRUE|FALSE[, pathlen: | не задано в документации; при отсутствии записи расширение в сертификат не добавляется | Указывает, является ли сертификат CA-сертификатом. Первое значение — CA, за ним TRUE или FALSE. При CA:TRUE допустимо необязательное имя pathlen с неотрицательным числом — максимальное число CA, которые могут находиться в цепочке ниже этого. Как multi-valued расширение поддерживает и короткую (через запятую), и длинную (@section) форму — они эквивалентны.⚠️ Для клиентского сертификата документация требует либо CA:FALSE, либо полного отсутствия расширения — промежуточного варианта нет. pathlen:0 не означает «нельзя подписывать»: такой CA не может подписывать под-CA, но подписывает конечные сертификаты. pathlen осмыслен только при CA:TRUE. |
| certificatePolicies = [critical, ][ia5org, ] | не задано в документации; документированный пример — certificatePolicies = 1.2.4.5, 1.1.3.4 | Raw-расширение, поддерживающее все определённые поля соответствующего расширения сертификата. Политики без квалификаторов задаются просто OID’ами через запятую. Для квалификаторов используется синтаксис '@section’, указывающий на секцию со всей информацией; секция обязана содержать OID политики под именем policyIdentifier. Квалификаторы cPSuri задаются как CPS.nnn = value, где nnn — число; квалификаторы userNotice — как userNotice.nnn = @notice. Секция notice может содержать explicitText, organization и noticeNumbers: первые два — текстовые строки, noticeNumbers — список чисел через запятую. Некоторому ПО может требоваться опция ia5org на верхнем уровне — она меняет кодирование с Displaytext на IA5String. Кодировку explicitText можно задать префиксом UTF8, BMP или VISIBLE с двоеточием.⚠️ organization и noticeNumbers обязаны присутствовать оба или ни одного — документация подчёркивает BOTH. Нумерация CPS.nnn/userNotice.nnn не декоративна: это единственный способ задать несколько квалификаторов, поскольку повторять одно имя поля в секции нельзя. Тип raw означает, что '@section’ здесь — часть собственного синтаксиса разбирающего кода, а не общая длинная форма multi-valued расширений, и смешивать голые OID с @polsect в одном списке допустимо. |
| name = [critical, ]value(s)знач.: модификатор записи, а не самостоятельное расширение; применим к записям любого из четырёх типов — string, multi-valued, raw, arbitrary | флаг отсутствует → расширение некритичное | Не расширение, а необязательный префикс значения в записи секции. Если ключевое слово critical присутствует первым элементом после '=', расширение помечается критическим; если его нет — расширение выпускается как некритичное. Работает одинаково для короткой формы (critical, CA:true, pathlen:1) и для длинной формы со ссылкой на секцию (critical, @basic_constraints), а также для произвольных расширений (critical, ASN1:… / critical, DER:…).⚠️ Никакого «умолчания по расширению» нет: не написали critical — получили некритичное расширение, даже там, где RFC 5280 требует критичности (nameConstraints, обычно keyUsage, basicConstraints у CA). Второе: записи с одинаковым именем не складываются — документация прямо говорит, что более поздняя запись перекрывает более раннюю, поэтому ‘keyUsage = digitalSignature’ ниже по секции затрёт ‘keyUsage = critical, keyCertSign’ целиком, вместе с флагом critical. Внутри секции длинной формы то же правило действует на уровне полей: OpenSSL не поддерживает несколько вхождений одного поля, распознаётся только последнее — множественные значения задаются числовым суффиксом (email.1, email.2). |
| crlDistributionPoints = [critical, ] | не задано в документации; документированные примеры — crlDistributionPoints = URI:http://example.com/myca.crl (и через запятую два URI) | Значения — либо пара имя-значение в форме subject alternative name, либо одно значение, задающее имя секции со всеми полями точки распространения. При использовании пары имя-значение создаётся DistributionPoint, где заданное значение становится полем fullName в distributionPoint, а поля reasons и cRLIssuer опускаются. В секционной форме: fullname — полное имя точки распространения в формате subjectAltName; relativename — фрагмент distinguished name, помещаемый в поле nameRelativeToCRLIssuer; CRLIssuer — в формате subjectAltName; reasons — многозначное поле с причинами отзыва.⚠️ Одновременно fullname и relativename задавать нельзя — документация требует только одно из двух. Простая форма молча теряет reasons и cRLIssuer: если нужны причины или отдельный издатель CRL, только секционная форма. В документированном примере полной формы секция указывается по имени без символа ‘@’ (crlDistributionPoints = crldp1_section), в отличие от привычной длинной формы multi-valued расширений. В том же примере поле пишется как CRLissuer — регистр в документации непоследователен (CRLIssuer в описании, CRLissuer в примере). |
| extendedKeyUsage = [critical, ] | не задано в документации; при отсутствии записи расширение не добавляется | Список назначений, для которых может использоваться открытый ключ сертификата. Каждое значение — либо короткое текстовое имя из документированной таблицы, либо OID в числовой форме. Документация отдельно оговаривает, что RFC 5280 определяет id-kp-serverAuth и id-kp-clientAuth как предназначенные для WWW, но на практике они применяются ко всем видам TLS-клиентов и серверов, и OpenSSL исходит из того же.⚠️ msSmartcardLogin в этой странице документации отсутствует полностью — короткого имени для него нет, задать его можно только числовым OID как произвольное значение списка. Неизвестное короткое имя не превращается в OID автоматически, поэтому опечатка в имени — ошибка конфигурации, а не тихое игнорирование. Имена регистрозависимы ровно в том виде, как в таблице (OCSPSigning, msCTLSign). |
| —знач.: — | — | В текущей версии страницы docs.openssl.org/master/man5/x509v3_config раздела для freshestCRL нет: расширение не упоминается ни в STANDARD EXTENSIONS, ни в DEPRECATED EXTENSIONS, ни в оглавлении. Документированного синтаксиса, значений и умолчания для него на этой странице не существует, поэтому приводить их здесь было бы домыслом.⚠️ Строку стоит держать в справочнике именно как отрицательный результат: если freshestCRL нужен, документация этой страницы даёт только один гарантированный путь — раздел ARBITRARY EXTENSIONS (формы ASN1: и DER:) с ручным кодированием содержимого и предупреждением о том, что при неаккуратном применении легко получить невалидное расширение. |
| inhibitAnyPolicy = [critical, ] | не задано в документации; документированный пример — inhibitAnyPolicy = 2 | Строковое расширение, значением которого должно быть неотрицательное целое число.⚠️ Тип string — форма @section к нему неприменима, только скалярное значение. Число задаёт, сколько ещё сертификатов в цепочке могут использовать anyPolicy, прежде чем он перестанет учитываться; 0 запрещает anyPolicy сразу ниже данного сертификата. |
| issuerAltName = [critical, ]issuer:copy | | не задано в документации; документированный пример — issuerAltName = issuer:copy | Альтернативные имена издателя. Поддерживает большинство опций subject alternative name, но не поддерживает email:copy, и добавляет значение issuer:copy, которое копирует альтернативные имена субъекта из сертификата издателя, если это возможно.⚠️ Разница в поддерживаемых значениях легко пропускается: email:copy здесь запрещён, а копирующее значение называется issuer:copy и берёт данные не из собственного subject, а из SAN сертификата издателя — и только «if possible», то есть тихо ничего не даст, если у издателя SAN нет. |
| issuingDistributionPoint = [critical, ]@section [section] fullname = … | relativename = … , onlysomereasons = … , onlyuser = TRUE|FALSE , onlyCA = … , onlyAA = … , indirectCRL = …знач.: тип: multi-valued. Поля: fullname, relativename (формат как у CRL distribution points), onlysomereasons (keyCompromise, CACompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn, AACompromise), onlyuser, onlyCA, onlyAA, indirectCRL — булевы | не задано в документации; документированный пример — issuingDistributionPoint = critical, @idp_section | Расширение должно появляться только в CRL. Многозначное расширение, синтаксис похож на «секцию», на которую ссылается расширение CRL distribution points. Значимые имена: fullname — полное имя точки распространения в формате subject alternative name; relativename — фрагмент distinguished name как значение поля nameRelativeToCRLIssuer; onlysomereasons — многозначное поле с причинами отзыва; onlyuser, onlyCA, onlyAA, indirectCRL — булевы значения.⚠️ Это расширение CRL, а не сертификата — в профиле клиентского сертификата ему места нет, и попытка положить его туда даёт формально валидный, но бессмысленный сертификат. Причины здесь называются onlysomereasons (не reasons, как в crlDistributionPoints), при том же наборе допустимых значений — перепутанное имя поля не сработает. Документированный пример помечает расширение как critical. |
| keyUsage = [critical, ] | не задано в документации; при отсутствии записи расширение не добавляется | Список разрешённых применений ключа. Документация перечисляет ровно девять определённых значений и приводит примеры: keyUsage = digitalSignature, nonRepudiation; keyUsage = digitalSignature, contentCommitment; keyUsage = critical, keyCertSign.⚠️ Список закрытый: в отличие от extendedKeyUsage, подстановка произвольного OID вместо имени документацией не предусмотрена. nonRepudiation и contentCommitment — два имени одного и того же бита, а не два разных значения, писать оба бессмысленно. По семантике RFC 5280 §4.2.1.3 encipherOnly и decipherOnly имеют смысл только совместно с keyAgreement, а keyCertSign — только у сертификата с basicConstraints CA:TRUE. |
| nameConstraints = [critical, ]permitted; | не задано в документации; документированные примеры — nameConstraints = permitted;IP:192.168.0.0/255.255.0.0, permitted;email:.example.com, excluded;email:.com | Многозначное расширение. Имя должно начинаться со слова permitted или excluded, за которым следует ‘;’. Остальная часть имени и значение следуют синтаксису subjectAltName, за исключением того, что email:copy не поддерживается, а форма IP должна состоять из IP-адреса и маски подсети, разделённых ‘/’. По семантике RFC 5280 §4.2.1.10 расширение применимо только в CA-сертификатах, и соответствующие CA обязаны помечать его критическим — на самой странице x509v3_config это требование не сформулировано, поэтому флаг critical нужно ставить вручную.⚠️ Разделитель после permitted/excluded — точка с запятой, а не двоеточие: двоеточие принадлежит уже вложенному type:value. IP задаётся не префиксом длины (/16), а полной маской (255.255.0.0). Умолчание OpenSSL — некритичное расширение, что противоречит требованию RFC 5280 к соответствующим CA, поэтому ‘critical’ обязателен явно. Ограничения по email вида ‘.example.com’ с ведущей точкой — это суффиксное правило поддомена, а не конкретный адрес. |
| noCheck = | не задано в документации; документированный пример — noCheck = ignored | Строковое расширение (OCSP No Check). Документация сообщает, что значение разбирается, но игнорируется — значим сам факт присутствия расширения, а не его содержимое.⚠️ Само значение бессмысленно, поэтому написанное там ‘ignored’, ‘true’ или ‘no’ одинаково даст одно и то же расширение — отключить его значением нельзя, отключается только удалением строки. Расширение предназначено сертификату OCSP-респондера и в обычном клиентском сертификате неуместно. |
| nsCertType = [critical, ] | не задано в документации; при отсутствии записи расширение не добавляется | Многозначное расширение, состоящее из списка включаемых флагов. Использовалось для указания назначений, для которых мог применяться сертификат. Документация прямо говорит, что теперь вместо него используются расширения basicConstraints, keyUsage и extended key usage. Допустимые значения: client, server, email, objsign, reserved, sslCA, emailCA, objCA.⚠️ Раздел DEPRECATED EXTENSIONS открывается словами о том, что эти расширения нестандартны, специфичны для Netscape и в значительной мере устарели, а их использование в новых приложениях не поощряется. Практическое следствие: nsCertType не заменяет keyUsage/extendedKeyUsage и современными валидаторами не учитывается — добавив только его, вы получите сертификат без реальных ограничений применения. Флаг reserved существует, но смысла в профиле не несёт. |
| nsComment = [critical, ] | не задано в документации; при отсутствии записи расширение не добавляется | Netscape Comment — строковое расширение, содержащее комментарий, который будет показан при просмотре сертификата в некоторых браузерах. Другие расширения этого же типа: nsBaseUrl, nsRevocationUrl, nsCaRevocationUrl, nsRenewalUrl, nsCaPolicyUrl и nsSslServerName.⚠️ Формулировка «will be displayed when the certificate is viewed in some browsers» описывает поведение времён Netscape — рассчитывать на показ комментария в современных клиентах нельзя. Как расширение типа string оно не принимает форму @section. Помечать его critical бессмысленно и вредно: критичное нераспознанное расширение обязано приводить к отбраковке сертификата. |
| policyConstraints = [critical, ]requireExplicitPolicy: | не задано в документации; документированный пример — policyConstraints = requireExplicitPolicy:3 | Многозначное расширение, состоящее из имён requireExplicitPolicy или inhibitPolicyMapping и неотрицательного целого значения. Как минимум одна компонента должна присутствовать.⚠️ Пустое расширение недопустимо — «At least one component must be present». Число — это глубина: сколько сертификатов в цепочке ещё может быть обработано до того, как ограничение вступит в силу, поэтому 0 означает «немедленно». Расширение имеет смысл только в CA-сертификате. |
| subjectAltName = [critical, ] | не задано в документации; при отсутствии записи расширение не добавляется | Альтернативные имена субъекта. Поддерживаемые типы идентификаторов: email (адрес почты), URI, DNS, RID (зарегистрированный OBJECT IDENTIFIER), IP, dirName (distinguished name), otherName. У опции email два специальных значения: copy автоматически включает в расширение все почтовые адреса из subject-имени сертификата, move — переносит их из subject-имени в расширение. Значение dirName указывает на секцию конфигурации с DN в виде пар имя-значение; многозначные AVA формируются добавлением символа ‘+’ перед именем. Значение otherName — произвольные данные, привязанные к OID: OID, точка с запятой и содержимое по синтаксису ASN1_generate_nconf(3); документированный пример — otherName:1.2.3.4;UTF8:some other identifier, а для неASCII-адресов по RFC 6531/RFC 8398 — otherName = 1.3.6.1.5.5.7.8.9;FORMAT:UTF8,UTF8String:nonasciiname.example.com.⚠️ copy и move различаются побочным эффектом: move ещё и вычищает почтовый адрес из subject DN. Если значение содержит запятую (типичный случай — URI:ldap://host/CN=foo,OU=bar), короткая форма даст ошибку, обязательна длинная через @section. В одной секции нельзя повторять одно и то же поле — два подряд ‘email =’ дадут только последний адрес, нужны email.1 и email.2. otherName:msUPN как готовое имя на этой странице не документирован — UPN задаётся общей формой otherName: |
| subjectKeyIdentifier = [critical, ]none | hash | | не указано в документации как значение по умолчанию конфигурации; документированный пример — subjectKeyIdentifier = hash | Идентификатор открытого ключа субъекта. Форма none означает, что расширение SKID не будет включено вовсе. Форма hash задаёт вычисление по RFC 5280 §4.2.1.2 (1): keyIdentifier — 160-битный SHA-1 от значения BIT STRING subjectPublicKey, без тега, длины и числа неиспользуемых битов. Третья форма — hex-строка, значение подставляется напрямую. По разделу HISTORY, в OpenSSL 4.0 синтаксис SKID и AKID обновлён, а утилиты командной строки больше не содержат специальной встроенной логики добавления этих расширений: они обрабатываются через конфигурационные файлы и опции CLI как любое другое расширение.⚠️ Явное указание hex-строки документация называет strongly discouraged — руками заданный идентификатор ломает связку с AKID потомков и построение цепочек. Обратная зависимость: при выпуске самоподписанного сертификата SKID нужно задать явно, если в AKID предполагается обязательный (always) keyid. И главное следствие OpenSSL 4.0 — раньше CLI мог подставлять эти расширения сам, теперь их отсутствие в конфиге означает их отсутствие в сертификате. |
| tlsfeature = [critical, ] | не задано в документации; документированный пример — tlsfeature = status_request | Многозначное расширение, состоящее из списка идентификаторов TLS-расширений. Каждый идентификатор может быть числом (0…65535) или поддерживаемым именем. Когда TLS-клиент присылает перечисленное расширение, ожидается, что TLS-сервер включит это расширение в свой ответ. Поддерживаемые имена: status_request и status_request_v2.⚠️ Имена — только два; всё остальное задаётся числом, поэтому опечатка в имени не деградирует в число. Практический риск на стороне эксплуатации: выставив status_request (Must Staple), вы обязываете сервер отдавать OCSP staple — при сбое стейплинга соединение отвергается клиентом, и «откатить» это до истечения сертификата нельзя. |
Версии и подводные камни:
Без
clientAuthвextendedKeyUsageсервер отвергнет сертификат при проверке назначения, даже если цепочка безупречна.
keyUsageобъявляют критичным: проверяющая сторона обязана понять расширение и соблюсти ограничения, иначе отбросить сертификат.Современные клиенты сверяют имя только с
subjectAltName;CNдля этой цели не используется.Сверено с: https://docs.openssl.org/master/man5/x509v3_config/, https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.3, https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2, https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.10, https://docs.openssl.org/master/man3/ASN1_generate_nconf/
Шаг 2. Отдаём сертификат владельцу: PKCS#12
Браузеру нужен один файл, в котором лежат сертификат, закрытый ключ и цепочка. Это PKCS#12 — .p12 или .pfx.
openssl pkcs12 -export \ -inkey /root/ca/PersonIntermediateCA/private/User1.key.pem \ -in /root/ca/PersonIntermediateCA/certs/User1.cert.pem \ -certfile /root/ca/PersonIntermediateCA/certs/ca-chain.cert.pem \ -name "User1 (CertService)" \ -out User1.p12
-certfile с цепочкой класть обязательно: без промежуточного сертификата внутри контейнера некоторые системы не построят путь доверия при импорте.
Пароль контейнера передавайте отдельно от файла — это не формальность. Файл с ключом и пароль к нему в одном письме — это просто ключ без пароля.
Отдельная засада ждёт тех, кому нужно поддержать старые системы. OpenSSL 3 по умолчанию шифрует контейнер AES-256 с PBKDF2, и Windows до 10 включительно, а также старые версии Java такой файл не открывают — молча или с невнятной ошибкой. Лечится явным выбором старых алгоритмов:
openssl pkcs12 -export -legacy \ -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1 \ -inkey User1.key.pem -in User1.cert.pem -certfile ca-chain.cert.pem \ -out User1-legacy.p12
Это осознанный размен: 3DES и SHA-1 слабее, но контейнер, который невозможно открыть, защищает ещё хуже.
Расширенный справочник: openssl pkcs12
Показать таблицу — 67 опций (openssl pkcs12)
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| -CAfile file | стандартное хранилище доверия | Файл доверенных сертификатов. Подробности — в разделе «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Влияет только на построение цепочки при -chain; на сам разбор PKCS#12 доверие не проверяется. Указание файла не отменяет стандартное хранилище — для этого нужны парные -no-* опции. |
| -CApath dir | стандартное хранилище доверия | Каталог доверенных сертификатов. См. «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Каталог должен быть подготовлен в формате хеш-ссылок (c_rehash/openssl rehash); обычная папка с .pem-файлами не сработает и цепочка не построится. |
| -CAstore uri | стандартное хранилище доверия | URI хранилища доверенных сертификатов. См. «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Универсальная замена -CAfile/-CApath, работающая через провайдеры хранилищ; доступность конкретной схемы URI зависит от загруженных провайдеров. |
| -CSP name | — | Записывает name как имя Microsoft CSP (Cryptographic Service Provider).⚠️ Чисто Windows-специфичный атрибут: при импорте на Windows он определяет, какому криптопровайдеру достанется ключ. Неверное или отсутствующее имя приводит к тому, что ключ попадает не к тому провайдеру и приложение его не видит; для не-Windows потребителей атрибут бесполезен. |
| -LMK | выключено | Добавляет в атрибуты идентификатор «Local Key Set».⚠️ Ещё один атрибут ради совместимости с Microsoft-стеком: часть ПО не подхватывает ключ из контейнера, пока в атрибутах нет Local Key Set. На остальных платформах он просто игнорируется. |
| -aes128 | — | Шифрует выводимые приватные ключи алгоритмом AES (128 бит).⚠️ Опция относится к секции parsing: она задаёт шифрование PEM-ключа на выходе, а не алгоритмы внутри создаваемого PKCS#12 (для этого -keypbe/-certpbe). Вместе с -noenc теряет смысл. |
| -aes192 | — | Шифрует выводимые приватные ключи алгоритмом AES (192 бита).⚠️ Промежуточная длина ключа поддерживается не всем потребляющим ПО; для PEM-вывода обычно берут -aes256 как самый предсказуемый вариант. |
| -aes256 | — | Шифрует выводимые приватные ключи алгоритмом AES (256 бит).⚠️ Не путайте с умолчанием AES_256_CBC + PBKDF2 внутри контейнера при -export: здесь речь только о защите PEM-файла, который команда пишет при разборе PKCS#12. |
| -aria128 | — | Шифрует выводимые приватные ключи алгоритмом ARIA (128 бит).⚠️ ARIA — корейский национальный стандарт; за пределами соответствующих требований такой PEM-ключ может не прочитаться сторонними библиотеками. |
| -aria192 | — | Шифрует выводимые приватные ключи алгоритмом ARIA (192 бита).⚠️ Как и прочие ARIA-варианты, применим только к PEM-выводу при разборе; наличие шифра в сборке проверяется через openssl list -cipher-algorithms. |
| -aria256 | — | Шифрует выводимые приватные ключи алгоритмом ARIA (256 бит).⚠️ Выбирать ARIA стоит только при явном требовании регулятора: совместимость с чужим ПО ниже, чем у AES, а выигрыша в безопасности нет. |
| -cacerts | выключено (выводятся все сертификаты) | Выводит только CA-сертификаты (без клиентских).⚠️ Зеркальная к -clcerts опция; вместе они взаимоисключающи по смыслу. Для сборки chain-файла обычно используют -cacerts -nokeys. |
| -camellia128 | — | Шифрует выводимые приватные ключи алгоритмом Camellia (128 бит).⚠️ Camellia распространена в японских стандартах; аппаратного ускорения, аналогичного AES-NI, обычно нет, поэтому вывод больших пакетов ключей заметно медленнее. |
| -camellia192 | — | Шифрует выводимые приватные ключи алгоритмом Camellia (192 бита).⚠️ Применяется только к PEM-выводу приватного ключа при разборе PKCS#12; на содержимое создаваемого контейнера не влияет. |
| -camellia256 | — | Шифрует выводимые приватные ключи алгоритмом Camellia (256 бит).⚠️ Стойкость сопоставима с AES-256, но принимающая сторона (веб-сервер, JVM, ОС) должна уметь читать PEM с таким шифром — проверяйте до раскатки. |
| -caname friendlyname | — | Задаёт «дружественное имя» для прочих сертификатов. Может использоваться несколько раз, чтобы задать имена всем сертификатам в порядке их следования. Netscape игнорирует friendly name у прочих сертификатов, MSIE их отображает.⚠️ Порядок повторов опции жёстко привязан к порядку сертификатов в контейнере — вставка ещё одного промежуточного CA сдвигает все имена. Разное поведение Netscape и MSIE означает, что на имя нельзя опираться как на надёжный признак. |
| -certfile filename | — | Файл с дополнительными сертификатами, добавляемыми в создаваемый PKCS#12, если задана опция -export.⚠️ Сертификаты добавляются «как есть», без построения и проверки цепочки: порядок и полнота цепочки — на вашей ответственности. Если нужна именно выстроенная цепочка, используйте -chain (при необходимости с -untrusted). Пароль для этого входа задаётся через -passcerts. |
| -certpbe algзнач.: имя алгоритма PKCS#5 v1.5 или PKCS#12 PBE; имя шифра из openssl list -cipher-algorithms (тогда используется PKCS#5 v2.0); NONE | AES_256_CBC с PBKDF2; в legacy-режиме — RC2_CBC или 3DES_CBC (в зависимости от сборки) | Выбирает алгоритм шифрования сертификатов в создаваемом контейнере. Правила выбора значения те же, что у -keypbe; специальное значение NONE отключает шифрование.⚠️ Та же проблема совместимости, что и у -keypbe, но острее: сертификаты часто читает стороннее ПО. Старые системы ждут PKCS#12-алгоритмы (RC2_CBC/3DES_CBC), а умолчание AES_256_CBC с PBKDF2 им незнакомо — отсюда классическая связка -legacy -certpbe … Значение NONE оставляет сертификаты открытыми: секрета в них нет, и это иногда полезно, чтобы утилита-импортёр могла прочитать цепочку без пароля. |
| -chain | выключено (в контейнер попадает только то, что дано явно) | Строит цепочку сертификатов конечного сертификата и включает её в создаваемый PKCS#12. Конечным считается первый сертификат из файла -in, если ключ не задан, иначе первый совпадающий с заданным ключом. Для построения используется стандартное хранилище доверия и недоверенные CA-сертификаты из -untrusted.⚠️ Опция зависит от системного хранилища доверия: на машине сборки цепочка построится, на другой — нет. Промежуточные CA, отсутствующие в хранилище, нужно подавать через -untrusted, иначе экспорт завершится ошибкой построения цепочки. |
| -clcerts | выключено (выводятся все сертификаты) | Выводит только клиентские сертификаты (без CA-сертификатов).⚠️ Разделение на «клиентские» и «CA» опирается на содержимое контейнера, а не на реальную роль сертификата; типовая связка для получения листового сертификата — -clcerts -nokeys. |
| -des | — | Шифрует выводимые приватные ключи алгоритмом DES.⚠️ Одиночный DES с 56-битным ключом сегодня не считается защитой; оставлен для чтения/записи совсем архаичных конфигураций. Не путайте с -descert, который управляет шифрованием сертификатов внутри создаваемого контейнера. |
| -des3 | — | Шифрует выводимые приватные ключи алгоритмом triple DES.⚠️ Исторический выбор для максимальной совместимости PEM-ключа со старым ПО: то же 3DES_CBC, что становится умолчанием для ключей в legacy-режиме. По современным меркам блок в 64 бита — слабое место, используйте только ради совместимости. |
| -descert | выключено; по умолчанию приватный ключ и сертификаты шифруются AES-256-CBC, если не задан -legacy | Шифрует сертификаты алгоритмом triple DES. По умолчанию приватный ключ и сертификаты шифруются AES-256-CBC, если не используется опция -legacy. Если -descert применяется вместе с -legacy, то triple DES используется и для приватного ключа, и для сертификатов.⚠️ Поведение опции меняется от соседства с -legacy: сама по себе она затрагивает только сертификаты, а в паре с -legacy переводит на 3DES и ключ тоже. Быстрый способ получить контейнер для очень старого ПО, но 3DES — устаревший шифр, применять только ради совместимости. |
| — (в текущей версии страницы отсутствует в SYNOPSIS и OPTIONS) | — | Опция удалена в OpenSSL 4.0. На текущей странице документации её нет ни в SYNOPSIS, ни в OPTIONS — она упоминается только в разделе HISTORY как удалённая.⚠️ Скрипты, передающие -engine (типично для работы с HSM и токенами через engine pkcs11), на OpenSSL 4.0 падают на неизвестной опции. Замена — механизм провайдеров: -provider/-provider-path/-propquery, а сам ключ или вход адресуется через URI в -inkey/-in. Слово “engine” на странице встречается только в HISTORY, так что искать описание опции в OPTIONS бесполезно. |
| -export | выключено (файл разбирается, а не создаётся) | Указывает, что PKCS#12-файл будет создан, а не разобран.⚠️ Опция-переключатель режима: от неё зависит смысл -in, -out и -password, а также то, какая из двух секций опций вообще применима. Без -export все export-опции просто игнорируются, и ошибка не выдаётся. |
| -help | — | Печатает сообщение об использовании команды со списком опций.⚠️ Справка сгруппирована так же, как SYNOPSIS: общие опции, PKCS#12 input (parsing) options и PKCS#12 output (export) options. Опции -engine в ней уже нет — она удалена в OpenSSL 4.0. |
| -idea | — | Шифрует выводимые приватные ключи алгоритмом IDEA.⚠️ Экзотический шифр: если он не включён в вашу сборку, опция завершится ошибкой. Наличие проверяется командой openssl list -cipher-algorithms. |
| -in filename|uri | standard input | Задаёт имя входного файла или URI. Без -export это разбираемый PKCS#12-файл; с -export — файл с сертификатами и ключом.⚠️ Принимает не только путь, но и URI (например, store:), что важно для аппаратных хранилищ после удаления -engine. При -export именно из этого файла берётся конечный сертификат для -chain — первый прочитанный, если ключ не задан, иначе первый совпадающий с ключом. |
| -info | выключено | Выводит дополнительную информацию о структуре PKCS#12-файла, использованных алгоритмах и счётчиках итераций.⚠️ Единственный штатный способ узнать, каким PBE зашифрован чужой контейнер и какой у него iteration count, — то есть понять заранее, понадобится ли -legacy. Сочетайте с -noout, чтобы не выводить сами ключи. |
| -inkey filename|uri | приватный ключ берётся из файла -in | Файл или URI с приватным ключом для создаваемого PKCS#12. Если опция не задана, приватный ключ должен содержаться во входном файле (-in).⚠️ Принимает URI, а не только путь, — это основной способ работы с ключом из внешнего хранилища после удаления -engine в OpenSSL 4.0. Пароль самого ключа берётся из -passin, а не из -passout. |
| -iter countзнач.: целое число итераций | 2048 | Задаёт счётчик итераций для ключа шифрования и для MAC. Значение по умолчанию — 2048. Итерации замедляют вывод ключа из пароля и тем самым затрудняют атаки по большим словарям распространённых паролей; MAC используется для проверки целостности файла, но, поскольку обычно защищён тем же паролем, тоже может стать целью атаки.⚠️ Умолчание 2048 по нынешним меркам мало — для контейнеров с ценными ключами счётчик стоит поднимать. Обратная сторона: одно значение применяется и к шифрованию, и к MAC, а слишком большой счётчик заметно тормозит импорт на слабых устройствах и в некоторых старых реализациях упирается в собственные лимиты. |
| -jdktrust usageзнач.: anyExtendedKeyUsage (единственное определённое на данный момент) | — | Экспортирует PKCS#12-файл в формате, совместимом с использованием в Java keystore. Принимает строковый параметр — имя trust OID, который будет присвоен связанному сертификату; на данный момент определено только “anyExtendedKeyUsage”. Поскольку Java keystore не принимает PKCS#12-файлы, содержащие одновременно доверенные сертификаты и пары ключей, применение этой опции подразумевает установку опции -nokeys.⚠️ Опция неявно включает -nokeys: попытка одной командой сделать truststore с ключом внутри даст контейнер без ключа, и это легко принять за потерю данных. Значение параметра сейчас ровно одно — anyExtendedKeyUsage; любое другое имя не сработает. |
| -keyex | — | Указывает, что приватный ключ предназначен для обмена ключами (key exchange). Опция интерпретируется только MSIE и подобным ПО Microsoft.⚠️ Наследие эпохи экспортных ограничений: «export grade»-ПО разрешало шифрование только с 512-битными RSA, а подпись — с ключами произвольной длины. Вне Microsoft-стека флаг ничего не меняет; взаимоисключающ с -keysig. |
| -keypbe algзнач.: имя алгоритма PKCS#5 v1.5 или PKCS#12 PBE; имя шифра из openssl list -cipher-algorithms (тогда используется PKCS#5 v2.0); NONE | AES_256_CBC с PBKDF2; в legacy-режиме — 3DES_CBC | Выбирает алгоритм шифрования приватного ключа в создаваемом контейнере. Допустимо любое имя алгоритма PKCS#5 v1.5 или PKCS#12 PBE; если указано имя шифра (как его печатает openssl list -cipher-algorithms), оно используется с PKCS#5 v2.0. Специальное значение NONE отключает шифрование.⚠️ Ключевая опция совместимости со старыми системами: документация прямо советует ради интероперабельности использовать только PKCS#12-алгоритмы (например, pbeWithSHA1And3-KeyTripleDES-CBC). Имя шифра переключает контейнер на PKCS#5 v2.0, который старые Windows, старые JDK и встроенные стеки не понимают — файл откроется у вас и не откроется у них. Значение NONE оставляет ключ в контейнере незашифрованным. |
| -keysig | — | Помечает приватный ключ как предназначенный только для подписи. Такие ключи применимы для подписи S/MIME, authenticode (подпись ActiveX) и клиентской аутентификации SSL.⚠️ Документация прямо предупреждает о баге: из-за него signing-only ключи для клиентской аутентификации SSL поддерживает только MSIE 5.0 и новее. Если клиентские сертификаты предназначены для разношёрстных клиентов, пометку лучше не ставить. |
| -legacy | выключено; без неё для сертификатов и ключей используется AES_256_CBC с PBKDF2 | Включает legacy-режим работы и автоматически загружает legacy-провайдер. В этом режиме алгоритм шифрования сертификатов по умолчанию — RC2_CBC или 3DES_CBC (в зависимости от того, включён ли шифр RC2 в сборке), а приватных ключей — 3DES_CBC.⚠️ Главная опция совместимости со старыми системами. Современное умолчание AES_256_CBC + PBKDF2 не понимают старые Windows, старые Java-keystore и прочее древнее ПО — им нужен именно legacy-режим с RC2_CBC/3DES_CBC. Обратная сторона: legacy-провайдер нужен и для чтения старых файлов, и без него разбор упадёт. Если OpenSSL установлен не системно, дополнительно требуется, например, -provider-path ./providers или переменная окружения OPENSSL_MODULES, указывающая на каталог с провайдерами. |
| -macalg digestзнач.: имя дайджеста | SHA256 | Задаёт алгоритм хеширования для MAC. Если опция не указана, используется SHA256.⚠️ Умолчание SHA256 безопаснее исторического SHA1, но очень старые импортёры умеют проверять MAC только по SHA1 — для них придётся явно указать -macalg sha1. Ошибка проверки MAC на приёмной стороне выглядит как «неверный пароль», хотя пароль верен. |
| -maciter | итерации MAC применяются по умолчанию | Опция включена ради совместимости с предыдущими версиями: раньше она требовалась, чтобы задействовать итерации MAC, теперь они применяются по умолчанию.⚠️ Фактически ничего не делает — присутствие в старых скриптах безвредно, но и пользы не приносит. Не путайте её с -nomaciter, который делает ровно противоположное и реально ослабляет файл. |
| -macsaltlenзнач.: длина соли в байтах | 16 (байт); до OpenSSL 3.6 — 8 | Задаёт длину соли в байтах для MAC. Согласно NIST SP 800-132 длина соли должна быть не менее 16 байт — это и есть значение по умолчанию.⚠️ Умолчание изменилось: до OpenSSL 3.6 оно составляло 8 байт, с 3.6 — 16. Из-за этого контейнеры, собранные одной и той же командой на разных версиях, различаются, а старые импортёры, жёстко ожидающие 8-байтовую соль, могут не принять новый файл — тогда длину задают явно. |
| -name friendlyname | — | Задаёт «дружественное имя» (friendly name) для сертификатов и приватного ключа. Это имя обычно показывается в списках ПО, импортирующего файл.⚠️ Единственный человекочитаемый идентификатор контейнера в интерфейсах импорта: без него в списке сертификатов Windows или в keystore появится безымянная запись, которую трудно отличить от соседних. |
| -no-CAfile | выключено | Отключает использование стандартного файла доверенных сертификатов. См. «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Нужна для воспроизводимой сборки контейнера: без неё результат -chain зависит от того, что лежит в системном хранилище конкретной машины. |
| -no-CApath | выключено | Отключает использование стандартного каталога доверенных сертификатов. См. «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Применяется вместе с -no-CAfile и -no-CAstore, когда нужно изолировать построение цепочки исключительно от переданных вручную файлов. |
| -no-CAstore | выключено | Отключает использование стандартного хранилища доверенных сертификатов. См. «Trusted Certificate Options» в openssl-verification-options(1).⚠️ Полная изоляция от системного доверия достигается только тройкой -no-CAfile -no-CApath -no-CAstore; пропуск любой из них оставляет источник доверия включённым. |
| -nocerts | выключено (сертификаты выводятся) | Сертификаты не выводятся.⚠️ Полностью отменяет вывод сертификатов, поэтому сочетать её с фильтрами -clcerts/-cacerts бессмысленно — те тоже сужают выборку сертификатов. Типовая связка для извлечения только ключа: -nocerts -noenc. |
| -nodes | выключено (ключ шифруется) | Устаревший синоним -noenc: приватные ключи выводятся без шифрования. Документация помечает опцию как deprecated с OpenSSL 3.0 и предписывает использовать -noenc.⚠️ Опция ещё присутствует в SYNOPSIS и продолжает работать, но объявлена устаревшей с OpenSSL 3.0 — статус зафиксирован дважды: и в описании самой опции, и в разделе HISTORY. В новых скриптах пишите -noenc; старые команды с -nodes пока не ломаются, однако рассчитывать на их бессрочную поддержку не стоит. |
| -noenc | выключено (ключ шифруется) | Вообще не шифрует приватные ключи при выводе.⚠️ Современное имя опции вместо устаревшего -nodes. Ключ ложится в PEM открытым текстом — это то, что нужно nginx/haproxy и автозапуску, но файл обязан иметь ограниченные права. Отменяет действие -aes*/-des3/-camellia* и т. п. |
| -noiter | выключено (счётчики равны 2048) | Устанавливает счётчик итераций шифрования равным 1. По умолчанию оба счётчика — шифрования и MAC — равны 2048; этими опциями их можно сделать равными 1.⚠️ Практически сводит на нет защиту пароля от перебора: одна итерация KDF означает, что словарная атака идёт на полной скорости. Оправдано только для совместимости с реализациями, не понимающими итерации, и никогда — для боевых ключей. |
| -nokeys | выключено (ключи выводятся) | Приватные ключи не выводятся.⚠️ Штатный способ вытащить из PKCS#12 только цепочку сертификатов без секретов. При экспорте с -jdktrust включается автоматически: Java keystore не принимает PKCS#12 одновременно с доверенными сертификатами и парами ключей. |
| -nomac | выключено (MAC добавляется) | Не добавляет MAC целостности. Полезно с FIPS-провайдером, поскольку MAC в PKCS#12 требует PKCS12KDF, который не является одобренным FIPS-алгоритмом и не может поддерживаться FIPS-провайдером.⚠️ Файл теряет защиту целостности, и многие импортёры отказываются принимать PKCS#12 без MAC. Прежде чем отключать MAC ради FIPS, рассмотрите -pbmac1_pbkdf2: он решает ту же задачу (PBKDF2 вместо PKCS12KDF), сохраняя проверку целостности. Читать такой файл придётся с -nomacver. |
| -nomaciter | выключено (счётчики равны 2048) | Устанавливает счётчик итераций MAC равным 1.⚠️ Ослабляет именно MAC, оставляя шифрование содержимого с нормальным счётчиком. Раньше требовалось для очень старого ПО (в частности, старых версий MSIE), сегодня это чистое понижение стойкости. |
| -nomacver | выключено (MAC проверяется) | Отключает проверку MAC целостности.⚠️ Позволяет разобрать файл с повреждённым или неподдерживаемым MAC (например, созданный с -nomac или с недоступным алгоритмом), но при этом вы теряете единственную проверку целостности контейнера. Пароль для расшифровки содержимого всё равно потребуется. |
| -noout | выключено | Подавляет вывод всех учётных данных, так что вход только проверяется.⚠️ Удобная проверка «файл цел и пароль верен»: вывода нет, но MAC всё равно проверяется, пока не задан -nomacver. Хорошо сочетается с -info для диагностики без утечки ключа в лог. |
| -out filename | standard output | При разборе — файл, куда пишутся сертификаты и приватные ключи в формате PEM; при -export — файл, куда пишется PKCS#12.⚠️ Умолчание stdout опасно в обе стороны: при разборе в терминал попадает приватный ключ, при -export — бинарный PKCS#12, который испортит вывод консоли и не сохранится корректно при копировании. |
| -passcerts argзнач.: pass:, env:, file:, fd:, stdin (см. openssl-passphrase-options(1)) | — | Источник пароля для входных сертификатов, например для -certfile и -untrusted.⚠️ Отдельный от -passin источник: если вспомогательные файлы защищены другим паролем, без -passcerts команда зависнет на интерактивном запросе или упадёт. |
| -passin argзнач.: pass:, env:, file:, fd:, stdin (см. openssl-passphrase-options(1)) | — | Источник пароля для входных данных, а также для шифрования выводимых приватных ключей. Формат arg описан в openssl-passphrase-options(1).⚠️ Один и тот же arg отвечает и за чтение входного PKCS#12, и за шифрование ключа, который команда пишет в PEM. Если нужен незашифрованный PEM-ключ, добавляйте -noenc, иначе ключ будет перешифрован тем же паролем. Несовместима с -twopass при импорте из PKCS#12. |
| -passout argзнач.: pass:, env:, file:, fd:, stdin (см. openssl-passphrase-options(1)) | — | Источник пароля для выходных файлов.⚠️ При -export задаёт пароль создаваемого контейнера PKCS#12. Форма pass:secret видна в списке процессов — в автоматизации используйте file: или env:. Несовместима с -twopass при экспорте. |
| -password argзнач.: pass:, env:, file:, fd:, stdin (см. openssl-passphrase-options(1)) | — | С опцией -export эквивалентна -passout, в остальных случаях эквивалентна -passin.⚠️ Смысл опции молча меняется от наличия -export: одна и та же строка команды при добавлении -export начинает задавать пароль вывода, а не входа. Несовместима с -twopass. |
| -pbmac1_pbkdf2 | выключено (классическая MAC-схема PKCS#12 на базе PKCS12KDF) | Использует PBMAC1 с PBKDF2 для защиты PKCS#12-файла кодом аутентификации сообщения.⚠️ Современная схема (RFC 9579), которая позволяет обойтись без не одобренного FIPS алгоритма PKCS12KDF, но её понимает далеко не всё ПО — старые импортёры такой контейнер отвергнут. Без этой опции параметр -pbmac1_pbkdf2_md игнорируется. |
| -pbmac1_pbkdf2_md digestзнач.: имя дайджеста | SHA256 | Задаёт алгоритм хеширования для KDF PBKDF2. Если не указан, используется SHA256. Пока не задана опция -pbmac1_pbkdf2, этот параметр игнорируется.⚠️ Игнорируется молча: без -pbmac1_pbkdf2 команда не ругнётся, а просто создаст контейнер с обычным MAC — легко получить не тот файл, который планировался. Это дайджест именно KDF, не путать с -macalg. |
| -propquery propq | — | Задаёт property query для выбора реализаций алгоритмов.⚠️ Тонкий инструмент выбора реализации (например, ограничение FIPS-реализациями). Ошибочный запрос приводит не к предупреждению, а к тому, что алгоритм просто не находится. |
| -provider nameзнач.: default, legacy, fips и др. | — | Загружает указанный провайдер. Подробности — в разделе «Provider Options» в openssl(1), provider(7), property(7).⚠️ Провайдеры — это замена удалённому механизму -engine. Для legacy-алгоритмов обычно достаточно опции -legacy, которая грузит legacy-провайдер сама; явный -provider нужен, когда нужен ещё и default-провайдер рядом. |
| -provider-path path | — | Задаёт каталог, в котором ищутся провайдеры.⚠️ Необходим при несистемной установке OpenSSL: документация -legacy прямо указывает использовать, например, -provider-path ./providers либо переменную OPENSSL_MODULES, иначе legacy-провайдер не найдётся. |
| -provparam [name:]key=value | — | Передаёт параметр провайдеру. Подробности — в разделе «Provider Options» в openssl(1).⚠️ Синтаксис с необязательным префиксом name: адресует параметр конкретному провайдеру; без префикса параметр относится ко всем загруженным. |
| -rand files | — | Источники энтропии для генератора случайных чисел. См. «Random State Options» в openssl(1).⚠️ На современных системах ядро даёт достаточную энтропию, и опция нужна редко; на встраиваемых платформах без /dev/urandom без неё соль и IV могут оказаться предсказуемыми. |
| -twopass | выключено (integrity- и encryption-пароль считаются одинаковыми) | Запрашивает раздельные пароли для целостности (MAC) и для шифрования содержимого.⚠️ Документация прямо предупреждает: большинство ПО всегда считает эти пароли одинаковыми, поэтому такой PKCS#12 окажется нечитаемым. Нельзя сочетать с -password, с -passin при импорте и с -passout при экспорте — пароли вводятся только интерактивно, что ломает скрипты. |
| -untrusted filename | — | Файл с недоверенными сертификатами, которые могут использоваться при построении цепочки. Актуален только при создании PKCS#12 с опцией -export и при заданной опции -chain. Сертификаты, реально вошедшие в цепочку, добавляются в вывод.⚠️ Без -chain опция молча ничего не делает. В контейнер попадут только те сертификаты из файла, что оказались частью цепочки, — «лишние» промежуточные не добавятся, поэтому файл можно смело подавать целым бандлом. |
| -writerand file | — | Сохраняет состояние генератора случайных чисел в файл. См. «Random State Options» в openssl(1).⚠️ Файл состояния — чувствительные данные: его чтение посторонним снижает непредсказуемость последующих операций, поэтому права доступа должны быть ограничены. |
Версии и подводные камни:
OpenSSL 3 по умолчанию шифрует контейнер современными алгоритмами; старые Windows и Java такой файл не открывают. Совместимость возвращают ключи
-legacy,-certpbe,-keypbeи-macalg.Пароль контейнера передавайте отдельно от самого файла: ключ и пароль в одном письме — это ключ без пароля.
Сверено с: https://docs.openssl.org/master/man1/openssl-pkcs12/
Шаг 3. nginx: включаем mTLS
Минимальный рабочий конфиг:
server { listen 443 ssl; http2 on; server_name certservice.info; ssl_certificate /etc/nginx/tls/server.cert.pem; ssl_certificate_key /etc/nginx/tls/server.key.pem; ssl_protocols TLSv1.2 TLSv1.3; # --- собственно mTLS --- ssl_client_certificate /etc/nginx/tls/person-ca-chain.cert.pem; ssl_verify_client optional; ssl_verify_depth 2; location / { proxy_pass http://pkidesk-portal:8080; } }
Три строки, и каждая заслуживает пояснения.
ssl_client_certificate задаёт файл доверенных CA — и одновременно тот самый список имён, который сервер отправляет браузеру. Здесь лежит цепочка только клиентского центра: промежуточный Person CA плюс корень. Положите сюда все три центра — и браузер предложит пользователю на выбор серверный сертификат.
ssl_verify_client optional — а не on, и это осознанный выбор. Проще всего показать разницу замерами на живом стенде:
Режим | Без сертификата | С действующим сертификатом |
|---|---|---|
|
|
|
|
|
|
|
|
|
При on человек без сертификата упирается в страницу nginx:
<head><title>400 No required SSL certificate was sent</title></head> <center><h1>400 Bad Request</h1></center> <center>No required SSL certificate was sent</center>
Формально честно, практически бесполезно: пользователь не понимает, что ему делать, а приложение об этой попытке даже не узнало и ничего не записало в журнал. При optional запрос доходит до приложения с $ssl_client_verify = NONE, и решение принимает оно — показывает страницу входа, объясняет, на каком шаге всё оборвалось, и предлагает выпустить сертификат.
Важное уточнение, которое стоит запомнить: optional мягок только к отсутствию сертификата. Если сертификат предъявлен, но проверку не прошёл — просрочен, отозван, выпущен чужим CA — nginx отвечает 400 The SSL certificate error, и до приложения запрос снова не доходит. Мягкий режим для этого случая — optional_no_ca: сертификат запрашивается, но подпись доверенным CA не требуется, а вся проверка перекладывается на приложение. Это уместно, когда проверять цепочку должен не веб-сервер, а прикладная логика; для нашей задачи — нет, пусть отозванные отсекаются как можно раньше.
ssl_verify_depth 2 — глубина цепочки, то есть сколько промежуточных центров допускается между клиентским сертификатом и доверенным корнем.
Здесь я собирался написать привычное «умолчания 1 для двухуровневой инфраструктуры не хватает» — и перед публикацией замерил на стенде. Оказалось, не так. Для нашей цепочки клиент → Person CA → Root CA:
ssl_verify_depth 0 -> HTTP 400 (The SSL certificate error) ssl_verify_depth 1 -> HTTP 200 ssl_verify_depth 2 -> HTTP 200 ssl_verify_depth 3 -> HTTP 200
Счёт идёт по промежуточным центрам, а не по звеньям цепочки целиком: корень как якорь доверия в лимит не входит. Один промежуточный центр — значит умолчания 1 достаточно, и в типовой двухуровневой схеме эта директива вообще не нужна. Значение 0 означает «принимаю только сертификаты, подписанные корнем напрямую».
Я всё же ставлю 2 — как запас на случай, когда между Person CA и корнем появится ещё один уровень. Но если у вас «не пускает», причина почти наверняка не здесь: смотрите $ssl_client_verify и состав ssl_client_certificate.
Отдельно: nginx различает причины отказа, а показывает одну
Наружу и в том, и в другом случае уходит 400, но внутри nginx отказы пронумерованы по-разному:
Код | Что произошло |
|---|---|
| сертификат предъявлен, но не прошёл проверку |
| сертификат не предъявлен, хотя требуется |
| обычный HTTP-запрос пришёл на HTTPS-порт |
Эти коды не отдаются клиенту — они существуют ровно для того, чтобы их перехватить:
error_page 495 496 = @nocert; location @nocert { return 302 /login?reason=cert; }
Разница между «стандартной страницей nginx» и «страницей с объяснением, где взять сертификат» — примерно вся разница между работающей системой и системой, в которую невозможно войти. Если вы всё-таки выбрали ssl_verify_client on, перехват 495 и 496 обязателен.
И ещё одно ограничение, о которое спотыкаются при попытке «сделать mTLS только на /admin»: ssl_verify_client недоступна в контексте location — только http и server. Прикрыть сертификатом один URL этой директивой нельзя. Типовой обход — optional на уровне сервера плюс проверка переменной там, где нужно:
location /admin/ { if ($ssl_client_verify != SUCCESS) { return 403; } proxy_pass http://pkidesk-portal:8080; }
Это ещё не итоговый конфиг. Он работает, но заставляет браузер спрашивать сертификат у каждого, кто просто открыл сайт, — и запоминать отказ. В Шаге 8 мы разделим сайт и вход на два виртуальных хоста, и модалка перестанет появляться там, где её не ждут.
Расширенный справочник: nginx — директивы mTLS
Показать таблицу — 37 директив (nginx)
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| error_page code … [=[response]] uri;знач.: коды ответа и целевой uri | — | Задаёт страницу, которая показывается при указанном коде ответа. Для mTLS полезна тем, что nginx использует три нестандартных кода, различающих причину отказа.⚠️ NGINX, контекст: http, server, location, if in location. Директива из ngx_http_core_module. Коды, специфичные для TLS: 495 — ошибка проверки клиентского сертификата, 496 — клиент не предъявил требуемый сертификат, 497 — обычный HTTP-запрос пришёл на HTTPS-порт. Без error_page человек видит стандартную страницу nginx с текстом 400 Bad Request и не понимает, что делать; перехватив 495 и 496, можно отдать осмысленную страницу или перенаправить на инструкцию по установке сертификата. |
| proxy_pass_request_headers on | off;знач.: on | off | proxy_pass_request_headers on; | Определяет, передавать ли проксируемому серверу поля заголовка исходного запроса.⚠️ NGINX, контекст: http, server, location. Выключение off отбрасывает заголовки ИСХОДНОГО запроса, но не отменяет proxy_set_header — заголовки, добавленные явно (в том числе поля клиентского сертификата), продолжают передаваться. Это удобный способ отдать бэкенду только доверенные, сформированные nginx заголовки mTLS и гарантированно отрезать клиентские подделки. Включено по умолчанию, поэтому по умолчанию клиент МОЖЕТ прислать свой X-Client-DN — перекрывайте такие заголовки явно. |
| proxy_set_header field value;знач.: имя заголовка и значение (текст, переменные и их комбинации) | proxy_set_header Host $proxy_host;proxy_set_header Connection close; | Позволяет переопределять или добавлять поля заголовка запроса, передаваемые проксируемому серверу. Значение может содержать текст, переменные и их комбинации. Именно этой директивой поля клиентского сертификата попадают в приложение, например proxy_set_header X-Client-DN $ssl_client_s_dn;.⚠️ NGINX, контекст: http, server, location. Наследование: директивы наследуются с предыдущего уровня конфигурации ТОЛЬКО если на текущем уровне нет ни одной proxy_set_header — добавив одну строку в location, вы теряете весь набор из server, включая проброс полей сертификата. Это причина номер один «пропавших» заголовков mTLS. Обязательно перезаписывайте заголовки, которые может прислать клиент (иначе он подделает X-Client-DN сам), и не забывайте, что при пустом значении заголовок не передаётся вовсе. $ssl_client_cert многострочный — его передают только в urlencoded-виде через $ssl_client_escaped_cert. Для HTTP/2 по умолчанию отправляется псевдозаголовок «:authority» со значением $proxy_host, если он не заменён явным «Host». |
| resolver address … [valid=time] [ipv4=on|off] [ipv6=on|off] [status_zone=zone];знач.: адреса DNS-серверов; valid=time; ipv4=on|off; ipv6=on|off; status_zone=zone | — | Задаёт серверы имён, используемые для преобразования имён upstream-серверов в адреса. Адрес может быть указан доменным именем или IP-адресом, с необязательным портом (1.3.1, 1.2.2); если порт не указан, используется порт 53. Серверы имён опрашиваются циклически. По умолчанию nginx кэширует ответы, используя значение TTL ответа; необязательный параметр valid позволяет это переопределить.⚠️ NGINX, контекст: http, server, location. Для mTLS директива не косметическая: без неё не работает ssl_ocsp (разрешение имени OCSP-responder’а) и ssl_stapling, причём отказ тихий — только запись в error_log. Умолчания нет, задавать обязательно. По умолчанию nginx ищет и IPv4, и IPv6 — в среде без IPv6 разумно ipv6=off (разрешение в IPv6 поддерживается с 1.5.8, ipv4=off — с 1.23.1). Документация прямо предупреждает про DNS-спуфинг: указывайте DNS-серверы в защищённой доверенной локальной сети. Параметр status_zone (1.17.1) доступен только в коммерческой подписке. |
| resolver_timeout time;знач.: время | resolver_timeout 30s; | Задаёт таймаут для разрешения имён.⚠️ NGINX, контекст: http, server, location. Умолчание 30s опасно велико для ssl_ocsp: при недоступном DNS каждое рукопожатие с клиентским сертификатом может ждать до 30 секунд, что превращает проблему с DNS в отказ в обслуживании. Для mTLS-контуров типично снижают до 2–5s. |
| ssl_buffer_size size;знач.: размер | ssl_buffer_size 16k; | Задаёт размер буфера, используемого при отправке данных. По умолчанию размер буфера равен 16k, что соответствует минимальным накладным расходам при отправке больших ответов. Для минимизации Time To First Byte может быть полезно использовать меньшие значения, например 4k.⚠️ NGINX, контекст: http, server. Появилась в 1.5.9. Классический компромисс: 16k хорош для больших ответов, но задерживает первый байт; 4k улучшает TTFB ценой большего числа TLS-записей. Не влияет на handshake и на mTLS напрямую — крутить стоит только когда вы реально меряете TTFB. |
| ssl_certificate file;знач.: путь к файлу | data:$variable | — | Задаёт файл с сертификатом в формате PEM для данного виртуального сервера. Если вместе с основным сертификатом нужно указать промежуточные, они должны находиться в том же файле в следующем порядке: сначала основной сертификат, затем промежуточные. Секретный ключ в формате PEM может быть размещён в том же файле.⚠️ NGINX, контекст: http, server. Порядок в файле критичен: сначала leaf, потом промежуточные — обратный порядок ломает построение цепочки у клиентов. С 1.11.0 директиву можно указывать несколько раз для сертификатов разных типов (RSA и ECDSA); раздельные цепочки для разных сертификатов поддерживает только OpenSSL 1.0.2+. С 1.15.9 в имени файла допустимы переменные (OpenSSL 1.0.2+), но тогда сертификат загружается на КАЖДОЕ рукопожатие — без ssl_certificate_cache это заметный удар по производительности. Значение data:$variable (1.15.10) грузит сертификат из переменной; неаккуратное применение может привести к записи секретного ключа в error_log. |
| ssl_certificate_cache off;ssl_certificate_cache max=N [inactive=time] [valid=time];знач.: off | max=N [inactive=time] [valid=time] | ssl_certificate_cache off; | Определяет кэш, хранящий SSL-сертификаты и секретные ключи, заданные с помощью переменных. Параметр max задаёт максимальное число элементов в кэше (при переполнении вытесняются наименее востребованные, LRU); inactive — время, после которого неиспользуемый элемент удаляется (по умолчанию 10 секунд); valid — время, в течение которого элемент считается корректным и может быть использован повторно (по умолчанию 60 секунд); off отключает кэш.⚠️ NGINX, контекст: http, server. Появилась в 1.27.4. Кэш имеет смысл только тогда, когда ssl_certificate / ssl_certificate_key заданы через переменные — для статических путей он бесполезен. По умолчанию выключен, поэтому динамическая выдача сертификатов без него грузит файлы на каждое рукопожатие. Умолчания inactive=10s и valid=60s заданы внутри директивы и не отображаются в строке Default — при ротации сертификатов помните про 60-секундное окно устаревания. Директива новая (1.27.4), на LTS-сборках её может не быть. |
| ssl_certificate_compression on | off;знач.: on | off | ssl_certificate_compression off; | Включает сжатие серверных сертификатов по TLS 1.3 (RFC 8879). Директива поддерживается при использовании OpenSSL 3.2 или выше; список поддерживаемых алгоритмов сжатия предоставляется библиотекой. Также поддерживается при использовании BoringSSL, где список включает zlib (1.29.3).⚠️ NGINX, контекст: http, server. Появилась в 1.29.1. Относится к сертификату СЕРВЕРА, не клиента. Требует свежих OpenSSL 3.2+ или BoringSSL — на типовых дистрибутивных сборках просто не заработает. Появилась в 1.29.1, на стабильных ветках отсутствует. |
| ssl_certificate_key file;знач.: путь к файлу | engine:name:id | store:scheme:id | data:$variable | — | Задаёт файл с секретным ключом в формате PEM для данного виртуального сервера. Вместо файла можно указать engine:name:id (1.7.9) — загрузка ключа с заданным id из OpenSSL-движка name; store:scheme:id (1.29.0) — загрузка ключа по URI-схеме, зарегистрированной OpenSSL-провайдером, например pkcs11; data: |
| ssl_ciphers ciphers;знач.: строка в формате OpenSSL | ssl_ciphers HIGH:!aNULL:!MD5; | Описывает разрешённые шифры. Шифры задаются в формате, поддерживаемом библиотекой OpenSSL. Полный список можно посмотреть с помощью команды «openssl ciphers».⚠️ NGINX, контекст: http, server. Директива не управляет наборами TLS 1.3 — там шифронаборы задаются отдельно, через ssl_conf_command Ciphersuites. Строка передаётся в OpenSSL как есть, синтаксическая ошибка обнаружится только при старте. Более старые версии nginx использовали другие шифры по умолчанию, поэтому «унаследованный» конфиг может вести себя иначе, чем ожидается. |
| ssl_client_certificate file;знач.: путь к файлу | — | Задаёт файл с доверенными сертификатами CA в формате PEM, используемыми для проверки клиентских сертификатов и OCSP-ответов, если включён ssl_stapling. Список этих сертификатов будет отправлен клиентам.⚠️ NGINX, контекст: http, server. Ключевое отличие от ssl_trusted_certificate: список DN этих CA уходит клиенту в сообщении CertificateRequest. Это удобно (браузер сам подсказывает нужный сертификат), но раскрывает структуру вашей PKI и раздувает handshake — при большом CA-бандле рукопожатие может упереться в лимиты клиента. Если раскрытие нежелательно, используйте ssl_trusted_certificate. Все промежуточные CA должны лежать в этом же файле. |
| ssl_conf_command name value;знач.: пара name value из SSL_CONF_cmd | — | Задаёт произвольные конфигурационные команды OpenSSL. Директива поддерживается при использовании OpenSSL 1.0.2 или выше. Несколько директив ssl_conf_command могут быть указаны на одном уровне.⚠️ NGINX, контекст: http, server. Появилась в 1.19.4. Наследование нетипичное: директивы наследуются с предыдущего уровня конфигурации ТОЛЬКО если на текущем уровне нет ни одной ssl_conf_command — одна директива на уровне server стирает весь набор из http. Документация прямо предупреждает: «Note that configuring OpenSSL directly might result in unexpected behavior». Именно через неё задаются TLS 1.3-шифронаборы (Ciphersuites) и включается защита от replay при ssl_early_data (Options AntiReplay). |
| ssl_crl file;знач.: путь к файлу | — | Задаёт файл с отозванными сертификатами (CRL) в формате PEM, используемый для проверки клиентских сертификатов. При использовании промежуточных сертификатов их CRL должны быть указаны в том же файле.⚠️ NGINX, контекст: http, server. Появилась в 0.8.7. CRL — статический файл: nginx читает его при загрузке конфигурации, автоматического обновления нет, после подкладывания нового CRL нужен reload. Просроченный CRL (nextUpdate в прошлом) OpenSSL считает невалидным, и проверка всех клиентских сертификатов начинает падать целиком — классический способ уронить прод в выходные. Если у промежуточного CA есть свой CRL, он обязан быть в том же файле, иначе отзыв на его уровне не проверится. |
| ssl_dhparam file;знач.: путь к файлу | — | Задаёт файл с параметрами DH для шифров с DHE. По умолчанию параметры не заданы, и поэтому шифры с DHE использоваться не будут.⚠️ NGINX, контекст: http, server. Появилась в 0.7.2. Обратная совместимость: до версии 1.11.0 по умолчанию использовались встроенные параметры, а начиная с 1.11.0 — никакие, то есть DHE-шифры молча отключаются. Если вы не обязаны поддерживать древних клиентов, файл не нужен вовсе: ECDHE предпочтительнее и быстрее. Генерация больших DH-параметров занимает минуты и не должна делаться на этапе деплоя. |
| ssl_early_data on | off;знач.: on | off | ssl_early_data off; | Разрешает или запрещает TLS 1.3 early data. Директива поддерживается при использовании OpenSSL 1.1.1 или выше (1.15.4) и BoringSSL. Если директива указана на уровне server, может использоваться значение из сервера по умолчанию.⚠️ NGINX, контекст: http, server. Появилась в 1.15.3. Запросы, отправленные в early data, подвержены replay-атакам — документация требует защищаться на уровне приложения, передавая proxy_set_header Early-Data $ssl_early_data;. Встроенная защита OpenSSL от повторов ОТКЛЮЧЕНА, так как мешает возобновлению сессий; при необходимости включается через ssl_conf_command Options AntiReplay;. Для mTLS это особенно неприятно: реплей запроса, аутентифицированного клиентским сертификатом, выглядит легитимным. |
| ssl_ecdh_curve curve;знач.: auto | имя кривой | список через двоеточие | ssl_ecdh_curve auto; | Задаёт кривую для шифров с ECDHE. При использовании OpenSSL 1.0.2 и выше можно указать несколько кривых через двоеточие (1.11.0). Специальное значение auto (1.11.0) указывает nginx использовать список, встроенный в библиотеку OpenSSL при использовании OpenSSL 1.0.2 и выше, либо prime256v1 для более старых версий.⚠️ NGINX, контекст: http, server. Появилась в 1.1.0 и 1.0.6. Прямое отношение к mTLS с ECDSA: при OpenSSL 1.0.2+ директива задаёт список кривых, поддерживаемых сервером, поэтому для работы ECDSA-сертификатов важно включить в него кривые, используемые в сертификатах. Ограничив список одной кривой, легко сломать проверку клиентских ECDSA-сертификатов на другой кривой. До 1.11.0 по умолчанию использовалась prime256v1. |
| ssl_ech_file file;знач.: путь к файлу в формате PEM (ECHConfig) | — | Задаёт файл с конфигурацией зашифрованного ClientHello (ECHConfig) в формате PEM, используемый для включения TLS 1.3 ECH в разделяемом (shared) режиме. Директива поддерживается при использовании OpenSSL 4.0 или выше.⚠️ NGINX, контекст: http, server. Появилась в 1.29.4. Требует OpenSSL 4.0+ — на подавляющем большинстве сборок недоступна. К проверке клиентских сертификатов отношения не имеет, но влияет на выбор виртуального сервера (SNI шифруется), а значит и на то, какой блок server с ssl_verify_client отработает. Результат обработки виден в $ssl_ech_status. |
| ssl_key_log path;знач.: путь к файлу | — | Включает журналирование SSL-ключей клиентских соединений и задаёт путь к файлу журнала ключей. Ключи записываются в формате SSLKEYLOGFILE, совместимом с Wireshark.⚠️ NGINX, контекст: http, server. Появилась в 1.27.2. Директива доступна только в составе коммерческой подписки (NGINX Plus) — в open source сборке её нет, конфиг с ней не запустится. Файл журнала позволяет расшифровать весь трафик в Wireshark: в проде это фактически утечка, включать только точечно и на время отладки mTLS. |
| ssl_ocsp on | off | leaf;знач.: on | off | leaf | ssl_ocsp off; | Включает OCSP-проверку цепочки клиентских сертификатов. Параметр leaf включает проверку только самого клиентского сертификата. Чтобы OCSP-проверка работала, директива ssl_verify_client должна быть установлена в on или optional. Для разрешения имени OCSP-responder’а должна быть указана директива resolver.⚠️ NGINX, контекст: http, server. Появилась в 1.19.0. Это проверка КЛИЕНТСКИХ сертификатов и её не надо путать с ssl_stapling — та про сертификат сервера. Три обязательных условия вместе: ssl_verify_client on|optional, ssl_ocsp on и работающий resolver; при отсутствии resolver проверка молча не заработает. Синхронный поход к чужому OCSP-серверу вставляется в handshake: недоступность responder’а превращается в отказ в обслуживании, поэтому в закрытых контурах чаще используют ssl_crl. Режим leaf сокращает число запросов, проверяя только конечный сертификат. |
| ssl_ocsp_cache off | [shared:name:size];знач.: off | shared:name:size | ssl_ocsp_cache off; | Задаёт имя и размер кэша, хранящего статус клиентских сертификатов для OCSP-проверки. Кэш разделяется между всеми рабочими процессами. Кэш с одним и тем же именем может использоваться в нескольких виртуальных серверах. Параметр off запрещает использование кэша.⚠️ NGINX, контекст: http, server. Появилась в 1.19.0. По умолчанию кэш ВЫКЛЮЧЕН, то есть каждое новое рукопожатие с клиентским сертификатом порождает поход к OCSP-responder’у. Под нагрузкой это и просаживает latency, и может привести к rate-limit со стороны CA. Практически всегда при ssl_ocsp on нужно явно задать shared-кэш (например ssl_ocsp_cache shared:OCSP:10m;). Учтите обратную сторону: закэшированный статус означает, что отзыв сертификата вступит в силу не мгновенно. |
| ssl_ocsp_responder url;знач.: URL, только http:// | — | Переопределяет URL OCSP-responder’а, указанный в расширении сертификата «Authority Information Access», для проверки клиентских сертификатов. Поддерживаются только responder’ы «http://».⚠️ NGINX, контекст: http, server. Появилась в 1.19.0. HTTPS-responder не поддерживается — в документации прямо сказано «Only “http://” OCSP responders are supported». Директива переопределяет AIA сразу для ВСЕЙ цепочки, поэтому в PKI с разными responder’ами у корневого и промежуточного CA переопределение может сломать проверку. Даже с явным URL всё равно нужен resolver, если в URL указано имя, а не IP. |
| ssl_password_file file;знач.: путь к файлу или именованному каналу | — | Задаёт файл с парольными фразами для секретных ключей, где каждая фраза указана на отдельной строке. Фразы перебираются по очереди при загрузке ключа.⚠️ NGINX, контекст: http, server. Появилась в 1.7.3. Без этой директивы зашифрованный ключ приведёт к интерактивному запросу пароля — systemd-юнит просто повиснет на старте или reload. Файл с паролями лежит на диске рядом с ключом, что во многом обесценивает шифрование ключа; документация показывает, что вместо файла можно использовать именованный канал (FIFO), чтобы пароль не хранился на диске. Перебор фраз «по очереди» означает, что при большом списке загрузка ключей замедляется. |
| ssl_prefer_server_ciphers on | off;знач.: on | off | ssl_prefer_server_ciphers off; | Указывает, что серверные шифры имеют приоритет над клиентскими при использовании протоколов SSLv3 и TLS.⚠️ NGINX, контекст: http, server. На TLS 1.3 не влияет — там приоритет сервера задаётся иначе (ssl_conf_command Options PrioritizeChaCha и т. п.). Включение фиксирует порядок из ssl_ciphers, что может ухудшить производительность на клиентах без AES-NI, если ChaCha20 окажется ниже по списку. По умолчанию выключено — многие «best practice»-конфиги из интернета включают это без надобности. |
| ssl_protocols [SSLv2] [SSLv3] [TLSv1] [TLSv1.1] [TLSv1.2] [TLSv1.3];знач.: SSLv2 | SSLv3 | TLSv1 | TLSv1.1 | TLSv1.2 | TLSv1.3 | ssl_protocols TLSv1.2 TLSv1.3; | Включает указанные протоколы. Если директива указана на уровне server, может использоваться значение из сервера по умолчанию — подробности в разделе «Выбор виртуального сервера».⚠️ NGINX, контекст: http, server. Внимание к умолчанию: TLSv1.3 входит в умолчание только начиная с 1.23.4, на более старых сборках умолчание было иным — не полагайтесь на память, сверяйтесь с версией. TLSv1.1/TLSv1.2 (1.1.13, 1.0.12) требуют OpenSSL 1.0.1+, TLSv1.3 (1.13.0) — OpenSSL 1.1.1+. Отдельно про mTLS: в TLS 1.3 запрос клиентского сертификата происходит уже после ServerHello, из-за чего диагностика отличий от TLS 1.2 и поведение ошибок 495/496 меняется. Поскольку значение может браться из сервера по умолчанию, задавать ssl_protocols per-server при общем listen-сокете небезопасно. |
| ssl_reject_handshake on | off;знач.: on | off | ssl_reject_handshake off; | Если включено, SSL-рукопожатия в блоке server будут отклонены. Позволяет, например, отклонять рукопожатия для всех имён серверов, кроме явно заданных, разместив директиву в default_server.⚠️ NGINX, контекст: http, server. Появилась в 1.19.4. Штатный способ закрыть default_server, не выписывая для него сертификат-заглушку. В сочетании с mTLS полезно, чтобы клиенты, пришедшие без SNI или с чужим именем, не попадали в серверный блок с ssl_verify_client off. Клиент получает ошибку рукопожатия, а не HTTP-ответ, — по логам приложения такие попытки не видны, смотрите error_log. |
| ssl_session_cache off | none | [builtin[:size]] [shared:name:size];знач.: off | none | builtin[:size] | shared:name:size | ssl_session_cache none; | Задаёт типы и размеры кэшей для хранения параметров сессий. off — использование кэша полностью запрещено, nginx явно сообщает клиенту, что сессии не могут переиспользоваться. none — использование кэша мягко запрещено: nginx сообщает клиенту, что сессии могут переиспользоваться, но реально их не хранит. builtin — кэш, встроенный в OpenSSL, используется только одним рабочим процессом, размер задаётся в сессиях (по умолчанию 20480). shared — кэш, разделяемый между всеми рабочими процессами, размер задаётся в байтах, один мегабайт вмещает около 4000 сессий.⚠️ NGINX, контекст: http, server. Разница между off и none тонкая, но реальная: off сообщает клиенту о запрете, none — вводит его в заблуждение. Умолчание none означает, что возобновления сессий фактически нет и каждое соединение делает полное рукопожатие — для mTLS это каждый раз повторная валидация клиентского сертификата (и повторный OCSP-запрос). С 1.23.2 shared-кэш дополнительно используется для автоматической генерации, хранения и ротации ключей TLS session ticket, если они не заданы явно через ssl_session_ticket_key. Значение может браться из сервера по умолчанию, если директива указана на уровне server. Использование builtin может приводить к фрагментации памяти. |
| ssl_session_ticket_key file;знач.: путь к файлу с 80 или 48 байтами случайных данных | — | Задаёт файл с секретным ключом, применяемым для шифрования и расшифровки TLS session tickets. Директива необходима, если один и тот же ключ нужно использовать на нескольких серверах. По умолчанию используется случайно сгенерированный ключ. Если задано несколько ключей, для шифрования используется только первый — это позволяет настроить ротацию ключей.⚠️ NGINX, контекст: http, server. Появилась в 1.5.7. Размер файла определяет алгоритм: 80 байт — AES256 (с 1.11.8), 48 байт — AES128; файл создаётся командой openssl rand 80 > ticket.key. Статический ключ, который никогда не ротируется, уничтожает forward secrecy для тикетов — ротацию делают, добавляя новый ключ первым и оставляя предыдущий вторым. С 1.23.2, если директива не задана, shared-кэш сессий сам генерирует и ротирует ключи, поэтому на одиночном узле директива обычно не нужна. |
| ssl_session_tickets on | off;знач.: on | off | ssl_session_tickets on; | Разрешает или запрещает возобновление сессий при помощи TLS session tickets. Если директива указана на уровне server, может использоваться значение из сервера по умолчанию.⚠️ NGINX, контекст: http, server. Появилась в 1.5.9. Включено по умолчанию — и это единственный механизм возобновления, который работает «из коробки», потому что ssl_session_cache по умолчанию none. При нескольких серверах без общего ssl_session_ticket_key каждый узел шифрует тикеты своим ключом, и возобновление за балансировщиком работает вполсилы. С точки зрения безопасности mTLS тикет — это долгоживущий носитель аутентифицированного состояния; если требуется forward secrecy, тикеты выключают. |
| ssl_session_timeout time;знач.: время | ssl_session_timeout 5m; | Задаёт время, в течение которого клиент может повторно использовать параметры сессии. Если директива указана на уровне server, может использоваться значение из сервера по умолчанию.⚠️ NGINX, контекст: http, server. Прямо влияет на «время жизни» mTLS-аутентификации: пока сессия возобновляется, клиентский сертификат заново не проверяется, поэтому отзыв сертификата вступит в силу не раньше истечения этого таймаута. Слишком большое значение — окно для использования отозванного сертификата; слишком маленькое — рост числа полных рукопожатий и OCSP-запросов. |
| ssl_stapling on | off;знач.: on | off | ssl_stapling off; | Разрешает или запрещает прикрепление OCSP-ответов сервером (OCSP stapling). Для работы OCSP stapling должен быть известен сертификат издателя сертификата сервера: если файл ssl_certificate не содержит промежуточных сертификатов, сертификат издателя должен быть указан в файле ssl_trusted_certificate. Для разрешения имени OCSP-responder’а должна быть указана директива resolver.⚠️ NGINX, контекст: http, server. Появилась в 1.3.7. Это про СЕРВЕРНЫЙ сертификат, а не про клиентский — не путайте с ssl_ocsp. Частая ошибка в mTLS-конфигах: включают ssl_stapling, думая, что настроили проверку отзыва клиентских сертификатов. Обратите внимание, что ssl_client_certificate и ssl_trusted_certificate при включённом ssl_stapling используются и для проверки OCSP-ответов, то есть эти файлы обслуживают сразу два разных механизма. |
| ssl_stapling_file file;знач.: путь к файлу в формате DER | — | Если задано, прикрепляемый OCSP-ответ будет взят из указанного файла вместо запроса к OCSP-responder’у, указанному в сертификате сервера. Файл должен быть в формате DER, как его выдаёт команда «openssl ocsp».⚠️ NGINX, контекст: http, server. Появилась в 1.3.7. Формат DER, а не PEM — легко ошибиться. Файл не обновляется автоматически: как и CRL, он протухает, и после истечения срока действия staple перестаёт помогать клиентам, а некоторые из них начнут ругаться. Нужен внешний cron/таймер, обновляющий файл и делающий reload. |
| ssl_stapling_responder url;знач.: URL, только http:// | — | Переопределяет URL OCSP-responder’а, указанный в расширении сертификата «Authority Information Access». Поддерживаются только responder’ы «http://».⚠️ NGINX, контекст: http, server. Появилась в 1.3.7. Не путайте с ssl_ocsp_responder: эта директива про сертификат сервера, та — про клиентские сертификаты. HTTPS-responder не поддерживается. |
| ssl_stapling_verify on | off;знач.: on | off | ssl_stapling_verify off; | Разрешает или запрещает проверку сервером OCSP-ответов. Для работы проверки сертификат издателя сертификата сервера, корневой сертификат и все промежуточные сертификаты должны быть настроены как доверенные при помощи директивы ssl_trusted_certificate.⚠️ NGINX, контекст: http, server. Появилась в 1.3.7. По умолчанию nginx НЕ проверяет OCSP-ответы, которые прикрепляет. Включение без полного набора доверенных сертификатов в ssl_trusted_certificate приведёт к тому, что stapling молча перестанет работать — ошибка видна только в error_log. |
| ssl_trusted_certificate file;знач.: путь к файлу | — | Задаёт файл с доверенными сертификатами CA в формате PEM, используемыми для проверки клиентских сертификатов и OCSP-ответов, если включён ssl_stapling. В отличие от сертификатов, заданных ssl_client_certificate, список этих сертификатов клиентам не отправляется.⚠️ NGINX, контекст: http, server. Появилась в 1.3.7. Не отправляя список CA клиенту, вы лишаете браузер подсказки — пользователь должен сам выбрать правильный сертификат из хранилища, а некоторые клиенты в такой ситуации вообще не предъявят сертификат. Для машинного mTLS (сервис-сервис) это идеальный вариант, для браузерного — часто нет. Также именно этой директивой задаются доверенные сертификаты для ssl_stapling_verify. |
| ssl_verify_client on | off | optional | optional_no_ca;знач.: on | off | optional | optional_no_ca | ssl_verify_client off; | Включает проверку клиентских сертификатов (mTLS). Результат проверки кладётся в переменную $ssl_client_verify. Параметр optional запрашивает клиентский сертификат и проверяет его, только если сертификат предъявлен. Параметр optional_no_ca запрашивает сертификат, но не требует, чтобы он был подписан доверенным CA — расчёт на то, что реальную проверку выполнит внешний по отношению к nginx сервис.⚠️ NGINX, контекст: http, server. Директива недоступна на уровне location: контекст только http и server, поэтому «mTLS на один URL» через неё не сделать — типовой обход это ssl_verify_client optional на уровне server плюс проверка $ssl_client_verify внутри нужного location. Параметр optional доступен с 0.8.7, optional_no_ca — с 1.3.8 и 1.2.5. Внутри nginx отказы различаются кодами 495 (сертификат предъявлен, но не прошёл проверку) и 496 (сертификат не предъявлен), которые можно перехватить через error_page; клиенту по умолчанию уходит 400 с текстами The SSL certificate error и No required SSL certificate was sent соответственно. Режим optional_no_ca цепочку НЕ валидирует: приложение обязано проверить сертификат само, иначе аутентификация фиктивна. Без ssl_client_certificate или ssl_trusted_certificate проверка не заработает. |
| ssl_verify_depth number;знач.: число | ssl_verify_depth 1; | Задаёт глубину проверки в цепочке клиентских сертификатов.⚠️ NGINX, контекст: http, server. Счёт идёт по промежуточным центрам между листом и якорем доверия; корень в лимит не входит. Замер на стенде этой статьи (цепочка клиент → Person Intermediate CA → Root CA): при 0 сервер отвечает 400, при 1, 2 и 3 — пускает. То есть для типовой двухуровневой инфраструктуры умолчания 1 достаточно, и распространённый совет «поставьте 2, иначе не заработает» к ней не относится. Значение 0 означает «принимаю только сертификаты, подписанные корнем напрямую». Проверяйте на своей цепочке: документация не расшифровывает, что именно считается глубиной. |
Версии и подводные камни:
Директивы
ssl_ocsp*появились в 1.19.0,ssl_crl— в 0.8.7,ssl_trusted_certificate— в 1.3.7. Параметрoptionalуssl_verify_clientдоступен с 0.8.7,optional_no_ca— с 1.3.8 и 1.2.5.
ssl_client_certificateиssl_trusted_certificateразличаются не доверием, а видимостью: имена CA из первого рассылаются клиентам вCertificateRequest— браузер по ним подсказывает нужный сертификат, — а из второго не рассылаются. Для браузерного mTLS нужен первый, для машинного часто достаточно второго.Для
ssl_ocspиssl_staplingобязательна директиваresolverизngx_http_core_module: без неё имя респондера не разрешается и проверка молча не работает.Переменные
$ssl_*вынесены в отдельную таблицу ниже, чтобы не смешивать директивы с тем, что они дают приложению.Проверки клиентских сертификатов (
ssl_ocsp,ssl_crl) и stapling серверного (ssl_stapling*) — разные механизмы; путать их легко, потому что имена похожи.Сверено с: https://nginx.org/en/docs/http/ngx_http_ssl_module.html, https://nginx.org/ru/docs/http/ngx_http_ssl_module.html, https://nginx.org/en/docs/http/ngx_http_core_module.html, https://nginx.org/en/docs/http/ngx_http_proxy_module.html
Расширенный справочник: переменные nginx $ssl_*
Всё, что приложение узнает о клиенте, оно узнает из этих переменных.
Показать таблицу — 28 переменных (переменные nginx)
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| $ssl_alpn_protocolзнач.: имя протокола ALPN | пустая строка | — | Возвращает протокол, выбранный при помощи ALPN во время SSL handshake. Если ALPN не использовался или согласование не состоялось — пустая строка.⚠️ NGINX. Появилась в 1.21.4. Пустая строка здесь не признак ошибки: клиент мог просто не прислать ALPN-расширение. Не стройте на этой переменной жёсткую логику отказа — используйте её для диагностики и разведения HTTP/1.1 и h2 в логах. |
| $ssl_cipherзнач.: имя шифра (например, ECDHE-RSA-AES128-GCM-SHA256) | — | Возвращает название используемого шифра для установленного SSL-соединения. Это фактически согласованный шифронабор текущего соединения.⚠️ NGINX. Не путайте с $ssl_ciphers: $ssl_cipher — один выбранный шифр, $ssl_ciphers — весь список, предложенный клиентом. Документация не указывает версию появления, значит переменная присутствует издавна; но для TLS 1.3 имена шифронаборов (TLS_AES_128_GCM_SHA256) отличаются по форме от TLS 1.2, и регулярки, написанные под старый формат, молча перестают срабатывать. |
| $ssl_ciphersзнач.: список через двоеточие; неизвестные — в шестнадцатеричном виде | — | Возвращает список шифров, поддерживаемых клиентом. Известные шифры указаны по имени, неизвестные — в шестнадцатеричном виде, например AES128-SHA:AES256-SHA:0x00ff.⚠️ NGINX. Появилась в 1.11.7. Документация прямо оговаривает: переменная полностью поддерживается только с OpenSSL 1.0.2 и выше; на более старых версиях она доступна только для новых сессий и может содержать только известные шифры. То есть при переиспользовании сессии значение может оказаться пустым — не годится как стабильный признак для авторизации, только для фингерпринтинга и статистики. |
| $ssl_client_certзнач.: PEM; каждая строка, кроме первой, с префиксным символом табуляции | — | Возвращает клиентский сертификат в формате PEM для установленного SSL-соединения, перед каждой строкой которого, кроме первой, вставляется символ табуляции. Предназначалась для использования в директиве proxy_set_header.⚠️ NGINX. Документация nginx прямо помечает переменную как устаревшую: «The variable is deprecated, the $ssl_client_escaped_cert variable should be used instead.» Табуляция в начале строк — это obs-fold из старого HTTP/1.1; такие заголовки RFC 7230 объявил устаревшими, HTTP/2 их вообще не имеет, а многие reverse-proxy и WAF на пути их нормализуют или режут. Не используйте в новых конфигах, замените на $ssl_client_escaped_cert. |
| $ssl_client_escaped_certзнач.: PEM, закодированный urlencode | — | Возвращает клиентский сертификат в формате PEM, закодированный в urlencode, для установленного SSL-соединения. Единственный вариант передачи сертификата, безопасный для HTTP-заголовка.⚠️ NGINX. Появилась в 1.13.5. Ключевое различие трёх переменных с сертификатом. $ssl_client_raw_cert — сырой многострочный PEM с реальными переводами строк: в заголовок его вставлять нельзя, HTTP-заголовок не может содержать CR/LF, попытка приведёт либо к обрезке значения, либо к инъекции заголовков. $ssl_client_cert — тот же PEM, но перед каждой строкой, кроме первой, вставлен символ табуляции: это старый приём obs-fold (свёрнутые многострочные заголовки), который документация nginx помечает как устаревший и который современные HTTP/1.1-парсеры и HTTP/2 отвергают или схлопывают. $ssl_client_escaped_cert — тот же PEM, но целиком urlencoded в одну строку без спецсимволов, поэтому годится для proxy_set_header без оговорок. На бэкенде значение нужно сначала urldecode, и только потом парсить как PEM. В боевом конфиге для передачи сертификата апстриму используйте только escaped-вариант. |
| $ssl_client_fingerprintзнач.: SHA1-отпечаток в шестнадцатеричном виде | — | Возвращает SHA1-отпечаток клиентского сертификата для установленного SSL-соединения. Компактный идентификатор конкретного сертификата, удобный для сопоставления с белым списком.⚠️ NGINX. Появилась в 1.7.1. Документация фиксирует именно SHA1 — не SHA-256; параметра для смены алгоритма нет. Для пиннинга это приемлемо (коллизия SHA1 требует контроля над обоими документами и всё равно упирается в проверку цепочки), но если у вас регламент запрещает SHA1, отпечаток придётся считать на бэкенде из $ssl_client_escaped_cert. Отпечаток меняется при каждом перевыпуске сертификата, поэтому белый список по fingerprint требует процедуры ротации, в отличие от списка по $ssl_client_s_dn. |
| $ssl_client_i_dnзнач.: строка DN согласно RFC 2253 | — | Возвращает строку «issuer DN» клиентского сертификата для установленного SSL-соединения согласно RFC 2253. Это DN издателя, то есть выпустившего сертификат УЦ.⚠️ NGINX. Появилась в 1.11.6. С версии 1.11.6 формат — RFC 2253: компоненты идут в порядке от младшего к старшему, разделитель — запятая, спецсимволы экранируются обратным слэшем (например CN=Test CA,O=Acme, Inc.,C=RU). До 1.11.6 под этим же именем отдавался старый формат, который теперь живёт в $ssl_client_i_dn_legacy. При обновлении nginx через границу 1.11.6 любое сравнение issuer DN со строковой константой ломается молча — сравнение просто перестаёт совпадать, и запрос уходит на бэкенд как «чужой». Проверяйте цепочку через ssl_client_certificate/ssl_trusted_certificate и $ssl_client_verify, а issuer DN используйте как дополнительный фильтр, а не как единственный критерий доверия. |
| $ssl_client_i_dn_legacyзнач.: строка DN в старом (до-RFC 2253) формате nginx | — | Возвращает строку «issuer DN» клиентского сертификата для установленного SSL-соединения в старом формате. Существует для совместимости с конфигами, написанными до смены формата.⚠️ NGINX. Появилась в 1.11.6. Само имя появилось в 1.11.6: английская документация говорит «Prior to version 1.11.6, the variable name was $ssl_client_i_dn», то есть старое поведение $ssl_client_i_dn переехало сюда, а под прежним именем начал отдаваться RFC 2253. Внимание: русская страница документации в этом абзаце содержит опечатку — под $ssl_client_i_dn_legacy там указано «переменная называлась $ssl_client_s_dn», хотя корректно $ssl_client_i_dn; ориентируйтесь на английскую версию. Старый формат перечисляет компоненты в обратном порядке и разделяет их символом /, поэтому напрямую сравнивать legacy- и не-legacy-значения нельзя. Для новых конфигов берите $ssl_client_i_dn. |
| $ssl_client_raw_certзнач.: PEM «как есть», многострочный | — | Возвращает клиентский сертификат в формате PEM для установленного SSL-соединения. Значение отдаётся без какой-либо дополнительной обработки строк.⚠️ NGINX. Это сырой многострочный PEM с настоящими переводами строк — в отличие от $ssl_client_cert (табуляция в начале строк) и $ssl_client_escaped_cert (urlencode). В proxy_set_header он непригоден: заголовок с CR/LF либо обрежется, либо создаст риск инъекции заголовков. Осмысленные применения — запись в файл через лог-формат, передача в auth_request-подсистему или скриптовые модули, но не HTTP-заголовок. Отдельно отметьте: документация НЕ помечает эту переменную как устаревшую (в отличие от $ssl_client_cert), но для передачи наверх всё равно нужен escaped-вариант. |
| $ssl_client_s_dnзнач.: строка DN согласно RFC 2253 | — | Возвращает строку «subject DN» клиентского сертификата для установленного SSL-соединения согласно RFC 2253. Основной источник идентичности клиента при mTLS-авторизации.⚠️ NGINX. Появилась в 1.11.6. Главная рабочая лошадка mTLS-авторизации и главный источник ошибок. Формат RFC 2253 действует с 1.11.6: порядок RDN обратный по сравнению со старым выводом, разделитель — запятая, экранирование обратным слэшем. Никогда не сравнивайте DN подстрокой через регулярку вида ~ “CN=(.+)” — CN может содержать экранированную запятую или знак равенства, и наивная регулярка позволит подделать identity через специально сформированный CN. Сравнивайте DN целиком со строковой константой (map с точным ключом) и только после того, как $ssl_client_verify равно SUCCESS. Помните также, что DN уникален лишь в пределах одного издателя: разные УЦ могут выпустить сертификаты с одинаковым subject DN, поэтому связку «доверенный набор CA + точный DN» нужно проверять вместе. |
| $ssl_client_s_dn_legacyзнач.: строка DN в старом (до-RFC 2253) формате nginx | — | Возвращает строку «subject DN» клиентского сертификата для установленного SSL-соединения в старом формате. Оставлена для совместимости со старыми конфигурациями.⚠️ NGINX. Появилась в 1.11.6. Документация: «Prior to version 1.11.6, the variable name was $ssl_client_s_dn» — то есть в 1.11.6 старое поведение переименовали в *_legacy, а имя $ssl_client_s_dn переключили на RFC 2253. Практическое следствие: конфиг, переехавший с nginx 1.10 на 1.12+ без правок, продолжает работать синтаксически, но проверка DN перестаёт совпадать — и легитимные клиенты получают отказ (или, при неаккуратно написанном default в map, наоборот проходят). При миграции либо переведите константы в RFC 2253, либо временно переключитесь на *_legacy, зафиксировав это как технический долг. |
| $ssl_client_serialзнач.: серийный номер в шестнадцатеричном виде | — | Возвращает серийный номер клиентского сертификата для установленного SSL-соединения. Вместе с issuer DN образует каноническую пару, однозначно идентифицирующую сертификат.⚠️ NGINX. Серийный номер уникален только в пределах одного УЦ, поэтому в белых списках и в журналах отзыва его нужно хранить в паре с $ssl_client_i_dn — сам по себе он не идентифицирует сертификат. Значение отдаётся в шестнадцатеричном виде и без ведущих нулей выравнивания, а регистр и длина зависят от УЦ: при сверке со списком отозванных сертификатов приводите обе стороны к одному регистру, иначе сравнение молча не сработает. Документация версию появления не указывает. |
| $ssl_client_sigalgзнач.: имя алгоритма подписи | пустая строка | — | Возвращает алгоритм подписи клиентского сертификата для установленного SSL-соединения. Значения соответствуют реестру TLS SignatureScheme IANA.⚠️ NGINX. Появилась в 1.29.3. Документация даёт два жёстких ограничения: переменная поддерживается только с OpenSSL 3.5 и выше (на более старых версиях значение — пустая строка) и доступна только для новых сессий. То есть на переиспользованной сессии ($ssl_session_reused = r) вы получите пусто даже на свежем OpenSSL. Использовать как критерий отказа («запретить клиентов с RSA-SHA1») нельзя без риска блокировать легитимные соединения — пустое значение неотличимо от «не поддерживается». |
| $ssl_client_v_endзнач.: дата окончания срока действия | — | Возвращает дату окончания срока действия клиентского сертификата. Парная к $ssl_client_v_start.⚠️ NGINX. Появилась в 1.11.7. Документация не описывает формат строки — не пишите под него регулярку по памяти, снимите фактическое значение на стенде и зафиксируйте в тестах. Для проверки «истёк или нет» практичнее $ssl_client_v_remain: он уже число. И главное — nginx проверяет срок действия сам в рамках ssl_verify_client, так что просроченный сертификат даст $ssl_client_verify со значением FAILED:certificate has expired; эти переменные нужны для предупреждений «истекает через N дней», а не для собственно контроля доступа. |
| $ssl_client_v_remainзнач.: число дней | — | Возвращает число дней, оставшихся до истечения срока действия клиентского сертификата. Удобно для проактивных предупреждений о скорой ротации.⚠️ NGINX. Появилась в 1.11.7. Самое полезное применение — не блокировка, а раннее оповещение: отдавать бэкенду заголовок с остатком дней и показывать баннер «сертификат истекает» либо собирать метрику из access_log. Для контроля доступа переменная избыточна: истёкший сертификат уже отсекается проверкой цепочки. Учтите, что значение — целое число дней; на границе суток и при расхождении часов между nginx и клиентом оно может «прыгать» на единицу, поэтому пороги вида < 1 ненадёжны, берите запас в несколько дней. |
| $ssl_client_v_startзнач.: дата начала срока действия | — | Возвращает дату начала срока действия клиентского сертификата. Соответствует полю notBefore сертификата.⚠️ NGINX. Появилась в 1.11.7. Формат строки документация не фиксирует — не полагайтесь на предполагаемый вид, проверьте эмпирически. Сертификат, у которого notBefore в будущем (типично при расхождении часов между УЦ и сервером), отвергается на этапе проверки цепочки и даёт FAILED:certificate is not yet valid — если такое встречается регулярно, лечится NTP на стороне УЦ, а не правкой конфига nginx. |
| $ssl_client_verifyзнач.: SUCCESS | FAILED:reason | NONE | — | Возвращает результат проверки клиентского сертификата. Ровно три формы значения: «SUCCESS», «FAILED:reason» и «NONE», если сертификат не был предоставлен.⚠️ NGINX. Центральная переменная всей mTLS-авторизации, и именно на ней чаще всего ошибаются. Разбор значений: SUCCESS — сертификат предъявлен и цепочка успешно проверена; NONE — сертификат вообще не был предоставлен (штатная ситуация при ssl_verify_client optional); FAILED:reason — сертификат предъявлен, но проверка не прошла, а reason — текст причины от OpenSSL (certificate has expired, unable to get local issuer certificate, self signed certificate и т. д.). Отсюда два практических правила. Первое: проверять надо строго на равенство SUCCESS, а не на «не FAILED» — условие вида if ($ssl_client_verify != FAILED) пропускает NONE, то есть анонимного клиента без сертификата. Второе: при ssl_verify_client optional и особенно optional_no_ca соединение устанавливается даже при неуспешной проверке, и весь контроль доступа целиком ложится на явную проверку этой переменной в конфиге — забыли проверить, и mTLS выключен де-факто при включённом де-юре. Отдельно про формат: до версии 1.11.7 результат FAILED не содержал строку reason, поэтому конфиги, сравнивающие значение с константой «FAILED», после обновления перестают совпадать — сравнивайте по префиксу или, лучше, только с SUCCESS. |
| $ssl_curveзнач.: имя кривой | шестнадцатеричный код | пустая строка | — | Возвращает согласованную кривую, использованную для обмена ключами во время SSL handshake. Известные кривые указаны по имени, неизвестные — в шестнадцатеричном виде, например prime256v1.⚠️ NGINX. Появилась в 1.21.5. Документация оговаривает: переменная поддерживается только с OpenSSL 3.0 и выше, на более старых версиях значением будет пустая строка. Не путайте с $ssl_curves — это одна согласованная кривая, а не список предложенных клиентом. Для контроля политики криптостойкости пустое значение неотличимо от «кривая не согласована», поэтому опирайтесь на ssl_ecdh_curve/ssl_conf_command, а переменную используйте для наблюдаемости. |
| $ssl_curvesзнач.: список через двоеточие; неизвестные — в шестнадцатеричном виде | — | Возвращает список кривых, поддерживаемых клиентом. Известные кривые указаны по имени, неизвестные — в шестнадцатеричном виде, например 0x001d:prime256v1:secp521r1:secp384r1.⚠️ NGINX. Появилась в 1.11.7. Два ограничения из документации сразу: поддерживается с OpenSSL 1.0.2 и выше (иначе пустая строка) и доступна только для новых сессий. Второе особенно коварно при включённом кэше сессий — на переиспользованных соединениях значение пустое, поэтому связка с $ssl_session_reused обязательна, если вы строите на этом TLS-фингерпринт. Как критерий авторизации непригодна. |
| $ssl_early_dataзнач.: 1 | пустая строка | — | Возвращает «1», если используется TLS 1.3 early data и операция handshake не завершена, иначе пустую строку. Признак того, что запрос приехал в 0-RTT.⚠️ NGINX. Появилась в 1.15.3. Это переменная безопасности, а не диагностики. Запросы в early data подвержены replay-атакам: злоумышленник может переслать перехваченный 0-RTT-запрос повторно. Штатный приём из документации nginx — передавать заголовок Early-Data наверх (proxy_set_header Early-Data $ssl_early_data;), чтобы бэкенд по RFC 8470 мог ответить 425 Too Early на небезопасные методы. Никогда не выполняйте изменяющие состояние операции по запросу, у которого эта переменная равна 1. Кроме того, в момент early data handshake ещё не завершён — часть остальных $ssl_*-переменных на этой стадии может быть не заполнена. |
| $ssl_ech_outer_server_nameзнач.: публичное имя сервера | пустая строка | — | Возвращает публичное имя сервера, запрошенное через SNI, если TLS 1.3 ECH был принят, иначе пустую строку. Это «внешнее» имя, видимое наблюдателю на канале, в отличие от настоящего имени из $ssl_server_name.⚠️ NGINX. Появилась в 1.29.4. Переменная новая (1.29.4) и осмысленна только при настроенном ssl_ech_file. Различайте два имени: $ssl_server_name — это внутреннее, реальное запрошенное имя, а $ssl_ech_outer_server_name — публичная обёртка. Если ваша логика маршрутизации или логирования исторически опиралась на «то имя, что видно снаружи», после включения ECH она смотрит уже не туда. Пустая строка означает, что ECH не был принят. |
| $ssl_ech_statusзнач.: FAILED | BACKEND | GREASE | SUCCESS | NOT_TRIED | — | Возвращает результат обработки TLS 1.3 ECH: «FAILED», «BACKEND», «GREASE», «SUCCESS» или «NOT_TRIED». Позволяет понять, что именно произошло с Encrypted Client Hello на данном соединении.⚠️ NGINX. Появилась в 1.29.4. Документация прямо предупреждает: переменная поддерживается только с OpenSSL 4.0 и выше, на более старых версиях значением будет пустая строка. Значит пустое значение — не шестой статус, а «сборка не умеет ECH», и его надо обрабатывать отдельно от NOT_TRIED. GREASE означает, что клиент прислал фиктивное ECH-расширение для проверки промежуточных узлов, а не настоящую попытку — считать это ошибкой не следует. |
| $ssl_protocolзнач.: TLSv1 | TLSv1.1 | TLSv1.2 | TLSv1.3 и т. п. | — | Возвращает протокол установленного SSL-соединения. Показывает фактически согласованную версию TLS.⚠️ NGINX. Версию появления документация не указывает — переменная есть давно. Типичная ошибка: пытаться запрещать старые версии сравнением этой переменной в блоке server и отдавать 403. К моменту, когда переменная доступна, handshake на слабом протоколе уже состоялся; правильное место запрета — директива ssl_protocols, которая просто не даст согласовать нежелательную версию. Переменную используйте в логах и для передачи наверх заголовком. |
| $ssl_server_nameзнач.: имя сервера из SNI | пустая строка | — | Возвращает имя сервера, запрошенное через SNI. Именно по нему nginx выбирает виртуальный сервер до чтения HTTP-заголовка Host.⚠️ NGINX. Появилась в 1.7.0. Не путайте с $host и $http_host: SNI приезжает на этапе TLS-handshake, а Host — уже в HTTP-запросе, и совпадать они не обязаны. Расхождение между ними — классический признак domain fronting, поэтому при жёстких требованиях безопасности эти два значения имеет смысл сверять. Пустая строка возможна: старые клиенты и обращения по IP-адресу SNI не присылают. |
| $ssl_session_idзнач.: идентификатор сессии в шестнадцатеричном виде | — | Возвращает идентификатор сессии установленного SSL-соединения. Используется для корреляции нескольких соединений одного клиента.⚠️ NGINX. Полезен для сшивки логов, но не является идентификатором пользователя и не должен участвовать в авторизации: идентификатор виден на канале и не аутентифицирует владельца. При TLS 1.3 и при использовании session tickets вместо session ID значение может быть пустым или нестабильным между соединениями, поэтому строить на нём привязку сессии к бэкенду ненадёжно. |
| $ssl_session_reusedзнач.: r | . | — | Возвращает «r», если SSL-сессия была использована повторно, иначе «.». Показывает, был ли полный handshake или возобновление.⚠️ NGINX. Появилась в 1.5.11. Важнейшая вспомогательная переменная для отладки всех «пустых» значений: документация помечает $ssl_ciphers, $ssl_curves, $ssl_sigalgs, $ssl_sigalg и $ssl_client_sigalg как доступные только для новых сессий. Если такая переменная неожиданно пуста — первым делом посмотрите, не равна ли эта переменная «r». Обратите внимание на нетипичные значения: не «1»/«0» и не «yes»/«no», а буква r и точка; сравнение с чем-то другим просто никогда не сработает. |
| $ssl_sigalgзнач.: имя алгоритма подписи | пустая строка | — | Возвращает алгоритм подписи сертификата сервера для установленного SSL-соединения. Значения берутся из реестра TLS SignatureScheme IANA.⚠️ NGINX. Появилась в 1.29.3. Речь о сертификате СЕРВЕРА — не перепутайте с $ssl_client_sigalg, имена отличаются лишь префиксом client_. Ограничения те же, что и у клиентского аналога: нужен OpenSSL 3.5 и выше (иначе пустая строка) и переменная доступна только для новых сессий, то есть на возобновлённых соединениях будет пусто. |
| $ssl_sigalgsзнач.: список через двоеточие; неизвестные — в шестнадцатеричном виде | — | Возвращает список алгоритмов подписи, поддерживаемых клиентом. Известные значения указаны по имени, неизвестные — в шестнадцатеричном виде, например 0xfe00:rsa_pkcs1_sha256:rsa_pss_rsae_sha256:ecdsa_secp256r1_sha256.⚠️ NGINX. Появилась в 1.31.2. Самая свежая переменная в списке (1.31.2) — прежде чем закладывать её в конфиг, проверьте версию nginx, иначе получите пустую строку без всякого предупреждения (nginx не считает обращение к неизвестной $ssl_*-переменной ошибкой конфигурации только если модуль её объявляет; на старых сборках имя просто не существует и конфиг может не пройти проверку). Документация даёт трёхступенчатую деградацию: полностью поддерживается с OpenSSL 4.0 и выше; для OpenSSL 1.0.2+ значения всегда показываются в шестнадцатеричном виде; на более старых версиях — пустая строка. Плюс доступна только для новых сессий. Итого: пригодна для TLS-фингерпринтинга, но не для решений о доступе. |
Версии и подводные камни:
В HTTP-заголовок годится только
$ssl_client_escaped_cert: значение заголовка не может содержать переводов строки, а PEM без них не бывает.
$ssl_client_verifyпринимает три формы:SUCCESS,NONEиFAILED:<причина>— последнюю удобно логировать целиком.Переменные
*_legacyсохраняют старый формат отличительного имени; основные с версии 1.11.6 отдают DN по RFC 2253.Сверено с: https://nginx.org/ru/docs/http/ngx_http_ssl_module.html, https://nginx.org/en/docs/http/ngx_http_ssl_module.html
Шаг 4. Проброс полей в приложение — и почему заголовкам нельзя верить
Приложение по ту сторону прокси не участвует в TLS-рукопожатии. Всё, что оно знает о клиенте, приходит заголовками:
location / { proxy_pass http://pkidesk-portal:8080; proxy_set_header X-SSL-Client-Verify $ssl_client_verify; proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn; proxy_set_header X-SSL-Client-I-DN $ssl_client_i_dn; proxy_set_header X-SSL-Client-Serial $ssl_client_serial; proxy_set_header X-SSL-Client-V-End $ssl_client_v_end; proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; }
Обратите внимание на последнюю строку. Нужен именно $ssl_client_escaped_cert — PEM в процентной кодировке. Причина проста: в значении HTTP-заголовка не может быть переводов строки, а PEM без них не бывает. Устаревшая переменная $ssl_client_cert подставляет сертификат с продолжениями строк и ломает разбор у всех, кто это принимает.
Теперь главное.
proxy_set_header задаёт заголовок, а не дополняет: то, что клиент прислал под тем же именем, затирается. Это и есть защита от подделки — но работает она ровно до тех пор, пока до приложения нельзя достучаться в обход nginx. Если приложение слушает на публичном интерфейсе, любой желающий пришлёт:
X-SSL-Client-Verify: SUCCESS X-SSL-Client-S-DN: /O=CertService/CN=Администратор
и станет администратором. Никакой TLS тут уже не поможет — рукопожатие просто не состоится, а заголовки будут те, что нужно злоумышленнику.
Поэтому доверие к заголовкам должно быть подтверждено, и подтверждать его нужно двумя независимыми способами:
Сеть. Приложение слушает только во внутреннем сегменте, куда снаружи хода нет.
Общий секрет. Прокси проставляет ещё один заголовок, значение которого знают только он и приложение:
proxy_set_header X-Proxy-Auth "${PROXY_SECRET}";
Приложение сверяет и адрес источника, и секрет. Не совпало — заголовки клиентского сертификата игнорируются целиком, и движок работает так, будто mTLS нет вовсе. Такое поведение по умолчанию честнее, чем «доверяем, если похоже на внутреннюю сеть»: сеть меняют, забывают, переносят в облако, а секрет либо есть, либо нет.
В движке это выглядит так — вход по сертификату разрешён только когда прокси подтверждён:
trusted, why := s.proxyTrusted(r) if !trusted { // Заголовки X-SSL-Client-* не читаются вообще. // Пользователь увидит на странице входа причину: «запрос пришёл с 10.0.0.5, // это не доверенный прокси» — а не молчаливый отказ. }
Есть и обратная сторона, менее очевидная. proxy_set_header затирает заголовок только там, где он написан. Виртуальный хост, который клиентский сертификат не запрашивает, этих строк не содержит — и проксирует клиентские заголовки наверх как самые обычные. То есть появление второго хоста молча открывает дыру в первом. Разбор этого случая и лечение — в разделе 8.5.
И ещё одно, о чём забывают почти всегда. Аутентификация по сертификату не защищает от межсайтовой подделки запроса. Браузер подставляет клиентский сертификат в рукопожатие автоматически — ровно так же, как автоматически подставляет cookie. Форма на чужом сайте, отправляющая POST на ваш /certs/1A00.../revoke, сработает. CSRF-токен нужен точно так же, как при парольном входе.
Шаг 5. Если прокси нет: приложение проверяет сертификат само
Предыдущий шаг был про то, что заголовкам от прокси нельзя верить без доказательств. Отсюда напрашивается вопрос: а можно обойтись без них? Можно. TLS умеет терминировать само приложение, и тогда сертификат лежит не в заголовке, который кто-то мог подделать, а в r.TLS.PeerCertificates — там, куда снаружи не дотянуться. Общий секрет не нужен, проверка адреса прокси не нужна, и целый класс ошибок «доверился не тому источнику» исчезает вместе с источником.
Внутри периметра прокси часто и нет: два сервиса ходят друг к другу напрямую, без браузера и без человека, который прочитал бы страницу с ошибкой. Разбор дальше на Go — стандартной библиотеки хватает целиком, без единой зависимости; в других языках имена другие, устройство то же.
Соблазн выглядит так: выставить ClientAuth: tls.RequireAndVerifyClientCert, положить в ClientCAs файл цепочки — и считать задачу решённой. Рукопожатие пройдёт, curl --cert вернёт двести, тесты станут зелёными. Проблема в том, что внутрь при этом входит любой предъявитель любого сертификата вашего УЦ — включая тот, что вы отозвали позавчера, и сертификат бухгалтера, выписанный для входа в веб-интерфейс. Ошибки здесь молчаливые: код работает, просто проверяет не то.
Что библиотека делает за вас
Уровней проверки пять, и порядок у них неочевидный. Цепочка строится при ClientAuth >= VerifyClientCertIfGiven, а RequireAnyClientCert стоит в перечислении ниже. То есть режим с самым грозным именем сертификат требует, но не проверяет вовсе: подойдёт любая самоподписанная бумажка, PeerCertificates будет заполнен, VerifiedChains останется пустым. Авторизация, написанная по принципу «сертификат есть — значит свой», в этом режиме не значит ничего.
Второе, что стоит усвоить сразу: ClientCAs — это множество корней, а не «список знакомых УЦ». Промежуточные Go берёт исключительно из того, что прислал клиент. Положить в ClientCAs файл ca-chain.cert.pem по привычке от ssl_client_certificate — значит сделать промежуточный УЦ самостоятельным якорем доверия, и подпись корня под ним больше никто не проверит.
А ClientCAs, оставленный пустым, — это не ошибка конфигурации, а тихая дыра: пул остаётся nil, проверка уходит в системное хранилище, и внутрь пускают любой публично доверенный УЦ с назначением clientAuth. nginx в такой ситуации просто не стартует: ssl_verify_client on без ssl_client_certificate — ошибка конфигурации. Go молча работает.
Есть и приятная новость, вопреки распространённому мнению: список доверенных УЦ Go клиенту отправляет — в TLS 1.2 полем certificate_authorities, в TLS 1.3 одноимённым расширением. Проверено на проводе: openssl s_client показывает Acceptable client certificate CA names с именами из пула. Значит, диалог выбора сертификата в браузере фильтруется так же, как за nginx. Оборотная сторона: список берётся из того же ClientCAs, и развилки «доверять, но не объявлять» — аналога ssl_trusted_certificate — в Go нет. Одно поле делает обе работы сразу.
Чего она не делает никогда
Отзыв. Совсем. Над Certificate.Verify в исходнике так и написано: WARNING: this function doesn't do any revocation checking. Отозванный сертификат пройдёт проверку без единой ошибки. В Части 2 отзыв включался одной директивой ssl_crl; здесь это ваш код: скачать список по адресу из расширения crlDistributionPoints, проверить подпись издателя, убедиться, что список не просрочен, найти серийный номер — и решить, что делать, если издатель недоступен. RFC 5280 в разделе 6.3.3 называет непроверенный статус UNDETERMINED и что с ним делать, не указывает: это решение приложения. Оно должно быть осознанным, потому что fail-open превращает недоступность издателя в тихий пропуск кого угодно.
Имя. Go не сверяет ни CN, ни SAN клиента ни с чем — сверять не с чем, списка разрешённых у него нет.
И самое неожиданное: общий корень — не граница доверия. На стенде этой статьи человеку выпущен сертификат от PersonIntermediateCA, служебному API положено принимать только машинные от ServerIntermediateCA. Оба промежуточных УЦ подписаны одним корнем. Предъявляем человеческий сертификат служебному API — и Certificate.Verify пропускает его без возражений: цепочка честно ведёт к доверенному корню. Не пускает внутрь только явная проверка издателя:
ok := false for _, chain := range chains { if len(chain) >= 2 && chain[1].Equal(s.issuer) { ok = true break } }
Сравнение — побайтовое, Equal сверяет DER целиком. Сверять строковые представления отличительных имён нельзя, и это не теория: на этом же стенде openssl ca отказался отзывать сертификат с сообщением ERROR:name does not match, показав два внешне одинаковых имени. Разница была в одном байте — файл index.txt лежал с CRLF, и \r стал частью имени из базы.
Отказ, который некому показать
Здесь у варианта без прокси есть неустранимая слабость. При RequireAndVerifyClientCert отказ случается внутри рукопожатия: net/http вызывает HandshakeContext до чтения запроса, обработчик не запускается вовсе, и отдать человеку страницу физически некуда. Аналога error_page 495 496 нет и быть не может. Всё, что остаётся, — строка в Server.ErrorLog:
tls: http: TLS handshake error from 172.22.0.4:38626: tls: client didn't provide a certificate
Клиент видит только обрыв. Замеры на стенде: без сертификата curl выходит с кодом 55 и http_code=000, с отклонённым сертификатом — 56 и те же нули. Chromium схлопывает все сертификатные алерты в один ERR_BAD_SSL_CLIENT_AUTH_CERT, так что человек не узнает даже, отозван его сертификат или просрочен.
Лечится тем же приёмом, что и в nginx: спрашивать, но не требовать. RequestClientCert ближе к optional_no_ca, чем к optional: библиотека сертификат запросит и примет любой, цепочку строить не станет, а всю проверку сделает приложение — уже на HTTP-уровне, где есть кому ответить. Тот же самый сертификат, отвергнутый в первом режиме молча, во втором получает ответ:
{ "отказ": "неподходящее назначение ключа", "причина": "в сертификате (или у промежуточного УЦ) нет extendedKeyUsage = clientAuth" }
Кстати о назначении ключа. Go применяет extendedKeyUsage вложенно вниз по цепочке: промежуточный УЦ, объявивший только serverAuth, ломает mTLS именно в Go и больше нигде. Это и есть типовое «в nginx работало, а тут x509: certificate specifies an incompatible key usage». А если проверяете сертификат вручную, помните про умолчание: пустое VerifyOptions.KeyUsages означает не «любое назначение», а ExtKeyUsageServerAuth. То есть по умолчанию вы проверяете клиентский сертификат как серверный.
Возобновление сессии: где проверка перестаёт выполняться
Место, где легко получить ту же беду, что была у nginx в CVE-2025-23419, только своими руками.
VerifyPeerCertificate не вызывается на возобновлённых соединениях. Повесили туда проверку отзыва или политику по OU — и она молча перестаёт работать, как только заработали сессионные тикеты; потолок их жизни в Go — семь суток. Более того, в TLS 1.3 на возобновлённом соединении CertificateRequest не отправляется вовсе, а PeerCertificates восстанавливаются из тикета: это данные из тикета, а не доказательство владения ключом.
Отсюда два рабочих места для своей логики. VerifyConnection — вызывается всегда, включая возобновление. Либо проверка прямо в HTTP-обработчике, на каждый запрос: она этой ловушки лишена по построению, потому что выполняется не один раз за соединение.
Если по ту сторону человек
Всё вышесказанное работает и для браузера — с двумя поправками.
Первая: слушатель, который спрашивает сертификат, спрашивает его у каждого подключившегося. Диалог выбора выскочит у случайного посетителя раньше, чем он поймёт, куда попал. Значит, нужно отдельное имя для входа — ровно как в Шаге 8. Отдельного порта не хватит: Firefox кэширует решение по имени хоста без порта (и даже без отпечатка серверного сертификата), так что запомненный отказ переедет с порта на порт.
Вторая, приятная: второй слушатель в Go не нужен. GetConfigForClient вызывается один раз на соединение, сразу после разбора ClientHello, и в возвращённом конфиге ClientAuth и ClientCAs ещё работают:
base.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { c := base.Clone() if strings.EqualFold(chi.ServerName, cfg.loginHost) { c.ClientAuth = tls.RequestClientCert c.ClientCAs = s.roots } else { c.ClientAuth = tls.NoClientCert } return c, nil }
Проверено на стенде: на публичном имени секции Acceptable client certificate CA names в выводе s_client нет вовсе — CertificateRequest не отправляется, диалог не появляется. На имени входа список есть. Дальше сессию переносят на публичный хост тем же трёхшаговым обменом с одноразовым пропуском, что описан в Шаге 8: cookie между хостами не ходит, а пропуск без привязки к состоянию браузера — это login CSRF.
Одна мелочь, которая стоила отладки: приложение строит абсолютные адреса для перехода между двумя своими именами, поэтому порт снаружи и внутри контейнера должен совпадать. При 11443:8443 редирект уводит браузер на несуществующий адрес.
Так кто же должен проверять
Если перед приложением уже стоит nginx или Apache — пусть проверяют они. Они умеют отзыв одной директивой, различают причины отказа кодами 495/496 и дают показать человеку страницу.
Если прокси нет — проверка переезжает в код целиком. Вопрос доверия к заголовкам исчезает, но появляются пять чужих обязанностей: якорь, издатель, назначение ключа, отзыв и права. Ни одну из них библиотека за вас не закроет.
Рабочий пример обеих сторон — в репозитории стенда: docker/consumer и docker/svc-client для связки «сервис — сервис», docker/svc-web для входа человека. Все три службы — на стандартной библиотеке, без единой зависимости.
Расширенный справочник: mTLS в Go — библиотека и её границы
Показать таблицы — 21 строка (crypto/tls, crypto/x509)
Уровни tls.ClientAuthType
Значение | Спрашивает сертификат | Строит и проверяет цепочку |
| Ближайший аналог в nginx |
|---|---|---|---|---|
| нет | нет | пусто |
|
| да | нет | пусто |
|
| да, иначе обрыв рукопожатия | нет | пусто | прямого аналога нет |
| да | да, если предъявлен | заполнено при предъявлении | близко к |
| да, иначе обрыв | да | заполнено |
|
Цепочка строится при
ClientAuth >= VerifyClientCertIfGiven, поэтомуRequireAnyClientCert(2) — режим «сертификат обязателен, доверие не проверяется». Значения объявлены вcrypto/tls/common.go.
Что crypto/tls проверяет сам, а что остаётся приложению
Проверка | Делает библиотека | Комментарий |
|---|---|---|
Подпись каждого звена цепочки | да | при |
Срок действия | да | сравнивается с |
| да | применяется вложенно вниз по цепочке: промежуточный УЦ только с |
| да | нарушение даёт |
Ограничения имён ( | да | применяются автоматически, но сверяются только с полями SAN |
Нераспознанное критичное расширение | да |
|
Тот ли это издатель | нет | общий корень пропускает сертификаты всех ваших промежуточных УЦ; издателя прибивают вручную по |
Имя клиента ( | нет |
|
Отзыв по CRL | нет |
|
Отзыв по OCSP | нет | в стандартной библиотеке нет вовсе: |
Права предъявителя | нет | «сертификат подлинный» и «этому предъявителю сюда можно» — разные вопросы |
Отдельная ловушка ручной проверки: пустое
VerifyOptions.KeyUsagesозначаетExtKeyUsageServerAuth, а не «любое назначение». Проверяя клиентский сертификат с умолчанием, вы проверяете его как серверный — и если УЦ выпускает сертификаты сразу сserverAuthиclientAuth, проверка пройдёт, а смысла в ней не будет.
Куда вешать свою логику
Точка | Сигнатура | Когда вызывается | На возобновлённом соединении | Появилась |
|---|---|---|---|---|
|
| после штатной проверки цепочки | не вызывается | Go 1.8 |
|
| после | вызывается | Go 1.15 |
|
| один раз на соединение, сразу после | — | Go 1.8 |
|
| только если | — | Go 1.4 |
HTTP-обработчик, |
| на каждый запрос | работает всегда | — |
Практическое следствие: политика доступа, живущая в
VerifyPeerCertificate, обходится обычным возобновлением сессии. Потолок жизни тикета — семь суток.И ещё одно:
ListenAndServeTLSс непустыми именами файлов перезаписываетCertificatesв клоне конфига, из-за чего выбор сертификата по SNI ломается. С колбэками её зовут какListenAndServeTLS("", "").Сверено с: https://pkg.go.dev/crypto/tls, https://pkg.go.dev/crypto/x509, https://pkg.go.dev/net/http, исходники
go/src/crypto/tls/handshake_server.go,handshake_server_tls13.go,common.go,go/src/crypto/x509/verify.go.
Расширенный справочник: директива nginx → как это в Go
Показать таблицу — 16 соответствий (nginx → crypto/tls)
nginx / Apache | Как это в Go | Нюанс |
|---|---|---|
|
| без заполненного |
| точного аналога нет |
|
|
| единственный способ ответить человеку страницей |
|
| только корни; промежуточные Go берёт из того, что прислал клиент |
| аналога нет |
|
| настраиваемого аналога нет | глубину ограничивают |
|
| загрузка, проверка подписи и |
|
| вне стандартной библиотеки: для проекта без зависимостей это выбор в пользу CRL |
| аналога нет | кэш пишете сами; срок годности списка и периодичность его обновления — разные вещи |
| аналога нет | при |
|
| строки вида |
|
| структура |
|
| ничего кодировать не нужно — сертификат уже разобран |
|
| проверка |
| аналога нет |
|
|
| потолок жизни тикета — 7 суток, задаётся библиотекой |
Кэш выбора сертификата в браузере переносится на вариант без прокси один в один: ключ строится только из конечной точки TLS (Chromium — пара «хост:порт», Firefox — имя хоста без порта) и ничего не знает о том, кто терминирует TLS.
Сверено с: https://nginx.org/en/docs/http/ngx_http_ssl_module.html, https://httpd.apache.org/docs/2.4/mod/mod_ssl.html, https://pkg.go.dev/crypto/tls, https://www.rfc-editor.org/rfc/rfc5280 (§6.3.3), https://www.rfc-editor.org/rfc/rfc8446.
Шаг 6. Apache: то же самое другими словами
Директивы другие, модель та же.
<VirtualHost *:443> ServerName certservice.info SSLEngine on SSLCertificateFile /usr/local/apache2/tls/server.cert.pem SSLCertificateKeyFile /usr/local/apache2/tls/server.key.pem SSLCertificateChainFile /usr/local/apache2/tls/ca-chain.cert.pem # --- mTLS --- SSLCACertificateFile /usr/local/apache2/tls/person-ca-chain.cert.pem SSLVerifyClient optional SSLVerifyDepth 2 <Location "/"> SSLOptions +StdEnvVars +ExportCertData </Location> RequestHeader set X-SSL-Client-Verify "%{SSL_CLIENT_VERIFY}s" RequestHeader set X-SSL-Client-S-DN "%{SSL_CLIENT_S_DN}s" RequestHeader set X-SSL-Client-Cert "%{SSL_CLIENT_CERT}s" ProxyPass / http://pkidesk-portal:8080/ ProxyPassReverse / http://pkidesk-portal:8080/ </VirtualHost>
Соответствие с nginx:
nginx | Apache mod_ssl |
|---|---|
|
|
|
|
|
|
(нет прямого аналога) |
|
|
|
|
|
|
|
|
|
|
|
Две особенности Apache, которых нет у nginx.
SSLCADNRequestFile разделяет то, что nginx объединяет. У nginx ssl_client_certificate — одновременно список доверия и список имён для CertificateRequest. Apache позволяет задать их раздельно: доверять широкому набору CA, а браузеру предлагать узкий. Ровно та проблема «браузер показывает не тот сертификат», которую у nginx приходится решать составом файла.
Переменные окружения не появляются сами. Без SSLOptions +StdEnvVars переменные SSL_CLIENT_S_DN_* будут пустыми, а без +ExportCertData не будет SSL_CLIENT_CERT с самим PEM. Это самая частая причина «а почему приложение ничего не видит». Опции недёшевы — поэтому их включают точечно, в нужном <Location>, а не глобально.
Отдельно про SSLVerifyClient в контексте <Directory> или <Location>: чтобы запросить сертификат только для части сайта, серверу приходится пересогласовывать соединение. В TLS 1.3 пересогласования нет как механизма, поэтому такая конструкция там не работает. Практический вывод: включайте SSLVerifyClient на уровне виртуального хоста, а разграничение доступа стройте уже в приложении.
Расширенный справочник: Apache mod_ssl — клиентская аутентификация
Показать таблицу — 28 директив (Apache mod_ssl)
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| SSLCACertificateFile file-pathзнач.: путь к файлу | — | Единый файл (all-in-one) со склеенными PEM-сертификатами удостоверяющих центров, чьих клиентов вы обслуживаете (File of concatenated PEM-encoded CA Certificates for Client Auth). Именно эти CA формируют доверие при проверке клиентского сертификата.⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Строки Default в таблице документации нет — директива просто не задана по умолчанию. Файл — обычная конкатенация PEM-сертификатов «in order of preference»; может использоваться вместо и/или вместе с SSLCACertificatePath. Ключевая эксплуатационная деталь: «This file is read at server startup, while the server is still running as root (before privilege dropping), so it may be owned by and readable only by root. The file is not re-read during normal operation; a server restart is required for changes to take effect». Значит, файл можно закрыть правами 0400 root, но и добавление нового CA не подхватится на лету — нужен рестарт (не просто reload конфигурации по смыслу текста). Второе: по умолчанию имена именно этих CA уходят клиенту как список приемлемых издателей в CertificateRequest — если это нежелательно, отделяйте список имён через SSLCADNRequestFile/SSLCADNRequestPath. |
| SSLCACertificatePath directory-pathзнач.: путь к каталогу | — | Каталог с PEM-сертификатами CA, используемыми для проверки клиентского сертификата при клиентской аутентификации (Directory of PEM-encoded CA Certificates for Client Auth).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Файлы в каталоге адресуются не по имени, а по хешу, и документация предупреждает: «So usually you can’t just place the Certificate files there: you also have to create symbolic links named hash-value.N». Просто скопировать .pem в каталог недостаточно — нужен c_rehash (или ручные симлинки), иначе сертификат молча не найдётся и проверка провалится без внятной диагностики. Обратите внимание на суффикс: здесь .N, тогда как для CRL-каталога (SSLCARevocationPath) суффикс другой — .rN. Как и для файлового варианта, каталог читается на старте под root и не перечитывается в рабочем режиме — нужен рестарт. |
| SSLCADNRequestFile file-pathзнач.: путь к файлу | — | Файл со склеенными PEM-сертификатами CA, задающий набор приемлемых имён удостоверяющих центров (File of concatenated PEM-encoded CA Certificates for defining acceptable CA names). Эти имена отправляются клиенту в рукопожатии, чтобы он выбрал подходящий сертификат.⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Ключевое отличие от SSLCACertificateFile: SSLCADNRequestFile влияет ТОЛЬКО на список имён CA, который сервер посылает клиенту в CertificateRequest, и НЕ участвует в фактической проверке цепочки — доверие по-прежнему определяется SSLCACertificateFile/SSLCACertificatePath. Документация: «If neither of the directives SSLCADNRequestPath or SSLCADNRequestFile are given, then the set of acceptable CA names sent to the client is the names of all the CA certificates given by the SSLCACertificateFile and SSLCACertificatePath directives; in other words, the names of the CAs which will actually be used to verify the client certificate». Практические сценарии: (1) клиентские сертификаты подписаны промежуточными CA, и вы хотите анонсировать именно их имена, чтобы браузер корректно отфильтровал сертификаты в диалоге выбора; (2) у вас сотни доверенных CA, и вы не хотите раздувать рукопожатие и раскрывать всему миру полный список доверенных издателей — тогда анонсируете узкий набор. Файл читается на старте под root и не перечитывается — нужен рестарт. |
| SSLCADNRequestPath directory-pathзнач.: путь к каталогу | — | Каталог с PEM-сертификатами CA, задающий набор приемлемых имён удостоверяющих центров, отправляемых клиенту при запросе клиентского сертификата (Directory of PEM-encoded CA Certificates for defining acceptable CA names).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Каталожный аналог SSLCADNRequestFile с той же семантикой: влияет на анонсируемый список имён CA, но не на доверие при проверке. Требует хеш-имён и симлинков вида hash-value.N — «you also have to create symbolic links named hash-value.N», иначе содержимое каталога просто не будет учтено. Забавная деталь документации: в примере используется путь с расширением .crt для каталога — SSLCADNRequestPath “/usr/local/apache2/conf/ca-names.crt/”; не копируйте пример вслепую, это именно каталог. Читается на старте под root, не перечитывается в рабочем режиме. |
| SSLCARevocationCheck chain|leaf|none [flags …]знач.: chain | leaf | none; флаги: no_crl_for_cert_ok | SSLCARevocationCheck none | Включает проверку отзыва по CRL (Enable CRL-based revocation checking). Значение chain применяет проверку ко всем сертификатам цепочки, leaf ограничивает проверку только конечным сертификатом клиента, none отключает.⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Optional flags available in httpd 2.4.21 or later. Status: Extension. Дефолт — none, то есть проверка отзыва по CRL выключена, даже если вы настроили SSLCARevocationFile/SSLCARevocationPath. Рекомендованное документацией значение — chain: «When set to chain (recommended setting), CRL checks are applied to all certificates in the chain, while setting it to leaf limits the checks to the end-entity cert». Обязательное условие: «At least one of SSLCARevocationFile or SSLCARevocationPath must be configured». Второй важный момент — поведение при отсутствующем CRL. Начиная с 2.3.15 семантика ужесточена: по умолчанию при chain или leaf CRL обязан присутствовать, иначе валидация падает с ошибкой «unable to get certificate CRL». Флаг no_crl_for_cert_ok возвращает старое мягкое поведение — он доступен с httpd 2.4.21, и документация приводит рецепт совместимости с ветвью 2.2: SSLCARevocationCheck chain no_crl_for_cert_ok. Практический вывод: chain без флага строг и требует CRL для каждого CA в цепочке, включая корневой, — это частая причина внезапного отказа после включения проверки. |
| SSLCARevocationFile file-pathзнач.: путь к файлу | — | Единый файл со склеенными PEM-кодированными списками отзыва (CRL) удостоверяющих центров, чьих клиентов вы обслуживаете (File of concatenated PEM-encoded CA CRLs for Client Auth).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Сам по себе не включает проверку отзыва. Начиная с 2.4 наличие CRL-файла бесполезно без SSLCARevocationCheck — её дефолт none, то есть при настроенном SSLCARevocationFile, но не заданном SSLCARevocationCheck отзыв не проверяется вообще, и отозванный сертификат будет принят. Это самая опасная тихая ошибка во всей группе. Вторая ловушка — жизненный цикл: файл читается на старте («This file is read at server startup… The file is not re-read during normal operation; a server restart is required for changes to take effect»), поэтому обновление CRL по расписанию обязано сопровождаться рестартом httpd, иначе вы будете работать с протухшим списком. Просроченный CRL (nextUpdate в прошлом) при включённой проверке приведёт к отказу в доступе всем клиентам этого CA. |
| SSLCARevocationPath directory-pathзнач.: путь к каталогу | — | Каталог со списками отзыва сертификатов (CRL) удостоверяющих центров, используемыми для отзыва клиентских сертификатов при клиентской аутентификации (Directory of PEM-encoded CA CRLs for Client Auth).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Суффикс симлинков здесь ДРУГОЙ, чем у сертификатов: документация требует «symbolic links named hash-value.rN» — с буквой r, тогда как для CA-сертификатов это hash-value.N. Перепутанный суффикс — классическая причина того, что CRL «лежит, но не работает». Как и остальные, каталог читается на старте под root и не перечитывается в рабочем режиме, поэтому ротация CRL требует рестарта. И точно так же требует явного SSLCARevocationCheck: без него (дефолт none) отзыв не проверяется. |
| SSLEngine on|offзнач.: on | off | SSLEngine off | Общий выключатель движка SSL/TLS (SSL Engine Operation Switch). Обычно ставится внутри секции |
| SSLOCSPDefaultResponder uriзнач.: URI респондера | — | Задаёт OCSP-респондер по умолчанию (Set the default responder URI for OCSP validation), используемый при проверке клиентских сертификатов.⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Без SSLOCSPOverrideResponder этот URI — только запасной вариант: «If SSLOCSPOverrideResponder is not enabled, the URI given will be used only if no responder URI is specified in the certificate being verified». То есть при наличии AIA-расширения в сертификате ваш корпоративный респондер будет проигнорирован, и Apache пойдёт по адресу из сертификата — возможно, во внешнюю сеть. Если вы хотите гарантированно ходить только на свой внутренний респондер (типовое требование в закрытом контуре), одной этой директивы мало: нужна пара SSLOCSPDefaultResponder + SSLOCSPOverrideResponder on, ровно как в примере документации. Строки Default в таблице нет. |
| SSLOCSPEnable on|leaf|off [flags]знач.: on | leaf | off; флаги: no_ocsp_for_cert_ok | SSLOCSPEnable off | Включает OCSP-проверку цепочки клиентского сертификата (Enable OCSP validation of the client certificate chain). В режиме leaf проверяется только сам клиентский сертификат.⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Mode leaf available in httpd 2.4.34 and later. Flag no_ocsp_for_cert_ok available in 2.4.29 and later… Status: Extension. OCSP выполняется ПОСЛЕ обычной проверки, включая CRL: «certificates in the client’s certificate chain will be validated against an OCSP responder after normal verification (including CRL checks) have taken place» — то есть OCSP не заменяет SSLCARevocationCheck, а дополняет его. Режим leaf появился только в 2.4.34: на более старых сборках on проверит всю цепочку, что означает OCSP-запрос на каждый CA и заметный рост латентности рукопожатия. Вторая ловушка: по умолчанию сертификат без URL OCSP-респондера (без расширения AIA) роняет валидацию — флаг no_ocsp_for_cert_ok (с 2.4.29) разрешает такие сертификаты пропускать. Это критично для смешанных цепочек, где промежуточный или корневой CA не публикует OCSP. Помните и об архитектурном риске: OCSP-запрос синхронный и блокирует рукопожатие, поэтому недоступный респондер превращается в отказ в обслуживании — обязательно настраивайте SSLOCSPResponderTimeout. |
| SSLOCSPNoverify on|offзнач.: on | off | SSLOCSPNoverify off | Пропустить проверку сертификатов OCSP-респондера (skip the OCSP responder certificates verification). По документации полезна главным образом при тестировании OCSP-сервера.⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Available in httpd 2.4.26 and later, if using OpenSSL 0.9.7 or later. Status: Extension. ВНИМАНИЕ на написание: директива пишется SSLOCSPNoverify — со строчной v, а не SSLOCSPNoVerify. Неверный регистр приведёт к ошибке запуска httpd |