Подключение к сайту бывает защищённым, а бывает нет — это надо знать всем детям. Только не все дети знают, что это значит и как работает. Кажется я это уже писал? Ах да, это же вторая часть статьи с разбором TLS.

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

Что нужно знать


Во-первых, что такое симметричное шифрование, MAC, электронная подпись и хеш-функции. Детали работы знать не обязательно, только принципы. Напомню: симметричное шифрование защищает сообщения от чтения, чтобы зашифровать и расшифровать нужен один и тот же ключ. Электронная подпись и MAC — это наборы байтов, которые дописываются к сообщению и (помимо прочего) защищают его от изменений. Для ЭП нужно два ключа (закрытым подпись вычисляется, открытым проверяется), а для MAC — один (и для вычисления, и для проверки).

Во-вторых, механизмы защиты соединения, которые обсуждали в первой статье. Самое важное: что такое и зачем нужен алгоритм обмена ключами Диффи-Хеллмана (DH(E)). Желательно знать что-то о сертификатах и о том, какую роль электронная подпись играет в TLS. На всём этом останавливаться не буду — это есть в первой части.

Задача


Цель статьи: отправить следующий HTTP-запрос и получить ответ защищённым образом.

GET / HTTP/1.1
Host: ok.ru

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 22

<h1>Привет!</h1>

В HTTP/1.1 запросы принято записывать в виде текста. Но надо понимать, что компьютеры умеют работать только с числами. И все протоколы нижних уровней (в том числе TCP и TLS) работают только с последовательностями байтов.

Далее под словами «текст», «строка», «число», «значение» и пр. подразумеваются именно последовательности байтов. Все «значения», которые используются во время соединения: идентификаторы, ключи, nonce, сертификаты, стенограммы и пр. — тоже последовательности байтов.

Чтобы передать текстовые HTTP-сообщения, надо их закодировать. Правила кодировки в HTTP читателю предлагается изучить самостоятельно, а я лишь приведу результат.

# Запрос
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a  # GET / HTTP/1.1\r\n
48 6f 73 74 3a 20 6f 6b 2e 72 75 0d 0a 0d 0a     # Host: ok.ru\r\n\r\n

Ответ
# Строка ответа и заголовки закодированы в ASCII
48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b 0d 0a  # HTTP/1.1 200 OK\r\n
43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20           # Content-Type:⎵
74 65 78 74 2f 68 74 6d 6c 3b 20                    # text/html;⎵
63 68 61 72 73 65 74 3d 75 74 66 2d 38 0d 0a        # charset=utf-8\r\n
43 6f 6e 74 65 6e 74 2d 4c 65 6e 67 74 68 3a 20 32 32 0d 0a         # Content-Length: 22\r\n
0d 0a                                                               # \r\n
# Тело в UTF-8 (потому что так написано в Content-Type)
3c 68 31 3e d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 21 3c 2f 68 31 3e   # <h1>Привет!</h1>


Вам придётся мне поверить, что мы умеем передавать по сети такие байты. Для этого существует стек TCP/IP, который является основой телекоммуникационно-информационной сети «Интернет», если слышали, но не темой этой статьи. Именно в виде такой последовательности байтов HTTP-сообщения передаются между компьютерами в схеме http://. Никакой защиты не предполагается: кто угодно может считать запрос, раскодировать и использовать на своё усмотрение.

В схеме https:// же запрос сначала как-то преобразуется протоколом TLS. Наша задача — разобраться, как именно.

Формальности


Условимся, что в блоках, как выше, числа шестнадцатеричные (две цифры — один байт), # отделяет комментарии. Все байты по сети передаются потоком: переводы строк и пробелы только для читаемости.

Например, наш HTTP-запрос можно эквивалентно записать так.
474554202f20485454502f312e310d0a486f73743a206f6b2e72750d0a0d0a


Вне блоков числа с приставкой 0x шестнадцатеричные, без приставки — десятичные. Вместо «байт» чаще буду говорить «октет» — так принято в обсуждении сетевых протоколов. «Стороны» — это клиент и/или сервер.

На момент выхода статьи актуальна версия TLS 1.3 — обсуждаем именно её. О предыдущих буду только местами заикаться для исторического контекста. Спецификация TLS 1.3 описана в RFC 8446. Её текст очень непоследовательный, но, надеюсь, после прочтения этой статьи вы сможете в нём ориентироваться. Не нужно бояться искать там ответы на любые вопросы.

TLS — это большой и сложный протокол, который разрабатывается десятилетиями. Я не могу вместить 160 страниц спецификации в небольшую статью, поэтому многое буду опускать. Задача статьи — дать представление, как выглядит типичное TLS-соединение. По возможности буду давать контекст, как реальные соединения могут отличаться, но, конечно, здесь описаны далеко не все детали.

Транспорт


Под аббревиатурой TLS на самом деле скрываются несколько протоколов. Начнём с протокола записи (record protocol). Без него не обходится никакой обмен данными.

Протокол записи описывает, удивительно, «записи» (records) — так в TLS называются единицы обмена информацией. Запись представляет собой последовательность байтов, в которой закодированы какие-то значения (поля). Следующая схема описывает, из чего состоит запись. Длина каждого поля написана в скобках в октетах.

TLSPlaintextRecord {
    # "Заголовок" записи
    Type (1),
    LegacyRecordVersion (2) = 0x0303,
    Length (2),
    
    # "Содержимое" записи
    Content (..),
}

  • Type (1 октет) — тип записи. От этого значения зависит, что в содержимом. Например, тип 0x16 значит, что там данные рукопожатия.
  • LegacyRecordVersion (2 октета) — устаревшее поле. В предыдущих версиях TLS оно имело какой-то смысл, но в TLS 1.3 осталось только для обратной совместимости. Его значение почти всегда просто равно 0x0303. Таких устаревших полей будет ещё немало.
  • Length (2 октета) — длина содержимого в октетах (не включая длину заголовка).
  • Content (Length октетов) — содержимое.

Сразу посмотрим пример. Предположим, во время рукопожатия произошла ошибка: допустим, клиент криво посчитал какой-нибудь MAC. Тогда сервер мог бы отправить такую запись:

# Заголовок
15     # Тип 0x15 — оповещение (Alert)
03 03  # Устаревшее поле
00 02  # Длина 0x0002 = 2 октета
# Содержимое длиной 2 октета
02 28  # Здесь как-то закодирована информация об ошибке

Это так называемая «незащищённая» запись: её содержимое не зашифровано и не содержит MAC. Незащищённые записи можно отправлять только в самом начале соединения, когда у клиента и сервера ещё нет общих ключей шифрования/MAC. С какого момента подключается защита, и как выглядит этот процесс, увидим позже. А пока перейдём к гвоздю программы.

Рукопожатие


Любое TLS-соединение начинается с рукопожатия. В первой статье мы получили схему, которая примерно иллюстрирует, в чём оно заключается. Привожу её здесь в чуть изменённом виде: я расположил действия в хронологическом порядке и добавил деталей. Всё, что там изображено, сейчас будем разбирать. Замечание: то, что в первой статье я называл «данные и ключ DH» здесь мне удобнее называть соответственно «открытый и закрытый ключ DH».

Дорисуйте сову


Обращаю внимание, что мы разбираем только типичное рукопожатие — с использованием DHE. Этот вариант наиболее интересный и содержательный. Есть и другие варианты, но они менее универсальные.

Например, рукопожатие с помощью PSK
PSK (Pre-Shared Key) — заранее выбранный ключ. Если вы читали первую часть, там мы забраковали их за непрактичностью. Тем не менее, у PSK есть достоинство: если использовать PSK, сертификаты не нужны — а это экономия трафика и времени. Для простых смертных самое распространённое применение PSK такое:

  1. Вы впервые подключаетесь к сайту и запускаете видео. Происходит то рукопожатие, которое сейчас обсудим: с помощью DHE.
  2. Клиент и сервер генерируют некоторое секретное значение, которое сохраняют на потом — PSK.
  3. У вас отключается вай-фай, потому что вы забыли его оплатить. Телефон переключается на мобильный интернет и меняется IP-адрес.
  4. Из-за этого TLS соединение разрывается, но видео надо продолжать загружать. Поэтому телефон подключается заново.
  5. Телефон отправляет PSK в ClientHello, а сервер его принимает. Тогда сертификат не нужен, и рукопожатие намного короче.

