Назад к блогу

Как Cloudflare ускорила TLS-рукопожатия: отказ от статического выбора ключевой группы

Как Cloudflare ускорила TLS-рукопожатия: отказ от статического выбора ключевой группы

Cloudflare переработала механизм выбора ключевых групп в TLS 1.3, чтобы избежать лишних сетевых задержек при установке защищённого соединения. В статье разбирается, как статический набор групп приводит к дополнительному раунду обмена при промахе с key_share и какие архитектурные решения в BoringSSL и OpenSSL позволяют клиенту угадывать нужную группу точнее. Это полезно всем, кто занимается оптимизацией HTTPS-инфраструктуры или интересуется внутренним устройством TLS-рукопожатий.

В TLS 1.3 клиент в первом же сообщении присылает серверу не только список поддерживаемых групп, но и готовые обмены ключами для некоторых из них. Если угадал с группой — рукопожатие идёт в один круг; если нет — сервер просит повторить, и добавляется лишний round-trip. Разбираем, как устроен этот выбор в BoringSSL и OpenSSL, чем плох статический набор групп и что меняется при динамическом.

Зачем вообще key_share в первом полёте

Клиент формирует расширение key_share в ClientHello, выбирая группы для обмена ключами и генерируя для каждой отдельную пару ключей. Список групп определяется при подготовке первого полёта: если клиент обязан использовать конкретную группу вместо своего обычного выбора (это происходит, когда сервер уже запросил её, например после HelloRetryRequest), берётся только эта группа; иначе, если сервер прислал supported_groups, выбирается первая группа из supported_group_list, которая есть в server_hint_list; иначе используются явно заданные client_key_share_selections; иначе по умолчанию берётся supported_group_list[0] и добавляется одна группа другого типа (пост-квантовая или классическая).

Для каждой выбранной группы создаётся объект SSLKeyShare, в буфер добавляются group_id, длина и результат генерации обмена ключами, а сам объект сохраняется в hs->key_shares. Отправка нескольких KeyShareEntry увеличивает размер первого полёта: каждая запись добавляет group_id и key_exchange в key_share_bytes, которые затем целиком копируются в расширение. Стоимость генерации ключей растёт, потому что генерация вызывается для каждой группы: для эллиптических кривых генерируется приватный ключ и вычисляется публичная точка, для X25519 — вызывается X25519_keypair.

RFC 8446 разрешает клиенту предлагать столько KeyShareEntry, сколько групп он поддерживает, и требует, чтобы значения key_exchange для каждой записи генерировались независимо.

Что происходит, если группа не угадана

Если клиент не прислал подходящий key_share — например, только неприемлемые или неподдерживаемые сервером группы DHE/ECDHE — сервер исправляет несоответствие с помощью HelloRetryRequest, и клиент должен перезапустить рукопожатие с подходящим расширением key_share.

HelloRetryRequest имеет тот же формат, что и ServerHello, и его расширения MUST содержать supported_versions, а также SHOULD содержать минимальный набор расширений, необходимых клиенту для формирования корректного ClientHello. В частности, он может содержать расширение key_share с указанием выбранной группы (selected_group), чтобы клиент заменил исходное расширение key_share на новое, содержащее только новый KeyShareEntry для указанной группы.

Клиент, получив HelloRetryRequest, должен проверить legacy_version, legacy_session_id_echo, cipher_suite и legacy_compression_method, а затем обработать расширения, начиная с supported_versions. Если HelloRetryRequest не приведёт к изменению ClientHello, клиент MUST прервать рукопожатие с алертом illegal_parameter, а при получении второго HelloRetryRequest в одном соединении — с алертом unexpected_message.

Дополнительный RTT возникает потому, что после HelloRetryRequest клиент отправляет новый ClientHello, и только затем сервер отвечает ServerHello:

ClientHello + key_share --------> HelloRetryRequest <-------- + key_share
ClientHello + key_share --------> ServerHello + key_share

Как устроен выбор группы на клиенте

BoringSSL

ssl_setup_key_shares сначала очищает hs->key_shares и hs->key_share_bytes, затем не создаёт key shares, если hs->max_version < TLS1_3_VERSION или задан hs->pake_prover — признак того, что клиент предлагает PAKE. Если supported_group_list пуст, возвращается ошибка SSL_R_NO_GROUPS_SPECIFIED.

