golang

TLS 1.3, только игрушечный

  • воскресенье, 29 января 2023 г. в 00:40:40
https://habr.com/ru/company/skillfactory/blog/713392/
  • Блог компании SkillFactory
  • Информационная безопасность
  • Криптография
  • Программирование
  • Go


Привет! Недавно я думала о том, насколько интересно изучать компьютерные сети, создавая рабочие версии реальных сетевых протоколов.


Мне пришло в голову, почему бы после создания своей версии протоколов traceroute, TCP и DNS не воплотить в жизнь TLS? Могу ли я сделать вариант TLS и больше узнать о его работе?


Я спросила в Twitter, сложно ли это, мне [помогли] и посоветовали, с чего начать, и я решила попробовать.


Создавать свою версию протокола — это интересно. Я узнала больше о роли криптографии — за это спасибо cryptopals. Я уже знала, что алгоритмы шифрования не нужно изобретать, а после того как разобралась в работе шифрования TLS 1.3, я ещё больше убедилась в этом.


Предупрежу: я не специалист в криптографии. Скорее всего, вы найдёте ошибки, которые касаются шифрования. Я не знакома с историей уязвимостей TLS, из-за которых TLS 1.3 приобрела нынешний вид.


С учётом сказанного давайте займёмся криптографией! Мой код на Github. Я решила использовать Go: мне подсказали, что для этого языка написали хорошие криптографические библиотеки.


Упрощения


Хотелось завершить этот проект за несколько дней, так что я решила упростить ряд вещей для ускорения процесса.


Моя цель — скачать главную страницу моего блога по TLS. Для этого не нужно создавать полную версию протокола. А это означает, что:


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

Отличный сайт по TLS


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


Я много раз читала его, а в документацию RFC для TLS 1.3 заглядывала лишь для того, чтобы прояснить некоторые детали.


Азы криптографии


Перед началом работы над этим проектом я думала, что TLS работает так:


  1. Сначала происходит какой-то обмен ключами по алгоритму Диффи-Хелмана.
  2. С помощью обмена ключами вы каким-то образом (но каким?) получаете симметричный ключ AES и шифруете оставшуюся часть соединения с помощью алгоритма AES.

В общем, всё верно, но сложнее.


Давайте перейдём к моей версии TLS. Надеюсь, не нужно говорить о том, что не стоит использовать этот код для реальных проектов.


hello


Сначала нужно отправить сообщение "Сlient Hello". В нём — четыре фрагмента информации:


  1. Случайным образом сгенерированный публичный ключ.
  2. 32 байта случайных данных ("Client Random").
  3. Доменное имя, к которому нужно подключиться (jvns.ca).
  4. Используемые наборы алгоритмов шифрования и алгоритмы цифровой подписи (я скопировала их с сайта tls.ulfheim.net). Согласование достаточно важно, но я его опускаю, ведь поддерживаться будут только один набор алгоритмов шифрования и алгоритм цифровой подписи.

Больше всего меня заинтересовал первый этап: как сгенерировать публичный ключ? Я долго не могла справиться, но в итоге решила её всего двумя строками:


privateKey := random(32)
publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)

Остальной код, генерирующий сообщение "client hello", находится здесь, но там нет ничего интересного, просто куча возни с битами.


Эллиптическая криптография


Здесь я не буду объяснять принципы криптографии на эллиптических кривых. Просто хочу сказать, насколько круто уметь:


  • генерировать случайную 32-байтную строку — приватный ключ;
  • «умножать» его на базовую точку кривой, чтобы вывести публичный ключ (это «умножение» эллиптической кривой, где n * P означает «прибавляем P к самой себе n раз»);
  • конец!

Я поставила «умножать» в кавычки, потому что оно не позволяет умножать точки на эллиптической кривой друг на друга. Умножать можно только точку на число.


Здесь находится сигнатура функции X25519, совершающей «умножение». Первый аргумент называется scalar («скалярный»), а второй — point («точка»). Важен и порядок аргументов. Если поменять их местами, результат будет неверным:


func X25519(scalar, point []byte) ([]byte, error)

Больше не буду говорить об эллиптической криптографии, но мне нравится, насколько она проста — намного понятнее RSA, где приватные ключи должны быть простыми числами.