Есть ещё варианты: например, внутри дата-центра администратор может выставить PSK на серверах, чтобы они подключались друг к другу по TLS без сертификатов. Но обсуждать такие сценарии мы не будем. Если интересно, гуглить надо про сообщение NewSessionTicket и расширения pre_shared_key и psk_key_exchange_modes в TLS.

Итак, рукопожатие заключается в том, что клиент и сервер обменивается некоторым количеством записей c типом 0x16. Внутри них лежат «сообщения» — последовательности байтов, которые описывает протокол рукопожатия (handshake protocol). Выглядят сообщения как-то так:

HandshakeMessage {
    # Поля, общие для всех сообщений
    Type (1),   # Тип сообщения
    Length (3), # Длина содержимого в октетах (длины Type и Length не считаются).
    # Зависит от типа
    MessageData (..),
}

Например, соединение начинается с того, что клиент отправляет серверу примерно такую запись:

# Заголовок записи
16     # Тип 0x16 — данные рукопожатия
03 03  # Устаревшее поле
00 95  # Длина содержимого записи 0x95 = 149 октетов
# Содержимое записи, тут находится сообщение
01        # Тип 0x01 — ClientHello
00 00 91  # Длина содержимого сообщения 0x91 = 145 октетов
...       # Ещё 145 октетов содержимого

Почему длина записи занимает два октета, а длина сообщения — три?
На самом деле одно сообщение не обязательно отправляется только в одной записи. Например, если оно слишком длинное (запись в TLS не может быть длиннее 2¹⁴ октетов, а соообщение может: например, если у сервера отвратительно длинная цепочка из отвратительно длинных сертификатов), отправитель может разбить его на куски и отправить в нескольких записях подряд.

Точно также в одной записи не обязательно одно сообщение. Если они короткие, можно положить несколько в одну запись (просто друг за другом). Но, чтобы не усложнять, далее в статье будет ровно одно сообщение на одну запись.

Сейчас будем обсуждать конкретные типы сообщений.

Обмен ключами


Это первый этап рукопожатия. Клиент и сервер выбирают, какие будут использовать

  1. Версию TLS;
  2. Алгоритм DHE;
  3. Алгоритм ЭП (возможно, в связке с хеш-функцией);
  4. «Набор шифров» (cipher suite), то есть связку из

    • алгоритма AEAD
    • и хеш-функции для генерации ключей;

    AEAD (Authenticated Encryption with Associated Data) — это разновидность алгоритмов, которые совмещают в себе симметричное шифрование и MAC. Подробнее, как с ними работать, увидим позже, а пока только имеем в виду, что они нужны для защиты данных.

Кроме того, стороны сразу обмениваются открытыми ключами DH.

Для этого клиент отправляет сообщение «ClientHello», где перечисляет всё, что поддерживает из этого списка, и указывает открытые ключи DH. Сервер выбирает конкретную версию и алгоритмы и сообщает свой выбор в сообщении «ServerHello». Там же отправляет свой ключ DH.

Сообщение ClientHello самое сложное из всего рукопожатия из-за количества информации, которое содержит. Не отчаиваемся: после разбора формата есть содержательный пример ClientHello, а остальные сообщения гораздо проще.

ClientHello {
    Type (1) = 0x01,
    Length (3),

    LegacyVersion (2) = 0x0303,
    Random (32),

    # Устарело
    LegacySessionIdLength (1),
    LegacySessionId (LegacySessionIdLength),

    CipherSuitesLength (2),
    CipherSuites (CipherSuitesLength),
  
    # Устарело
    LegacyCompressionMethods(2) = 0x0100,
    
    ExtensionsLength (2),
    Extensions (ExtensionsLength),
}

Штош, поехали.

  • Type и Length — уже видели. У ClientHello тип 0x01.
  • LegacyVersion — очередное устаревшее поле. Раньше с помощью него выбирали версию протокола. В TLS 1.3 это делается с помощью расширений (сейчас увидим), а LegacyVersion просто равно 0x0303.
  • Random — криптографически безопасное случайное число. Это механизм защиты от атак повторного воспроизведения (replay attacks), которые мы не обсуждали. Не вдаюсь в подробности, но суть такая: значения Random отправляют и клиент, и сервер, и они оба учитываются при генерации ключей. Поэтому даже если провести два рукопожатия с совершенно одинаковыми параметрами (версиями, алгоритмами, ключами DH и т.д.), ключи шифрования получатся разные.
  • LegacySessionId (LegacySessionIdLength октетов) — устарело. Раньше в TLS существовали «сессии» для быстрого переподключения. В TLS 1.3 их функцию выполняют PSK (как я описывал в спойлере), а это поле осталось для обратной совместимости.
  • CipherSuites (CipherSuitesLength октетов) — список наборов шифров, которые клиент поддерживает. Напоминаю, что набор шифров состоит из связки алгоритм AEAD + хеш-функция для генерации ключей. У каждой такой связки есть своё название и идентификатор.

    Например, набор шифров TLS_AES_256_GCM_SHA384 состоит из алгоритма AEAD под названием AES_256_GCM и хеш-функции SHA384; его идентификатор 0x1302. Все доступные в TLS наборы шифров и их идентификаторы перечислены на сайте IANA.
  • LegacyCompressionMethods — угадайте с трёх раз. Раньше TLS поддерживал сжатие, но оказалось, что оно приводит к уязвимостям (гуглить атаку CRIME). В итоге сначала браузеры перестали его включать, а в TLS 1.3 сжатие вообще убрали из протокола. Сегодня это поле всегда равно 0x0100, то есть клиент поддерживает единственный (0x01) алгоритм сжатия — без сжатия (0x00).
  • Extensions (ExtensionsLength октетов) — список расширений. Здесь указываются данные, которым не досталось самостоятельных полей. Расширения в потоке байтов идут прямо друг за другом, выглядят как-то так:

    Extension {
        Type (2),
        Length (2),
        Content (Length),
    }
    