Далее выбирается набор групп: при override_group_id != 0 используется только эта группа; иначе, если есть server_supported_groups_hint, выбирается первая группа из supported_group_list, которая есть в подсказке; иначе, если задан client_key_share_selections, используется он; иначе по умолчанию берётся supported_group_list[0] и добавляется одна группа с противоположным значением is_post_quantum_group, после чего список обрезается до двух групп.

В ext_key_share_add_clienthello для каждой выбранной группы создаётся SSLKeyShare::Create(group_id), генерируется ключ и добавляется в буфер; если override_group_id == 0 и включён GREASE, перед этим добавляется фиктивная группа — она нужна, чтобы сервер не полагался на точный список групп. Число групп в key_share зависит от выбранного набора: при override_group_id — одна, при подсказке сервера — одна, при явных client_key_share_selections — сколько задано, иначе — до двух (одна пост-квантовая и одна классическая).

Пост-квантовыми считаются только две группы: SSL_GROUP_X25519_MLKEM768 и SSL_GROUP_MLKEM1024.

OpenSSL

tls_construct_ctos_key_share выбирает группы так: если s->s3.group_id != 0 и s->s3.tmp.pkey == NULL, добавляется ровно одна группа s->s3.group_id, а s->s3.tmp.num_ks_pkey сбрасывается в 0; иначе берётся список групп через tls1_get_requested_keyshare_groups, и если он содержит единственный элемент 0 (признак отсутствия префикса *), то список заменяется на tls1_get_supported_groups и включается режим add_only_one.

Далее в цикле по группам каждая проверяется на допустимость и валидность, и для подходящей вызывается add_key_share; при add_only_one цикл прерывается после первой добавленной группы. Внутри add_key_share, если номер итерации меньше s->s3.tmp.num_ks_pkey, берётся уже сохранённый ключ, иначе генерируется новый; публичный ключ кодируется и записывается в пакет как KeyShareEntry. Для первой группы сохраняются s->s3.tmp.pkey и s->s3.group_id, а также заполняются массивы сохранённых ключей и их групп.

После полученного HRR проверяется, что запрошенная группа не входила в уже отправленные, что она поддерживается и допустима; затем s->s3.group_id обновляется, все старые ключи освобождаются, счётчик сохранённых ключей обнуляется и s->s3.tmp.pkey сбрасывается. При повторном построении ClientHello после HRR GREASE-элемент key_share не добавляется, если HRR ожидается, группа уже выбрана и публичный ключ ещё не сгенерирован, — чтобы запрошенная запись была единственной.

supported_groups и key_share — разные расширения с разной стоимостью

Список supported_groups формируется иначе: сначала проверяется, не используется ли PAKE, и если да, расширение не отправляется; затем создаётся вложенная структура из двух length-prefixed полей, при включённом GREASE добавляется фиктивная группа, после чего перебирается список поддерживаемых групп и для каждой, кроме пост-квантовых при max_version < TLS1_3_VERSION, добавляется её идентификатор. В OpenSSL список берётся через tls1_get_supported_groups, при SSL_OP_GREASE добавляется GREASE-группа, а затем каждая группа проверяется на валидность и допустимость и отбрасывается, если она несовместима с выбранными ECDHE/FFDHE или не разрешена политикой.

key_share — отдельное расширение: оно не отправляется при max_version < TLS1_3_VERSION или при PAKE, иначе упаковывает уже сгенерированные hs->key_share_bytes. Разная стоимость объясняется тем, что supported_groups содержит только идентификаторы групп, а key_share содержит реальные криптографические параметры: генерацию ключей и сериализацию публичного ключа. RFC 8446 также различает их: supported_groups перечисляет поддерживаемые группы, а key_share содержит криптографические параметры, причём клиент MAY отправить пустой client_shares, чтобы запросить выбор группы сервером ценой дополнительного round trip.

Как устроен выбор группы на сервере

OpenSSL

Функция tls_accept_ksgroup принимает выбранную группу обмена ключами: записывает её в s->s3.group_id и s->s3.group_id_candidate, кэширует в сессии, генерирует параметры группы и устанавливает открытый ключ клиента.