Не знаю, можно ли использовать любую 32-битную строку как приватный ключ для любой эллиптической кривой или только для конкретной кривой Curve25519.

Парсим hello-ответ cервера


На следующем этапе сервер говорит "hello". Это довольно скучная часть, в общем, нужно только спарсить ответ для получения публичного 32-байтного ключа сервера. Код здесь.


Вычисляем ключи для шифрования рукопожатия


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


  • ключ рукопожатия клиента/вектор инициализации (для посылаемых при рукопожатии данных клиента);
  • ключ рукопожатия сервера/вектор инициализации (для посылаемых при рукопожатии данных сервера);
  • ключ приложения клиента/вектор инициализации (для остальных данных, посылаемых клиентом);
  • ключ приложения сервера/вектор инициализации (для остальных данных, посылаемых сервером);
  • думаю, что есть ещё один ключ для возобновления сеанса TLS, но здесь я его не использую.

Для получения общего секретного ключа начнём с совмещения публичного ключа сервера и нашего приватного ключа. Это так называемая «эллиптическая кривая Дифф — Хеллмана» (ECDH). Она довольно проста — «умножаем» приватный ключ сервера на наш публичный ключ:


sharedSecret, err := curve25519.X25519(session.Keys.Private, session.ServerHello.PublicKey)

Получаем 32-байтный секретный ключ, общий для сервера и клиента. Ура!


Но нам нужно 96 байт ( (16 + 12) * 4 ) общей длины ключей. А это больше, чем 32!


Время формировать ключи


Процесс превращения одного секретного ключа в несколько называется «формированием ключей». В TLS 1.3 для этого используется алгоритм "HKDF" («Основанная на хеше функция формирования ключа»). Если честно, я его не понимаю, но вот как выглядит мой код: много раз попеременно вызываем hkdf.Expand и hkdf.Extract:


func (session *Session) MakeHandshakeKeys() {
    zeros := make([]byte, 32)
    psk := make([]byte, 32)
    // ok so far
    if err != nil {
        panic(err)
    }
    earlySecret := hkdf.Extract(sha256.New, psk, zeros) // TODO: psk might be wrong
    derivedSecret := deriveSecret(earlySecret, "derived", []byte{})
    session.Keys.HandshakeSecret = hkdf.Extract(sha256.New, sharedSecret, derivedSecret)
    handshakeMessages := concatenate(session.Messages.ClientHello.Contents(), session.Messages.ServerHello.Contents())

    cHsSecret := deriveSecret(session.Keys.HandshakeSecret, "c hs traffic", handshakeMessages)
    session.Keys.ClientHandshakeSecret = cHsSecret
    session.Keys.ClientHandshakeKey = hkdfExpandLabel(cHsSecret, "key", []byte{}, 16)
    session.Keys.ClientHandshakeIV = hkdfExpandLabel(cHsSecret, "iv", []byte{}, 12)

    sHsSecret := deriveSecret(session.Keys.HandshakeSecret, "s hs traffic", handshakeMessages)
    session.Keys.ServerHandshakeKey = hkdfExpandLabel(sHsSecret, "key", []byte{}, 16)
    session.Keys.ServerHandshakeIV = hkdfExpandLabel(sHsSecret, "iv", []byte{}, 12)
}

Написать работающий код было сложно. Снова и снова я передавала неверные аргументы. Я справилась только благодаря множеству примеров входных и выходных данных и образцов кода на https://tls13.ulfheim.net. Удалось написать ряд юнит-тестов и сверить результаты работы моего кода с примерами на сайте.


Наконец-то все мои ключи сформированы, время начать расшифровку!


Немного про вектор инициализации


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


Здесь мы получаем различные ВИ для разных сообщений, используя XOR-шифрование на ВИ с числом уже отправленных/полученных сообщений.


Шаг четвёртый: пишем код для расшифровки


Теперь у нас есть все ключи и ВИ, и мы можем написать функцию decrypt («расшифровка»).


Я думала, что в TLS применяется AES, но, как оказалось, там используется так называемое аутентифицированное шифрование поверх AES. О нём я раньше не слышала.