Читатель с орлиным глазом заметил, что в ClientHello не фигурируют ни алгоритмы/ключи DH, ни алгоритмы ЭП. А всё потому, что эта информация находится в расширениях. Чтобы провернуть рукопожатие с помощью DHE, нам понадобятся следующие.

  • supported_versions (идентификатор 0x2b) — список версий TLS, которые клиент поддерживает. Каждая версия имеет идентификатор из двух октетов (TLS 1.3 — это 0x0304, TLS 1.2 — 0x0303 и т.д.).
    Формат и пример
    SupportedVersionsExtension {
        Type (2) = 0x002b,
        # Длина расширения, то есть длина VersionsLength (1 октет) + длина Versions
        ExtensionLength (2),
    
        # Длина списка, то есть длина Versions
        VersionsLength (1),
        Versions (VersionsLength),
    }
    

    Если вам кажется, что значение ExtensionLength всегда ровно на 1 больше значения VersionsLength, вам не кажется (и такие же казусы есть в других расширениях). Это связано с идеологией TLS по поводу полей с переменной длиной. И содержимое расширения, и список версий являются такими, поэтому перед ними обоими нужно указывать длину, даже если можно опустить ExtensionLength и сэкономить два октета.

    Но оптимизировать особого смысла нету. Такие «лишние» поля есть только в сообщениях рукопожатия — коротких и редких. Основную часть трафика составляют пакеты с данными приложения, а там всё оптимизировано.

    # Пример
    00 2b  # Тип расширения — supported_versions
    00 05  # Длина расширения 5 октетов
    04     # Длина списка версий 4 октета
    03 04  # Клиент поддерживает TLS 1.3
    03 02  # И TLS 1.1
    

  • supported_groups (0x0a) — алгоритмы DHE, которые клиент поддерживает. Они отличаются тем, какие алгебраические группы (группы вычетов или эллиптических кривых) используются в вычислениях — отсюда название расширения.

    Далее в статье под «алгоритм DH» и «группа» я имею в виду одно и то же (хотя это не совсем корректно). У каждой группы есть название (например, x25519 или secp256r1) и идентификатор (соответственно 0x001d и 0x0017). Полный список вновь есть на сайте IANA.
    Формат и пример
    SupportedGroupsExtension {
        Type (2) = 0x000a,
        ExtensionLength (2), # Всегда на 2 больше GroupsLength
    
        GroupsLength (2),
        Groups (GroupsLength),
    }
    

    00 0a  # Тип supported_groups
    00 06  # Длина расширения 6 октетов
    00 04  # Длина списка 4 октета
    00 1d  # 1. Клиент поддерживает группу x25519
    00 17  # 2. и группу secp256r1
    

  • key_share (0x33) — открытые ключи DH клиента. Перед отправкой ClientHello клиент генерирует пару закрытый/открытый ключ DH и указывает открытый здесь.

    Формат ключей разный у разных групп, а на момент отправки ClientHello клиент не знает, какую выберет сервер. Поэтому клиент может сгенерировать и положить в key_share ключи для нескольких групп, а уже получив ServerHello, выбрать, какой использовать. Можно указать по ключу для каждой группы из supported_groups, можно некоторые опустить, хоть все. Если сервер выберет группу, для которой нет key_share, он попросит отправить ключ дополнительным сообщением (смотреть, как это выглядит, не будем; кому интересно — гуглить «сообщение HelloRetryRequest»).
    Формат и пример
    ClientKeyShareExtension {
        Type (2) = 0x0033,
        ExtensionLength (2), # Всегда на 2 больше ClientSharesLength
    
        ClientSharesLength (2),
        ClientShares (ClientSharesLength)
    }
    
    # ClientShares состоит из таких элементов
    ClientShare {
        Group (2), # Идентификатор группы, как в supported_groups
    
        ShareLength (2),
        Share (ShareLength), # Открытый ключ DH
    }
    

    00 33  # Тип key_shares
    00 6b  # Длина расширения 0x6b = 107 октетов
    00 69  # Длина списка 0x69 = 105 октетов
    00 1d  # Первый открытый ключ будет для группы x25519 (идентификатор 0x001d)
    00 20  # Длина ключа 0x20 = 32 октета
    # Сам ключ
    71 46 fe c3 d8 78 2c 70 72 ad 84 49 de d7 ef 87 
    7c 54 71 44 6b 96 c5 36 f5 d8 b1 57 5f a8 28 4c
    00 17  # Второй для secp256r1
    00 41  # Длина ключа 0x41 = 65 октетов
    # Сам ключ
    04 36 f1 4f c7 98 4a b9 c3 48 fa 93 fe 33 1c 4a
    44 39 ae ef 4e dc c1 78 26 bc 8a 3b 9d ce fd 14
    9e 85 9c 50 28 15 65 69 e2 00 dc 19 8b 8e 7a ba
    be 2c a7 53 5e 02 f6 90 ba 9f af d2 3a 3a af 60
    46
    

  • signature_algorithms (0x0d) — какие алгоритмы ЭП поддерживает клиент. В первой части я упоминал, что в сертификатах подпись обычно считается не от всего сертификата, а от его хеша. В TLS такая же история, поэтому здесь указывается и хеш-функция. Список возможных связок (алгоритм ЭП + хеш-функция) с идентификаторами есть угадайте где.
    Формат и пример
    SignatureAlgorithmsExtension {
        Type (2) = 0x000d,
        ExtensionLength (2), # Всегда на 2 больше AlgorithmsLengh
        AlgorithmsLength (2),
        Algorithms (AlgorithmsLength), 
    }
    

    00 0d  # Тип signature_algorithms
    00 06  # Длина расширения 6 октетов
    00 04  # Длина списка 4 октета
    # Клиент поддерживает  
    05 03  # 1. ecdsa_secp384r1_sha384 (подпись ECDSA secp384r1, хеш SHA384)
    08 07  # 2. ed25518 (подпись Ed25519, хеш-функция не требуется)
    


Ура, мы готовы к примеру! Этих расширений достаточно, чтобы собрать запись с сообщением ClientHello, которой может начинаться реальное TLS-соединение.

Смотрим и вдохновляемся
# Заголовок записи
16     # Тип 0x16 — данные рукопожатия
03 03  # Устаревшее поле
00 bb  # Длина содержимого 0xbb = 187 октетов

# Сообщение
01        # Тип ClientHello
00 00 b7  # Длина 0xb7 = 183 октета
03 03     # Устаревшее поле 
# 32 случайных октета
de 74 ae bd 5c 44 a4 5a 35 fa 23 af d7 f1 7f 51 
39 95 7e bb 77 45 87 a6 72 80 81 d1 cd 29 07 d9 
00     # Устаревшее поле (я просто оставил пустым)
00 06  # Длина списка наборов шифров — 6 октетов
13 01  # Клиент поддерживает наборы TLS_AES_128_GCM_SHA256,
13 02  # TLS_AES_256_GCM_SHA384
13 03  # и TLS_CHACHA20_POLY1305_SHA256
01 00  # Устаревшее поле с алгоритмами сжатия
 
# Начинаются расширения
00 88  # Их длина 0x88 = 136 октетов

00 2b  # supported_versions
00 03  # Длина расширения 3 октета
02     # Длина списка 2 октета
03 04  # Клиент поддерживает только TLS 1.3

00 0a  # supported_groups
00 06  # Длина расширения 6 октетов
00 04  # Длина списка 4 октета
00 17  # Клиент поддерживает группу x25519
00 1d  # и secp256r1

00 33  # key_share
00 6b  # Длина расширения 0x6b = 107 октетов
00 69  # Длина списка 0x69 = 105 октетов
00 1d  # Первый ключ для группы x25519
00 20  # Длина ключа 0x20 = 32 октета
# Ключ
71 46 fe c3 d8 78 2c 70 72 ad 84 49 de d7 ef 87 
7c 54 71 44 6b 96 c5 36 f5 d8 b1 57 5f a8 28 4c 
00 17  # Второй ключ для группы secp256r1
00 41  # Длина 0x41 = 65 октетов
# Сам ключ
04 36 f1 4f c7 98 4a b9 c3 48 fa 93 fe 33 1c 4a 
44 39 ae ef 4e dc c1 78 26 bc 8a 3b 9d ce fd 14 
9e 85 9c 50 28 15 65 69 e2 00 dc 19 8b 8e 7a ba 
be 2c a7 53 5e 02 f6 90 ba 9f af d2 3a 3a af 60 46 

00 0d  # signature_algorithms
00 04  # Длина 4 октета
00 02  # Длина списка 2 октета
04 03  # Клиент поддерживает только ecdsa_secp256r1_sha256

Или без комментариев:

16 03 03 00 bb 01 00 00 b7 03 03 de 74 ae bd 5c
44 a4 5a 35 fa 23 af d7 f1 7f 51 39 95 7e bb 77
45 87 a6 72 80 81 d1 cd 29 07 d9 00 00 06 13 01
13 02 13 03 01 00 00 88 00 2b 00 03 02 03 04 00
0a 00 06 00 04 00 17 00 1d 00 33 00 6b 00 69 00
1d 00 20 71 46 fe c3 d8 78 2c 70 72 ad 84 49 de
d7 ef 87 7c 54 71 44 6b 96 c5 36 f5 d8 b1 57 5f
a8 28 4c 00 17 00 41 04 36 f1 4f c7 98 4a b9 c3
48 fa 93 fe 33 1c 4a 44 39 ae ef 4e dc c1 78 26
bc 8a 3b 9d ce fd 14 9e 85 9c 50 28 15 65 69 e2
00 dc 19 8b 8e 7a ba be 2c a7 53 5e 02 f6 90 ba
9f af d2 3a 3a af 60 46 00 0d 00 04 00 02 04 03


ServerHello


Итак, сервер получает всё это дело и выбирает версии и алгоритмы, которые он тоже поддерживает. Может быть ситуация, когда поддерживаемые версии/наборы шифров/алгоритмов ЭП/DHE на сервере и клиенте не пересекаются. Тогда TLS-соединение установить не получится: сервер сообщает об этом клиенту с помощью той самой записи, которую я приводил в качестве примера и разрывает соединение.

Если же всё в порядке, сервер сообщает свой выбор в сообщении ServerHello. Там же он отправляет свой открытый ключ DH (который только что сгенерировал в паре с закрытым).

ServerHello {
    Type (1) = 0x02,
    Length (3),
    
    LegacyVersion (2) = 0x0303,
    Random (32),
    
    # Устарело
    LegacySessionIdEchoLength (1),
    LegacySessionIdEcho (LegacySessionIdEchoLength),

    CipherSuite (2),
    # Устарело
    LegacyCompressionMethod (1) = 0x00,
    
    ExtensionsLength (2),
    Extensions (ExtensionsLength)
}