check_overlap ищет первое пересечение между списком приоритетных групп и списком кандидатов: перебирает кандидатов, для каждого проверяет наличие в приоритетном списке, допустимость и валидность, и если найденный индекс в приоритетном списке меньше ранее сохранённого, обновляет выбранную группу.

При включённой опции SSL_OP_SERVER_PREFERENCE сервер сначала ищет пересечение между группами текущего кортежа сервера и присланными key shares, и при наличии принимает эту группу; если пересечения с key shares нет, ищет пересечение с supported_groups клиента и при наличии запоминает группу для HRR. Без этой опции приоритет у клиента: сначала ищется пересечение между key shares клиента и текущим кортежем сервера, и при наличии группа принимается; иначе ищется пересечение supported_groups клиента с кортежем сервера и при наличии группа запоминается для HRR. Если ни key shares, ни supported_groups не пересекаются с текущим кортежем, сервер переходит к следующему кортежу.

Когда сервер отвечает ServerHello, а когда шлёт HRR

hrr_sends_key_share возвращает истину, только если s->s3.peer_tmp == NULL (то есть сервер ещё не получил от клиента параметры обмена ключами для выбранной группы) и s->s3.group_id != 0. В tls_construct_stoc_key_share, если сервер уже отправил HRR и hrr_sends_key_share ложно, расширение key_share не отправляется; иначе в ServerHello кладётся только выбранный s->s3.group_id без публичного ключа. Если HRR не ожидается и s->s3.peer_tmp == NULL, значит key_share от клиента не получен — это возможно только при возобновлении сессии, и тогда расширение не отправляется. Если сессия возобновляется и в режиме PSK не выставлен флаг DHE, сервер тоже не отправляет key_share.

Во всех остальных случаях сервер формирует расширение key_share с выбранной группой и, в зависимости от типа группы, либо генерирует обычный ключ и выполняет вывод общего секрета, либо выполняет инкапсуляцию KEM, после чего помечает обмен ключами выполненным.

BoringSSL

do_send_hello_retry_request формирует HelloRetryRequest: собирает сообщение ServerHello со случайным значением-маркером, session_id, выбранным шифром и расширениями supported_versions и key_share, где в key_share указывается группа, которую сервер просит клиента использовать. После отправки HRR выставляется флаг used_hello_retry_request и состояние переходит к чтению второго ClientHello.

do_read_second_client_hello получает следующее сообщение, проверяет, что это ClientHello, и разбирает его; при ошибке разбора отправляется фатальный алерт decode_error. Проверка нового key_share выполняется при разборе расширения: расширение обязательно, иначе ошибка SSL_R_MISSING_KEY_SHARE с алертом missing_extension; список key shares разбирается, ищется запись с запрошенной группой, при повторной записи для той же группы возвращается SSL_R_DUPLICATE_KEY_SHARE с алертом illegal_parameter, а признак нахождения выставляется только если найден непустой ключ.

HelloRetryRequest: точные условия и последствия

Сервер отправляет HelloRetryRequest, если он может найти приемлемый набор параметров, но ClientHello не содержит достаточной информации для продолжения рукопожатия. В обновлённом ClientHello клиент должен заменить исходное расширение key_share на содержащее только новый KeyShareEntry для группы, указанной в selected_group. Также клиент должен обновить расширение pre_shared_key, если оно присутствует, пересчитав obfuscated_ticket_age и binder, и опционально удалив несовместимые PSK; может опционально добавить, удалить или изменить длину padding. Клиент не должен предлагать PSK, связанные с хешем, отличным от хеша выбранного cipher suite.

Обработка на клиенте

do_read_hello_retry_request читает сообщение, парсит ServerHello, проверяет, что выбранный шифр был предложен и поддерживается версией, инициализирует transcript hash и, если это HRR, обновляет его специальным образом. Затем разбираются расширения cookie, key_share, supported_versions и encrypted_client_hello; если нет ни cookie, ни key_share, возвращается ошибка EMPTY_HELLO_RETRY_REQUEST. Если key_share присутствует, извлекается group_id, проверяется, что группа поддерживается и что она не была в исходном ClientHello; затем заново формируются key shares. После этого transcript hash обновляется, а для принятого ECH — и внутренний transcript; PSK, чей хеш не совпадает с выбранным шифром, удаляются.

