TLS 1.3, только игрушечный
- воскресенье, 29 января 2023 г. в 00:40:40
Привет! Недавно я думала о том, насколько интересно изучать компьютерные сети, создавая рабочие версии реальных сетевых протоколов.
Мне пришло в голову, почему бы после создания своей версии протоколов traceroute, TCP и DNS не воплотить в жизнь TLS? Могу ли я сделать вариант TLS и больше узнать о его работе?
Я спросила в Twitter, сложно ли это, мне [помогли] и посоветовали, с чего начать, и я решила попробовать.
Создавать свою версию протокола — это интересно. Я узнала больше о роли криптографии — за это спасибо cryptopals. Я уже знала, что алгоритмы шифрования не нужно изобретать, а после того как разобралась в работе шифрования TLS 1.3, я ещё больше убедилась в этом.
Предупрежу: я не специалист в криптографии. Скорее всего, вы найдёте ошибки, которые касаются шифрования. Я не знакома с историей уязвимостей TLS, из-за которых TLS 1.3 приобрела нынешний вид.
С учётом сказанного давайте займёмся криптографией! Мой код на Github. Я решила использовать Go: мне подсказали, что для этого языка написали хорошие криптографические библиотеки.
Хотелось завершить этот проект за несколько дней, так что я решила упростить ряд вещей для ускорения процесса.
Моя цель — скачать главную страницу моего блога по TLS. Для этого не нужно создавать полную версию протокола. А это означает, что:
К счастью, перед тем как начать, я вспомнила, что уже видела сайт с подробным — по байтам — разбором работы TLS 1.3 и примерами кода всех составляющих протокола. Я загуглила и нашла его — Новое иллюстрированное TLS-соединение.
Я много раз читала его, а в документацию RFC для TLS 1.3 заглядывала лишь для того, чтобы прояснить некоторые детали.
Перед началом работы над этим проектом я думала, что TLS работает так:
В общем, всё верно, но сложнее.
Давайте перейдём к моей версии TLS. Надеюсь, не нужно говорить о том, что не стоит использовать этот код для реальных проектов.
hello
Сначала нужно отправить сообщение "Сlient Hello"
. В нём — четыре фрагмента информации:
"Client Random"
).jvns.ca
).Больше всего меня заинтересовал первый этап: как сгенерировать публичный ключ? Я долго не могла справиться, но в итоге решила её всего двумя строками:
privateKey := random(32)
publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)
Остальной код, генерирующий сообщение "client hello"
, находится здесь, но там нет ничего интересного, просто куча возни с битами.
Здесь я не буду объяснять принципы криптографии на эллиптических кривых. Просто хочу сказать, насколько круто уметь:
n * P
означает «прибавляем P к самой себе n раз»);Я поставила «умножать» в кавычки, потому что оно не позволяет умножать точки на эллиптической кривой друг на друга. Умножать можно только точку на число.
Здесь находится сигнатура функции X25519
, совершающей «умножение». Первый аргумент называется scalar
(«скалярный»), а второй — point
(«точка»). Важен и порядок аргументов. Если поменять их местами, результат будет неверным:
func X25519(scalar, point []byte) ([]byte, error)
Больше не буду говорить об эллиптической криптографии, но мне нравится, насколько она проста — намного понятнее RSA, где приватные ключи должны быть простыми числами.
Не знаю, можно ли использовать любую 32-битную строку как приватный ключ для любой эллиптической кривой или только для конкретной кривой Curve25519.
На следующем этапе сервер говорит "hello". Это довольно скучная часть, в общем, нужно только спарсить ответ для получения публичного 32-байтного ключа сервера. Код здесь.
После получения публичного ключа сервера и отправки серверу своего публичного ключа можно начать вычисление ключей, которые будут использоваться для шифрования. К моему удивлению, в 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"
, чтобы подтвердить, что всё окончено. Код здесь. Рукопожатие завершено! Самое сложное позади, получение и отправка данных довольно просты.
Я написала функцию SendData
(«Отправить данные»), которая шифрует и отправляет данные с помощью наших ключей. Здесь мы используем ключи приложения, а не рукопожатия. Сгенерировать HTTP довольно просто:
req := fmt.Sprintf("GET / HTTP/1.1\r\nHost: %s\r\n\r\n", domain)
session.SendData([]byte(req))
Настал момент, которого я долго ждала, — расшифровка ответа сервера! Но предстояло узнать ещё кое-что о 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 блока.
То же нужно сделать и при отправке данных, но я не написала этот функционал, ведь требуется отправить всего один пакет.
С 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 и использовала их в юнит-тестах, чтобы понять, работают ли правильно мой парсинг и шифрование. С тестами разработка стала гораздо быстрее и проще.
Было весело! Я узнала, что:
Мой код ужасен, с его помощью можно подключиться только к моему сайту (jvns.ca
).
Я не понимаю всех принципов архитектуры TLS, но я неплохо провела время, узнала много нового и, думаю, это поможет мне в будущем понять больше о TLS.
Если вы хотите узнать больше о криптографии и вы ещё не проходили задания на сайте cryptopals, то советую вам сделать это. На этом сайте вы сможете реализовать множество интересных атак на криптографические системы.
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также