У большинства полей смысл такой же, как у соответствующих в ClientHello. Чуть отличается CipherSuite: в ClientHello это был список переменной длины, а в ServerHello указывает только один конкретный набор шифров, который он выбрал. LegacySessionIdEcho — очередное устаревшее поле, куда сервер просто копирует значение из LegacySessionId в ClientHello, и все благополучно о нём забывают.

В рукопожатии с DHE сообщение ServerHello может и обязано иметь только два расширения: supported_versions, key_share. В первом сервер сообщает выбранную версию TLS, а во втором — алгоритм DH (группу) и открытый ключ DH. supported_groups не нужно, потому что группа и так указывается в key_share; signature_algorithms не нужно, потому что алгоритм ЭП клиент позже узнает из сертификата. Формат расширений расписывать не буду: он такой же, как в ClientHello, только вместо списков везде конкретные идентификаторы. Лучше посмотрим на пример целого сообщения.

ServerHello, который мог бы прийти в ответ на наш ClientHello.
# Заголовок записи
16     # Тип 0x16 — данные рукопожатия
03 03  # Устаревшее поле
00 58  # Длина содержимого 0x58 = 88 октетов

# Сообщение
02        # Тип ServerHello
00 00 54 # Длина 0x54 = 84 октета
03 03     # Устаревшее поле
# 32 случайных октета
83 3b d9 07 d8 da 92 2d bc 82 3e 8c 65 5a 0e 2d
1e 09 10 02 a5 44 48 67 0e 28 65 3b 6b 80 ba 2a
00     # У нас LegacySessionId было пустым, так что и тут пустое

13 02  # Сервер выбрал набор шифров TLS_AES_256_GCM_SHA384
00     # Сжатие отключено

# Расширения
00 2c  # Длина всех расширений 0x2c = 44 октета

00 2b  # supported_versions
00 02  # Длина 2 октета
03 04  # Сервер выбрал TLS 1.3

00 33  # key_share
00 24  # Длина 34 октета
00 1d  # Сервер выбрал группу x25519
00 20  # Длина открытого ключа DH 32 октета
# Сам ключ
c5 26 6a b9 ca 84 9b ea 25 da 15 af 7d 91 9a 6d
f2 ae 3e 9c fc b5 bb 6c 29 7f 2d a5 30 28 e7 65


Генерация ключей


Каждая хозяйка должна уметь порадовать свою семью ароматным ключом шифрования. Хотите научиться готовить его дома? Тогда читайте дальше! Ингредиенты:

  1. Хеш-функция из набора шифров;
  2. Длина ключа шифрования и nonce (что это такое, не очень важно), которые тоже зависят от набора шифров (а именно от алгоритма AEAD);
  3. Алгоритм DHE из supported_groups;
  4. Открытый ключ DHE собеседника из key_share. Чтобы сгенерировать ключи на сервере используется key_share клиента, на клиенте — key_share сервера;
  5. Свой закрытый ключ DHE;
  6. Стенограмма (transcript) рукопожатия. Это просто конкатенация байтов, составляющих сообщения. Пока что их всего два: ClientHello и ServerHello. Заголовки записей в стенограмму не включаются, только сами сообщения (начиная с октета, содержащего тип сообщения).

А после обмена сообщениями ClientHello/ServerHello у клиента и сервера как раз есть вся эта информация: как неожиданно и приятно.

Рецепт приготовления довольно хитрый. Я приведу его в спойлере, чтобы показать, что генерация ключей — это не что-то непостижимое, а вполне конкретный алгоритм, который вы можете написать сами. Но разбирать не будем, иначе застрянем надолго. Да и это не очень полезно для среднестатистического разработчика. Кому всё-таки интересно: код содержит комментарии.

Пожилым женщинам и беременным детям открывать не рекомендуется
Скрипт на питоне, как из перечисленных данных получить ключи. Чтобы запустить, понадобится библиотека cryptography.

from cryptography.hazmat.primitives import hashes, hmac
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey

# Данные из ClientHello/ServerHello, которые нам понадобятся

# Предположим, что выбрали набор TLS_AES_256_GCM_SHA384
hash_algorithm = hashes.SHA384()         # Хеш-функция
digest_size = hash_algorithm.digest_size # Длина результата хеш-функции
cipher_key_length = 32                   # Длина ключа у шифра AES_256_GCM
cipher_iv_length = 12                    # Длина nonce у шифра AES_256_GCM

# Закрытый ключ DH, который клиент/сервер генерирует перед отправкой 
# своего сообщения Hello. Я буду приводить пример вывода ключей для двух 
# алгоритмов DH: x25519 и secp256r1, поэтому тут создаю два ключа.
my_key_share_x25519 = X25519PrivateKey.generate()
my_key_share_secp256r1 = ec.generate_private_key(ec.SECP256R1())

# Содержимое key_share собеседника.
peer_key_share_x25519 = b'...'
peer_key_share_secp256r1 = b'...'

# Стенограммы
client_hello_transcript = bytes.fromhex('01...') # Байты, составляющие ClientHello
server_hello_transcript = bytes.fromhex('02...') # Байты, составляющие ServerHello

def derive_keys_and_iv(client_traffic_secret: bytes, server_traffic_secret: bytes):
    """
    Функция генерации ключей. Принимает два секретных значения. После ServerHello это
    значения client_handshake_traffic_secret и server_handshake_traffic_secret, алгоритм расчёта
    которых ниже. После рукопожатия ключи пересчитаются с другими значениями *_traffic_secret.
    """
    client_write_key = hkdf_expand_label(client_traffic_secret, b'key', b'', cipher_key_length)
    client_write_iv = hkdf_expand_label(client_traffic_secret, b'iv', b'', cipher_iv_length)

    server_write_key = hkdf_expand_label(server_traffic_secret, b'key', b'', cipher_key_length)
    server_write_iv = hkdf_expand_label(server_traffic_secret, b'iv', b'', cipher_iv_length)

    return client_write_key, client_write_iv, server_write_key, server_write_iv

def derive_handshake_secrets(
    psk_share: bytes,
    dhe_share: bytes,
    transcript: bytes,
):
    """
    Генерирует два секретных значения: client_handshake_traffic_secret и server_handshake_traffic_secret.
    Принимает psk_share (мы не обсуждали PSK, так что у нас это просто строка из digest_size нулей);
    dhe_share — результат алгоритма DHE. Пример, как он считается, есть ниже;
    transcript — стенограмма рукопожатия (ClientHello + ServerHello)
    """
    zero_string = b'\0' * digest_size

    early_secret = hkdf_extract(zero_string, psk_share)
    derived_secret = derive_secret(early_secret, b'derived', b'')
    handshake_secret = hkdf_extract(derived_secret, dhe_share)

    client_handshake_traffic_secret = derive_secret(
        handshake_secret, 
        b'c hs traffic', 
        transcript
    )
    server_handshake_traffic_secret = derive_secret(
        handshake_secret, 
        b's hs traffic', 
        transcript
    )

    return handshake_secret, client_handshake_traffic_secret, server_handshake_traffic_secret

# Вспомогательные функции
def hkdf_extract(salt: bytes, input_key_material: bytes) -> bytes:
    """Реализует первую половину алгоритма HKDF (RFC 5869)."""
    hm = hmac.HMAC(salt, hash_algorithm)
    hm.update(input_key_material)
    return hm.finalize()
def encode_vector(length_bytes_count: int, content: bytes):
    """
    Кодирует поле переменной длины, как принято в TLS
    (приписывает в начале длину).
    """
    return len(content).to_bytes(length_bytes_count, 'big') + content
def hkdf_expand_label(secret: bytes, label: bytes, context: bytes, length: bytes) -> bytes:
    """
    Вспомогательная функция. Определена в RFC 8446 (раздел 7.1).
    """
    hkdf_label = (
        length.to_bytes(2, 'big') +
        # Внимание: строка b'tls13 ' с пробелом!
        encode_vector(1, b'tls13 ' + label) +
        encode_vector(1, context)
    )
    hkdf = HKDFExpand(
        algorithm=hashes.SHA384(), 
        length=length, 
        info=hkdf_label,
    )
    return hkdf.derive(secret) 
def derive_secret(secret, label, messages):
    """
    Ещё вспомогательная функция из RFC 8446 (раздел 7.1).
    """

    # Считаем хеш от messages
    h = hashes.Hash(hash_algorithm)
    h.update(messages)
    mesages_hash = h.finalize() 

    return hkdf_expand_label(
        secret=secret, 
        label=label, 
        context=mesages_hash, 
        length=hash_algorithm.digest_size
    )