do_send_second_client_hello строит второй ClientHello (при принятом ECH — с пустым enc), добавляет его и переходит к чтению ServerHello.

Проверки в OpenSSL

tls_parse_stoc_key_share сначала читает group_id и отвергает GREASE-значения. Если это HelloRetryRequest, она получает список поддерживаемых групп и ищет group_id в нём. При нарушении — группа не найдена, не разрешена или невалидна — вызывается фатальная ошибка SSL_R_BAD_KEY_SHARE с алертом illegal_parameter. В обычном ServerHello вместо supported_groups проверяется, что group_id совпадает с одной из групп, для которых клиент уже отправил key share; если совпадения нет, также возвращается SSL_R_BAD_KEY_SHARE.

Пограничные случаи и защита от зацикливания

Повторный HRR блокируется при чтении ServerHello: если после уже отправленного второго ClientHello приходит ещё один HRR, клиент отвечает фатальным алертом UNEXPECTED_MESSAGE и завершает рукопожатие.

В do_read_hello_retry_request защита строится иначе: при разборе key_share в HRR проверяется, что запрошенная группа не входила в уже отправленные key shares, иначе — ILLEGAL_PARAMETER и ошибка WRONG_CURVE. Аналогичная проверка есть в tls_parse_stoc_key_share: перебор уже отправленных групп и сравнение с запрошенной даёт SSL_R_BAD_KEY_SHARE. Дополнительно требуется, чтобы HRR содержал хотя бы cookie или key_share, иначе EMPTY_HELLO_RETRY_REQUEST. После обработки HRR выставляется флаг used_hello_retry_request, а состояние переводится к отправке второго ClientHello, что исключает повторную обработку HRR в этом же полёте.

Дубликаты расширений

tls1_check_duplicate_extensions проверяет блок расширений ClientHello или ServerHello на дубликаты типов: сначала подсчитывает расширения, затем собирает их типы в массив, сортирует его и сравнивает соседние элементы — при совпадении типов дубликат обнаружен. Если расширений нет, проверка сразу завершается успешно. Порядок расширений при этом не проверяется.

ssl_parse_client_hello_with_trailing_data разбирает ClientHello: читает версию, случайное значение, session_id (с проверкой длины), для DTLS — cookie, затем cipher_suites (длина не меньше 2 и чётная) и compression_methods (длина не меньше 1); при ошибке любого шага разбор прерывается с ошибкой SSL_R_CLIENTHELLO_PARSE_FAILED.

Cookie в HRR

tls_parse_ctos_cookie сначала решает, нужно ли вообще проверять cookie: она игнорирует cookie, если нет ни одного колбэка проверки или если cookie не требуется. Затем разбирает cookie как длину с 2-байтовым префиксом и проверяет, что его длина не меньше SHA256_DIGEST_LENGTH, отделяя последние SHA256_DIGEST_LENGTH байт как HMAC. Далее вычисляет HMAC-SHA256 по оставшейся части cookie и сравнивает с полученным; при несовпадении возвращает ошибку SSL_R_COOKIE_MISMATCH.

После этого читается 2-байтовое поле format, и если оно не равно COOKIE_STATE_FORMAT_VERSION, cookie игнорируется. Затем проверяется версия: она должна совпадать с TLS 1.3, иначе ошибка SSL_R_BAD_PROTOCOL_VERSION_NUMBER. Далее читаются group_id и cipher, и если они не совпадают с текущими, возвращается ошибка SSL_R_BAD_CIPHER. Наконец, читаются оставшиеся поля, и проверяется, что после них осталось ровно SHA256_DIGEST_LENGTH байт.

Возраст cookie проверяется так: если время из cookie больше текущего или прошло больше 600 секунд, cookie считается устаревшим и игнорируется.

Что видит клиент и как это влияет на latency

При несовпадении параметров сервер отвечает HelloRetryRequest — сообщением того же формата, что и ServerHello, но с полем random, равным маркеру kHelloRetryRequest, и расширениями supported_versions, cookie и key_share. Клиент, получив HRR, обязан проверить служебные поля, затем обработать расширения и отправить второй обновлённый ClientHello; при этом transcript не сбрасывается, а дополняется специальным образом. Второй HRR в одном соединении запрещён — клиент обязан прервать рукопожатие с алертом unexpected_message. Сервер, отправляя HRR, обновляет transcript и переходит в состояние отправки HRR, а затем читает второй ClientHello.