Вот как в Википедии описано аутентифицированное шифрование:


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

Я делала задания на сайте Cryptopals и встречала похожую атаку во втором наборе упражнений (не знаю, точно ли она такая же или нет).


Как бы то ни было, вот код для аутентифицированного шифрования по спецификации TLS 1.3. Думаю, что для шифровки применяется счётчик с аутентификацией Галуа (GCM):


func decrypt(key, iv, wrapper []byte) []byte {

    block, err := aes.NewCipher(key)
    if err != nil {
        panic(err.Error())
    }

    aesgcm, err := cipher.NewGCM(block)
    if err != nil {
        panic(err.Error())
    }

    additional := wrapper[:5]
    ciphertext := wrapper[5:]

    plaintext, err := aesgcm.Open(nil, iv, ciphertext, additional)
    if err != nil {
        panic(err.Error())
    }
    return plaintext
}

Расшифровка рукопожатия сервера


Затем сервер посылает данные о рукопожатии. Там находятся сертификаты и т. д.


Здесь мой код для расшифровки рукопожатия сервера. Происходит чтение зашифрованных данных из сети, расшифровка и сохранение:


record := readRecord(session.Conn)
if record.Type() != 0x17 {
    panic("expected wrapper")
}
session.Messages.ServerHandshake = decrypt(session.Keys.ServerHandshakeKey, session.Keys.ServerHandshakeIV, record)

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


Я удивилась, узнав, что для установки TLS соединения сертификат сервера вообще не требуется (хотя его и необходимо проверить). Мне казалось, что его, как минимум, нужно спарсить для получения ключа.


Нам нужно прохешировать рукопожатие на следующем шаге, так что его необходимо сохранить.


Больше ключей


Для формирования ещё большего количества симметричных ключей используем хеш SHA256 рукопожатия, полученного с сервера. Мы почти у цели!


Код почти тот же самый, что использовался при формировании ключей ранее, но я всё равно его покажу: меня поразило, насколько сложно сгенерировать все эти ключи:


func (session *Session) MakeApplicationKeys() {
    handshakeMessages := concatenate(
        session.Messages.ClientHello.Contents(),
        session.Messages.ServerHello.Contents(),
        session.Messages.ServerHandshake.Contents())

    zeros := make([]byte, 32)
    derivedSecret := deriveSecret(session.Keys.HandshakeSecret, "derived", []byte{})
    masterSecret := hkdf.Extract(sha256.New, zeros, derivedSecret)

    cApSecret := deriveSecret(masterSecret, "c ap traffic", handshakeMessages)
    session.Keys.ClientApplicationKey = hkdfExpandLabel(cApSecret, "key", []byte{}, 16)
    session.Keys.ClientApplicationIV = hkdfExpandLabel(cApSecret, "iv", []byte{}, 12)

    sApSecret := deriveSecret(masterSecret, "s ap traffic", handshakeMessages)
    session.Keys.ServerApplicationKey = hkdfExpandLabel(sApSecret, "key", []byte{}, 16)
    session.Keys.ServerApplicationIV = hkdfExpandLabel(sApSecret, "iv", []byte{}, 12)
}

Завершение рукопожатия


Затем на сервер нужно отправить сообщение "handshake finished", чтобы подтвердить, что всё окончено. Код здесь. Рукопожатие завершено! Самое сложное позади, получение и отправка данных довольно просты.


Генерация HTTP-запроса


Я написала функцию SendData («Отправить данные»), которая шифрует и отправляет данные с помощью наших ключей. Здесь мы используем ключи приложения, а не рукопожатия. Сгенерировать HTTP довольно просто:


req := fmt.Sprintf("GET / HTTP/1.1\r\nHost: %s\r\n\r\n", domain)
session.SendData([]byte(req))

Расшифровка ответа!


Настал момент, которого я долго ждала, — расшифровка ответа сервера! Но предстояло узнать ещё кое-что о TLS.


Данные в TLS распределены по блокам


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


Не знаю, почему у блоков именно такой размер (возможно, чтобы каждый блок помещался в TCP-пакет?), но мне кажется, что теоретически они могут быть размером до 65535 байт, ведь поле их размера — 2 байта. Все полученные мной блоки имели размер в 1386 байт.


