Каждый новый узел I2P при первом запуске должен откуда то получить начальный список узлов. Для этого существуют специальные сервера (reseed), адреса которых жестко прописаны в коде. Раньше загрузка осуществлялась по http, однако с недавних пор reseed-ы стали переходить на https. Для успешной работы «пурпурного» I2P также потребовалось внести соответствующие изменения. Используемая там криптографическая библиотека crypto++ не поддерживает ssl. Вместо использования дополнительной библиотеки типа openssl, фактически дублирующей криптографию, был выбран рассмотренный ниже вариант.
Начальная загрузка это единственное место в I2P, где используется https.
С другой стороны, статья будет интересно тем кому интересно понять, как работает ssl и попробовать самому.
Нашей целью является получение файла i2pseeds.su3 размером порядка 100K с одного из reseed узлов I2P. Данный файл подписан отдельным сертификатом, независимым от сертификата узла, поэтому проверку сертификата можно исключить. Относительно небольшая длина получаемых данных позволяет нам не реализовывать механизмы сжатия и восстановления разорванных соединений.
Будет использоваться исключительно TLS 1.2 и набор шифров TLS_RSA_WITH_AES_256_CBC_SHA256. Иначе говоря, для шифрования данных используется AES256 в режиме CBC, и RSA — для согласования ключа.
Данный выбор обусловлен тем, что AES256-CBC является наиболее используемым шифрованием в I2P, а RSA для упрощения реализации протокола путем сокращения числа сообщений, требуемых для согласования ключа. Помимо RSA и AES также потребуются следующие криптографические функции из crypto++:
Используется реализация RSA на основе PKCS v1.5. Длина ключа может быть любой и определяется сертификатом.
Абсолютно все передаваемые сообщения начинаются с 5-байтного заголовка, первый байт которого содержит тип сообщения, следующие 2 байта — номер версии протокола (0x03, 0x03 для TSL 1.2) и затем длина оставшейся части(содержимого) сообщения — 2 байта в Big Endian, тем самым определяя границы сообщений.
Таким образом при получении новых данных следует сначала следует прочитать 5 байтов заголовка, а затем сколько байтов содержится в поле длины.
Встречаются сообщения 4-х типов:
В нашей реализации зашифрованные данные выглядят следующим образом:
16 байт IV для CBC, в TSL 1.2 для каждого сообщения собственный IV;
данные длиной вплоть то 64K — длина заголовков;
32 байт MAC, вычисляемый для 13-байтного заголовка и данных, заголовок состоит из 8 байтного порядкового номера начиная с нуля, типа сообщения (0x17 или 0x16), версии и длины данных. Все в BigEndian. Ключ для HMAC также вычисляется в процессе установки соединения;
заполнитель, с тем расчетом, чтобы длина зашифрованных данных был кратна 16 байт, последний байт содержит число байтов заполнителя без учета его самого. Если длина сообщения оказывается кратной 16 байт, то будет добавлено еще 16 байтов ради этого последнего байта с длиной.
В процессе установки мы должны решить две задачи:
В нашем случае последовательность сообщений выглядит следующим образом:
ClientHello--> (0x01)
<--ServerHello (0x02)
<--Certificate (0x0B)
<--ServerHelloDone (0x0E)
-->ClientKeyExchange (0x10)
-->ChangeCipherSpec
-->Finished (0x14)
<--ChangeCipherSpec
<--Finished (0x14)
где "-->" означает отправку ообщения, а "<--" — получение.
Все сообщения, за исключением ChangeChiperSpec являются сообщением типа 0x16 — установка соединения. Содержимое сообщения этого типа начинается с собственного 4-байтного заголовка, первый байт которого это типа сообщения установки соединения, как обозначено выше, и 3 байта длины оставшегося сообщения, старший байт которой в нашем случае всегда нуль.
Рассмотрим эти сообщения подробно.
Первое сообщение, которое отправляется нами серверу после успешного подключения. Поскольку мы используем один конкретный набор шифров, то в нашем случае оно будет постоянным. Вот таким:
Это сообщение говорит серверу о том, что мы поддерживаем TLS 1.2, это новое соединение (длина идентификатора сеанса равна нулю) и поддерживаем единственный набор шифров — RSA с AES256. Также мы передаем набор из 32-х «случайных» байтов для генерации ключей. Если эти байты действительно случайные, то их следует где то запомнить, потому что они понадобятся в дальнейшем.
«Брат-близнец» ClientHello, за исключением того, что тип сообщения 0x02 вместо 0x01, и непустой идентификатор сеанса. Из этого сообщения нам потребуется лишь 32 случайных байта.
Может содержать несколько сертификатов, сначала идет длина всей группы сертификатов, затем перед каждым сертификатом своя длина. Нас интересует только первый сертификат и прочитать длину следует 2 раза. Сам сертификат представляет собой X.509 в кодировке DER. Из него нам нужен публичный ключ RSA.
Не содержит ничего полезного, однако учитывается при вычислении хэша для Finished.
К этому моменту у нас достаточно информации для генерации и согласовании ключей, происходящих в 3 этапа: генерация случайного секретного ключа, вычисление мастер-ключа, расширение мастер ключа для получения ключей шифрования и контрольной суммы.
Случайный секретный ключ представляет собой 48 байт, первые 2 из которых представляют собой номер версии (0x03, 0x03), и оставшиеся 46 генерируются случайным образом. Далее эти 48 байт шифруются публичным ключем RSA, и вместе с длиной зашифрованного блока отправляются серверу. Следует отметить, что длина зашифрованного блока будет равна длине ключа, а не 48 байт. Например для сертификатов с 2048-битным ключем эта длина будет 256, а длина передаваемых данных — 258.
Отправляется сразу же после ClientKeyExchange. Всегда одинаковое:
Это сообщение типа 0x14 и вычислении хэша для Finished не участвует.
Для дальнейшего вычисления ключей нам потребуется псевдослучайная функция, принимающая на входе 4 параметра: секретный ключ, только что отосланный серверу, метку в виде текстовой строки, блок первоначальных данных и желаемую длину результата.
В TLS 1.2 она определена следующим образом:
PRF(secret, label, seed) = P_SHA256(secret, label + seed);
P_SHA256(secret, seed) = HMAC_SHA256(secret, A(1) + seed) +
HMAC_SHA256(secret, A(2) + seed) +
HMAC_SH256(secret, A(3) + seed) +…
где A определяется по индукции
A(0) = seed,
A(i) = HMAC_SHA256 (secret, A(i -1)).
То есть на каждом шаге мы делаем пересчет контрольной суммы с предыдущего шага, а затем вычисляем котрольную сумму от объединения результата с текстовой строкой и первоначальными данными, повторяя это до тех пор пока не получится нужная длина.
Теперь мастер ключ вычисляется по формуле
PRF(secret, «master secret», clientRandom + serverRadom, 48);
где clientRandom это 32 случайных байта из ClientHello, а serverRandom — из ServerHello.
Далее его следует расширить до 128-байтного блока, содержащий 4 32-байтных ключа в следующей последовательности: ключ MAC для отправки, ключ MAC для получения, ключ шифрования для отправки, ключ дешифрования для получения.
Ключ MAC для получения нами не используется.
Расширение ключа производится по формуле
PRF(masterSecret, «key expansion», serverRandom + clientRadom, 128)
clientRadom и serverRadom тут меняются местами.
К этому моменту у нас есть все необходимое чтобы начать обмен данными, но, к сожалению, мы должны послать сообщение Finished, содержащие правильные данные, в противном случае сервер разорвет соединение.
Если все предыдущие сообщения были довольно тривиальными, то Finshed является более сложным. Во первых оно типа 0x16, но его содержимое полностью зашифровано, при этом при вычислении контрольной суммы также фигурирует 0x16, а не 0x17 как для других зашифрованных сообщений.
Само сообщение содержит первые 12 байт от
PRT(masterSecret, «client finished», hash, 12)
где hash это SHA256 от следующей последовательности сообщений:
ClientHello, ServerHello, Certficate, ServerHelloDone, ClientKeyExchange. Все сообщения учитываются без 5 байтного заголовка.
Если сообщение сформировано корректно, то сервер ответит ChangeCipherSpec и Finished, в противном случае сообщением об ошибке.
После этого мы сервер готов к обмену данными и мы посылаем наш HTTP запрос и получаем ответ.
Рассмотренный в статье подход позволяет эффективно работать с https для приложений, не требующих его полной реализации. Вместо сторонних реализаций ssl, тянущих собственную криптографию, можно использовать уже присутствующую в проекте, как это показано на примере crypto++, что уменьшает число зависимостей, улучшает поддержку и переносимость.
Реализовано и используется практически в i2pd — C++ реализации I2P
Начальная загрузка это единственное место в I2P, где используется https.
С другой стороны, статья будет интересно тем кому интересно понять, как работает ssl и попробовать самому.
Изобретаем велосипед
Нашей целью является получение файла i2pseeds.su3 размером порядка 100K с одного из reseed узлов I2P. Данный файл подписан отдельным сертификатом, независимым от сертификата узла, поэтому проверку сертификата можно исключить. Относительно небольшая длина получаемых данных позволяет нам не реализовывать механизмы сжатия и восстановления разорванных соединений.
Будет использоваться исключительно TLS 1.2 и набор шифров TLS_RSA_WITH_AES_256_CBC_SHA256. Иначе говоря, для шифрования данных используется AES256 в режиме CBC, и RSA — для согласования ключа.
Данный выбор обусловлен тем, что AES256-CBC является наиболее используемым шифрованием в I2P, а RSA для упрощения реализации протокола путем сокращения числа сообщений, требуемых для согласования ключа. Помимо RSA и AES также потребуются следующие криптографические функции из crypto++:
- HMAC для вычисления контрольных сумм зашифрованных сообщений и псевдослучайных функций. Следует обратить внимание, что используется стандартная реализация HMAC, а не из I2P
- SHA256 хэш для использования вместе с HMAC и для вычисления контрольной суммы всех сообщений, участвовавших в установке соединения
- Функции для работы с описаниями на языке ASN.1 в кодировке DER. Требуется для извлечения публичного ключа из сертификата X.509
Используется реализация RSA на основе PKCS v1.5. Длина ключа может быть любой и определяется сертификатом.
Передача сообщений по SSL
Абсолютно все передаваемые сообщения начинаются с 5-байтного заголовка, первый байт которого содержит тип сообщения, следующие 2 байта — номер версии протокола (0x03, 0x03 для TSL 1.2) и затем длина оставшейся части(содержимого) сообщения — 2 байта в Big Endian, тем самым определяя границы сообщений.
Таким образом при получении новых данных следует сначала следует прочитать 5 байтов заголовка, а затем сколько байтов содержится в поле длины.
Встречаются сообщения 4-х типов:
- 0x17 — данные. Содержимое представляет собой зашифрованные HTTP сообщения, а нашем случае с помощью AES256, ключ которого вычисляется в процессе установки соединения. Размер данных должен быть кратен 16 байт
- 0x16 — установка соединения. Нескольких типов, определяемым соответствующим полем внутри содержимого. Незашифрованные, за исключением сообщения типа 'finished', отсылаемого последним.
- 0x15 — предупреждение. Сообщение о том, что «что-то пошло не так». Закрываем соединение. Содержит коды того, что же именно пошло не так, можно использовать для отладки.
- 0x14 — изменение шифра. Отсылается сразу же после согласования ключа. Содержимое представляет 1 байт, всегда содержащий 0x01. Фактически является частью процесса установки соединения.
В нашей реализации зашифрованные данные выглядят следующим образом:
16 байт IV для CBC, в TSL 1.2 для каждого сообщения собственный IV;
данные длиной вплоть то 64K — длина заголовков;
32 байт MAC, вычисляемый для 13-байтного заголовка и данных, заголовок состоит из 8 байтного порядкового номера начиная с нуля, типа сообщения (0x17 или 0x16), версии и длины данных. Все в BigEndian. Ключ для HMAC также вычисляется в процессе установки соединения;
заполнитель, с тем расчетом, чтобы длина зашифрованных данных был кратна 16 байт, последний байт содержит число байтов заполнителя без учета его самого. Если длина сообщения оказывается кратной 16 байт, то будет добавлено еще 16 байтов ради этого последнего байта с длиной.
Установка соединения
В процессе установки мы должны решить две задачи:
- Согласовать и вычислить ключи для шифрования и HMAC
- Отослать правильную последовательность сообщений, чтобы другая стороны не закрыла соединение, а перешла в режим обмена данными
В нашем случае последовательность сообщений выглядит следующим образом:
ClientHello--> (0x01)
<--ServerHello (0x02)
<--Certificate (0x0B)
<--ServerHelloDone (0x0E)
-->ClientKeyExchange (0x10)
-->ChangeCipherSpec
-->Finished (0x14)
<--ChangeCipherSpec
<--Finished (0x14)
где "-->" означает отправку ообщения, а "<--" — получение.
Все сообщения, за исключением ChangeChiperSpec являются сообщением типа 0x16 — установка соединения. Содержимое сообщения этого типа начинается с собственного 4-байтного заголовка, первый байт которого это типа сообщения установки соединения, как обозначено выше, и 3 байта длины оставшегося сообщения, старший байт которой в нашем случае всегда нуль.
Рассмотрим эти сообщения подробно.
ClientHello
Первое сообщение, которое отправляется нами серверу после успешного подключения. Поскольку мы используем один конкретный набор шифров, то в нашем случае оно будет постоянным. Вот таким:
static uint8_t clientHello[] =
{
0x16, // handshake
0x03, 0x03, // version (TLS 1.2)
0x00, 0x2F, // length of handshake
// handshake
0x01, // handshake type (client hello)
0x00, 0x00, 0x2B, // length of handshake payload
// client hello
0x03, 0x03, // highest version supported (TLS 1.2)
0x45, 0xFA, 0x01, 0x19, 0x74, 0x55, 0x18, 0x36,
0x42, 0x05, 0xC1, 0xDD, 0x4A, 0x21, 0x80, 0x80,
0xEC, 0x37, 0x11, 0x93, 0x16, 0xF4, 0x66, 0x00,
0x12, 0x67, 0xAB, 0xBA, 0xFF, 0x29, 0x13, 0x9E, // 32 random bytes
0x00, // session id length
0x00, 0x02, // chiper suites length
0x00, 0x3D, // RSA_WITH_AES_256_CBC_SHA256
0x01, // compression methods length
0x00, // no compression
0x00, 0x00 // extensions length
};
Это сообщение говорит серверу о том, что мы поддерживаем TLS 1.2, это новое соединение (длина идентификатора сеанса равна нулю) и поддерживаем единственный набор шифров — RSA с AES256. Также мы передаем набор из 32-х «случайных» байтов для генерации ключей. Если эти байты действительно случайные, то их следует где то запомнить, потому что они понадобятся в дальнейшем.
ServerHello
«Брат-близнец» ClientHello, за исключением того, что тип сообщения 0x02 вместо 0x01, и непустой идентификатор сеанса. Из этого сообщения нам потребуется лишь 32 случайных байта.
Certificate
Может содержать несколько сертификатов, сначала идет длина всей группы сертификатов, затем перед каждым сертификатом своя длина. Нас интересует только первый сертификат и прочитать длину следует 2 раза. Сам сертификат представляет собой X.509 в кодировке DER. Из него нам нужен публичный ключ RSA.
ServerHelloDone
Не содержит ничего полезного, однако учитывается при вычислении хэша для Finished.
ClientKeyExchange
К этому моменту у нас достаточно информации для генерации и согласовании ключей, происходящих в 3 этапа: генерация случайного секретного ключа, вычисление мастер-ключа, расширение мастер ключа для получения ключей шифрования и контрольной суммы.
Случайный секретный ключ представляет собой 48 байт, первые 2 из которых представляют собой номер версии (0x03, 0x03), и оставшиеся 46 генерируются случайным образом. Далее эти 48 байт шифруются публичным ключем RSA, и вместе с длиной зашифрованного блока отправляются серверу. Следует отметить, что длина зашифрованного блока будет равна длине ключа, а не 48 байт. Например для сертификатов с 2048-битным ключем эта длина будет 256, а длина передаваемых данных — 258.
ChangeCipherSpec
Отправляется сразу же после ClientKeyExchange. Всегда одинаковое:
static uint8_t changeCipherSpecs[] =
{
0x14, // change cipher specs
0x03, 0x03, // version (TLS 1.2)
0x00, 0x01, // length
0x01 // type
};
Это сообщение типа 0x14 и вычислении хэша для Finished не участвует.
Псевдослучайная функция (PRF)
Для дальнейшего вычисления ключей нам потребуется псевдослучайная функция, принимающая на входе 4 параметра: секретный ключ, только что отосланный серверу, метку в виде текстовой строки, блок первоначальных данных и желаемую длину результата.
В TLS 1.2 она определена следующим образом:
PRF(secret, label, seed) = P_SHA256(secret, label + seed);
P_SHA256(secret, seed) = HMAC_SHA256(secret, A(1) + seed) +
HMAC_SHA256(secret, A(2) + seed) +
HMAC_SH256(secret, A(3) + seed) +…
где A определяется по индукции
A(0) = seed,
A(i) = HMAC_SHA256 (secret, A(i -1)).
То есть на каждом шаге мы делаем пересчет контрольной суммы с предыдущего шага, а затем вычисляем котрольную сумму от объединения результата с текстовой строкой и первоначальными данными, повторяя это до тех пор пока не получится нужная длина.
Теперь мастер ключ вычисляется по формуле
PRF(secret, «master secret», clientRandom + serverRadom, 48);
где clientRandom это 32 случайных байта из ClientHello, а serverRandom — из ServerHello.
Далее его следует расширить до 128-байтного блока, содержащий 4 32-байтных ключа в следующей последовательности: ключ MAC для отправки, ключ MAC для получения, ключ шифрования для отправки, ключ дешифрования для получения.
Ключ MAC для получения нами не используется.
Расширение ключа производится по формуле
PRF(masterSecret, «key expansion», serverRandom + clientRadom, 128)
clientRadom и serverRadom тут меняются местами.
Finished
К этому моменту у нас есть все необходимое чтобы начать обмен данными, но, к сожалению, мы должны послать сообщение Finished, содержащие правильные данные, в противном случае сервер разорвет соединение.
Если все предыдущие сообщения были довольно тривиальными, то Finshed является более сложным. Во первых оно типа 0x16, но его содержимое полностью зашифровано, при этом при вычислении контрольной суммы также фигурирует 0x16, а не 0x17 как для других зашифрованных сообщений.
Само сообщение содержит первые 12 байт от
PRT(masterSecret, «client finished», hash, 12)
где hash это SHA256 от следующей последовательности сообщений:
ClientHello, ServerHello, Certficate, ServerHelloDone, ClientKeyExchange. Все сообщения учитываются без 5 байтного заголовка.
Если сообщение сформировано корректно, то сервер ответит ChangeCipherSpec и Finished, в противном случае сообщением об ошибке.
После этого мы сервер готов к обмену данными и мы посылаем наш HTTP запрос и получаем ответ.
Выводы
Рассмотренный в статье подход позволяет эффективно работать с https для приложений, не требующих его полной реализации. Вместо сторонних реализаций ssl, тянущих собственную криптографию, можно использовать уже присутствующую в проекте, как это показано на примере crypto++, что уменьшает число зависимостей, улучшает поддержку и переносимость.
Реализовано и используется практически в i2pd — C++ реализации I2P