# Примеры, как получается dhe_share, для групп x25519 и secp256r1.

def derive_x25519_share():
    peer_public_key = X25519PublicKey.from_public_bytes(peer_key_share_x25519)
    return my_key_share_x25519.exchange(peer_public_key)

def derive_secp256r1_share():
    # В cryptography для secp256r1 нет простой функции (по крайней мере я не знаю), 
    # которая распарсит key_share и достанет оттуда открытый ключ (как выше 
    # X25519PublicKey.from_public_bytes). Поэтому приходится парсить вручную.
    curve = ec.SECP256R1()
    curve_key_size = curve.key_size // 8
    curve_x = peer_key_share_secp256r1[1:curve_key_size+1]
    curve_y = peer_key_share_secp256r1[curve_key_size+1:]
    curve_numbers = ec.EllipticCurvePublicNumbers(curve_x, curve_y, curve)
    server_public_key = curve_numbers.public_key()

    return my_key_share_secp256r1.exchange(server_public_key)


# Для примера x25519, можно поменять на secp256r1
selected_group = 'x25519' 

# Проворачиваем алгоритм DHE в зависимости от того,
# какую группу выбрал сервер
match selected_group:
    case 'x25519':
        dhe_share = derive_x25519_share()
    case 'secp256r1':
        dhe_share = derive_secp256r1_share()
    case _:
        raise Exception('Unsupported group')

# handshake_secret пока не нужен, но понадобится для пересчёта ключей
handshake_secret, \
client_handshake_traffic_secret, \
server_handshake_traffic_secret = \
    derive_handshake_secrets(
        # Если бы мы использовали PSK, тут бы был PSK.
        # А так просто куча нулей.
        b'\0' * digest_size,
        dhe_share,
        client_hello_transcript + server_hello_transcript
    )

# Сами ключи
client_write_key, client_write_iv, \
server_write_key, server_write_iv = \
    derive_keys_and_iv(client_handshake_traffic_secret, server_handshake_traffic_secret)

Я запустил этот код для наших ClientHello/ServerHello (и key_share из них) и получил такие значения.

client_write_key = bytes.fromhex('2f1688e9db0251c5efba86aa0e367d797c55e9bad839652c34af645f87e762dc')
client_write_iv  = bytes.fromhex('c8c393817ab1a926a361670e')
server_write_key = bytes.fromhex('636b63af2c0a1e2126e93245f8ebc78449df9fcb29f2d3f1fe948f4f03666923')
server_write_iv  = bytes.fromhex('b5067f19c3b9ec8a26a2072c')

Замечание: если вы запустите скрипт, скорее всего получатся другие числа, потому что у вас сгенерируется другой закрытый ключ DH. Чтобы получить такие, я использовал следующий ключ. Замечание²: закрытые ключи ни в коем случае нельзя никому сообщать! Я пишу, потому что к моменту публикации статьи уже его сотру и забуду. А если кто-то посторонний узнает ваш закрытый ключ, прощай защита соединения.

my_key_share_x25519 = X25519PrivateKey.from_private_bytes(bytes.fromhex('7146fec3d8782c7072ad8449ded7ef877c5471446b96c536f5d8b1575fa8284c'))


В результате алгоритма получается четыре значения: client_write_key, client_write_iv, server_write_key и server_write_iv. Они не окончательные: ключи ещё поменяются в конце рукопожатия. Но эти значения уже можно использовать, чтобы зашифровать оставшуюся его часть. Посмотрим, как реализована

Защита записей


Все записи после ServerHello шифруются и содержат MAC (кроме пережитка прошлого ChangeCipherSpec, который позволю себе проигнорировать). Для этого используется тот самый алгоритм AEAD из набора шифров. Ему нужно 4 значения.

  1. Секретный ключ. Это client_write_key или server_write_key, смотря кто отправляет запись.
  2. Nonce. Это уникальное число, которое отличается для каждой записи — тоже элемент защиты от replay-атак. Чтобы его генерировать, клиент и сервер ведут счётчики (с нуля): один для отправленных записей, второй для полученных. Чтобы вычислить nonce, нужно применить операцию XOR к значению счётчика и значению client_write_iv/server_write_iv (смотря кто отправил запись). Например, когда сервер шифрует (а клиент потом расшифровывает) первую запись, в качестве nonce они используютxor(0, server_write_iv).
  3. Сами данные, которые надо защитить. В качестве них выступает такая последовательность байтов:

    TLSInnerText {
        Content (?),
        Type (1),
        Padding (?),
    }
    

    Content и Type — содержимое и тип, как в незащищённых записях. Padding («дополнение») — произвольное число нулевых октетов. Это мера безопасности: его добавляют, чтобы скрыть истинную длину содержимого.

    Длины Content и Padding нигде не указаны, а их ведь нужно как-то узнать, чтобы потом расшифровать запись. Но они и так понятны: последний ненулевой октет — всегда тип записи, после него дополнение, а перед — содержимое.
  4. «Связанные» данные (associated data, AD). Они не шифруются, но включаются в MAC: зачем это, скоро увидим. В TLS они выглядят так:

    AssociatedData {
        OpaqueType (1) = 0x17,
        LegacyRecordVersion (2) = 0x0303,
        EncryptedLength (2)
    }
    

    EncryptedLength — длина того, что получится в результате алгоритма AEAD. Она зависит от длины шифруемых данных, для каждого конкретного алгоритма AEAD по своему. Например, для AES_256_GCM она ровно на 16 октетов больше длины TLSInnerText. Бонусные очки тому, кто догадался, что ровно столько октетов в этом алгоритме занимает MAC.

После того, как запись зашифрована, она помещается в такую структуру, очень похожую на незащищённую запись.

TLSProtectedRecord {
    # Заголовок 
    OpaqueType (1) = 0x17,
    LegacyRecordVersion (2) = 0x0303,
    Length (2),

    # Содержимое
    ProtectedContent (Length)
}

Тип здесь всегда равен 0x17 независимо от того, что внутри записи: настоящий тип можно узнать только расшифровав запись. ProtectedContent — то, что получается в результате AEAD.

Забавный факт: связанные данные (AD) в точности совпадают с заголовком защищённой записи (как неожиданно и приятно для того, кто её потом будет расшифровывать). Здесь прослеживается их смысл: они защищают заголовок от изменений. Главная его часть — длина записи. Она нужна получателю, чтобы правильно считать и расшифровать запись, поэтому шифровать длину нельзя. Злоумышленник мог бы её изменить, и получатель считал бы запись неправильно. Но так как длина входит в связанные данные, такая подмена будет замечена.

На этих кадрах сервер защищает свою первую запись. Дикая природа удивительна.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# Ключи, которые получили в предыдущем разделе
server_write_key = bytes.fromhex('636b63af2c0a1e2126e93245f8ebc78449df9fcb29f2d3f1fe948f4f03666923')
server_write_iv  = bytes.fromhex('b5067f19c3b9ec8a26a2072c')

def get_nonce(sequence_number: int, write_iv: bytes):
    xor = lambda x, y: bytes(a ^ b for a, b in zip(x, y))
    # cipher_iv_length определено в разделе про генерацию ключей
    return xor(write_iv, sequence_number.to_bytes(cipher_iv_length, 'big'))

def protect_record(sequence_number: int, write_key: bytes, write_iv: bytes, 
                   type: bytes, content: bytes):
    inner_text = (content + type + 
        # 4 октета дополнения, потому что я так захотел.
        # Можно было больше, можно было меньше, можно вообще без него
        bytes.fromhex('00000000'))
    associated_data = (bytes.fromhex('170303') +
        # Я для примера буду использовать шифр AES_256_GCM. У него 
        # длина результата ровно на 16 больше длины текста.
        # Считаем её (len(inner_text) + 16) и записываем в виде 
        # последовательности из двух октетов.
        (len(inner_text) + 16).to_bytes(2, 'big'))
    # Здесь используется порядковый номер сообщения
    nonce = get_nonce(sequence_number, write_iv)

    # Запускаю алгоритм AES_256_GCM
    aesgcm = AESGCM(write_key)
    protected_content = aesgcm.encrypt(nonce, inner_text, associated_data)

    # Составляем защищённую запись
    protected_record = (
        bytes.fromhex('170303') + # OpaqueType и LegacyRecordVersion
        len(protected_content).to_bytes(2, 'big') + # Длина
        protected_content # Содержимое
    )
    return protected_record