0-RTT и early_data при HRR

При HRR второй ClientHello никогда не предлагает early data: в BoringSSL при установленном флаге used_hello_retry_request расширение не добавляется, а при разборе ServerHello стоит утверждение, что второй ClientHello early data не предлагает, поэтому расширения early data автоматически отвергаются как незапрошенные. Если сервер не прислал расширение early_data и HRR не использовался, причина отказа фиксируется как «сервер отклонил» при повторном использовании сессии или «сессия не возобновлена» иначе; при HRR причина уже была заполнена ранее. Принятие early data сервером требует, чтобы сессия была возобновлена: иначе возвращается unsupported_extension, а при успехе выставляются признаки принятия.

В OpenSSL early data при HRR отвергается с алертом illegal_parameter. PSK binder проверяется сравнением вычисленного значения с присланным; при несовпадении — ошибка SSL_R_DIGEST_CHECK_FAILED и алерт decrypt_error. Клиент BoringSSL предлагает только режим PSK_DHE_KE, а сервер принимает PSK только если в списке клиента есть этот режим; иначе расширение pre_shared_key игнорируется.

Компромиссы: почему отказываются от статического выбора

Отправка нескольких key_share в первом ClientHello снижает вероятность HRR, потому что сервер может найти подходящую группу среди предложенных. Однако каждый key_share увеличивает размер ClientHello, так как для каждого выбранного group_id создаётся отдельная запись с сгенерированным обменом ключами. Генерация ключей для каждого key_share требует вычислительных затрат: для эллиптических кривых — генерация приватного ключа и вычисление публичной точки.

В BoringSSL по умолчанию выбираются две группы: наиболее предпочтительная и одна пост-квантовая или классическая, что увеличивает и размер, и стоимость по сравнению с одной группой.

Динамический выбор групп в BoringSSL

Список групп по умолчанию — фиксированный массив из четырёх идентификаторов в порядке предпочтения: сначала гибридная пост-квантовая группа X25519MLKEM768, затем классическая X25519, затем P-256 и P-384. Полный перечень известных групп задаётся таблицей, где каждой группе сопоставлены NID, group_id, имя и алиас; функции поиска выполняют поиск по этой таблице. Конкретная реализация группы выбирается по group_id: для P-256/P-384/P-521 — эллиптическая, для X25519 — X25519, для X25519MLKEM768 — гибридная, для MLKEM1024 — пост-квантовая, иначе — nullptr.

Пост-квантовыми считаются только две группы. Компромисс между безопасностью и производительностью виден в том, что пост-квантовые группы пропускаются, если максимальная версия ниже TLS 1.3, то есть они не предлагаются в старых протоколах. При этом в списке по умолчанию гибридная пост-квантовая группа стоит первой, что даёт приоритет пост-квантовой стойкости, а классические группы оставлены для совместимости и как более дешёвые альтернативы.

Как сравнивали и что получилось

Стенд и методология

Использовался тестовый стенд: виртуальная машина KVM с Ubuntu 26.04 и OpenSSL 3.5.5, веб-сервер Angie 1.11.7, процессор AMD Ryzen 7 5800x (4 CPU в ВМ), 4096 МБ оперативной памяти; хостовая ОС — Ubuntu 24.04 с OpenSSL 3.0.13, нагрузочная утилита wrkx. Сравнивались три варианта: базовый Angie из пакетов с системным OpenSSL 3.5.5, статическая сборка Angie с OpenSSL 4.0.1 и статическая сборка Angie с AWS-LC 5.0.0; переключение между статическими сборками выполнялось через символическую ссылку /usr/sbin/angie.

В конфигурации ssl_protocols заданы TLSv1.2 и TLSv1.3, а директивы ssl_conf_command для Ciphersuites закомментированы. В тестовом сервере активны сертификаты ECDSA, а сертификаты RSA закомментированы.

