TLS обычно просто работает: сертификат выпущен, замок зелёный, вопросов нет. Вопросы начинаются, когда клиент отказывается доверять сертификату, а сообщение об этом ничего не объясняет.
Эта статья устроена вокруг стенда. Один сервер, четыре клиента —
openssl, curl, Go и Java — и десять способов сломать цепочку доверия
по одному признаку за раз. Дальше — что видно в трафике до всякого
шифрования, чем взаимное рукопожатие отличается от обычного и сколько
сообщений нужно, чтобы договориться.
Два ожидания, с которыми я подходил к стенду, замеры опровергли. Об этом ниже прямо: полезнее, чем пересказ того, что и так все знают.
digital-cookbook/security/tls-fundamentalsВ статье
- матрица из 40 клеток: 10 поломок цепочки на 4 клиентах;
- почему исход одинаковый, а разница — в сказанном;
- четыре клиента, но три независимые реализации: OpenSSL считает за двоих;
- имя сервера открытым текстом: 447 байт у одного клиента против 1567 у другого;
- взаимное рукопожатие и то, что отказ приходит уже после его конца;
- 8 конкретных байт, которыми сервер сообщает о понижении версии;
- сколько сообщений нужно на самом деле: 8 против 7, а не «вдвое меньше»;
- чего стенд не мерил и почему это сказано вслух.
Условия замера
Читать числа ниже нужно вместе с условиями. Без них они ничего не значат.
Клиенты берутся из образов, а не с машины автора. curl 8.18.0,
OpenSSL 3.5.8, Java 25 — контейнерами; клиент на Go 1.27 собирается из
исходников стенда. Это не аккуратность ради аккуратности: правило
пришлось вывести дважды, и оба раза дорого.
Хостовый curl под Windows собран не с OpenSSL, а с системным движком, и
он отверг целую цепочку — потребовал состояния отзыва, которого для
частного удостоверяющего центра взять негде. Вся его колонка стала бы
ложными отказами, и выглядели бы они совершенно правдоподобно.
На той же машине нашлось два разных OpenSSL, и какой из них сработает, решал порядок каталогов в пути. Один печатал сведения о сессии, другой нет. Фикстура молча зависела бы от того, из какой оболочки её сняли.
Сертификаты выпущены стендом и датированы от жёстко заданной опорной даты. Просроченный обязан быть просроченным всегда: отсчёт от «сегодня» через год показывал бы другое, и никто бы не заметил.
Доверенный центр передаётся клиентам явно, системное хранилище не используется — иначе результат зависел бы от машины.
Сервер один и тот же для всех четверых. С отдельным сервером на клиента расхождение исходов нельзя было бы отделить от расхождения серверов.
Клиентов четыре, а независимых реализаций три. openssl и curl
работают на одной библиотеке — OpenSSL 3.5.8 у первого и OpenSSL 3.5.4
внутри второго; отдельные семьи только у Go и у Java. Это существенно
для всего, что ниже: два клиента одной семьи подтверждают друг друга
слабее, чем два независимых.
Версии библиотеки у них всё же разные, так что полностью избыточными их считать тоже нельзя. Семья каждого клиента выводится из строки версии и записывается в фикстуру — это величина, а не оговорка на память.
Прогон снят на двух платформах — Windows и Linux, — и это проверяемо. Каждый прогон оставляет сводку опубликованных величин вместе с версиями среды; обе сводки лежат в стенде, и сравнивает их не автор, а проверка. Все числа ниже совпали.
Сравниваются именно величины, а не хеши файлов: фикстуры содержат случайные поля рукопожатия и различаются при каждом прогоне даже на одной машине, так что хеш создал бы только видимость строгости. Строка версии Go из сравнения исключена явно — она содержит имя платформы, и отдельная проверка требует, чтобы она различалась: совпади она, обе сводки были бы сняты на одной машине.
Понадобилось это не для красоты. В первой версии стенда выпуск сертификатов звал openssl и python с машины автора, и на другой системе прогон падал, хотя все проверки были зелёными.
Что изолировано в стенде, и что осталось за его пределами
Цепочка доверия отвечает на один вопрос: подтверждает ли кто-то, кому вы уже доверяете, что этот открытый ключ принадлежит этому имени.
Проверок, из которых складывается ответ, много. Стенд изолирует четыре группы — те, что удаётся сломать по одной за раз, не задев остальные:
- путь к корню — цепочка подписей от предъявленного сертификата до того, что лежит в хранилище доверенных;
- окно действия — сегодняшняя дата внутри срока;
- имя — запрошенное имя есть в списке имён сертификата;
- порядок и состав присланного — что именно сервер положил в цепочку и в каком порядке.
Это не полный состав проверки. Полный описан в RFC 5280, и в нём есть как минимум ещё вот что:
- ограничение «это удостоверяющий центр» — сертификат без такого признака не имеет права подписывать другие; без этой проверки любой выданный лист становился бы центром;
- ограничение длины пути — сколько промежуточных центров разрешено ниже; стенд его задаёт (в глубокой ветке пришлось поднять, иначе второй промежуточный не выпустился бы), но не ломает;
- ограничения на имена и политики — центру можно разрешить подписывать только определённые домены;
- критичные расширения — расширение, помеченное критичным и непонятое клиентом, обязано приводить к отказу;
- ограничения алгоритмов — слабую подпись клиент вправе не принять независимо от того, что цепочка сходится.
Ни одно из этих условий стенд не ломает, и утверждать о них по его фикстурам нечего. Названы они здесь потому, что без них читатель унесёт из статьи модель «проверяется четыре вещи», а она неверна.
Отдельно стоит отзыв: он не живёт в сертификате вовсе. Об этом дальше.
Ось 1: десять поломок на четырёх клиентах
Матрица — 10 случаев на 4 клиентах, 40 клеток. Контрольных случаев два: обычная цепочка и отдельный контроль для пары о порядке сертификатов, у которой другая длина пути.
Прогноз был записан в стенд до запуска. Он совпал во всех клетках — и в том числе совпало главное ожидание, записанное там же честно: матрица выйдет скучной.
Она и вышла. Во всех десяти строках все четыре клиента ответили одинаково. Целая цепочка — соединились все; сломанная — отказали все. Ни одной строки, где клиенты разошлись бы в исходе.
Это само по себе результат: базовая проверка цепочки одинакова у трёх
независимых реализаций — OpenSSL, Go и Java. Не у четырёх: openssl и
curl считают одной и той же библиотекой, и их согласие — одно
подтверждение, а не два.
Ищущим драму придётся искать её в другом месте.
Драма нашлась в том, что клиенты говорят.
Одинаковый отказ, разные объяснения
В шести случаях из десяти клиент отказывает. Считаем не «понятность» сообщений, а величину, которую можно проверить: сколько различимых причин видно по тексту. Если две разные поломки дают одно и то же сообщение, по нему их не различить.
| клиент | реализация | различимых причин из 6 |
|---|---|---|
| openssl | OpenSSL 3.5.8 | 6 |
| curl | OpenSSL 3.5.4 | 6 |
| go | Go crypto/tls | 4 |
| java | Java JSSE | 4 |
Второй столбец меняет чтение таблицы. Это не «двое против двоих»: первые две строки — одна библиотека, и их совпадение ожидаемо. Правильное прочтение — одна семья различает шесть причин, две другие по четыре.
Три случая, которые Go и Java не различают, — оборванная цепочка, самоподписанный сертификат и чужой удостоверяющий центр. Все три Go описывает одной фразой:
x509: certificate signed by unknown authority
Тот же openssl различает их тремя разными кодами: «не найден издатель», «самоподписанный сертификат», «самоподписанный сертификат в цепочке». Поломки три, и лечатся они по-разному: в первом случае сервер не досылает промежуточный, во втором сертификат вообще не от центра, в третьем центр есть, но чужой. Клиент, у которого все три звучат одинаково, отправляет разбираться наугад.
Это не «Go хуже». Это цена, которую платит абстракция: библиотека сводит разные ошибки построения пути к одной, потому что для кода решение одинаковое — не доверять. Для человека, читающего лог, — нет.
Порядок сертификатов: требование, которого никто не проверяет
Спецификация требует, чтобы в присланной цепочке каждый следующий сертификат заверял предыдущий. Стенд проверяет это парой: цепочка поглубже — корень, два промежуточных, лист, — и в сломанном случае два промежуточных меняются местами. Больше не меняется ничего.
Принимают все четыре клиента. Порядок им безразличен: присланные сертификаты они берут как набор и строят путь сами.
Пара понадобилась со своим контролем, и это отдельная история. Первая версия случая переставляла лист с промежуточным — и ломала заодно соответствие ключа первому сертификату. Отказ объяснялся бы двумя причинами сразу, и различить их было бы нечем. Случай, который ломает две вещи, не доказывает ничего.
Отзыв: его нет в сертификате
Отозванный сертификат в матрице выглядит как целый: все четыре клиента соединяются. Это не халатность клиентов — им просто нечего проверять. Отзыв не является свойством сертификата, он живёт в состоянии, которое ведёт удостоверяющий центр.
Стенд показывает это на четырёх клетках, где меняется ровно одно — дали клиенту список отозванных или нет:
| сертификат | список отозванных | исход |
|---|---|---|
| отозванный | не дан | соединился |
| отозванный | дан | отказал: certificate revoked |
| контрольный | не дан | соединился |
| контрольный | дан | соединился |
Последняя строка — та, без которой вывод был бы ложным. Не будь её, «отказал со списком» объяснялось бы двумя причинами: то ли сертификат отозван, то ли проверка отзыва вообще ничего не пропускает.
Это не выдумка для красоты. Первая попытка передать список падала на несуществующей опции: клиент выходил с ненулевым кодом, и «отказ» получали обе строки. Различие не мерилось вовсе, а таблица выглядела осмысленной.
Отзыв проверялся только одним клиентом. Список передаётся клиентам по-разному, и у трёх остальных это отдельная настройка среды или код. Таблица выше показывает не «кто проверяет отзыв», а другое: один и тот же сертификат получает разный ответ в зависимости от того, дали ли клиенту состояние, которого в самом сертификате нет.
Ось 2: взаимное рукопожатие
Сервер требует сертификат и от клиента — то, на чём строится mTLS между сервисамиСкоро. 16 строк: четыре случая на четырёх клиентах.
Ожидание было такое: когда клиентский сертификат сломан, клиент увидит общий отказ, а причина останется в логе сервера. Опровергнуто. Причина приходит клиенту отдельным сообщением и называется точно:
remote error: tls: expired certificate
remote error: tls: certificate required
Асимметрия оказалась не в том, что сообщают, а в том, когда. Отказ по клиентскому сертификату приходит уже после того, как рукопожатие завершилось: клиент считает, что договорился, и узнаёт правду на первой попытке читать.
Для стенда это обернулось собственной ошибкой, которую стоит назвать. Клиенты стенда не пытались читать после рукопожатия — и двое из четырёх отчитались об успехе там, где соединение было мертво. Числа выглядели осмысленно. Если ваш код проверяет только «соединение установлено», он проверяет не то.
А настоящая находка снова счётная. Клиентский сертификат, выданный чужим центром, Go и Java просто не отправляют: сервер сообщает, какие центры он принимает, подходящего сертификата у клиента нет, и он посылает пустой список. Сервер видит «клиент не прислал сертификата» — и клиент получает ровно то же самое.
То есть на поломку «мой сертификат от не того центра» оба отвечают «нужен сертификат». Сообщение правдиво по факту и молчит о причине.
Ось 3: что видно до всякого шифрования
Между клиентом и сервером стенд ставит посредника: он сохраняет первые байты соединения и передаёт его дальше. Читает посредник по границам записей протокола, а не «сколько пришло за раз» — об этом ниже отдельно.
Имя сервера вынуто из этих байт по смещению, а не переписано из аргументов клиента: иначе замер свёлся бы к печати того, что и так известно.
| клиент | байт в приветствии | имя найдено со смещения |
|---|---|---|
| openssl | 1552 | 156 |
| curl | 1567 | 156 |
| go | 1514 | 117 |
| java | 447 | 153 |
Имя запрашиваемого сервера едет открытым текстом. Наблюдателю этого уже достаточно, чтобы понять, куда вы идёте.
Числа в первом столбце однажды означали не то, что написано. Посредник
делал одно чтение из сокета и объявлял полученное приветствием — а
сколько придёт за одно чтение, решает сеть, не протокол. На одной
платформе приветствие curl вышло 1567 байт, на другой 1460: ровно
типичный размер сегмента, то есть приветствие приехало разрезанным, и
записан был первый кусок.
Заметило это не чтение байтов и не набор проверок, а сравнение сводок между платформами. Теперь чтение идёт по границам записей, и после починки обе платформы дают 1567.
Насчёт сертификата нужна оговорка, и она про версию. Её можно было бы взять из описания протокола, но первый ответ сервера у нас и так сохраняется — дешевле измерить.
Один и тот же сервер, одна и та же цепочка, меняется только согласованная версия:
| согласовано | что видно наблюдателю | сертификат открытым текстом |
|---|---|---|
| TLS 1.2 | ServerHello, Certificate, ServerKeyExchange, ServerHelloDone | 1693 байта |
| TLS 1.3 | ServerHello, дальше зашифрованные записи | 0 байт |
Ноль в последней строке означает не «сертификата нет», а «снаружи его не прочитать»: в потоке видна запись на 1719 байт, в которую он и уложен, — видно длину, но не содержимое.
Различие принципиально ровно потому, что статья сравнивает версии: переход на новую прячет сертификат, но не имя.
Это же имя решает, какой сертификат отдаст сервер, когда за одним адресом живёт много имён. На стыке с балансировщиком отсюда растут отдельные неприятности — например, объединение соединений и ответ 421.
Приветствие Java — 447 байт против полутора тысяч у остальных. Разница не про безопасность: столько занимают предложенные наборы шифров, заготовки ключей и прочее содержимое.
Заодно видно, как протокол врёт ради совместимости. Версий в приветствии три разных места:
| клиент | в заголовке записи | в приветствии | в расширении |
|---|---|---|---|
| openssl | TLS 1.0 | TLS 1.2 | TLS 1.3, TLS 1.2 |
| curl | TLS 1.0 | TLS 1.2 | TLS 1.3, TLS 1.2 |
| go | TLS 1.0 | TLS 1.2 | TLS 1.3, TLS 1.2 |
| java | TLS 1.2 | TLS 1.2 | TLS 1.3, TLS 1.2 |
Первые два поля намеренно занижены: так соединение проходит через посредников, знающих только старые версии. Настоящий список — в отдельном расширении. Кто читает версию из заголовка записи, читает заглушку.
Протоколы приложения предлагает один curl: h2, http/1.1. Остальные
трое молчат — им нечего согласовывать.
Ответ на открытое имя — и насколько он готов
Стандартный ответ на имя открытым текстом — шифровать само приветствие клиента: RFC 9849. Оно прячет и имя, и список предлагаемых протоколов. Вопрос в том, кто это умеет.
Проверка сделана действием, а не чтением справки, и это оказалось
существенно: у curl нужный флаг есть, а библиотека его не
поддерживает — попытка воспользоваться отвечает прямым отказом. Счёт по
наличию флагов завысил бы поддержку ровно на одного клиента из четырёх.
Умеет 1 из 4: только Go 1.27. OpenSSL 3.5.8 не предлагает ни одного ключа, Java 25 — ни одного метода.
Единица из четырёх читается иначе, если помнить дату. Документ вышел в марте 2026 года, за полгода до этих замеров, а до того много лет существовал черновиком. Это не «все опаздывают», а обычная картина первого года после выхода стандарта — и заодно повод не ждать, что имя перестанет быть видимым в ближайшее время.
Стенд не мерил, работает ли это на практике. Проверено только наличие поддержки. Для настоящего замера нужны записи в системе имён и поддержка на стороне сервера, ни того ни другого здесь нет.
Метка о понижении версии: восемь байт
Здесь стенд подходит ближе всего к целому классу атак, а не к устройству протокола.
Сервер, который умеет новую версию, но договорился на старую, обязан положить восемь опознавательных байт в конец случайного поля своего приветствия. Клиент, умеющий новую версию, увидит их и поймёт, что понижение могло быть навязано посредником, а не выбрано честно.
Это байты, а не рассуждение. Пара, в которой различие ровно одно — потолок версии у клиента; сервер в обоих замерах один и тот же и всегда способен на 1.3:
| клиент предлагает | согласовано | хвост случайного поля |
|---|---|---|
| без ограничения | TLS 1.3 | случайные байты |
| до 1.2 | TLS 1.2 | 444f574e47524401 |
Восемь байт читаются как DOWNGRD и единица.
Понижать потолок сервера было бы ошибкой: сервер, не умеющий новой версии, метки и не кладёт — и замер показал бы её отсутствие, а вывод получился бы «защиты нет».
Здесь же чуть не вышло неверное утверждение. В приветствии сервера поле версии в обоих случаях говорит 1.2: в новой версии протокола оно всегда говорит одно и то же. По этому полю выходило, что оба соединения договорились на старую версию. Настоящая версия лежит в отдельном расширении.
Ось 4: сколько сообщений нужно, чтобы договориться
Считаются сообщения рукопожатия, а не время и не пакеты: время зависит от машины, пакеты — от сети и от того, как их сложила операционная система.
| рукопожатие | сообщений | пересылок |
|---|---|---|
| полное, TLS 1.2 | 8 | 4 |
| полное, TLS 1.3 | 7 | 3 |
| возобновлённое | 5 | 3 |
Второе опровергнутое ожидание. Про новую версию принято говорить, что она заметно короче. По сообщениям выигрыш — одно. По этой величине разговоры о заметном сокращении не подтверждаются.
Существенная разница в другом столбце. Пересылка — это переход слова от стороны к стороне, то есть ожидание ответа через всю сеть. Их 4 против 3, и вот это стоит времени. Когда говорят «на один круг меньше», имеют в виду именно пересылки — а не сообщения, которых почти поровну.
Тот же механизм лежит в основе быстрого старта соединений в QUICСкоро, где экономия пересылок — главный смысл.
Возобновление убирает ровно два сообщения: Certificate и
CertificateVerify. Формулировать это надо аккуратно: сервер здесь
не остаётся неподтверждённым. Он подтверждает себя заранее
согласованным ключом, криптографически связанным с тем соединением, где
цепочку проверяли по-настоящему.
Точнее так: возобновление не повторяет проверку сертификатной цепочки в этом соединении, а не «пропускает подлинность». Разница существенная: подлинность есть, но она унаследована. Отсюда и вопрос, который стоит задавать, решая, как долго хранить такое состояние, — насколько давним подтверждением вы согласны пользоваться.
И один дефект замера, который дал бы неверное число. Сперва счёт шёл до завершающего сообщения клиента — и версии выходили равными, по 7. В старой версии клиент завершает раньше сервера, в новой позже; у старой из счёта выпадал ответ сервера. Числа были верные, граница — нет.
Почему 1.3 выглядит так
Раздел объяснительный. Измерения под ним нет, кроме метки о понижении выше; проверяется он по RFC 8446, а не стендом. Говорю это прямо, потому что дальше идут утверждения того же тона, что и измеренные, и смешивать их нельзя.
Новая версия протокола — это в основном список того, что из неё выбросили. Каждый выброшенный механизм связан с классом атак, из-за которого от него отказались.
Повторное согласование позволяло начать новое согласование внутри установленного соединения. Класс атак: данные, отправленные до и после согласования, склеивались в один поток, и посредник мог приписать своё начало к чужому запросу. Выброшено целиком; обновление ключей заменено отдельным сообщением, которое ничего не пересогласовывает.
Режимы шифрования с дополнением до блока, где целостность считалась поверх открытого текста. Класс атак: время обработки и вид ошибки зависели от того, правильное ли дополнение, и по этой разнице восстанавливался открытый текст. Оставлены только режимы, где шифрование и целостность неразделимы.
Сжатие внутри протокола. Класс атак: длина сжатого сообщения зависит от того, повторяется ли в нём подставленная атакующим строка, и по длине угадывается секрет. Выброшено; сжимать содержимое — дело приложения, и там та же ловушка никуда не делась.
Обмен ключами без прямой секретности, когда сеансовый ключ шифровался открытым ключом сертификата. Класс атак: запись трафика сегодня и раскрытие ключа сервера завтра расшифровывают вчерашнее. Оставлен только обмен, при котором ключ сервера подтверждает личность, но не участвует в получении сеансового ключа.
Понижение версии. Класс атак: посредник вычёркивает из приветствия поддержку новых версий, и стороны договариваются на старую с известными слабостями. Ответ — та самая метка; она в этом разделе и есть единственное измеренное.
Заметно, что список — не про арифметику и не про длины ключей. Про них отдельно, в криптографию для разработчиковСкоро; эта статья — про протокол.
Что стенд не мерил
Названо явно, чтобы умолчание не читалось как результат.
Работоспособность шифрованного приветствия. Проверено только наличие поддержки у клиентов.
Проверку отзыва у трёх клиентов из четырёх. Проба сделана одним клиентом; остальным список передаётся иначе.
Терминацию и её последствия. Где обрывать TLS — на краю или до самого приложения — вопрос архитектуры, а не поведения клиентов, и стенд про него ничего не говорит. Раньше это слово стояло в заголовке статьи; убрал его оттуда, потому что заголовок обещал больше, чем статья даёт.
Поведение с публичными удостоверяющими центрами. Все сертификаты здесь свои; настоящие центры добавляют прикрепление ключей, журналы прозрачности и сроки, которых у стенда нет.
Числа сняты на одной паре сторон. Другой сервер даст другие сообщения об ошибках. Различие в числе различимых причин — свойство клиентских библиотек, а не универсальный закон.
Реализаций три, а не четыре. Стенд ничего не говорит о семьях, в него не попавших, — а их немало: NSS в браузерах, Schannel в Windows, BoringSSL, GnuTLS, rustls. Совпадение трёх не обещает совпадения остальных.
Выводы
Матрица из десяти поломок не показала ни одного расхождения в исходе: четыре клиента — три независимые реализации — одинаково пускают целую цепочку и одинаково отвергают сломанную. Базовая проверка работает.
Различаются они в объяснениях: шесть различимых причин у OpenSSL против четырёх у Go и Java. Три разные поломки, которые лечатся по-разному, у двух реализаций из трёх звучат одинаково.
Имя запрашиваемого сервера едет открытым текстом в первом же пакете, и поддержка шифрования этого имени пока есть у одного клиента из четырёх.
Два расхожих утверждения замеры не подтвердили. Когда клиентский сертификат сломан, клиент получает точную причину: асимметрия здесь во времени, а не в содержании. И новая версия протокола короче старой на одно сообщение, а не «вдвое», — существенная разница в пересылках, 3 против 4.
Всё это меряется за вечер на своём удостоверяющем центре. Стенд воспроизводим одной командой, а числа выше читаются из его фикстур автоматической сверкой — не из моей памяти о них.
Ссылки
- Уже вышло и опирается на этот фундамент: OAuth 2.0 и OIDCготовится, с 2 октября (доверие к раздаче ключей держится на TLS), строительные блоки системного дизайнаготовится, с 26 декабря.
- Первичные источники: RFC 5280 (проверка цепочки и сертификатов), RFC 8446 (TLS 1.3), RFC 9849 (шифрованное приветствие клиента).
- Смежное: mTLS между сервисамиСкоро, HTTP и QUICСкоро, HAProxy L4/L7, zero-trust и network policyСкоро, криптография для разработчиковСкоро.
Комментарии