# Пример защиты записи
type = bytes.fromhex('16') # Тип — данные рукопожатия
content = bytes.fromhex('0800001200100010000c000a0008687474702f312e31') # Содержимое
# Это первая защищённая запись, её порядковый номер 0
protected = protect_record(0, server_write_key, server_write_iv,
                           type, content)
print(protected.hex())


С помощью скрипта в спойлере я сделал из незащищённой записи защищённую:

16                           17
03 03                        03 03
00 14                    =>  00 29 
08 00 00 12 00 10 00 10      d2 77 6d de 60 69 9c 50 0d ac 06 f1 4b e1
00 0c 00 0a 00 08 68 74      0f 10 e8 41 80 b2 7e 38 ee 68 e2 f0 8a c1
74 70 2f 31 2e 31            9a 13 92 48 7c fc ac 32 7b 4a c6 71 a4 

Именно в виде, как справа, передаются по сети все записи после ServerHello. Но что это за запись слева? Тип 0x16 (данные рукопожатия), а тип сообщения 0x08 — такого мы не видели. Ах да, это же

EncryptedExtensions


Это сообщение передаётся внутри первой защищённой записи. Здесь сервер передаёт оставшиеся расширения, которые можно зашифровать, не испоганив обмен ключами (то есть все, кроме supported_versions, key_share и связанных с PSK, которые мы не обсуждали).

До этого, обсуждая ClientHello, мы увидели только самые необходимые расширения, но, конечно, на самом деле их очень много (если бы только где-то был полный список с идентификаторами...). Но большинство довольно грустные, так что я ограничусь разбором двух самых интересных.

  • Server Name Indication (SNI, идентификатор 0x00) — домен сервера, к которому клиент подключатся. Это нужно, когда на одном устройстве запущено несколько серверов с разными доменами — очень распространённая ситуация. Решение, на какой именно сервер отправить запрос принимает прокси, для этого нужно знать домен.

    Вообще, он отправляется в HTTP-заголовке Host. Но это происходит после TLS-рукопожатия. А информация о домене нужна во время него, например, чтобы прокси-сервер выбрал, какой отправить сертификат: ведь у каждого домена он свой.

    В первой части я упоминал, что это не идеальная ситуация. Сообщение ClientHello не зашифровано, а значит SNI видят все. Для злоумышленников это, возможно, не слишком интересно, зато интересно некоторым общественным организациям. SNI позволяет определить, к какому сайту вы подключаетесь, и заблокировать соединение, если по мнению организации этот сайт не поспособствует вашему развитию как личности. Возможно, когда-то это изменится: сейчас внедряется стандарт Encrypted ClientHello (ECH) с говорящим названием. Но пока его поддерживают единицы серверов.

    Пример SNI, который мог бы быть в ClientHello
    00     # Тип server_name (SNI)
    00 0a  # Длина 10 октетов
    00 08  # Длина списка 8 октетов (на момент выхода статьи список может состоять только из одного домена)
    00     # Тип элемента — доменное имя (0x00, на момент выхода статьи поддерживается только такие)
    00 05  # Длина 5 октетов
    6f 6b 2e 72 75 # Домен "ok.ru", закодирован в ASCII
    


  • Application Level Protocol Negotiation (ALPN, 0x10) — позволяет договориться, какой протокол прикладного уровня будет использоваться поверх TLS.

    Простейший пример: выбор версии HTTP. Протоколы HTTP/1.1 и HTTP/2 работают поверх TLS и используют один и тот же порт, но форматы их сообщений радикально отличаются. Поэтому для выбора версии используется ALPN. Клиент в ClientHello сообщает, какие версии он поддерживает, а сервер выбирает одну в EncryptedExtensions. У меня есть статья с разбором HTTP/2, там можно подробнее почитать о протоколе и о налаживании соединения.

    Пример ALPN, который мог бы быть в ClientHello
    00 10  # Тип ALPN
    00 0e  # Длина расширения 14 октетов
    00 0c  # Длина списка 12 октетов
    08     # Длина идентификатора первого протокола 8 октетов
    68 74 74 70 2f 31 2e 31  # Идентификатор HTTP/1.1 (строка "http/1.1" в ASCII)
    02     # Длина второго 2 октета
    68 32  # Идентификатор HTTP/2 (строка "h2" в ASCII)
    

    В жизни не поверите, где есть список протоколов с идентификаторами.

    Пример EncryptedExtensions с ALPN, которое сервер мог бы отправить в ответ и которое мы зашифровали выше.
    16
    03 03
    00 14
    08        # Сообщение EncryptedExtensions
    00 00 12  # Длина 18 октетов
    00 10  # Длина расширений 16 октетов
    00 10  # Первое (и единственное) расширение — ALPN
    00 0c  # Длина 12 октетов
    00 0a  # Длина списка 10 октетов
    00 08  # Длина протокола 8 октетов
    68 74 74 70 2f 31 2e 31  # Идентификатор HTTP/1.1
    



Аутентификация


На данный момент у клиента и сервера есть ключи шифрования, но клиент не знает, кто этот «сервер» на самом деле. Правда тот, к кому он хочется подключиться, или с подвихом?

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

Клиент проверяет, что доверяет сертификату сервера и, если да, проверяет подпись с помощью открытого ключа ЭП из сертификата. В TLS для этого служат сообщения Certificate и CertificateVerify.

Certificate {
    Type (1) = 0x0b,
    Length (3),
    CertificateRequestContextLength (1),
    CertificateRequestContext (CertificateRequestContextLength),
    CertificateListLength (3),
    CertificateList (CertificateListLength)
}
# Поле CertificateList состоит из таких элементов
CertificateEntry {
    CertificateLength (3),
    Certificate (CertificateLength),
    ExtensionsLength (2),
    Extensions (ExtensionsLength),
}

В сообщении Certificate от сервера поле CertificateRequestContext всегда пустое. В TLS по сертификату может аутентифицироваться не только сервер, но и клиент: мы это разбирать не будем, но тогда CertificateRequestContext не пусто.

В поле CertificateList сертификатов столько, сколько их всего в цепочке. Сертификат сервера (листовой) обязательно должен быть первым. Все остальные (промежуточные) могут идти в произвольном порядке. Могут быть лишние: сервер может отправить корневой (который, вообще говоря, не нужно отправлять, и клиент его проигнорирует), а может и вообще какой-нибудь левый не из цепочки (только неясно, зачем).

Поле Certificate внутри CertificateEntry — последовательность байтов, составляющих сертификат. По умолчанию это должен быть сертификат X.509 (хотя это не единственный вариант). Extensions — список расширений, свои для каждого сертификата. Они не очень интересные, позволю себе не обсуждать.

У сервера может быть несколько листовых сертификатов. Если они для разных доменных имён, при выборе он руководствуется расширением SNI из ClientHello. Если у них разные алгоритмы ЭП, то расширением signature_algorithm. Если нет сертификата с алгоритмом подписи, который поддерживает клиент, TLS-соединению быть не суждено.

Пример Certificate и скрипт для его генерации.
Я взял цепочку сертификатов, которую сгенерировал в конце первой статьи (chain.crt). Получилось так:

16
03 03
04 04 # Длина записи 0x404 = 1028 октетов

0b        # Тип Certificate
00 04 03  # Длина 0x403 = 1027 октетов
00        # Пустое CertificateContext
00 03 ff  # Длина списка сертификатов 0x3ff = 1023 октета
00 01 сe  # Длина первого 0x1ce = 462 октета
30 82 01 ca 30 ... # Сертификат длиной 462 октета
00 00     # Расширений нет
00 02 27  # Длина второго 0x227 = 551 октет
30 82 02 23 30 ... # Сертификат длиной 551 октет
00 00     # Расширений нет

Не забываем, что сообщения после ServerHello защищены, так что по сети эта запись передаётся в каком-то таком виде:

17
03 03 
04 19  # Длина 0x419 = 1049 октетов
9b d8 0b b4...  # 1049 октетов зашифрованного месива

Составил я эти записи с помощью следующего скрипта. У меня цепочка сертификатов лежит в файле ./chain.crt. Чаще всего сертификаты хранятся в файлах .crt или .pem в текстовом виде (формат называется PEM): скрипт работает именно для таких. Бывают ещё двоичные файлы сертификатов (например, .p12): как считывать такие читателю предлагаю узнать самостоятельно.

