TLS-фундамент: цепочка доверия, рукопожатие и что видно в трафике

Десять поломок цепочки доверия на четырёх клиентах: исход везде одинаковый, а различает их сказанное — openssl называет все шесть причин отказа, Go и Java по четыре. Плюс имя сервера открытым текстом в первом же пакете и честный счёт сообщений 1.2 против 1.3

TLS обычно просто работает: сертификат выпущен, замок зелёный, вопросов нет. Вопросы начинаются, когда клиент отказывается доверять сертификату, а сообщение об этом ничего не объясняет.

Эта статья устроена вокруг стенда. Один сервер, четыре клиента — openssl, curl, Go и Java — и десять способов сломать цепочку доверия по одному признаку за раз. Дальше — что видно в трафике до всякого шифрования, чем взаимное рукопожатие отличается от обычного и сколько сообщений нужно, чтобы договориться.

Два ожидания, с которыми я подходил к стенду, замеры опровергли. Об этом ниже прямо: полезнее, чем пересказ того, что и так все знают.

digital-cookbook/security/tls-fundamentals

Ретрофутуристичная схема-блюпринт на кремовой бумаге. По центру вертикальная цепь из трёх металлических пластин, соединение между второй и третьей разорвано; под цепью табличка «одна и та же сломанная цепочка». От разрыва вправо расходятся линии к четырём одинаковым карточкам с подписями «openssl», «curl», «go», «java» — карточки одинаковые, потому что решение у всех одно. Справа вертикальная шкала с заголовком «сколько различимых объяснений»: верхнее деление против первых двух карточек подписано «6 причин», нижнее против последних двух — «4 причины». Слева вверху узкая полоса-конверт с выделенным охрой первым сегментом и подписью «имя сервера — открытым текстом». В левом нижнем углу легенда из двух строк: «исход один» и «объяснения разные»

В статье

  • матрица из 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Скоро, криптография для разработчиковСкоро.

Обсуждение в Telegram

Присоединиться →

Комментарии