От Root CA до User Authorization в nginx+apache. Часть 3. Вход по клиентскому сертификату: прокси и…
- пятница, 28 августа 2026 г. в 00:00:13
Продолжение первой и второй частей. В первой мы развернули двухуровневую инфраструктуру: 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 мы выпускали 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, этих ссылок не несут — их придётся перевыпустить.
Оговорка о составе: в таблице не только то, что кладут в конечный сертификат. Часть расширений — nameConstraints, policyConstraints, inhibitAnyPolicy, basicConstraints с CA:TRUE — по RFC 5280 предназначена для сертификатов удостоверяющих центров, а issuingDistributionPoint и freshestCRL относятся к CRL. Они здесь потому, что при разборе чужого клиентского сертификата вы всё равно на них наткнётесь и должны понимать, что видите. В колонке «Описание и нюансы» такие строки помечены.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
|
| не задано в документации; документированные примеры — 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/
Браузеру нужен один файл, в котором лежат сертификат, закрытый ключ и цепочка. Это 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 слабее, но контейнер, который невозможно открыть, защищает ещё хуже.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| -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/
Минимальный рабочий конфиг:
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.
Наружу и в том, и в другом случае уходит 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 мы разделим сайт и вход на два виртуальных хоста, и модалка перестанет появляться там, где её не ждут.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| 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
Всё, что приложение узнает о клиенте, оно узнает из этих переменных.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| $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
Приложение по ту сторону прокси не участвует в 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-токен нужен точно так же, как при парольном входе.
Предыдущий шаг был про то, что заголовкам от прокси нельзя верить без доказательств. Отсюда напрашивается вопрос: а можно обойтись без них? Можно. 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 для входа человека. Все три службы — на стандартной библиотеке, без единой зависимости.
Уровни 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 / 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.
Директивы другие, модель та же.
<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 на уровне виртуального хоста, а разграничение доступа стройте уже в приложении.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| 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 «Invalid command». Смысловая ловушка важнее: включение on отключает проверку подписи ответа респондера, то есть подменённый или поддельный OCSP-ответ будет принят как достоверный, что обесценивает весь механизм отзыва. Документация ограничивает область применения тестированием — «mostly useful when testing an OCSP server». В боевом контуре вместо этого используйте SSLOCSPResponderCertificateFile, чтобы явно указать доверенные сертификаты респондера. Доступна с 2.4.26 и требует OpenSSL 0.9.7+. |
| SSLOCSPOverrideResponder on|offзнач.: on | off | SSLOCSPOverrideResponder off | Принудительно использовать респондер из SSLOCSPDefaultResponder независимо от того, указан ли респондер в самом проверяемом сертификате (Force use of the default responder URI for OCSP validation).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Дефолт off, поэтому по умолчанию побеждает адрес из AIA-расширения сертификата. Включение on — стандартный приём для изолированных контуров и для случаев, когда AIA указывает на внешний или недоступный URL. Обратная сторона: если вы включили on и указали неверный или недоступный SSLOCSPDefaultResponder, вы сломаете аутентификацию для ВСЕХ клиентов сразу, включая тех, чьи сертификаты содержали рабочий AIA. Директива бессмысленна без заданного SSLOCSPDefaultResponder. |
| SSLOCSPProxyURL urlзнач.: URL HTTP-прокси | — | URL HTTP-прокси, через который выполняются все запросы к OCSP-респондерам (Proxy URL to use for OCSP requests).⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Available in httpd 2.4.19 and later. Status: Extension. Незаменима в закрытых контурах, где веб-сервер не имеет прямого выхода в интернет, но OCSP-респондер CA расположен снаружи: без неё OCSP-проверка будет молча упираться в таймаут SSLOCSPResponderTimeout на каждом рукопожатии. Документация указывает именно HTTP-прокси — «the URL of a HTTP proxy that should be used for all queries to OCSP responders»; настройка применяется ко всем OCSP-запросам, выборочной маршрутизации по респондерам нет. Прокси становится единой точкой отказа для клиентской аутентификации, планируйте его отказоустойчивость. Строки Default нет; доступна с httpd 2.4.19. |
| SSLOCSPResponderCertificateFile fileзнач.: путь к файлу | — | Набор доверенных PEM-сертификатов OCSP-респондеров, используемых при проверке сертификата респондера (Set of trusted PEM encoded OCSP responder certificates).⚠️ 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. Указанные здесь сертификаты доверяются безоговорочно: «The supplied certificates are implicitly trusted without any further validation». Это значит, что файл нужно защищать так же строго, как приватные ключи, — любой, кто может в него писать, может подсунуть доверенного респондера. Правильное применение — когда сертификат респондера самоподписан или не вкладывается в сам OCSP-ответ («This is typically used where the OCSP responder certificate is self signed or omitted from the OCSP response»). Это цивилизованная альтернатива грубому SSLOCSPNoverify on: вы сохраняете проверку подписи, но задаёте якорь доверия вручную. Строки Default нет. Доступна с 2.4.26 при OpenSSL 0.9.7+. |
| SSLOCSPResponderTimeout secondsзнач.: секунды | SSLOCSPResponderTimeout 10 | Таймаут запросов к OCSP-респондеру, действует когда включён SSLOCSPEnable (Timeout for OCSP queries).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Дефолтные 10 секунд — это 10 секунд блокировки TLS-рукопожатия при недоступном респондере, причём на каждое новое соединение. При проверке всей цепочки (SSLOCSPEnable on, а не leaf) задержки складываются по числу сертификатов. В боевой конфигурации это прямой путь к исчерпанию воркеров при падении OCSP-сервиса: значение стоит снижать до 2-3 секунд и одновременно ограничивать область проверки режимом leaf. Помните, что mod_ssl не имеет режима soft-fail для OCSP, аналогичного флагу no_crl_for_cert_ok у CRL, — единственный смягчающий флаг no_ocsp_for_cert_ok касается только отсутствия URL респондера в сертификате, а не недоступности самого респондера. |
| SSLOCSPResponseMaxAge secondsзнач.: секунды; -1 — без ограничения | SSLOCSPResponseMaxAge -1 | Максимально допустимый возраст («свежесть») OCSP-ответа (Maximum allowable age for OCSP responses).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Дефолт -1 означает, что максимальный возраст НЕ проверяется: «The default value (-1) does not enforce a maximum age, which means that OCSP responses are considered valid as long as their nextUpdate field is in the future». Это тонкий момент безопасности — CA может выпускать долгоживущие ответы с далёким nextUpdate, и Apache примет ответ, подписанный, например, неделю назад. Если ваша модель угроз требует близкой к реальному времени информации об отзыве, задайте явное значение в секундах. Обратный риск: слишком маленькое значение при кэширующем респондере или расхождении часов приведёт к отбраковке валидных ответов — это отдельно от допуска на расхождение времени, который задаётся SSLOCSPResponseTimeSkew. |
| SSLOCSPResponseTimeSkew secondsзнач.: секунды | SSLOCSPResponseTimeSkew 300 | Максимально допустимое расхождение времени при проверке OCSP-ответа — применяется к полям thisUpdate и nextUpdate (Maximum allowable time skew for OCSP response validation).⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Дефолт 300 секунд, то есть 5 минут допуска на рассинхронизацию часов. Если на веб-сервере или на OCSP-респондере сломался NTP и расхождение превысило этот допуск, OCSP-проверка начнёт падать для всех клиентов, а сообщение об ошибке будет указывать на проблему с ответом, а не на часы — диагностика уводит в сторону. Перед тем как расширять это значение, чините NTP: увеличение окна ослабляет защиту от повторного воспроизведения старых ответов. Директива относится именно к допуску на «часы», тогда как ограничение общей давности ответа задаётся отдельно через SSLOCSPResponseMaxAge. |
| SSLOCSPUseRequestNonce on|offзнач.: on | off | SSLOCSPUseRequestNonce on | Определяет, включать ли nonce в OCSP-запросы (Use a nonce within OCSP queries). По умолчанию nonce всегда добавляется и сверяется с nonce в ответе.⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Available in httpd 2.4.10 and later. Status: Extension. Nonce защищает от replay-атаки старым валидным ответом, поэтому выключать его нежелательно. Но многие промышленные респондеры отдают предподписанные кэшированные ответы и nonce не поддерживают — документация прямо называет Microsoft OCSP Responder: «When the responder does not use nonces (e.g. Microsoft OCSP Responder), this option should be turned off». Симптом при несовпадении: OCSP-проверка стабильно падает, хотя респондер отвечает и сертификат не отозван. Если вы интегрируетесь с ADCS/Microsoft OCSP, ставьте off сразу — и компенсируйте ослабление защиты разумным SSLOCSPResponseMaxAge. Появилась в 2.4.10, на более ранних сборках директивы нет. |
| SSLOptions [+|-]option …знач.: StdEnvVars | ExportCertData | FakeBasicAuth | StrictRequire | OptRenegotiate | LegacyDNStringFormat | — | Управляет набором run-time опций mod_ssl (Configure various SSL engine run-time options), в том числе экспортом данных клиентского сертификата в окружение и режимом авторизации.⚠️ Apache mod_ssl, контекст: server config, virtual host, directory, .htaccess (Override: Options). Status: Extension. Первое правило слияния: «if multiple SSLOptions could apply to a directory, then the most specific one is taken completely; the options are not merged. However if all the options on the SSLOptions directive are preceded by a plus (+) or minus (-) symbol, the options are merged». То есть смешивать в одной директиве опции со знаком и без — ошибка проектирования: всегда пишите +StdEnvVars, а не StdEnvVars, иначе вложенный блок затрёт весь родительский набор. Для mTLS критичны четыре опции. StdEnvVars включает стандартные SSL-переменные окружения (SSL_CLIENT_S_DN и прочие) — по умолчанию выключен ради производительности, поэтому приложение «не видит» сертификат, пока вы его не включите; документация советует включать точечно для CGI/SSI. ExportCertData добавляет SSL_SERVER_CERT, SSL_CLIENT_CERT и SSL_CLIENT_CERT_CHAIN_n — именно так PEM клиента передаётся в бэкенд. FakeBasicAuth превращает Subject DN клиентского сертификата в имя пользователя HTTP Basic; пароль при этом не запрашивается, и в файле пользователей у каждой записи должен стоять фиксированный хеш xxj31ZMTZzkVA (DES-версия слова password) — документация тут же советует более общий AuthBasicFake из mod_auth_basic. StrictRequire делает отказ от SSLRequire/SSLRequireSSL безусловным: без него «it is possible for other authorization directives (such as |
| SSLProtocol [+|-]protocol …знач.: SSLv3 | TLSv1 | TLSv1.1 | TLSv1.2 | TLSv1.3 | all | SSLProtocol all -SSLv3 (up to 2.4.16: all) | Определяет, какие версии протокола SSL/TLS принимаются в новых соединениях (Configure usable SSL/TLS protocol versions). Имена протоколов регистронезависимы, знаки + и - добавляют и убирают версии.⚠️ Apache mod_ssl, контекст: server config, virtual host. Status: Extension. Дефолт записан с исторической оговоркой: SSLProtocol all -SSLv3, а до версии 2.4.16 — просто all. Ключевой подвох в значении all: это «shortcut for +SSLv3 +TLSv1 или - при OpenSSL 1.0.1 и новее - +SSLv3 +TLSv1 +TLSv1.1 +TLSv1.2 +TLSv1.3», то есть all включает и давно устаревшие TLSv1/TLSv1.1. Для боевого mTLS-контура перечисляйте версии явно, а не полагайтесь на all -SSLv3. TLSv1.3 доступен только при сборке с OpenSSL 1.1.1 и новее; SSLv3 объявлен устаревшим в RFC 7568. Связь с клиентской аутентификацией: выбор версии определяет, сработает ли per-directory схема SSLVerifyClient, поскольку TLS 1.3 не поддерживает пересогласование. Отдельная тонкость из документации: до OpenSSL 1.1.1 настройка протоколов для name-based виртуальных хостов вела себя не так, как ожидается, — на таких сборках разные наборы протоколов на vhost с общим IP/портом задать нельзя. |
| SSLProxyMachineCertificateFile filenameзнач.: путь к файлу | — | Файл со склеенными PEM-сертификатами и ключами, которыми сам Apache как прокси аутентифицируется перед удалёнными серверами (File of concatenated PEM-encoded client certificates and keys to be used by the proxy). Это обратная сторона mTLS: здесь Apache выступает клиентом.⚠️ Apache mod_ssl, контекст: server config, virtual host, proxy section. Доступна с The proxy section context is allowed in httpd 2.4.30 and later / Inclusion of non-leaf (CA) certificates is permitted only in httpd 2.4.59 and later… Status: Extension. Не путайте с SSLCACertificateFile: та описывает, кому мы доверяем как клиентам, а эта — каким сертификатом мы сами представляемся вышестоящему серверу. Файл содержит пары сертификат+приватный ключ в любом порядке ((certificate, key) или (key, certificate)). Два жёстких ограничения по ключам: «Currently there is no support for encrypted private keys» — зашифрованный ключ не поддерживается, и поддерживаются только PKCS1-кодировки RSA, DSA, EC; ключ в формате PKCS8 (начинается с -----BEGIN PRIVATE KEY-----) нужно конвертировать, документация приводит команду openssl rsa -in private-pkcs8.pem -outform pem. Логика выбора сертификата: если удалённый сервер прислал список приемлемых CA, mod_ssl итерирует по нему и берёт первый подходящий; если списка нет — «mod_ssl will use the first configured client cert/key», то есть просто первый в файле. Если список прислан, а совпадений нет, сертификат не будет отправлен вовсе и рукопожатие, скорее всего, провалится. Версионные ограничения: контекст proxy section — только с 2.4.30, включение non-leaf (CA) сертификатов в этот же файл — только с 2.4.59; на более старых сборках промежуточные CA выносите в SSLProxyMachineCertificateChainFile. Строки Default нет. |
| SSLRenegBufferSize bytesзнач.: размер в байтах | SSLRenegBufferSize 131072 | Размер буфера, в который mod_ssl складывает тело HTTP-запроса на время TLS-пересогласования (Set the size for the SSL renegotiation buffer).⚠️ Apache mod_ssl, контекст: directory, .htaccess (Override: AuthConfig). Status: Extension. Эта директива существует именно из-за per-directory mTLS: «If an SSL renegotiation is required in per-location context, for example, any use of SSLVerifyClient in a Directory or Location block, then mod_ssl must buffer any HTTP request body into memory until the new SSL handshake can be performed». Дефолт 131072 байта, то есть 128 КБ: загрузка файла больше этого размера в защищённую сертификатом локацию упрётся в лимит и запрос будет отклонён — крайне неочевидный симптом «маленькие запросы проходят, большие нет». Поднимать значение нужно осознанно, документация предупреждает: «in many configurations, the client sending the request body will be untrusted so a denial of service attack by consumption of memory must be considered» — буфер выделяется на каждое соединение, и щедрый лимит превращается в вектор исчерпания памяти. Правильное решение чаще не в увеличении буфера, а в отказе от per-directory пересогласования в пользу mTLS на уровне vhost. Обратите внимание: контекст — только directory и .htaccess, в server config или virtual host директива не применяется. |
| SSLRequire expressionзнач.: логическое выражение | — | Разрешает доступ только если истинно произвольно сложное булево выражение (Allow access only when an arbitrarily complex boolean expression is true). Основной механизм авторизации по атрибутам клиентского сертификата — например, по полям Subject DN или издателю.⚠️ Apache mod_ssl, контекст: directory, .htaccess (Override: AuthConfig). Status: Extension. Директива объявлена устаревшей: «SSLRequire is deprecated and should in general be replaced by Require expr». В новых конфигурациях пишите Require expr — синтаксис ap_expr является надмножеством. Но переносить выражения дословно нельзя: документация описывает ровно одно расхождение, и оно коварно. В SSLRequire операторы <, <=, … полностью эквивалентны lt, le, … и работают «in a somewhat peculiar way that first compares the length of two strings and then the lexical order» — сначала сравнивается ДЛИНА строк, потом лексикографический порядок. В ap_expr же <, <= делают лексикографическое сравнение строк, а -lt, -le (и алиасы без дефиса lt, le) — целочисленное. Итог: механический перенос условия может тихо изменить результат авторизации, не выдав ошибки конфигурации. Второе: контекст — только directory и .htaccess (в virtual host её поставить нельзя), и по умолчанию отказ может быть перекрыт другими директивами авторизации, если не задан SSLOptions +StrictRequire. Строки Default в таблице нет. |
| SSLRequireSSLзнач.: нет аргументов | — | Запрещает доступ, если запрос идёт не поверх SSL/TLS (Deny access when SSL is not used for the HTTP request). Все запросы без HTTPS отклоняются.⚠️ Apache mod_ssl, контекст: directory, .htaccess (Override: AuthConfig). Status: Extension. Синтаксис без аргументов — это не переключатель, у директивы нет формы on|off и нет значения по умолчанию; она либо присутствует, либо нет. Назначение — страховка от ошибок конфигурации: «very handy inside the SSL-enabled virtual host or directories for defending against configuration errors that expose stuff that should be protected». Важная оговорка: это защита от раскрытия по HTTP, а НЕ проверка клиентского сертификата — наличие TLS ничего не говорит о том, предъявил ли клиент сертификат; для этого нужен SSLVerifyClient. И как у SSLRequire, отказ по умолчанию может быть перекрыт другими директивами авторизации — чтобы сделать его безусловным, добавьте SSLOptions +StrictRequire. Контекст ограничен directory и .htaccess. |
| SSLStrictSNIVHostCheck on|offзнач.: on | off | SSLStrictSNIVHostCheck off | Определяет, допускать ли клиентов без SNI к name-based виртуальному хосту (Whether to allow non-SNI clients to access a name-based virtual host).⚠️ Apache mod_ssl, контекст: server config, virtual host. Доступна с Available in Apache 2.2.12 and later. Status: Extension. Прямое отношение к mTLS: параметры клиентской аутентификации выбираются по виртуальному хосту, а без SNI сервер не знает, какой vhost имелся в виду, и применит настройки дефолтного — клиент может попасть не в тот набор правил SSLVerifyClient/SSLCACertificateFile, чем вы рассчитывали. Включение on закрывает эту неоднозначность. Область действия зависит от места: в дефолтном name-based vhost on закрывает доступ SNI-неосведомлённым клиентам ко ВСЕМ vhost на данной паре IP/порт, в обычном vhost — только к нему, а в server config настройка наследуется всеми vhost, которые её не переопределили. Ограничение сборки: «This option is only available if httpd was compiled against an SNI capable version of OpenSSL». Дефолт off, то есть по умолчанию защиты нет. Доступна с Apache 2.2.12. |
| SSLUserName varnameзнач.: имя SSL-переменной окружения, например SSL_CLIENT_S_DN_CN | — | Задаёт, какая SSL-переменная попадёт в поле «user» объекта запроса Apache (Variable name to determine user name). Через это значение обычно выставляется REMOTE_USER.⚠️ Apache mod_ssl, контекст: server config, directory, .htaccess (Override: AuthConfig). Status: Extension. Это ключевое звено между mTLS и прикладной авторизацией: без него REMOTE_USER может вообще не выставиться — «Without SSLUserName, REMOTE_USER may not be set for other modules and CGI scripts», и приложение за Apache не узнает, кто пришёл. Типовое значение SSLUserName SSL_CLIENT_S_DN_CN подставляет CN клиентского сертификата. Обратите внимание на контекст: в таблице документации указано «server config, directory, .htaccess» — virtual host в списке НЕ значится, что стоит учитывать при раскладке конфигурации. varname может быть любой из SSL-переменных окружения, но чтобы они были заполнены, потребуется SSLOptions +StdEnvVars. Совместно с FakeBasicAuth (или AuthBasicFake) эта директива определяет, какая именно часть сертификата станет именем пользователя. Практический риск: CN не обязан быть уникальным в рамках CA — если ваша модель доступа завязана на identity, надёжнее брать полный DN (SSL_CLIENT_S_DN) или связку с серийным номером. Строки Default нет. |
| SSLVerifyClient levelзнач.: none | optional | require | optional_no_ca | SSLVerifyClient none | Задаёт уровень проверки клиентского сертификата (Type of Client Certificate verification). Именно эта директива включает запрос клиентского сертификата в TLS-рукопожатии. В per-server контексте применяется к стандартному рукопожатию при установлении соединения.⚠️ Apache mod_ssl, контекст: server config, virtual host, directory, .htaccess (Override: AuthConfig). Status: Extension. Главная ловушка — контекст directory/.htaccess. Документация прямо пишет: «In per-directory context it forces a SSL renegotiation with the reconfigured client verification level after the HTTP request was read but before the HTTP response is sent». То есть mTLS «только для /admin» реализуется через TLS-пересогласование, а не через обычное рукопожатие. Это влечёт два следствия. Первое: тело запроса приходится буферизовать в память до завершения нового рукопожатия — см. SSLRenegBufferSize (его описание прямо ссылается на «any use of SSLVerifyClient in a Directory or Location block»), что даёт вектор DoS от недоверенного клиента. Второе: в TLS 1.3 пересогласования нет вообще — на этой же странице в описании SSLCipherSuite сказано «Since TLSv1.3 does not offer renegotiations», поэтому per-directory схема на TLS 1.3 не работает так, как на TLS 1.2. Практика: включайте SSLVerifyClient require на уровне virtual host, а разграничение по путям делайте через SSLRequire/Require expr, либо выносите mTLS на отдельный vhost/порт. Отдельно: значение optional_no_ca документация сопровождает предупреждением «This option cannot be relied upon for client authentication» — сертификат принимается без успешной проверки, и опираться на него как на аутентификацию нельзя. |
| SSLVerifyDepth numberзнач.: целое число (0, 1, 2, …) | SSLVerifyDepth 1 | Максимальная глубина цепочки CA при проверке клиентского сертификата (Maximum depth of CA Certificates in Client Certificate verification). Глубина — это максимальное число промежуточных издателей, то есть количество CA-сертификатов, которые разрешено пройти при проверке.⚠️ Apache mod_ssl, контекст: server config, virtual host, directory, .htaccess (Override: AuthConfig). Status: Extension. Значение по умолчанию — 1, а не 10. Это самый частый источник ошибок при внедрении собственного CA: документация поясняет, что «A depth of 0 means that self-signed client certificates are accepted only, the default depth of 1 means the client certificate can be self-signed or has to be signed by a CA which is directly known to the server». Иначе говоря, при дефолте работает только двухуровневая схема Root CA -> клиент. Как только вы вводите промежуточный (issuing) CA — типовая схема Root -> Intermediate -> клиент — проверка начнёт падать, пока вы явно не поднимете значение как минимум до 2. Диагностировать тяжело: в логе будет невнятная ошибка проверки цепочки, а не «глубина превышена». Как и SSLVerifyClient, в per-directory контексте эта директива форсирует пересогласование TLS. |
Версии и подводные камни:
Столбцы Syntax, Default, Context и Status приведены так, как их печатает сама документация mod_ssl.
SSLVerifyClientвнутри<Directory>/<Location>требует пересогласования соединения, которого в TLS 1.3 нет; задавайте директиву на уровне виртуального хоста.
SSLCADNRequestFile/SSLCADNRequestPathзадают список имён CA дляCertificateRequestотдельно от списка доверия — у nginx такого разделения нет.Сверено с: https://httpd.apache.org/docs/2.4/mod/mod_ssl.html
Колонка «По умолч.» здесь означает не значение, а условие появления: почти все переменные пусты, пока не включён SSLOptions +StdEnvVars, а сам сертификат и цепочка требуют ещё и +ExportCertData. Всегда определены только HTTPS и SSL_TLS_SNI — по ним удобно проверять, что запрос вообще пришёл по TLS.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| HTTPSзнач.: flag | определена всегда | Флаг того, что соединение обслуживается по HTTPS. Одна из двух переменных, которые mod_ssl экспортирует безусловно.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Не требует SSLOptions StdEnvVars — вместе с SSL_TLS_SNI это единственное исключение из правила «по умолчанию не заполняется». Тип значения в документации указан как flag, а не string. |
| SSL_CIPHERзнач.: string | не заполняется без SSLOptions StdEnvVars | Имя спецификации шифронабора, согласованного для соединения.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. |
| SSL_CIPHER_ALGKEYSIZEзнач.: number | не заполняется без SSLOptions StdEnvVars | Число бит ключа шифра, возможных для данного алгоритма.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Это потенциальная (possible) длина ключа алгоритма, а не реально использованная — за реальную отвечает SSL_CIPHER_USEKEYSIZE. |
| SSL_CIPHER_EXPORTзнач.: string: true, false | не заполняется без SSLOptions StdEnvVars | true, если согласованный шифр относится к экспортным (export cipher).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Тип — string, а не flag: значение сравнивается как строка true. |
| SSL_CIPHER_USEKEYSIZEзнач.: number | не заполняется без SSLOptions StdEnvVars | Число бит ключа шифра, фактически использованных.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Легко спутать с SSL_CIPHER_ALGKEYSIZE: здесь — actually used, там — possible; для экспортных шифров эти числа расходятся. |
| SSL_CLIENT_A_KEYзнач.: string | не заполняется без SSLOptions StdEnvVars | Алгоритм открытого ключа в сертификате клиента.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Длину ключа переменная не сообщает — на странице таких переменных для сертификатов нет. |
| SSL_CLIENT_A_SIGзнач.: string | не заполняется без SSLOptions StdEnvVars | Алгоритм, которым подписан сертификат клиента.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Речь об алгоритме подписи сертификата, а не о подписи в рукопожатии TLS. |
| SSL_CLIENT_CERTзнач.: string (PEM) | не заполняется без SSLOptions ExportCertData | Сертификат клиента целиком в PEM-кодировке.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует именно SSLOptions ExportCertData, а не StdEnvVars. Документация прямо предупреждает, что такой экспорт «bloats up the environment» — включать стоит только там, где CGI действительно разбирает сертификат. |
| SSL_CLIENT_CERT_CHAIN_nзнач.: string (PEM), n = 0,1,2,… | не заполняется без SSLOptions ExportCertData | Сертификаты цепочки клиента в PEM-кодировке, по одному на индекс n.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions ExportCertData. Индексация начинается с 0 (n = 0,1,2,…), число элементов заранее неизвестно — перебирать нужно до первой отсутствующей переменной. |
| SSL_CLIENT_CERT_RFC4523_CEAзнач.: string | не заполняется без SSLOptions StdEnvVars | Серийный номер и издатель клиентского сертификата в формате CertificateExactAssertion из RFC 4523.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Это не PEM-сертификат, а компактная пара «серийник + издатель» в LDAP-нотации RFC 4523 — удобна для сверки со списком отзыва или каталогом, но не заменяет SSL_CLIENT_CERT. |
| SSL_CLIENT_I_DNзнач.: string | не заполняется без SSLOptions StdEnvVars | Issuer DN сертификата клиента целиком.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат *_DN изменился в Apache HTTPD 2.3.11 — совместимость переключается опцией LegacyDNStringFormat директивы SSLOptions. |
| SSL_CLIENT_I_DN_x509[_n][_RAW]знач.: string; x509 — один из C,ST,L,O,OU,CN,T,I,G,S,D,UID,Email | не заполняется без SSLOptions StdEnvVars | Отдельный компонент Issuer DN клиентского сертификата (например SSL_CLIENT_I_DN_CN).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Действуют те же правила, что и для Subject DN: с 2.2.0 — нулевой индекс _n для одноимённых атрибутов (имя без суффикса эквивалентно _0), причём через StdEnvVars первый атрибут добавляется только под именем без суффикса; с 2.4.32 — суффикс _RAW после индекса, отключающий перекодировку значения в UTF-8. |
| SSL_CLIENT_M_SERIALзнач.: string | не заполняется без SSLOptions StdEnvVars | Серийный номер клиентского сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Тип значения — string: серийный номер X.509 не помещается в число, обрабатывать его как integer нельзя. |
| SSL_CLIENT_M_VERSIONзнач.: string | не заполняется без SSLOptions StdEnvVars | Версия клиентского сертификата (поле version структуры X.509).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Все переменные группы SSL_CLIENT_* имеют смысл только тогда, когда клиент реально предъявил сертификат. |
| SSL_CLIENT_SAN_DNS_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи расширения subjectAltName клиентского сертификата типа dNSName.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Переменная всегда индексирована суффиксом n; варианта без индекса документация не описывает. |
| SSL_CLIENT_SAN_Email_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи расширения subjectAltName клиентского сертификата типа rfc822Name.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Имя переменной обязательно несёт числовой индекс n (записей SAN может быть несколько), и регистр в части Email именно такой, как в документации. |
| SSL_CLIENT_SAN_OTHER_msUPN_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи subjectAltName клиентского сертификата типа otherName в форме Microsoft User Principal Name (OID 1.3.6.1.4.1.311.20.2.3).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Это единственная форма otherName для клиента, описанная на странице; произвольные otherName-типы через переменные не отдаются. |
| SSL_CLIENT_S_DNзнач.: string | не заполняется без SSLOptions StdEnvVars | Subject DN из сертификата клиента целиком.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат *_DN-переменных изменился в Apache HTTPD 2.3.11 — за старым поведением документация отсылает к опции LegacyDNStringFormat директивы SSLOptions. |
| SSL_CLIENT_S_DN_x509[_n][_RAW]знач.: string; x509 — один из C,ST,L,O,OU,CN,T,I,G,S,D,UID,Email | не заполняется без SSLOptions StdEnvVars | Отдельный компонент Subject DN клиентского сертификата: имя переменной образуется подстановкой кода атрибута X.509 вместо x509 (например SSL_CLIENT_S_DN_CN).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. С httpd 2.2.0 допустим числовой суффикс _n — нулевой индекс для выбора одного из одноимённых атрибутов (SSL_CLIENT_S_DN_OU_0, _OU_1); имя без суффикса эквивалентно _0. Но при заполнении таблицы через StdEnvVars первый (или единственный) атрибут добавляется ТОЛЬКО под именем без суффикса — записей с _0 не создаётся, и обращение к _OU_0 вернёт пусто. С httpd 2.4.32 к компоненту DN можно добавить необязательный суффикс _RAW, подавляющий преобразование значения в UTF-8; он ставится после индексного суффикса (SSL_CLIENT_S_DN_OU_RAW, SSL_CLIENT_S_DN_OU_0_RAW). Формат *_DN изменился в 2.3.11 (см. LegacyDNStringFormat). |
| SSL_CLIENT_VERIFYзнач.: string: NONE, SUCCESS, GENEROUS, FAILED:reason | не заполняется без SSLOptions StdEnvVars | Результат проверки клиентского сертификата: NONE, SUCCESS, GENEROUS или FAILED:reason.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Значение FAILED идёт с двоеточием и причиной (FAILED:reason), поэтому точное сравнение со строкой FAILED не сработает — нужна проверка по префиксу. GENEROUS — отдельное состояние, а не синоним SUCCESS. |
| SSL_CLIENT_V_ENDзнач.: string | не заполняется без SSLOptions StdEnvVars | Окончание срока действия клиентского сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Для проверок «скоро истечёт» удобнее числовая SSL_CLIENT_V_REMAIN, чем разбор этой строки. |
| SSL_CLIENT_V_REMAINзнач.: string | не заполняется без SSLOptions StdEnvVars | Количество дней, оставшихся до истечения клиентского сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Доступна с 2.1. Требует SSLOptions StdEnvVars. Единственная переменная раздела с явной отметкой о версии: доступна только начиная с 2.1. Тип в таблице — string, хотя содержимое числовое. |
| SSL_CLIENT_V_STARTзнач.: string | не заполняется без SSLOptions StdEnvVars | Начало срока действия клиентского сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат строки времени документация не специфицирует — парсить его как фиксированный шаблон рискованно. |
| SSL_COMPRESS_METHODзнач.: string | не заполняется без SSLOptions StdEnvVars | Согласованный метод сжатия SSL.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Перечня возможных значений документация не приводит. |
| SSL_PROTOCOLзнач.: string: SSLv3, TLSv1, TLSv1.1, TLSv1.2 | не заполняется без SSLOptions StdEnvVars | Версия протокола SSL/TLS, по которой установлено текущее соединение.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Документация 2.4 перечисляет в качестве значений только SSLv3, TLSv1, TLSv1.1, TLSv1.2 — TLSv1.3 в этом списке страницы отсутствует. |
| SSL_SECURE_RENEGзнач.: string: true, false | не заполняется без SSLOptions StdEnvVars | true, если поддерживается безопасное пересогласование (secure renegotiation), иначе false.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Значение — строка true/false, а не флаг; сравнивать нужно со строковыми литералами. |
| SSL_SERVER_A_KEYзнач.: string | не заполняется без SSLOptions StdEnvVars | Алгоритм открытого ключа серверного сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. |
| SSL_SERVER_A_SIGзнач.: string | не заполняется без SSLOptions StdEnvVars | Алгоритм, которым подписан серверный сертификат.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. |
| SSL_SERVER_CERTзнач.: string (PEM) | не заполняется без SSLOptions ExportCertData | Серверный сертификат целиком в PEM-кодировке.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions ExportCertData, а не StdEnvVars. Заметьте асимметрию с клиентом: цепочка экспортируется только для клиента (SSL_CLIENT_CERT_CHAIN_n), переменной SSL_SERVER_CERT_CHAIN_n на странице нет. |
| SSL_SERVER_I_DNзнач.: string | не заполняется без SSLOptions StdEnvVars | Issuer DN серверного сертификата целиком.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат *_DN изменился в Apache HTTPD 2.3.11 — см. LegacyDNStringFormat. |
| SSL_SERVER_I_DN_x509[_n][_RAW]знач.: string; x509 — один из C,ST,L,O,OU,CN,T,I,G,S,D,UID,Email | не заполняется без SSLOptions StdEnvVars | Отдельный компонент Issuer DN серверного сертификата (например SSL_SERVER_I_DN_O).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Применимы общие правила DN-компонентов: индексный суффикс _n с httpd 2.2.0 (имя без суффикса эквивалентно _0, но StdEnvVars не создаёт записей с _0) и суффикс _RAW с httpd 2.4.32, ставящийся после индекса и отключающий перекодировку в UTF-8. |
| SSL_SERVER_M_SERIALзнач.: string | не заполняется без SSLOptions StdEnvVars | Серийный номер серверного сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Как и у клиентского серийника, тип — string; числовым его считать нельзя. |
| SSL_SERVER_M_VERSIONзнач.: string | не заполняется без SSLOptions StdEnvVars | Версия серверного сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. |
| SSL_SERVER_SAN_DNS_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи расширения subjectAltName серверного сертификата типа dNSName.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Индексируется суффиксом n; неиндексированного имени в документации нет. |
| SSL_SERVER_SAN_Email_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи расширения subjectAltName серверного сертификата типа rfc822Name.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Имя всегда с индексом n. |
| SSL_SERVER_SAN_OTHER_dnsSRV_nзнач.: string | не заполняется без SSLOptions StdEnvVars | Записи subjectAltName серверного сертификата типа otherName в форме SRVName (OID 1.3.6.1.5.5.7.8.7, RFC 4985).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Обратите внимание на асимметрию: для сервера документирована форма otherName dnsSRV, а для клиента — msUPN; одноимённых пар нет. |
| SSL_SERVER_S_DNзнач.: string | не заполняется без SSLOptions StdEnvVars | Subject DN серверного сертификата целиком.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат *_DN изменился в Apache HTTPD 2.3.11 (см. опцию LegacyDNStringFormat директивы SSLOptions). |
| SSL_SERVER_S_DN_x509[_n][_RAW]знач.: string; x509 — один из C,ST,L,O,OU,CN,T,I,G,S,D,UID,Email | не заполняется без SSLOptions StdEnvVars | Отдельный компонент Subject DN серверного сертификата (например SSL_SERVER_S_DN_CN).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. С httpd 2.2.0 доступен нулевой индекс _n для одноимённых атрибутов — пример из документации: SSL_SERVER_S_DN_OU_0 и SSL_SERVER_S_DN_OU_1; при этом через StdEnvVars первый (или единственный) атрибут кладётся только под именем без суффикса, записи с _0 не создаются. С httpd 2.4.32 добавляется необязательный суффикс _RAW (после индексного), подавляющий преобразование значения в UTF-8: SSL_SERVER_S_DN_OU_RAW, SSL_SERVER_S_DN_OU_0_RAW. |
| SSL_SERVER_V_ENDзнач.: string | не заполняется без SSLOptions StdEnvVars | Окончание срока действия серверного сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Формат строки не задокументирован, а счётчика оставшихся дней для сервера не существует — мониторинг придётся строить на собственном разборе. |
| SSL_SERVER_V_STARTзнач.: string | не заполняется без SSLOptions StdEnvVars | Начало срока действия серверного сертификата.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Аналога SSL_CLIENT_V_REMAIN для сервера в документации нет — «сколько дней осталось» по серверному сертификату переменной не отдаётся. |
| SSL_SESSION_IDзнач.: string (hex-encoded) | не заполняется без SSLOptions StdEnvVars | Идентификатор SSL-сессии в hex-кодировке.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Значение приходит уже в hex-виде — дополнительного декодирования документация не описывает. |
| SSL_SESSION_RESUMEDзнач.: string: Initial, Resumed | не заполняется без SSLOptions StdEnvVars | Признак того, новая это SSL-сессия или возобновлённая (Initial или Resumed).⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Документация отдельно предупреждает: при включённом HTTP KeepAlive несколько запросов могут обслуживаться в рамках одной и той же (Initial или Resumed) сессии, поэтому значение не характеризует отдельный запрос. |
| SSL_SRP_USERзнач.: string | не заполняется без SSLOptions StdEnvVars | Имя пользователя SRP.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Заполняется только при использовании SRP-аутентификации TLS; версии, с которой переменная появилась, страница не указывает. |
| SSL_SRP_USERINFOзнач.: string | не заполняется без SSLOptions StdEnvVars | Дополнительная информация о пользователе SRP.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Содержимое поля определяется файлом SRP-верификаторов, а не mod_ssl; структура на странице не описана. |
| SSL_TLS_SNIзнач.: string | определена всегда | Содержимое TLS-расширения SNI, если оно было передано клиентом в ClientHello.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Не требует SSLOptions StdEnvVars — вместе с HTTPS это вторая переменная, определённая всегда. Но «всегда определена» не значит «всегда непуста»: значение появляется только если клиент действительно прислал SNI в ClientHello. |
| SSL_VERSION_INTERFACEзнач.: string | не заполняется без SSLOptions StdEnvVars | Версия самого модуля mod_ssl.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Относится к модулю, а не к криптобиблиотеке — версия OpenSSL лежит в SSL_VERSION_LIBRARY. |
| SSL_VERSION_LIBRARYзнач.: string | не заполняется без SSLOptions StdEnvVars | Версия библиотеки OpenSSL.⚠️ Apache mod_ssl, контекст: SSI/CGI namespace. Требует SSLOptions StdEnvVars. Это версия, с которой модуль работает, а не версия mod_ssl (см. SSL_VERSION_INTERFACE). |
Версии и подводные камни:
Переменные
SSL_*не появляются сами: нуженSSLOptions +StdEnvVars, а дляSSL_CLIENT_CERTи цепочки — ещё и+ExportCertData.
StdEnvVarsзаметно дороже по производительности, поэтому его включают точечно в нужном<Location>, а не глобально.Суффикс
_nуSSL_CLIENT_SAN_*иSSL_CLIENT_CERT_CHAIN_n— порядковый номер, нумерация с нуля.Сверено с: https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#envvars, https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#ssloptions
SSLRequire позволяет пускать по условию, не привлекая приложение: например, только владельцев сертификатов с определённым OU.
<Location "/admin"> SSLRequire %{SSL_CLIENT_S_DN_OU} eq "CertService. IT-Department." \ and %{SSL_CLIENT_VERIFY} eq "SUCCESS" </Location>
Выглядит удобно — и ровно поэтому стоит сказать, чем за это платят. Правило живёт в конфиге веб-сервера: чтобы дать человеку доступ, нужен доступ к серверу и перезапуск. Приложение об этом правиле не знает и в своём журнале ничего не покажет. Для «пустить или не пустить в целый раздел» это приемлемо, для управления людьми — нет.
Параметр | Синтаксис / значения | По умолч. | Описание и нюансы |
|---|---|---|---|
| word “!=” word | — | String inequality — строковое неравенство.⚠️ Apache mod_ssl, контекст: ap_expr, stringcomp. Алиаса без дефиса вида ne у него нет: ne — это алиас целочисленного -ne, а не строкового !=. Перепутать легко, ошибка проявится только на числовых значениях с ведущими нулями или разной длины. |
| word “!~” regex | — | String does not match the regular expression — строка не совпадает с регулярным выражением.⚠️ Apache mod_ssl, контекст: ap_expr и SSLRequire. Отрицательное совпадение не заполняет обратные ссылки $0 … $9 осмысленными значениями — по определению захватов не было. В примере mod_ssl именно !~ используется для отсечения слабых шифров: %{SSL_CIPHER} !~ m/^(EXP|NULL)-/. |
| rebackref ::= “$” [0-9]знач.: $0, $1, $2, $3, $4, $5, $6, $7, $8, $9 | — | Regular expression backreferences — обратные ссылки на регулярные выражения. Строки |
| “-A” word | — | Alias for -U — псевдоним оператора -U.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Именно алиас -U (URL), а не -F (файл) — распространённая путаница при чтении конфигураций, унаследованных из mod_rewrite-словаря. |
| “-F” word | — | Истина, если строка — валидный файл, доступный с учётом ВСЕХ настроенных на сервере механизмов контроля доступа для этого пути. Для проверки используется внутренний подзапрос (subrequest).⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Документация прямо предупреждает: используйте осторожно — это может ударить по производительности сервера («use it with care - it can impact your server’s performance!»). В отличие от -f, здесь учитываются директивы авторизации, то есть результат зависит от конфигурации, а не только от файловой системы. |
| “-L” word | — | Аргумент трактуется как имя файла. Истина, если файл существует и является символьной ссылкой.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — по документации недоступен в некоторых модулях, например mod_include (и в контекстах с урезанными правами, в частности при обработке в .htaccess). Status: restricted. Имя оператора регистрозависимо: -L и -l — не одно и то же, строчный вариант в документации не определён. Полный синоним — -h. |
| “-R” word | — | То же, что “%{REMOTE_ADDR} -ipmatch …”, но эффективнее.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Работает именно с REMOTE_ADDR — не с CONN_REMOTE_ADDR и не с адресом из заголовков прокси. За обратным прокси без корректной подстановки REMOTE_ADDR условие будет проверять адрес прокси. Пример: |
| “-T” wordзнач.: ложь для: пустая строка, “0”, “off”, “false”, “no” (регистронезависимо); истина во всех остальных случаях | — | Ложь, если строка пуста, либо равна “0”, “off”, “false” или “no” (без учёта регистра). Истина во всех остальных случаях.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Список «ложных» значений закрытый: “disabled”, “none”, “nil” будут ИСТИННЫ. Это единственный оператор с такой «мягкой булевой» семантикой — %{HTTPS} и подобные переменные удобно проверять именно им, а не сравнением со строкой. |
| “-U” word | — | Истина, если строка — валидный URL, доступный с учётом всех настроенных на сервере механизмов контроля доступа для этого пути. Для проверки используется внутренний подзапрос.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Та же оговорка о производительности, что и у -F: внутренний подзапрос на каждый вызов. Внутренний подзапрос может сам запускать обработчики и авторизацию — легко получить рекурсию, если условие стоит в конфигурации, применяемой и к подзапросу. |
| “-d” word | — | Аргумент трактуется как имя файла. Истина, если файл существует и является каталогом.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — отмечен «restricted»; по документации restricted-операторы недоступны в некоторых модулях, например mod_include (то же ограничение действует и для контекстов с урезанными правами, в частности при обработке в .htaccess). Status: restricted. Унарные операторы имеют вид “-[a-zA-Z]” (минус и один символ) и ЧУВСТВИТЕЛЬНЫ к регистру. Обращение к файловой системе на каждый запрос — стоимость на горячем пути. В контекстах, где оператор ограничен (restricted), выражение не будет работать. |
| “-e” word | — | Аргумент трактуется как имя файла. Истина, если файл (либо каталог, либо специальный файл) существует.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — по документации недоступен в некоторых модулях, например mod_include (и в контекстах с урезанными правами, в частности при обработке в .htaccess). Status: restricted. -e истинен и для каталогов, и для специальных файлов — для проверки «обычный файл» нужен -f, для каталога -d. Имя оператора регистрозависимо. |
| word “-eq” word | word “eq” wordзнач.: алиас без дефиса: eq | — | Integer equality — целочисленное равенство. Есть алиас без ведущего дефиса: eq.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. Именно eq/-eq, а не ==, следует использовать для числовых переменных вроде %{TIME_HOUR} и %{REQUEST_STATUS}, иначе ‘09’ и ‘9’ окажутся разными. В SSLRequire написание eq имело совершенно другую природу (синоним ==, строковое сравнение). |
| “-f” word | — | Аргумент трактуется как имя файла. Истина, если файл существует и является обычным файлом.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — по документации недоступен в некоторых модулях, например mod_include (и в контекстах с урезанными правами, в частности при обработке в .htaccess). Status: restricted. Проверяется существование и тип, но НЕ права доступа с точки зрения конфигурации сервера — для этого предназначен -F. Пример из документации: |
| word “-fnmatch” word | — | То же, что -strmatch, но слэши не покрываются подстановочными знаками.⚠️ Apache mod_ssl, контекст: ap_expr, binary operator. Именно этот оператор нужен для путей: /a/* не совпадёт с /a/b/c. Выбор между -strmatch и -fnmatch — самая частая причина «правило не срабатывает / срабатывает слишком широко» на путях URI. |
| word “-ge” word | word “ge” wordзнач.: алиас без дефиса: ge | — | Integer greater than or equal — целочисленное «больше или равно». Алиас без дефиса: ge.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. В примере условного логирования документация использует строковую форму: CustomLog logs/access-errors.log common “expr=%{REQUEST_STATUS} >= 400” — здесь >= лексический; для гарантированно числового сравнения статусов корректнее -ge. |
| word “-gt” word | word “gt” wordзнач.: алиас без дефиса: gt | — | Integer greater than — целочисленное «больше». Алиас без дефиса: gt.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. Пример из документации использует именно числовую форму: Require expr %{TIME_HOUR} -gt 9 && %{TIME_HOUR} -lt 17. С > результат для часов «09» и «10» был бы иным. |
| “-h” word | — | Аргумент трактуется как имя файла. Истина, если файл существует и является символьной ссылкой (то же, что -L).⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — по документации недоступен в некоторых модулях, например mod_include (и в контекстах с урезанными правами, в частности при обработке в .htaccess). Status: restricted. Полный синоним -L, отдельной семантики не несёт; наличие двух написаний — источник ложного ощущения, будто одно из них проверяет что-то иное. |
| word “in” “{” wordlist “}” | word “in” listfunction | word “-in” …знач.: алиас: in | — | String contained in wordlist — строка содержится в списке слов. Список задаётся либо литерально в фигурных скобках { ‘foo’, ‘bar’, ‘baz’ }, либо list-функцией (единственная предоставляемая — PeerExtList от mod_ssl). Документированный алиас: in.⚠️ Apache mod_ssl, контекст: ap_expr (в SSLRequire — только форма in). Сравнение со списком строковое и точное — шаблоны в элементах списка не раскрываются. Пример из документации: CustomLog … “expr=%{REQUEST_STATUS} -in {‘405’,‘410’}” — обратите внимание, что статусы записаны как СТРОКИ; числовая семантика здесь не действует. |
| word “-ipmatch” word | — | IP address matches address/netmask — IP-адрес совпадает с адресом/маской подсети.⚠️ Apache mod_ssl, контекст: ap_expr, binary operator. Правый операнд — address/netmask, а не регулярное выражение и не шаблон. Для проверки именно REMOTE_ADDR документация предлагает более эффективный унарный -R вместо связки %{REMOTE_ADDR} -ipmatch … |
| word “-le” word | word “le” wordзнач.: алиас без дефиса: le | — | Integer less than or equal — целочисленное «меньше или равно». Алиас без дефиса: le.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. Аналогично -lt: <= в ap_expr лексический, le/-le — числовой; в SSLRequire le был эквивалентен <= с семантикой длины+лексики. |
| word “-lt” word | word “lt” wordзнач.: алиас без дефиса: lt | — | Integer less than — целочисленное «меньше». Алиас без дефиса: lt.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. Не путать с < : в ap_expr < — лексическое сравнение строк, а -lt/lt — целочисленное. В SSLRequire lt означало «сначала по длине строки, затем лексически». Это тот самый оператор, ради которого при миграции SSLRequire → Require expr нужно переписывать все числовые сравнения. |
| “-n” word | — | Истина, если строка не пуста.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Проверяется только непустота: строка “0” или “off” для -n истинна. Для «булевой» трактовки значения нужен -T. |
| word “-ne” word | word “ne” wordзнач.: алиас без дефиса: ne | — | Integer inequality — целочисленное неравенство. Алиас без дефиса: ne.⚠️ Apache mod_ssl, контекст: ap_expr, integercomp. ne — целочисленный оператор, несмотря на то, что в SSLRequire то же слово было синонимом строкового !=. Прямой перенос выражения меняет семантику молча. |
| “-s” word | — | Аргумент трактуется как имя файла. Истина, если файл существует и не пуст.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator, RESTRICTED — по документации недоступен в некоторых модулях, например mod_include (и в контекстах с урезанными правами, в частности при обработке в .htaccess). Status: restricted. Не путать с -n/-z, которые проверяют пустоту СТРОКИ, а не файла. Для нулевого размера существующего файла -f истинен, а -s ложен. |
| word “-strcmatch” word | — | То же, что -strmatch, но без учёта регистра.⚠️ Apache mod_ssl, контекст: ap_expr, binary operator. Регистронезависимость касается только сравнения символов шаблона и строки; поведение подстановочных знаков относительно слэшей такое же, как у -strmatch (слэши матчатся), а не как у -fnmatch. |
| word “-strmatch” wordзнач.: подстановочные знаки: *, ?, [] | — | Левая строка совпадает с шаблоном, заданным правой строкой (содержащей подстановочные знаки , ?, []).⚠️ Apache mod_ssl, контекст: ap_expr, binary operator. Это glob-шаблон, а не регулярное выражение: ^ и $ не якорят, . не работает. В отличие от -fnmatch, слэши здесь ПОКРЫВАЮТСЯ подстановочными знаками, поэтому * пройдёт через границы сегментов пути. |
| “-z” word | — | Истина, если строка пуста.⚠️ Apache mod_ssl, контекст: ap_expr, unary operator (не restricted). Точное отрицание -n, но не отрицание -T: -z ‘off’ ложно, тогда как -T ‘off’ тоже ложно — операторы отвечают на разные вопросы. |
| /regexp/ | m#regexp#знач.: алиас с произвольным разделителем: m#regexp# | — | Форма записи регулярного выражения. Вторая форма (m#regexp#) позволяет использовать разделители, отличные от /.⚠️ Apache mod_ssl, контекст: ap_expr. Без формы m#…# любой слэш внутри шаблона пути придётся экранировать; документация сама пользуется этим приёмом: m#^/special_path.php$#. Разделитель после m — произвольный символ, но открывающий и закрывающий должны совпадать. |
| /regexp/i | m#regexp#iзнач.: суффикс i — регистронезависимость | — | Case insensitive regular expression — регистронезависимое регулярное выражение; задаётся суффиксом i после закрывающего разделителя.⚠️ Apache mod_ssl, контекст: ap_expr. Суффикс i ставится именно после закрывающего разделителя, а не внутри шаблона; иных документированных флагов (m, s, x) на странице нет — только i. |
| word “<” word | word “<=” word | word “>” word | word “>=” word | — | ПРЕДУПРЕЖДЕНИЕ О РАЗНОЙ СЕМАНТИКЕ. В SSLRequire операторы <, <=, … полностью эквивалентны операторам lt, le, … и работают довольно своеобразно: сначала сравнивается ДЛИНА двух строк, а затем — лексический порядок. В ap_expr, напротив, два набора операторов сравнения: <, <=, … выполняют лексическое сравнение строк, а -lt, -le, … — целочисленное сравнение; для последних есть также алиасы без ведущих дефисов: lt, le, …⚠️ Apache mod_ssl, контекст: SSLRequire и ap_expr — семантика РАЗНАЯ. Это единственное задокументированное расхождение между SSLRequire и надмножеством ap_expr — и самая опасная точка миграции. Пример: в SSLRequire %{TIME_HOUR} < “9” сравнивает сначала длины («10» длиннее «9», значит больше) и ведёт себя почти как числовое; после переноса в Require expr тот же < станет чисто лексическим, и «10» окажется МЕНЬШЕ «9». Корректный перенос числовых сравнений — только через -lt/-le/-gt/-ge. Обратите внимание: алиасы lt/le/gt/ge в ap_expr означают ЦЕЛОЧИСЛЕННОЕ сравнение, а в SSLRequire — длину+лексику, то есть одно и то же написание меняет смысл. |
| word “==” wordзнач.: алиас: = | — | String equality — строковое равенство. Документированный альтернативный вид записи: =.⚠️ Apache mod_ssl, контекст: ap_expr, stringcomp. Сравнение строковое, а не числовое: ‘007’ == ‘7’ ложно. Для чисел нужен -eq. В SSLRequire аналог — == с синонимом eq, но там eq не означает целочисленное сравнение, тогда как в ap_expr eq — это алиас именно целочисленного -eq. |
| word “=~” regex | — | String matches the regular expression — строка совпадает с регулярным выражением. Присутствует в обеих грамматиках: и в SSLRequire, и в ap_expr.⚠️ Apache mod_ssl, контекст: ap_expr и SSLRequire. Успешное совпадение заполняет обратные ссылки $0 … $9, которые обычно доступны только внутри того же выражения. Регулярное выражение записывается в одной из документированных форм (/regexp/ или m#regexp#), а не как произвольная строка в кавычках. |
| SSLOptions +ExportCertDataзнач.: SSL_SERVER_CERT, SSL_CLIENT_CERT, SSL_CLIENT_CERT_CHAIN_n (n = 0,1,2,…) | — | Создаёт дополнительные переменные окружения CGI/SSI: SSL_SERVER_CERT, SSL_CLIENT_CERT и SSL_CLIENT_CERT_CHAIN_n (n = 0,1,2,…). В них лежат PEM-кодированные сертификаты X.509 сервера и клиента для текущего HTTPS-соединения; дополнительно отдаются все прочие сертификаты цепочки клиента.⚠️ Apache mod_ssl, контекст: значение SSLOptions. Раздувает окружение процесса («bloats up the environment»), поэтому вынесено в отдельную опцию «on demand». PEM-сертификаты — многострочные значения в переменных окружения; часть CGI-обвязок и логгеров обрабатывает их некорректно. |
| SSLOptions +FakeBasicAuthзнач.: пароль в файле пользователей: xxj31ZMTZzkVA (DES); MD5-вариант: | — | Subject Distinguished Name (DN) клиентского сертификата X509 транслируется в имя пользователя HTTP Basic Authorization, что позволяет применять штатные механизмы аутентификации Apache для контроля доступа. Имя пользователя — это в точности Subject клиентского сертификата (можно получить командой OpenSSL: openssl x509 -noout -subject -in certificate.crt). Пароль у пользователя не запрашивается; каждая запись в файле пользователей должна содержать пароль |
| SSLOptions +LegacyDNStringFormat | — | Влияет на формат значений переменных SSL_{CLIENT,SERVER}_{I,S}_DN. Начиная с версии 2.3.11 Apache HTTPD по умолчанию использует формат, совместимый с RFC 2253: запятые как разделители атрибутов, поддержка не-ASCII символов (конвертируются в UTF8), экранирование различных спецсимволов обратными слэшами и сортировка с атрибутом “C” в конце. Если задан LegacyDNStringFormat, используется старый формат: атрибут “C” идёт первым, разделителями служат слэши, а не-ASCII и спецсимволы не обрабатываются сколько-нибудь последовательно.⚠️ Apache mod_ssl, контекст: значение SSLOptions. Доступна с 2.3.11 (с этой версии по умолчанию действует новый, RFC 2253-совместимый формат). Ровно этот флаг ломает сравнения DN в SSLRequire и записи FakeBasicAuth при переезде с 2.2 на 2.4: строка вида /C=RU/O=Foo превращается в O=Foo,C=RU. Старый формат документирован как не обрабатывающий не-ASCII и спецсимволы «in any consistent way» — сравнение по нему ненадёжно. |
| SSLOptions +OptRenegotiate | — | Включает оптимизированную обработку пересогласования (renegotiation) SSL-соединения, когда SSL-директивы используются в per-directory контексте. По умолчанию действует строгая схема: КАЖДАЯ per-directory переконфигурация параметров SSL вызывает ПОЛНЫЙ handshake пересогласования. С этой опцией mod_ssl старается избегать лишних handshake’ов, выполняя более гранулярные (но всё ещё безопасные) проверки параметров.⚠️ Apache mod_ssl, контекст: значение SSLOptions. Документация прямо предупреждает: гранулярные проверки иногда могут не соответствовать ожиданиям пользователя, поэтому опцию рекомендуется включать только per-directory, а не глобально. |
| word “in” “PeerExtList(” word “)” / PeerExtList(object-ID)знач.: object-ID: описательное имя, известное библиотеке SSL (например “nsComment”), либо числовой OID (например “1.2.3.4.5.6”) | — | Функция PeerExtList(object-ID) ожидает найти ноль или более экземпляров расширения сертификата X.509, идентифицируемого заданным object ID (OID), в клиентском сертификате. Выражение истинно, если строка слева совпадает ТОЧНО со значением расширения с этим OID (если присутствует несколько расширений с одинаковым OID, совпасть должно хотя бы одно). Object ID задаётся либо описательным именем, распознаваемым библиотекой SSL, таким как “nsComment”, либо числовым OID вида “1.2.3.4.5.6”. Значения типов, известных библиотеке SSL, приводятся к строке перед сравнением. Для расширения неизвестного библиотеке типа mod_ssl разберёт значение, если оно относится к примитивным ASN.1-типам UTF8String, IA5String, VisibleString или BMPString; для таких типов строковое значение при необходимости конвертируется в UTF-8 и затем сравнивается с левой частью выражения. В ap_expr это единственная доступная list-функция: встроенных list-функций нет, PeerExtList предоставляет mod_ssl.⚠️ Apache mod_ssl, контекст: mod_ssl; list-valued function; работает и в SSLRequire, и вне неё (в ap_expr — с оператором -in). Сравнение только на ТОЧНОЕ совпадение — подстроки и шаблоны не поддерживаются. Расширения типов, не входящих в перечисленные примитивные ASN.1-типы и не известных библиотеке SSL, не будут разобраны — условие просто не сработает, без ошибки конфигурации. Пример из документации: SSLRequire “foobar” in PeerExtList(“1.2.3.4.5.6”). |
| Require expr expression | — | Провайдер авторизации expr позволяет принимать решения о доступе на основании произвольных выражений: доступ разрешён, если выражение вычисляется в true. Именно эта директива названа в документации 2.4 заменой устаревшей SSLRequire — «the deprecated SSLRequire expressions can be replaced by Require expr». Синтаксис выражения описан в документации ap_expr (expr.html). Обычно выражение вычисляется ДО аутентификации; однако если выражение вернуло false и ссылается на переменную %{REMOTE_USER}, аутентификация будет выполнена и выражение будет вычислено повторно.⚠️ Apache mod_ssl, контекст: directory, .htaccess; Override: AuthConfig (директива Require, mod_authz_core). Доступна с 2.4.16 (для формы с обрамляющими двойными кавычками). Status: Base (mod_authz_core) — документированная замена SSLRequire. До httpd 2.4.16 обрамляющие двойные кавычки ДОЛЖНЫ быть опущены — конфиг с кавычками не запустится на старых 2.4.x, и наоборот. Вычисление до аутентификации означает, что %{REMOTE_USER} и другие post-auth переменные в общем случае пусты (исключение — описанный механизм повторного вычисления). При переносе SSLRequire → Require expr необходимо вручную проверить все сравнения <, <=, >, >= из-за разной семантики. |
| SSLOptions [+|-]option …знач.: StdEnvVars | ExportCertData | FakeBasicAuth | StrictRequire | OptRenegotiate | LegacyDNStringFormat | — | Управляет набором run-time опций SSL-движка на уровне каталога. Синтаксис записывается дословно как «SSLOptions [+|-]option …». Директива разрешена в .htaccess при Override: Options. Если несколько SSLOptions применимы к каталогу, берётся целиком самая специфичная — опции НЕ сливаются; слияние происходит только если ВСЕ опции в директиве записаны с префиксом + или -: «+» добавляет опцию к действующим, «-» убирает.⚠️ Apache mod_ssl, контекст: server config, virtual host, directory, .htaccess; Override: Options. Status: Extension (mod_ssl). Главная ловушка — смешанная запись: достаточно одной опции без знака, чтобы весь набор перестал мержиться и вложенный SSLOptions полностью вытеснил родительский. Строка Default в документации отсутствует — значение по умолчанию явно не задано (документировано лишь, что StdEnvVars «per default is disabled»). Override — именно Options, а не AuthConfig, в отличие от SSLRequire/SSLRequireSSL. |
| SSLRequire expression | — | Задаёт общее требование доступа: доступ разрешён, только если произвольно сложное булево выражение истинно. ВАЖНО: в 2.4 директива объявлена устаревшей — «SSLRequire is deprecated and should in general be replaced by Require expr». Синтаксис ap_expr, используемый Require expr, является надмножеством синтаксиса SSLRequire с одним исключением — семантикой операторов сравнения. Контекст: directory, .htaccess; Override: AuthConfig (то есть в .htaccess работает только при AllowOverride AuthConfig). Выражение разбирается во внутреннее машинное представление при загрузке конфигурации и вычисляется при обработке запроса; в контексте .htaccess выражение и разбирается, и исполняется каждый раз, когда файл .htaccess встречается в ходе обработки запроса.⚠️ Apache mod_ssl, контекст: directory, .htaccess; Override: AuthConfig. Status: Extension (mod_ssl), DEPRECATED в 2.4. Три ловушки сразу. (1) Deprecated: писать новую конфигурацию на SSLRequire нельзя, документированная замена — Require expr. (2) Операторы <, <=, >, >= здесь НЕ те же, что в ap_expr (см. отдельную строку про семантику) — механический перенос выражения в Require expr меняет результат сравнений. (3) Отказ, вынесенный SSLRequire, может быть перекрыт другими директивами авторизации, если не включён SSLOptions +StrictRequire. Плюс в .htaccess выражение перепарсивается на каждый запрос — это стоимость на горячем пути. |
| SSLRequireSSL | — | Запрещает доступ, если для текущего соединения не включён HTTP over SSL (то есть HTTPS). Очень удобна внутри SSL-включённого виртуального хоста или каталогов для защиты от ошибок конфигурации, раскрывающих то, что должно быть защищено. При наличии этой директивы отклоняются все запросы, не использующие SSL. Аргументов нет.⚠️ Apache mod_ssl, контекст: directory, .htaccess; Override: AuthConfig. Status: Extension (mod_ssl). Не заменяет редирект на HTTPS: директива именно ОТКАЗЫВАЕТ в доступе, а не перенаправляет. Как и SSLRequire, её отказ может быть перекрыт другими директивами авторизации, если не задан SSLOptions +StrictRequire. Контекст ограничен directory/.htaccess — в server config и virtual host она не объявлена. |
| SSLOptions +StdEnvVars | disabled | Включает создание стандартного набора SSL-переменных окружения CGI/SSI. По умолчанию выключено из соображений производительности, так как шаг извлечения информации — довольно дорогая операция; обычно включается только для CGI- и SSI-запросов.⚠️ Apache mod_ssl, контекст: значение SSLOptions. Без StdEnvVars переменные SSL_* не появятся в окружении CGI/SSI — но на доступность %{SSL_*} внутри SSLRequire это не влияет: там переменные читаются напрямую. Включать глобально дорого, документация прямо рекомендует ограничивать |
| SSLOptions +StrictRequire | — | Принудительно запрещает доступ, если SSLRequireSSL или SSLRequire решили, что доступ должен быть запрещён. Без StrictRequire другие директивы авторизации (например, |
| base64(word) | %{base64:funcargs} | — | Кодирует строку в base64.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Кодирование, а не шифрование и не хеш — значение тривиально обратимо; для сравнения секретов непригодно. |
| comp ::= word “==” word | word “eq” word | word “!=” word | word “ne” word | word “<” word | word “lt” word | word “<=” word | word “le” word | word “>” word | word “gt” word | word “>=” word | word “ge” word | word “in” “{” wordlist “}” | word “in” “PeerExtList(” word “)” | word “=~” regex | word “!~” regex | — | Полный список форм сравнения в SSLRequire: равенство ==/eq, неравенство !=/ne, порядковые |
| env(word) | %{env:funcargs} | — | Возвращает первое совпадение среди note, reqenv, osenv — именно в этом порядке.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function; помечена «ordering». Порядок поиска фиксирован (note → reqenv → osenv), поэтому note может неожиданно затенить одноимённую переменную окружения запроса. Плюс та же оговорка «ordering» про раннее вычисление в |
| escape(word) | %{escape:funcargs} | — | Экранирует специальные символы в %hex-кодировании.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Обратная операция unescape не полностью симметрична: она оставляет закодированные слэши нетронутыми и возвращает пустую строку при встрече %00 — цикл escape→unescape не гарантирует исходную строку. |
| expr ::= “true” | “false” | “!” expr | expr “&&” expr | expr “” expr | “(” expr “)” | comp | — | Булев остов грамматики SSLRequire: литералы true и false, отрицание !, конъюнкция &&, дизъюнкция , скобочная группировка и сравнение (comp). Выражение должно соответствовать этой BNF-грамматике. |
| expr ::= “true” | “false” | “!” expr | expr “&&” expr | expr “||” expr | “(” expr “)” | comp ; comp ::= stringcomp | integercomp | unaryop word | word binaryop word | word “in” “{” wordlist “}” | word “in” listfunction | word “=~” regex | word “!~” regex | — | Стартовая точка грамматики ap_expr для булевых выражений — expr. Формы сравнения: строковое сравнение (stringcomp), целочисленное (integercomp), унарный оператор с одним операндом, бинарный оператор между двумя word, принадлежность списку литералов или списку из list-функции, а также =~ / !~ с регулярным выражением. Для директив вроде LogMessage, принимающих выражения со строковым значением, стартовой точкой в BNF служит string.⚠️ Apache mod_ssl, контекст: ap_expr. В отличие от SSLRequire, в ap_expr появились отдельные категории unaryop и binaryop — операторы с ведущим дефисом. Из-за этого выражение, валидное в SSLRequire, может распарситься в ap_expr иначе; и наоборот, унарные операторы недоступны в SSLRequire. |
| file(word) | %{file:funcargs} | — | Читает содержимое файла (включая символы конца строк, если они присутствуют).⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function; помечена как RESTRICTED — по документации restricted-функции недоступны в некоторых модулях, например mod_include; то же ограничение действует в контекстах с урезанными правами, в частности при обработке в .htaccess. Status: restricted. Функция помечена «restricted» — там, где ограничение действует (модули вроде mod_include, а также .htaccess), она недоступна, и выражение с ней работать не будет. Содержимое возвращается ВМЕСТЕ с завершающим переводом строки — сравнение file(‘/path’) == ‘secret’ почти всегда ложно из-за \n в конце. Плюс чтение файла на каждый запрос. |
| filesize(word) | %{filesize:funcargs} | — | Возвращает размер файла (или 0, если файл не существует или не является обычным файлом).⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function; помечена как RESTRICTED — по документации restricted-функции недоступны в некоторых модулях, например mod_include; то же ограничение действует в контекстах с урезанными правами, в частности при обработке в .htaccess. Status: restricted. Отсутствие файла и пустой файл неразличимы — оба дают 0; чтобы отличить, нужен -f или -e. Функция «restricted»: в mod_include-подобных модулях и в .htaccess-контекстах она недоступна. Возвращает строку, поэтому для сравнения размеров нужны целочисленные операторы -gt/-lt, а не > и <. |
| ldap(word) | %{ldap:funcargs} | — | Экранирует символы согласно правилам экранирования LDAP distinguished name (RFC4514) и экранирования LDAP-фильтров (RFC4515). Доступна в httpd 2.4.53 и более поздних версиях.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Доступна с 2.4.53. Версионная функция: на httpd < 2.4.53 конфигурация с ldap() не загрузится. Одна функция покрывает два разных набора правил (RFC4514 для DN и RFC4515 для фильтров) — документация не разделяет их отдельными вызовами. |
| md5(word) | %{md5:funcargs} | — | Хеширует строку алгоритмом MD5, затем кодирует хеш шестнадцатеричным представлением.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Результат — hex-строка в нижнем регистре (пример из документации: md5(‘foo’) == ‘acbd18db4cc2f85cedef654fccc4a4d8’), поэтому сравнение с hex в верхнем регистре не совпадёт. MD5 криптографически скомпрометирован — не использовать для решений безопасности. |
| note(word) | %{note:funcargs} | — | Поиск note запроса (внутренней пометки запроса).⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function; помечена «ordering». Помечена «ordering»: требует внимания к порядку выполнения компонентов сервера, особенно при использовании внутри директивы |
| osenv(word) | %{osenv:funcargs} | — | Поиск переменной окружения операционной системы.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Это окружение процесса сервера, а не окружение запроса: значения задаются до старта httpd (например, через PassEnv/SetEnv на уровне ОС), и запросом не меняются. В таблице функций пометки «ordering» у osenv нет, но в описании упорядочивания она упомянута как часть цепочки env. |
| req(word) | %{req:funcargs} | http(word)знач.: алиас: http | — | Получить заголовок HTTP-запроса; имена заголовков могут добавляться в заголовок Vary ответа.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Побочный эффект: имя заголовка автоматически добавляется в Vary ответа HTTP, кроме случаев, отдельно оговорённых для директивы, принимающей выражение. Это меняет кэширование. Чтобы этого избежать, документация предписывает req_novary. |
| req_novary(word) | %{req_novary:funcargs} | — | То же, что req, но имена заголовков НЕ будут добавляться в заголовок Vary. Раздел Version History страницы expr.html фиксирует: функция доступна начиная с версии 2.4.4.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Доступна с 2.4.4. Единственная функция с явно задокументированной версией появления, помимо ldap. На httpd < 2.4.4 конфигурация с req_novary не загрузится. Используйте её осознанно: подавляя Vary, вы берёте на себя ответственность за корректность кэширования вариативного ответа. |
| reqenv(word) | %{reqenv:funcargs}знач.: сокращение: v | — | Поиск переменной окружения запроса. В описании этой функции документация в скобках указывает: в качестве сокращения для доступа к переменным можно также использовать v — это единственное упоминание имени v на странице, и отдельной функции v в списке функций НЕТ. Функция позволяет проверять специальные переменные окружения (такие как no-gzip, nokeepalive и т. п.), а также любые переменные, установленные через SetEnv, SetEnvIf или mod_rewrite.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function; помечена «ordering». Ловушка порядка вычисления («ordering»): при обращении к переменным окружения внутри |
| resp(word) | %{resp:funcargs} | — | Получить заголовок HTTP-ответа (большинство заголовков ответа ещё не будут установлены во время |
| sha1(word) | %{sha1:funcargs} | — | Хеширует строку алгоритмом SHA1, затем кодирует хеш шестнадцатеричным представлением.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Как и у md5 — hex в нижнем регистре. Соли или итераций нет: это «сырой» хеш строки, для проверки паролей непригоден. |
| tolower(word) | %{tolower:funcargs} | — | Преобразует строку в нижний регистр.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Имена функций нечувствительны к регистру (TOLOWER == tolower), а вот содержимое строк — чувствительно. Для регистронезависимого сравнения часто проще применить /regexp/i или -strcmatch, чем нормализовать обе стороны. |
| toupper(word) | %{toupper:funcargs} | — | Преобразует строку в верхний регистр.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Документация не описывает поведение на не-ASCII и локаль-зависимые правила — полагаться на корректный регистр многобайтных символов нельзя. |
| unbase64(word) | %{unbase64:funcargs} | — | Декодирует строку из base64; возвращает УСЕЧЁННУЮ строку, если встречен байт 0x00.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Усечение по 0x00 — тихое: декодированные бинарные данные обрезаются без ошибки, и сравнение может неожиданно совпасть с префиксом. Классический риск при разборе Authorization: Basic. |
| unescape(word) | %{unescape:funcargs} | — | Декодирует строку в %hex-кодировании, ОСТАВЛЯЯ закодированные слэши нетронутыми; возвращает пустую строку, если обнаружен %00.⚠️ Apache mod_ssl, контекст: ap_expr, string-valued function. Два неочевидных поведения сразу: %2F не превращается в / (защита от обхода путей), а любой %00 обнуляет ВЕСЬ результат — условие, построенное на unescape, при подсунутом %00 сравнивается с пустой строкой. Это надо учитывать при написании правил безопасности. |
| word ::= word “.” word | digit | “'” string “'” | ‘"’ string ‘"’ | variable | rebackref | function ; variable ::= “%{” varname “}” | “%{” funcname “:” funcargs “}” ; rebackref ::= “$” [0-9] ; function ::= funcname “(” word “)” ; listfunction ::= listfuncname “(” word “)” | — | Операнды ap_expr: конкатенация через точку, целое, строка в одинарных или двойных кавычках, переменная, обратная ссылка на регулярное выражение или вызов функции. Переменная записывается либо как %{varname}, либо как %{funcname:funcargs} — вторая форма эквивалентна вызову функции внутри строковой подстановки. Функция принимает один word и возвращает строку; list-функция возвращает список строк для оператора -in.⚠️ Apache mod_ssl, контекст: ap_expr. Две формы вызова функции — funcname(word) и %{funcname:funcargs} — не взаимозаменяемы синтаксически: первая допустима в булевом контексте выражения, вторая используется внутри строк и в directive-аргументах вида “expr=%{md5:foo}”. Строки в ap_expr можно писать и в одинарных кавычках — в SSLRequire грамматика допускает только двойные (cstring ::= “…”). |
| word ::= digit | cstring | variable | function ; wordlist ::= word | wordlist “,” word ; digit ::= [0-9]+ ; cstring ::= “…” ; variable ::= “%{” varname “}” ; function ::= funcname “(” funcargs “)” | — | Операнды SSLRequire: целое число, строка в двойных кавычках, переменная вида %{varname} или вызов функции funcname(funcargs). Список wordlist — перечисление word через запятую. В качестве varname может использоваться любая из переменных, описанных в разделе Environment Variables. В качестве funcname доступные функции перечислены в документации ap_expr.⚠️ Apache mod_ssl, контекст: SSLRequire. Набор функций SSLRequire в 2.4 не перечислен на странице mod_ssl — документация переадресует к списку функций ap_expr (плюс собственная list-функция PeerExtList mod_ssl). Старые функции 2.2-эпохи (strmatch, oidmatch и т. п.) на странице 2.4 не документированы — опираться на них в конфиге, ссылаясь на эту страницу, нельзя. |
Версии и подводные камни:
SSLRequireразграничивает доступ на уровне веб-сервера: правило живёт в конфиге, требует перезапуска и не видно приложению.
FakeBasicAuthподставляет отличительное имя клиента как имя пользователя HTTP Basic; в файле паролей для таких записей используется фиксированная строка-пароль.Сверено с: https://httpd.apache.org/docs/2.4/mod/mod_ssl.html, https://httpd.apache.org/docs/2.4/expr.html, https://httpd.apache.org/docs/2.4/mod/mod_authz_core.html
Отозванный сертификат остаётся криптографически корректным: цепочка сходится, срок не вышел, подпись верна. Если сервер не проверяет отзыв — уволенный сотрудник продолжает заходить.
Механизма два, и они ведут себя по-разному при сбое.
CRL — статический файл:
ssl_crl /etc/nginx/crl/PersonIntermediateCA.crl.pem;
Две вещи, на которых спотыкаются.
Первая: nginx требует PEM, а по адресу из crlDistributionPoints традиционно раздают DER. Это разные файлы, и держать нужно оба. В движке публикация делает оба сразу:
/export/crl/PersonIntermediateCA.crl ← DER, для раздачи по CDP /export/crl/PersonIntermediateCA.crl.pem ← PEM, для ssl_crl
Вторая, и куда более болезненная: просроченный CRL строже отсутствующего. Нет директивы ssl_crl — проверки нет, пускают всех. Есть директива, а файл устарел — nginx отвергает всех клиентов. Это правильное поведение («не смог проверить» и «проверил, всё хорошо» — разные вещи), но оно означает, что включать ssl_crl можно только вместе с регулярным перевыпуском. Забытый cron кладёт вход всей организации ровно в момент nextUpdate.
OCSP спрашивает статус конкретного сертификата онлайн:
ssl_ocsp leaf; ssl_ocsp_responder http://ocsp.certservice.info/; resolver 127.0.0.11 valid=300s ipv6=off;
resolver обязателен: без него nginx не может разрешить имя ответчика, и проверка молча не работает — те же грабли, что со stapling в Части 2.
leaf вместо on означает «проверять только сам клиентский сертификат, не всю цепочку». Для промежуточного CA отзыв проверяется по CRL корня, и делать это на каждом рукопожатии незачем.
Что выбрать? Если клиентов немного и они внутри организации — OCSP: свежее и не требует помнить про cron. Если ответчик может быть недоступен — CRL, потому что файл переживает недоступность сети. На боевом стенде этой статьи включён OCSP, а строка ssl_crl оставлена закомментированной с объяснением почему.
Всё, что мы настроили до сих пор, работает — но пользоваться этим неприятно, и вот почему.
При ssl_verify_client optional на всём виртуальном хосте браузер получает CertificateRequest на первом же соединении с сайтом. Не после нажатия «Войти», не на странице входа — на любом запросе, включая тот, которым человек просто открыл главную. Всплывает модальное окно выбора сертификата, и человек, который зашёл почитать, вынужден на него отвечать.
Хуже другое. Браузер запоминает ответ — в том числе отказ.
В Chromium решение кладётся в SSLClientAuthCache; ключ — пара «хост и порт», и комментарий в исходниках прямо говорит, что пустой сертификат означает предпочтение «этому серверу сертификат не слать». Кэш живёт в памяти и переживает всё, кроме перезапуска браузера. В Firefox то же самое делает nsIClientAuthRememberService, причём ключ там — хост и контейнер, без порта: example.com:443 и example.com:8443 делят одно решение. Отказ сохраняется наравне с выбором.
Практический вывод: человек один раз машинально нажал «Отмена» — и вход по сертификату для него перестал существовать. Никакого «попробуйте ещё раз» не будет: браузер молча предъявит пустой список и уйдёт. Пользовательского способа сбросить это решение в Chrome нет вовсе; в Firefox спасает приватное окно или контейнерная вкладка, потому что у них другой набор атрибутов происхождения.
Значит, спрашивать сертификат надо не у всех и не всегда, а только когда человек об этом попросил. То есть по кнопке.
Вариантов три.
Отдельный location на том же хосте. Не работает: ssl_verify_client доступна только в контекстах http и server. Прикрыть сертификатом один URL этой директивой нельзя — рукопожатие происходит до того, как сервер узнает путь.
Отдельный порт. Работает и проще всего в настройке, но адрес вида https://certservice.info:9443/ выглядит как ошибка и плохо переживает корпоративные прокси.
Отдельное имя хоста. Выбрано это: login.certservice.info. Публичный сайт сертификат не спрашивает вовсе, а кнопка ведёт на хост входа, где его спрашивают всегда.
Проверим, что получилось. Проба обходит все четыре сценария и смотрит, приходит ли в рукопожатии сообщение CertificateRequest:
SNI=certservice.info (публичный) CN=certservice.info CertificateRequest: нет рукопожатие прошло SNI=login.certservice.info (вход) CN=login.certservice.info CertificateRequest: ДА рукопожатие прошло БЕЗ SNI (default_server) CN=— CertificateRequest: нет рукопожатие ОТКЛОНЕНО SNI=чужое.имя CN=— CertificateRequest: нет рукопожатие ОТКЛОНЕНО
Именно то, что нужно: модалка не появляется нигде, кроме хоста входа.
Разделение на два виртуальных хоста выглядит тривиальным. Оно таким не является.
Грабля первая: один сертификат на два имени. Естественное желание — выписать один серверный сертификат с обоими именами в subjectAltName. Так делать нельзя. Браузер имеет право переиспользовать одно HTTP/2-соединение для всех имён, покрытых предъявленным сертификатом, — это называется connection coalescing и введено ради скорости. В результате запрос к login.certservice.info уедет в соединение, согласованное под SNI certservice.info, где CertificateRequest никто не присылал. nginx честно ответит 421 Misdirected Request, а вход просто не заработает — причём плавающим образом, в зависимости от того, что браузер успел открыть раньше.
Лечение: два разных сертификата без общих имён. У нас так:
server CN=certservice.info SAN: DNS:certservice.info, DNS:www.certservice.info login CN=login.certservice.info SAN: DNS:login.certservice.info
Грабля вторая: кто такой default_server. Клиент, не приславший SNI, попадает в сервер по умолчанию. Если по умолчанию окажется хост входа — под клиентскую аутентификацию попадут все подряд: старые интеграции, проверки доступности, сканеры. Если наоборот, публичный, — получится «мягкая посадка» там, где ожидалась строгая. Правильный ответ: сервер по умолчанию не должен быть ни тем, ни другим.
server { listen 443 ssl default_server; ssl_reject_handshake on; # появилась в 1.19.4 }
Обе последние строки пробы выше — это она: и без SNI, и с незнакомым SNI рукопожатие отклоняется, сертификат сервера даже не показывается.
Грабля третья: SNI и Host — разные вещи. Виртуальный сервер выбирается по SNI на этапе TLS, а потом заново, по заголовку Host, на этапе HTTP. Совпадения nginx по умолчанию не требует. Значит, можно установить соединение с SNI публичного хоста (сертификат не спросят) и отправить в нём запрос с Host: login.certservice.info.
У nginx есть встроенная защита, дающая 421, но срабатывает она только если у целевого сервера включён ssl_verify_client и SNI вообще был прислан. Полагаться на это не стоит — проще потребовать совпадения явно, в обоих блоках:
if ($host != $ssl_server_name) { return 421; }
Проверка на стенде: curl с SNI публичного хоста и подменённым Host получает HTTP 421.
Ровно эта конструкция — два виртуальных хоста с разной политикой клиентских сертификатов — попадает в известную уязвимость и в последовавшую за ней регрессию:
Версии | Что происходит |
|---|---|
до 1.27.4 / 1.26.3 | CVE-2025-23419: возобновление TLS 1.3-сессии позволяет переиспользовать её в другом виртуальном сервере и обойти проверку клиентских сертификатов |
1.27.4 – 1.29.1 | уязвимость закрыта, но рукопожатие падает при возобновлении сессии с другим значением SNI |
1.29.2 и новее | исправлено и то, и другое |
Стенд статьи собран на 1.29.8. Если вы повторяете схему на более старой ветке — либо обновляйтесь, либо отключайте возобновление сессий, потому что переносить кэш сессий в отдельный server не помогает: сессии возобновляются глобально.
Человек предъявил сертификат на login.certservice.info. Сессия нужна на certservice.info. Cookie между ними сама не переедет.
Соблазн — расширить её атрибутом Domain=certservice.info. Это ошибка, и ошибка неочевидная: по RFC 6265 атрибут Domain всегда включает поддомены, то есть Domain=certservice.info в точности эквивалентен .certservice.info. Сессия начала бы уходить на pki.certservice.info и ocsp.certservice.info — публичные статические хосты того же макета, которые про сессии ничего не знают и в чьих журналах доступа она немедленно окажется.
Поэтому cookie остаётся host-only, а между хостами передаётся одноразовый пропуск.
Наивная реализация — редирект с ?token=… — ломается сразу в двух местах.
Первое: пропуск в адресной строке попадает в журнал доступа nginx, в историю браузера и в заголовок Referer при первом же переходе по внешней ссылке. Лечится передачей методом POST: хост входа отдаёт крохотную страницу с формой, которая отправляется сама, а без JavaScript — по видимой кнопке.
Второе, и куда серьёзнее: login CSRF. Если пропуск привязан только к пользователю, атакующий получает его своим сертификатом и подсовывает браузеру жертвы. Жертва молча оказывается в сессии атакующего и продолжает работать там, считая её своей: подаёт заявки, загружает CSR, а видит их потом атакующий. Классическая и очень недооценённая атака.
Лечение — привязать пропуск к тому браузеру, который начал вход. Поток становится трёхшаговым:
Публичный хост, /login/start. Заводит случайное «состояние», кладёт его в cookie __Host-ca_portal_login и запоминает у себя вместе с адресом возврата. Префикс __Host- не случаен: он запрещает атрибут Domain и требует Secure и Path=/, то есть cookie гарантированно host-only, и подложить её с соседнего поддомена нельзя.
Хост входа, /login/cert?state=…. Здесь nginx уже потребовал сертификат. Портал проверяет его, находит учётную запись и выдаёт пропуск, привязанный к названному состоянию. Пропуск под выдуманное состояние не выдаётся вовсе.
Публичный хост, POST /login/exchange. Пропуск гасится (одноразово, при любом исходе), состояние из тела сверяется с состоянием из cookie постоянным по времени сравнением, и только при совпадении выдаётся сессия.
Адрес возврата — тот самый next — не путешествует между хостами. Он лежит на сервере рядом с состоянием, и после обмена берётся оттуда. Иначе получился бы открытый редирект: человек нажимает «Войти», успешно проходит аутентификацию и уезжает на чужой сайт уже в доверенном состоянии.
Проверки на стенде:
Шаг 1: перенаправление на хост входа ok Cookie состояния выставлена (__Host-) ok Шаг 2: сертификат принят, пропуск выдан ok Шаг 3: обмен пропуска на сессию переход на https://certservice.info:8443/audit Вошли на публичном хосте (без модалки) HTTP 200 Повторное использование пропуска HTTP 303 (одноразовый)
Прежде чем писать код, я отдал описание схемы на разбор трём ИИ-агентам. Запускались они независимо друг от друга, не видели ответов соседей и получали разные углы атаки: первый искал, как украсть пропуск, второй — как подменить сессию, третий — как злоупотребить выпуском. Задание каждому формулировалось не «оцени», а «опровергни»: найди работающую атаку, а если сомневаешься — считай схему уязвимой. Вердикт совпал у всех трёх — «небезопасна», и почти сорок конкретных сценариев.
Дальше — важная оговорка, без которой этот приём вредит больше, чем помогает. Агент — не аудитор: он одинаково уверенно выдаёт и настоящую атаку, и правдоподобную выдумку, а формулировка «опровергни» этот перекос ещё и усиливает. Поэтому список находок — не приговор, а очередь на проверку: каждый принятый пункт я воспроизводил на стенде, и часть сценариев там же и отсеялась, не сойдясь с тем, что веб-сервер делает на самом деле.
Большинство того, что подтвердилось, я уже перечислил выше. Но одна находка била не в новую схему, а в уже написанный и работающий код, и стоит отдельного разбора.
Портал доверяет заголовкам X-SSL-Client-*, когда подтверждён прокси. Пока хост один, всё честно: nginx выставляет эти заголовки в каждом запросе, затирая то, что прислал клиент. Но как только появляется второй виртуальный хост, который сертификат не запрашивает и, следовательно, эти заголовки не ставит, — nginx проксирует наверх клиентские заголовки как самые обычные. И тогда:
curl -k https://certservice.info:8443/me \ -H 'X-SSL-Client-Verify: SUCCESS' \ -H 'X-SSL-Client-S-DN: /O=CertService/CN=bootstrap administrator'
даёт вход администратором. Проверка «прокси доверенный» здесь бесполезна: прокси действительно доверенный, подделаны данные.
Лечение с двух сторон. В nginx публичный хост обязан стереть заголовки явно — пустое значение у proxy_set_header означает «не передавать вовсе»:
proxy_set_header X-SSL-Client-Verify ""; proxy_set_header X-SSL-Client-S-DN ""; proxy_set_header X-SSL-Client-I-DN ""; proxy_set_header X-SSL-Client-Serial ""; proxy_set_header X-SSL-Client-V-End ""; proxy_set_header X-SSL-Client-Cert "";
А в приложении — правило, которое не зависит от конфигурации веб-сервера: заголовки о сертификате осмысленны только на том хосте, который его запрашивает, и на всех прочих игнорируются целиком.
// Заголовки клиентского сертификата осмысленны ТОЛЬКО на хосте, который его запрашивает. if trusted && !s.onCertLoginHost(r) && r.Header.Get(s.cfg.HeaderVerify) != "" { id.Notes = append(id.Notes, "Заголовки клиентского сертификата пришли на публичный хост и потому не учитываются: "+ "их выставляет только хост входа.") trusted = false }
Проверка на стенде после исправления: тот же curl получает HTTP 303 — его отправляют на страницу входа.
Мораль тут не про конкретную ошибку. Схема была безопасной ровно до тех пор, пока хост был один; добавление второго виртуального хоста молча превратило её в дырявую, не изменив ни строчки в том коде, который стал уязвимым. Именно такие изменения и стоит отдавать на разбор до того, как они уедут в прод.
Вход по сертификату работает, и работает так, как его ждёт человек, а не так, как удобнее серверу. Мы:
разобрали, что именно проверяет сервер при клиентской аутентификации и почему список CA важен не только для доверия, но и для того, что увидит пользователь в браузере;
включили mTLS в nginx и Apache, разобрав все директивы и все переменные, которыми поля сертификата попадают в приложение;
поняли, почему заголовкам от прокси нельзя верить без подтверждения — и что CSRF-токен нужен даже при входе по сертификату;
разобрали развилку «прокси или приложение»: что стандартная библиотека Go проверяет сама, чего не проверяет никогда и почему общий корень — не граница доверия;
настроили проверку отзыва клиентов через CRL и OCSP и увидели, чем просроченный CRL опаснее отсутствующего;
сделали вход по кнопке: публичный сайт не спрашивает сертификат вовсе, а перенос сессии между хостами защищён одноразовым пропуском, привязанным к состоянию браузера;
нашли и закрыли дыру в уже работавшем коде — её подсказала состязательная проверка схемы ИИ-агентами, а подтвердил стенд.
Повторяете у себя — не забудьте, что стенд статьи опубликован на нестандартном порту
8443, потому что 443 на том хосте занят другим сервисом. На выделенном хосте достаточно поменять левую часть публикации порта.
И вопрос к вам — он же опрос под статьёй. Развилка «кто проверяет сертификат» решается по-разному даже внутри одной компании: браузерам ставят прокси, а сервисы проверяют друг друга сами. Интересно, как это распределено на практике. А если в комментариях расскажете, на чём именно обожглись, — тем полезнее будет следующая часть.
Потому что теперь сервер умеет проверять сертификаты, и остался последний вопрос: а откуда у людей эти сертификаты берутся?
Все три части мы выпускали их руками: openssl req, openssl ca, скопировать файл, собрать PKCS#12, передать пароль отдельным каналом. Пока администратор один, это работает. На пятом человеке начинается «а мне тоже выпусти», на десятом — «чей это сертификат в index.txt и кто вообще разрешил его выпустить», а на первом же увольнении выясняется, что отзывать некому и непонятно, что именно. Ручная церемония не масштабируется — и, что хуже, не оставляет следов.
Значит, нужны заявки, выпуск по правилам, роли, журнал действий и ответ на вопрос «кто и на каком основании это подписал». Об этом — Часть 4: свой web-УЦ, генерация ключа прямо в браузере (ключ не уезжает на сервер вообще) и разбор архитектурных развилок — включая ту, из-за которой ключ удостоверяющего центра не должен находиться в одном процессе с веб-приложением.
Всё в статье сверено с официальной документацией; реферальных и сокращённых ссылок нет.
Веб-серверы:
nginx — ngx_http_ssl_module — ssl_verify_client, ssl_client_certificate, ssl_crl, ssl_ocsp*, переменные $ssl_client_*
nginx — ngx_http_proxy_module — proxy_set_header и передача заголовков
nginx — ngx_http_core_module — resolver
Apache — mod_ssl — SSLVerifyClient, SSLCA*, SSLOCSP*, SSLOptions, SSLRequire, переменные окружения
Apache — mod_headers — RequestHeader
nginx — CHANGES и security advisories — CVE-2025-23419 и регрессия возобновления сессий с другим SNI
nginx — server_names — выбор виртуального сервера по SNI и по Host
Документация OpenSSL (ветка master):
openssl-pkcs12 — контейнер для браузера
openssl-ca — политика выпуска, -updatedb, copy_extensions
openssl-verify и openssl-verification-options — -purpose sslclient
x509v3_config — keyUsage, extendedKeyUsage, subjectAltName
Стандартная библиотека Go (для развилки «проверяет само приложение»):
crypto/tls — Config.ClientAuth, ClientCAs, VerifyPeerCertificate, VerifyConnection, GetConfigForClient, ConnectionState
crypto/x509 — Certificate.Verify, VerifyOptions, ParseRevocationList, RevocationListEntry
net/http — Request.TLS, Server.TLSConfig, Server.ErrorLog, ListenAndServeTLS
Исходники crypto/tls — handshake_server.go, handshake_server_tls13.go, common.go: порядок значений ClientAuthType, отправка certificate_authorities, поведение при возобновлении сессии
golang.org/x/crypto/ocsp — OCSP вне стандартной библиотеки
Стандарты (RFC):
RFC 6265 — HTTP State Management: атрибут Domain и префиксы имён cookie
RFC 8446 — TLS 1.3: CertificateRequest, CertificateVerify, отсутствие пересогласования
RFC 5246 — TLS 1.2, клиентская аутентификация
RFC 5280 — профиль X.509: keyUsage, extendedKeyUsage, subjectAltName, а также §6.3.3 — проверка отзыва как обязательный шаг валидации пути и статус UNDETERMINED
RFC 6960 — OCSP
RFC 7292 — PKCS#12
Код:
github.com/DidenkoMS/ssl_article — репозиторий цикла: дерево УЦ, конфиги и docker-макет
github.com/DidenkoMS/pkidesk — движок PKIDesk, выросший из этой части в отдельный проект