import base64

with open('./chain.crt', 'r') as chain:
    chain = chain.read()
    # В cryptography на самом деле есть функция x509.load_pem_x509_certificates, которая
    # умеет парсить сертификаты в формате PEM. Но я здесь делаю это вручную для наглядности.
    base64_certs = chain \
        .replace('-----BEGIN CERTIFICATE-----', '') \
        .split('-----END CERTIFICATE-----')[:-1]
    certificates = [base64.b64decode(cert) for cert in base64_certs]

def get_certificate_entry(cert_bytes: bytes):
    """Составляем из сертификата CertificateEntry"""
    return (
        len(cert_bytes).to_bytes(3, 'big') + # Длина сертификата
        cert_bytes + # Сам сертификат
        bytes.fromhex('0000') # Список расширений пустой
    )

certificate_list = b''.join(get_certificate_entry(cert) for cert in certificates)
certificate_message_content = (
    bytes.fromhex('00') + # CerificateContext пустое
    len(certificate_list).to_bytes(3, 'big') + # Длина списка
    certificate_list # Сам список
)
certificate_message = (
    bytes.fromhex('0b') + # Тип Certificate
    len(certificate_message_content).to_bytes(3, 'big') + # Длина
    certificate_message_content # Содержимое
)
# Certificate — это вторая защищённая запись (первая была EncryptedExtensions).
# Значение счётчика на ней равно 1.
protected = protect_record(1, server_write_key, server_write_iv,
                           b'\x16', certificate_message)
print(certificate_message.hex())
print(protected.hex())


До этого вы могли в точности воспроизвести мои вычисления, скопировав скрипт и подставив стенограммы и ключи из наших примеров. Сейчас же так не получится: ведь если вы сгенерируете свои сертификаты, у вас будут другие ключи ЭП, поэтому запись получится другая. Поэтому если вы вдруг запускаете скрипты по ходу чтения и получаете другой результат, не удивляйтесь.

Клиент получает Certificate и проверяет, что доверяет сертификату. Если так, то он смотрит на следующее сообщение, отправленное сервером:

CertificateVerify {
    Type (1) = 0x0f,
    Length (3),
    SignatureAlgorithm (2),
    SignatureLength (2),
    Signature (SignatureLength),
}

SignatureAlgorithm — идентификатор алгоритма подписи (как в signature_algorithms в ClientHello). Signature — собственно подпись. Чтобы посчитать её, надо составить конкатенацию из вот такой ерунды:

  1. Октет 0x20, повторённый 64 раза.
  2. Строка TLS 1.3, server CertificateVerify, записанная в ASCII.
  3. Октет 0x00.
  4. Хеш от стенограммы всех отправленных до сих пор сообщений (ClientHello, ServerHello, EncryptedExtensions, Certificate). Хеш-функция из набора шифров.

Затем переслать десяти друзьям, покрутиться и посмотреть под подушку. А если серьёзно, то посчитать от этого хеш (если он требуется для выбранного алгоритма подписи) и применить собственно алгоритм ЭП. Если интересно, что за прикол с октетом 0x20: у него есть сакральный смысл, но в подробности не вдаюсь.

Алгоритм вычисления подписи
В примере ClientHello мы указывали, что клиент поддерживает подпись ecdsa_secp256r1_sha256 — её и будем использовать для примера. У меня ключ подписи лежит в файле ./server.key, который я создал в конце первой статьи. Это как раз был ключ ECDSA secp256r1, как неожиданно и приятно.

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec

# Хеш-функция из набора шифров — SHA384
hash_algorithm = hashes.SHA384()
# Ключ сервера, который я генерировал в конце первой статьи.
with open('./server.key', 'br') as f:
    signature_private_key = serialization.load_pem_private_key(
        f.read(),
        # В первой статье мы зашифровали ключ, а чтобы что-то подписывать
        # надо его расшифровать. Поэтому здесь указываем пароль.
        password=b'...'
    )

# Стенограммы
client_hello = b'01...'
server_hello = b'02...'
# Те сообщения, что передаются внутри защищённых записей,
# в стенограмме учитываются в расшифрованном виде
encrypted_extensions = b'08...'
certificate = b'0b...'
transcript = client_hello + server_hello + encrypted_extensions + certificate

# Считаем хеш от стенограммы
h = hashes.Hash(hash_algorithm)
h.update(transcript)
transcript_hash = h.finalize()

# Составляем ерунду
signature_content = (
    b'\x20' * 64 + # 64 раза октет 0x20
    b'TLS 1.3, server CertificateVerify' + # Волшебная строка
    b'\x00' + # Октет 0x00
    transcript_hash # Хеш стенограммы
)
# Считаем подпись
algorithm = ec.ECDSA(hashes.SHA256())
signature = signature_private_key.sign(signature_content, algorithm)

certificate_verify_content = (
    bytes.fromhex('0403') + # Алгоритм подписи ecdsa_secp256r1_sha256 
    len(signature).to_bytes(2, 'big') +
    signature
)
certificate_verify = (
    bytes.fromhex('0f') + # Тип сообщения 0x0f
    len(certificate_verify_content).to_bytes(3, 'big') +
    certificate_verify_content
)

# Защищаем запись. CertificateVerify — третье сообщение
# (первое EncryptedExtensions, второе Certificate), значение счётчика 2.
protected_record = protect_record(2, b'\x16', certificate_verify)
print(certificate_verify.hex())
print(protected_record.hex())


Клиент получает CertificateVerify и проверяет подпись. Если она верна, то ура: cервер не поддельный. Если нет, то соединению быть не суждено.

Finished


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

Сообщение Finished выполняет две цели. Во-первых оно служит индикатором, что рукопожатие завершено, и что нужно пересчитать ключи (как сейчас увидим). Во-вторых, в сценариях рукопожатия с PSK сообщение CertificateVerify не отправляется, поэтому Finished необходимо, чтобы удостовериться, что в рукопожатие никто не вмешался и что стороны правильно посчитали ключи шифрования, что ключи совпадают.

В случае рукопожатия с DHE же CertificateVerify отправляется и в целом выполняет те же функции. Я не знаю об атаках, когда злоумышленник может вмешаться в рукопожатие так, чтобы подпись в CertificateVerify совпала, но MAC в Finished не совпал. То есть в моём понимании Finished здесь отправляется только для однообразия: чтобы каков бы ни был сценарий рукопожатия, оно всегда завершалось этим сообщением. Но если кто-то приведёт пример атаки, которую Finished предотвращает в рукопожатии с DHE, буду признателен.

Finished {
    Type (1) = 0x14,
    Length (3),
    VerifyData (?)
}

Здесь VerifyData — это MAC, его длина зависит от хеш-функции из набора шифров. Вычисляется VerifyData немного хитро, так что снова просто приведу скрипт с алгоритмом.

Вычисление VerifyData
from cryptography.hazmat.primitives import hashes, hmac

# Стенограмма. Сюда входят все сообщения от ClientHello до CertificateVerify сервера
transcript = b'01...'

# Функция hkdf_expand_label и переменные digest_size, server_handshake_traffic_secret,
# hash_algorithm такие, как определяли в разделе про генерацию ключей

base_key = server_handshake_traffic_secret
finished_key = hkdf_expand_label(base_key, b'finished', b'', digest_size)

hmac = hmac.HMAC(finished_key, hash_algorithm)
hmac.update(transcript)
verify_data = hmac.finalize()


Клиент проверяет MAC и отправляет серверу точно такое же сообщение Finished, в котором MAC вычислен по точно такому же алгоритму, только вместо server_handshake_traffic_secret использует client_handshake_traffic_secret, и к стенограмме добавляется ещё сообщение Finished от сервера.

Пересчёт ключей


Наконец, после того как стороны обменялись Finished, происходит вычисление новых значений для server_write_key, server_write_iv, client_write_key, client_write_iv. В этот раз в их расчёте участвует стенограмма всего рукопожатия.

Скрытый текст
# Стенограмма рукопожатия от ClientHello до Finished сервера 
# (Finished клиента не учитывается)
transcript = b'01...'