После получения каждого блока нужно:


  • вычислить новый ВИ — old_iv xor num_records_received;
  • расшифровать его с помощью ключа и нового ВИ;
  • прибавить к счётчику полученных.

Вот как выглядит функция ReceiveData() («Получить данные»).


Самое интересное здесь — iv[11] ^= session.RecordsReceived — в этой части вектор инициализации настраивается для каждого блока.


func (session *Session) ReceiveData() []byte {
    record := readRecord(session.Conn)
    iv := make([]byte, 12)
    copy(iv, session.Keys.ServerApplicationIV)
    iv[11] ^= session.RecordsReceived
    plaintext := decrypt(session.Keys.ServerApplicationKey, iv, record)
    session.RecordsReceived += 1
    return plaintext
}

iv[11] подразумевает, что блоков будет меньше 255, что в общем для TLS, конечно, неверно, но мне было лень делать по-другому, ведь для скачивания главной страницы моего блога требовалось всего 82 блока.


То же нужно сделать и при отправке данных, но я не написала этот функционал, ведь требуется отправить всего один пакет.


Проблема: получаем весь блок данных в TLS


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


Я исправила эту проблему прямо в лоб — засунула TCP-соединение в цикл, который исполнялся до тех пор, пока я не получала нужные данные:


func read(length int, reader io.Reader) []byte {
    var buf []byte
    for len(buf) != length {
        buf = append(buf, readUpto(length-len(buf), reader)...)
    }
    return buf
}

Думаю, что полноценная имплементация TLS потребовала бы использования пула потоков, сопрограмм (горутин) или чего-то схожего.


Всё готово?


После завершения HTTP-ответа видим байты: []byte{48, 13, 10, 13, 10, 23}. Думаю, это происходит из-за того, что мой HTTP-сервер использует "chunked transfer encoding" (механизм передачи данных небольшими частями). Заголовка Content-Length («Длина содержимого») там нет, и вместо этого мне приходится «ловить» эти байты в конце строки.


Вот код для получения HTTP-ответа. Запускаем цикл, пока не находим эти байты, затем останавливаемся.


func (session *Session) ReceiveHTTPResponse() []byte {
    var response []byte
    for {
        pt := session.ReceiveData()
        if string(pt) == string([]byte{48, 13, 10, 13, 10, 23}) {
            break
        }
        response = append(response, pt...)
    }
    return response
}

Вот и всё!


Наконец я запускаю программу и скачиваю главную страницу своего блога. Заработало!


$ go build; ./tiny-tls
HTTP/1.1 200 OK
Date: Wed, 23 Mar 2022 19:37:47 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
... еще много заголовков и HTML-кода ...

Да, не особо впечатляет, он такой же, как при запуске команды curl -i https://jvns.ca, только без форматирования. Но я так обрадовалась, когда увидела его.


Юнит-тесты очень полезны


Каждый раз, когда я пишу такой сетевой код, я забываю о пользе юнит-тестирования. Мучаюсь с парсингом и форматированием неработающего кода, и получаю "NOPE" от сервера.


И тогда я вспоминаю о юнит-тестах. В этом проекте я скопировала данные из примера на https://tls13.ulfheim.net и использовала их в юнит-тестах, чтобы понять, работают ли правильно мой парсинг и шифрование. С тестами разработка стала гораздо быстрее и проще.


Что я узнала


Было весело! Я узнала, что:


  • эллиптическая кривая Диффи–Хеллмана очень интересна, а с помощью кривой Curve25519 можно использовать любую 32-байтную строку в качестве приватного ключа;
  • в TLS существует множество различных симметричных ключей, а процесс формирования ключей довольно сложный;
  • в TLS используется AES, а поверх него — аутентифицированное шифрование;
  • в TLS данные отправляются и получаются как блоки, а не как поток.

Мой код ужасен, с его помощью можно подключиться только к моему сайту (jvns.ca).


Я не понимаю всех принципов архитектуры TLS, но я неплохо провела время, узнала много нового и, думаю, это поможет мне в будущем понять больше о TLS.


Совет для интересующихся криптографией


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