Применялись три сценария нагрузки:

  • Измерение скорости TLS-рукопожатий: запрос к локации /zero/, отдающей минимальный ответ, с заголовком Connection: close, при 4 потоках, 100 соединениях, длительности 20 с и ограничении 10000 запросов в секунду.
  • Загрузка малых ресурсов: запрос файла 1kb.txt при 4 потоках, 100 соединениях, 20 с и 100000 запросов в секунду.
  • Тестирование пропускной способности: запрос файла 100mb.txt при 4 потоках, 16 соединениях, 20 с и 32 запросах в секунду.

Тесты запускались на хостовой машине в сторону ВМ. Для исключения побочных факторов использовался один рабочий процесс Angie, и во всех сценариях достигалась полная утилизация процессора работой процесса Angie (100%).

Результаты

TLS 1.3 с ECDSA-сертификатами, тест рукопожатий (CPS):

БиблиотекаCPS
AWS-LC 5.0.07201
OpenSSL 3.5.53888,2
OpenSSL 4.0.14268,8

TLS 1.3, keep-alive, ECDSA-сертификат, шифр TLS_AES_256_GCM_SHA384, тест малых запросов (RPS):

БиблиотекаRPS
AWS-LC 5.0.074368
OpenSSL 3.5.573769,2
OpenSSL 4.0.172126,4

В рукопожатиях AWS-LC в явных лидерах, а версии OpenSSL различаются в пользу более свежей. В тесте малых запросов AWS-LC практически сравнялся с двумя версиями OpenSSL, сохранив небольшое преимущество.

Тест пропускной способности (TLS 1.3, сертификат ECDSA, шифр TLS_AES_256_GCM_SHA384), ГБ/с:

БиблиотекаГБ/с
AWS-LC 5.0.01,612
OpenSSL 3.5.51,508
OpenSSL 4.0.11,498

Основной вклад в результат теста должна вносить реализация симметричного шифрования AES-256. По результатам побеждает AWS-LC, но различие между всеми вариантами укладывается в 10%, и отрыв слишком мал для кастомной сборки Angie.

Отдельно для OpenSSL 3.5.5 измерены три шифра: AES-128 — 1,554; AES-256 — 1,524; CHACHA20 — 1,18 ГБ/сек. Побеждает AES-128, за ним с небольшим отрывом AES-256, последний — CHACHA20, различие около 30%. Встроенный бенчмарк openssl показывает лучшие результаты для AES-128, далее AES-256 и CHACHA20, при этом разница между первым и третьим укладывается в 20%.

Оговорки

Значения некоторых директив изменялись, чтобы обеспечить условия теста, в частности версии ssl_protocols и шифры в ssl_conf_command. Возможности процессора могут значительно влиять на результаты, например наличие инструкций AES-NI или векторных расширений AVX. Выбор конкретного варианта зависит от задачи и требует подробного тестирования в реалистичных условиях нагрузки.

Что из этого следует на практике

  • Статический набор key shares — это ставка на то, что сервер выберет одну из угаданных групп. Промах стоит лишнего round-trip: клиент шлёт второй ClientHello, и только потом приходит ServerHello.
  • Расширение supported_groups почти бесплатно — это лишь идентификаторы групп. key_share стоит дорого: для каждой группы генерируется отдельная пара ключей, и для эллиптических кривых это включает вычисление публичной точки.
  • Динамический выбор позволяет подстроить набор групп под подсказку сервера или явную конфигурацию, но по умолчанию BoringSSL всё равно отправляет до двух групп — одну пост-квантовую и одну классическую, — чтобы снизить вероятность HRR, не раздувая первый полёт.
  • Пост-квантовые группы предлагаются только при TLS 1.3 и выше; в списке по умолчанию гибридная пост-квантовая группа стоит первой, а классические оставлены для совместимости и как более дешёвые альтернативы.
  • Повторный HRR запрещён: клиент обязан прервать рукопожатие. Cookie в HRR имеет ограниченный срок жизни — 600 секунд, после чего игнорируется.
  • При HRR early data не предлагается, а PSK, чей хеш не совпадает с выбранным шифром, удаляются; binder пересчитывается.
  • По результатам бенчмарка Angie разница в пропускной способности между AWS-LC и обеими версиями OpenSSL укладывается в 10%, а в тесте рукопожатий AWS-LC опережает OpenSSL более чем в полтора раза — то есть выигрыш от смены TLS-библиотеки проявляется прежде всего на нагрузке, где доминируют рукопожатия.

Где смотреть в коде

Источники

Похожее