def derive_traffic_secrets():
    zero_string = b'\0' * digest_size
    # handshake_secret здесь из раздела про генерацию ключей.
    # Вспомогательные функции оттуда же.
    derived_secret_2 = derive_secret(handshake_secret, b'derived', b'')
    master_secret = hkdf_extract(derived_secret_2, zero_string)

    client_application_traffic_secret_0 = derive_secret(master_secret, b'c ap traffic', transcript)
    server_application_traffic_secret_0 = derive_secret(master_secret, b's ap traffic', transcript)

    return client_application_traffic_secret_0, server_application_traffic_secret_0 

client_application_traffic_secret_0, server_application_traffic_secret_0 = derive_traffic_secrets()
client_write_key, client_write_iv, server_write_key, server_write_iv = \
    derive_keys_and_iv(client_application_traffic_secret_0, server_application_traffic_secret_0)


После этого счётчики для nonce сбрасываются в значение 0. Все последующие записи шифруются этими новыми ключами. Рукопожатие завершено.

Данные приложения


Наконец, мы можем отправить наши HTTP-сообщения! Здесь нет ничего хитрого: мы составляем точно такие же защищённые записи, как были в рукопожатии, только с типом 0x17 (данные приложения) вместо 0x16:

17     # Данные приложения
03 03  # Устаревшее поле
00 1f  # Длина
# HTTP-запрос
47 45 54 20 2f 20 48 54 54 50  # GET / HTTP/1.1...
2f 31 2e 31 0d 0a 48 6f 73 74
3a 20 6f 6b 2e 72 75 0d 0a 0d 0a

Это расшифровка записи. А по сети она передаётся в защищённом виде:

# Тип 0x17, но не потому что данные приложения,
# а потому что у всех защищённых записей тип 0x17
17     
03 03  # Устаревшее поле
00 2f  # Длина
# Месиво из байтов, в котором зашифрован HTTP-запрос и MAC
9e 9d 56 56 d8 33 66 8f 8f 08 c4 7a b6 97 ba dd 
54 5f a2 63 58 0d 9c 27 dc a8 fd 4e c2 59 b8 b7 
9a c4 be 7f a8 aa 1c c0 4e fd 85 9a 8e 55 6b

Как это выглядит в скрипте
# Клиент

# Запрос из начала статьи
http_request = bytes.fromhex('474554202f20485454502f312e310d0a486f73743a206f6b2e72750d0a0d0a')
# Это первое сообщение клиента после пересчёта ключей, его индекс 0.
# Ключи client_write_key и client_write_iv те, что получились в результате пересчёта.
protected_request = protect_record(0, client_write_key, client_write_iv,
                                                     b'\x17', http_request)

# Когда сервер получает этот запрос, он расшифровывает его, потому что
# знает индекс сообщения и значения client_write_key, client_write_iv 

# Сервер
http_response = bytes.fromhex('485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a20746578742f68746d6c3b20636861727365743d7574662d380d0a436f6e74656e742d4c656e6774683a2032320d0a0d0a3c68313ed09fd180d0b8d0b2d0b5d182213c2f68313e')
# Это первое сообщение сервера после пересчёта, так что индекс опять 0.
protected_response = protect_record(0, server_write_key, server_write_iv,
                                                       b'\x17', http_request)


Не слишком завораживающая получилась кульминация. Поэтому, чтобы заворожиться и преисполниться, предлагаю посмотреть на полную расшифровку обмена данными между клиентом и сервером: от ClientHello до данных приложения.

Смотрим и вдохновляемся
### Клиент => Сервер ###
16 03 03 00 bb # Заголовок записи
01 00 00 b7    # Сообщение ClientHello, 183 октета
03 03
# 32 случайных октета
de 74 ae bd 5c 44 a4 5a 35 fa 23 af d7 f1 7f 51 
39 95 7e bb 77 45 87 a6 72 80 81 d1 cd 29 07 d9 
00
00 06 13 01 13 02 13 03 # Наборы шифров: TLS_AES_256_GCM_SHA384 и ещё два
01 00 00 88
00 2b 00 03 02 03 04    # supported_versions: TLS 1.3
00 0a 00 06 00 04 00 17 00 1d # supported_groups: x25519, secp256r1
00 33 00 6b 00 69       # key_share
# Для x25519
00 1d 00 20  
71 46 fe c3 d8 78 2c 70 72 ad 84 49 de d7 ef 87 
7c 54 71 44 6b 96 c5 36 f5 d8 b1 57 5f a8 28 4c 
# Для secp256r1
00 17 00 41 
04 36 f1 4f c7 98 4a b9 c3 48 fa 93 fe 33 1c 4a 
44 39 ae ef 4e dc c1 78 26 bc 8a 3b 9d ce fd 14 
9e 85 9c 50 28 15 65 69 e2 00 dc 19 8b 8e 7a ba 
be 2c a7 53 5e 02 f6 90 ba 9f af d2 3a 3a af 60 46 
00 0d 00 04 00 02 04 03 # signature_algorithms: ecdsa_secp256r1_sha256

### Сервер => Клиент ###
16 03 03 00 58
02 00 00 54 03 03 # ServerHello
83 3b d9 07 d8 da 92 2d bc 82 3e 8c 65 5a 0e 2d
1e 09 10 02 a5 44 48 67 0e 28 65 3b 6b 80 ba 2a
00
13 02  # Набор шифров будет TLS_AES_256_GCM_SHA384
00 00 2c
00 2b 00 02 03 04 # Версия TLS: 1.3
00 33 00 24 00 1d 00 20  # Группа: x25519
# Открытый ключ DH
c5 26 6a b9 ca 84 9b ea 25 da 15 af 7d 91 9a 6d
f2 ae 3e 9c fc b5 bb 6c 29 7f 2d a5 30 28 e7 65

# Все последующие записи перед отправкой по сети защищаются.
# Здесь привожу их в расшифрованном виде.

16 03 03 00 06
08 00 00 02 00 00 # EncryptedExtensions, расширений нет

16 03 03 04 04
0b 00 04 03 00 # Certificate
00 03 ff 
# Первый сертификат (листовой)
00 01 сe 30 82 01 ca 30 ... 00 00
# Второй (промежуточный)
00 02 27 30 82 02 23 30 ... 00 00

16 03 03 00 50
0f 00 00 4c 04 03  # CertificateVerify, алгоритм ecdsa_secp256r1_sha256
00 48 2f 93 c1 ... # Подпись закрытым ключом ЭП сервера

# В этот момент клиент проверяет, что доверят сертификату,
# и что подпись верна

16 03 03 00 24
14 00 00 20     # Finished
82 aa b1 ...    # HMAC от стенограммы рукопожатия

### Клиент => Сервер ###

16 03 03 00 24
14 00 00 20     # Finished
92 28 eb ...    # HMAC от стенограммы рукопожатия

17 03 03 00 1f  # Запись 0x17 (данные приложения)
47 45 54 20 ... # HTTP-запрос

### Сервер => Клиент ###
17 03 03 00 65
48 54 54 50 ... # HTTP-ответ



Заключение


Вот так выглядит типичное взаимодействие между TLS-клиентом и TLS-сервером. Это далеко не все возможности протокола. Например, мы не обсудили

  • Обмен ключами с помощью PSK;
  • Аутентификация клиента. Сервер может запросить сертификат клиента с помощью сообщения CertificateRequest, тогда клиент перед своим Finished должен будет отправить Certificate и CertificateVerify;
  • Протокол оповещения (alert protocol). С его помощью клиент/сервер сообщают собеседнику об ошибках (например, не получается распарсить запись; или клиент и сервер не могут договориться, какой набор шифров использовать; или подпись неверна);
  • И многое, многое другое.

Тем не менее, этого должно быть достаточно, чтобы примерно понять принцип. Домашнее задание — попробовать повзаимодействовать с каким-нибудь TLS-сервером (например, с гуглом): отправить ClientHello, получить ответ, сгенерировать ключи и т.д.

Подсказка, как это делать на питоне
import socket

# Налаживаем TCP-соединение
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("google.com", 443))
    
    s.sendall(b'160303...') # Отправляем запись с ClientHello
    response_type = s.recv(1) # Смотрим на первый байт ответа
    if response_type == b'\0x16':
       # Если тип записи — данные рукопожатия, парсим ServerHello
       ...
    else: ...

Смотреть, что вы там отправляете серверу и что он отвечает удобно в программе Wireshark — обязательный инструмент при работе с сетевыми протоколами.

Если интересно узнать больше о TLS, призываю читать спецификацию. Это самый подробный и авторитетный источник информации о протоколе. А мы на этом закончим с TLS.



Возможно, захочется почитать и это:


Комментарии (0)