Мотивация
В русскоязычном интернете трудно найти информацию об API-библиотеке OpenSSL. Большое внимание уделяется использованию консольных команд для манипуляции с самоподписанными сертификатами для веб-серверов или OpenVPN-серверов.
Такой подход хорош, когда нужно сделать пару сертификатов в час. А если потребуется создать сразу пару сотен за минуту? Или писать скрипт и разбирать вывод из консоли? А если в процессе произошла ошибка?
При использовании API генерация сертификатов, проверка валидности и подпись выполняются гораздо проще. Появляется возможность контролировать и обрабатывать ошибки на всех этапах работы, а также указывать дополнительные параметры сертификата (поскольку не все параметры можно задавать из консоли) и производить тонкую настройку.
Отдельно стоит отметить сетевую составляющую. Если сертификат есть и просто лежит на диске, он бесполезен.
К сожалению, очень мало русской документации по вопросу организации SSL-сервера, по тому, как организовать SSL-клиент для получения данных. Официальная документация не настолько полна и хороша, чтобы можно было сразу включиться в работу с библиотекой. Не все функции описаны подробно, приходится экспериментировать с параметрами, с тем, в какой последовательности и что именно нужно очищать, а что библиотека удалит самостоятельно.
Данная статья — компиляция моего опыта по работе с библиотекой OpenSSL при реализации клиент-серверного приложения. Описанные в ней функции будут работать как на десктопе, так и на Android-устройствах. К статье прилагается репозиторий с кодом на C/C++ для того, чтобы вы могли увидеть работу описываемых функций.
Цель
При изучении новой библиотеки или технологии я стараюсь решать проблемы с помощью нового функционала. В данном случае попробуем сделать MITM
для перехвата трафика к HTTPS-серверу.
Сформируем требования к программе:
Ожидать подключения по порту (SSL-сервер)
При появлении входящего подключения:
- Подключиться к HTTPS-серверу
- Прочитать запрос клиента к серверу
- Передать прочитанные от клиента данные серверу
- Прочитать ответ сервера
- Передать ответ сервера клиенту
- Сбросить соединения
Так как у нас будет SSL-сервер, нам понадобятся сертификат удостоверяющего центра и сертификат для нашего сервера.
Пусть эти данные будут генерироваться нашей программой, а сертификат CA будет выгружаться в файл в рабочей папке программы.
Разработка будет вестись на Ubuntu, прочий инструментарий: компилятор GCC 5.4.0, OpenSSL 1.0.2, curl 7.52.1, CMake 3.8.1 (единственный не из пакетов).
Для отправки запросов к нашему приложению будем использовать curl из консоли. Поскольку нам нужно указать CA-сертификат, команда будет выглядеть так:
curl --cacert ca.crt -v https://127.0.0.1:5566 -H "Host: taigasystem.com"
Указание заголовка Host требуется для того, чтобы curl корректно составил HTTP-запрос. Без этого сервер ответит ошибкой.
Начало и завершение работы
Для работы с библиотекой OpenSSL ее нужно инициализировать. Используйте следующий код:
#include <openssl/bio.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
...
void InitOpenSSL()
{
OpenSSL_add_all_algorithms();
ERR_load_BIO_strings();
ERR_load_crypto_strings();
SSL_load_error_strings();
SSL_library_init();
}
Перед завершением приложения следует провести очистку библиотеки, для этого можно использовать следующий код:
void ClearOpenSSL()
{
EVP_cleanup();
CRYPTO_cleanup_all_ex_data();
ERR_remove_thread_state(NULL);
ERR_free_strings();
}
Контекст
Большинство операций библиотеки OpenSSL требуют наличия контекста. Эта структура, которая хранит используемые алгоритмы, их параметры и прочие данные. Она создается с помощью функции:
SSL_CTX *SSL_CTX_new(const SSL_METHOD *method);
Список методов, которые можно передать в эту функцию, довольно обширен, но документация говорит нам, что нужно использовать SSLv23_server_method()
для сервера и SSLv23_client_method()
для клиента.
При этом библиотека автоматически выберет максимально безопасный протокол, поддерживаемый клиентом и сервером.
Вот пример создания контекста для клиента:
SSL_CTX *ctx = NULL;
ctx = SSL_CTX_new(SSLv23_client_method());
if (ctx == NULL)
{
//Обработка ошибок
}
Для корректного удаления контекста следует использовать функцию SSL_CTX_free
.
Мне не очень нравится использовать SSL_CTX_free
каждый раз, когда контекст необходимо удалить. Можно использовать умные указатели с указанием функции удаления или обернуть структуру в класс RAII:
std::shared_ptr<SSL_CTX> m_ctx(ctx, SSL_CTX_free);
Работа с ошибками
Большинство функций библиотеки OpenSSL возвращают 1 как признак успешного выполнения. Вот обычный код с проверкой на возникновение ошибки:
if (SSL_CTX_load_verify_locations(ctx, fileName, NULL) != 1)
{
//Обработка ошибки
}
Однако иногда этого недостаточно, а иногда необходимо более подробное описание проблемы. Для этого OpenSSL использует в каждом потоке отдельную очередь сообщений. Чтобы извлечь код ошибки из очереди, следует использовать функцию ERR_get_error()
.
Сам по себе код ошибки не очень понятен пользователю, поэтому можно использовать функцию ERR_error_string
для получения строкового представления о коде ошибки. Если функция возвращает 0, это значит, что ошибки нет.
Вот пример получения строки с описанием ошибки по коду ошибки:
#include <openssl/err.h>
...
//Вызов каких-либо функций библиотеки OpenSSL
std::cerr << ERR_error_string(ERR_get_error(), NULL) << std::endl;
...
Второй параметр функции ERR_error_string
— это указатель на буфер, который должен быть не менее 120 символов длиной. Если его не указать, то будет использоваться статический буфер, который перезаписывается при каждом вызове этой функции.
Стоит отметить, что для каждого отдельного потока создается отдельная очередь сообщений об ошибке.
Ключи
Теперь чтобы организовать OpenSSL-сервер, нам потребуется создать сертификат удостоверяющего центра и сертификат сервера. Для каждого из них нам нужно создать ключи для подписи.
Создание
Для хранения пары закрытый/открытый ключ в OpenSSL используется структура EVP_PKEY
. Данная структура создается следующим образом:
EVP_PKEY *pkey = NULL;
pkey = EVP_PKEY_new();
if (pkey == NULL)
{
//Обработка ошибки
}
Подробнее о EVP можно прочитать здесь.
Обратная для EVP_PKEY_new
функция EVP_PKEY_free
освобождает память и удаляет структуру EVP_PKEY
.
Теперь необходимо подготовить BIGNUM
структуру для генерации RSA (подробнее об этой структуре можно узнать здесь):
BIGNUM *big = NULL;
big = BN_new();
if (big == NULL)
{
//Обработка ошибки
}
else if (BN_set_word(big, RSA_F4) != 1)
{
//Обработка ошибки
BN_free(big);
}
Функция BN_set_word
устанавливает размер для структуры BIGNUM
. Допустимыми являются значения RSA_3
и RSA_F4
, последний — предпочтительнее.
Настал черед для генерации ключей. Для этого нужно создать структуру RSA
:
RSA *rsa = NULL;
rsa = RSA_new();
if (rsa == NULL)
{
//Обработка ошибки
}
Теперь сама генерация ключей:
if (RSA_generate_key_ex(rsa, 4096, big, NULL) != 1)
{
//Обработка ошибки
}
4096 это размер ключа, который мы хотим получить.
Заканчиваем генерацию ключей записью новых ключей в структуру EVP_PKEY
:
if (EVP_PKEY_assign_RSA(pkey, rsa) !=1)
{
//Обработка ошибки
}
PEM-формат
PEM — достаточно простой формат для хранения ключей и сертификатов. Он представляет собой текстовый файл, в котором последовательно хранятся записи вида:
-----BEGIN RSA PRIVATE KEY-----
MIIJJwIBAAKCAgEAvNwgYmIyfvY6IsVZwRCkAHTOhwE3Rp/uNcUoTcPl5atOwPVW
JLY3odYmILsa8se7B/aNNzO7AlvXwlzxinQ3AF7l37LqGzf8v16TFVN4kit8vrq0
V9bBXHpiWH+YQT4gBVmSkwqEMZ/wQlUOIxz4Q2M7cXRu4fRe3rt3kGHCPJ66Ybax
yEp6nfdK8IKsyxqAXjBkqfC5rkdw2n7UAd/OnPRCDowyvythDb8jR1LkbJjlIatK
....
yajhmBDpS11hzuWHhDmpjbrV79OMRzKQAWBKRubObtGIsFB2CzbabusV+oq/Y78y
OxriZYqoRv3WB5GH/pPO9w1ptveddLU33NVBSRfFS1jyqyj/1CqXlE4gcQ==
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIFkTCCA3mgAwIBAgIJAMPIqA2oVd/SMA0GCSqGSIb3DQEBCwUAMF8xCzAJBgNV
BAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1vc2NvdzEUMBIGA1UE
...
bt9NHGnCxYcParG+YqU5UTUrCUGUfnZhJAX+qkgsVSC5c81Tk0VXTQx3EiEvdzV+
wUX9LMRLIxjy1D5AO6a29LkzNAvw+iFm36VO+ssdkJW4Q6MAYA==
-----END CERTIFICATE-----
Тут стоит отметить, что количество символов ----- в начале заголовка и в конце, а также в закрывающей строке должно быть одинаковым.
Более подробно этот формат описан тут:
— RFC1421 Part I: Message Encryption and Authentication Procedures
— RFC1422 Part II: Certificate-Based Key Management
— RFC1423 Part III: Algorithms, Modes, and Identifiers
— RFC1424 Part IV: Key Certification and Related Services
Запись ключей в PEM-формате
Допустим, у нас есть пара открытый/закрытый ключ в структуре EVP_PKEY, тогда для записи их в файл следует использовать функции PEM_write_PrivateKey
и PEM_write_PUBKEY
.
Вот пример использования этих функций:
FILE *f = fopen("server.pem", "wb");
if (!PEM_write_PrivateKey(f, key, NULL, NULL, 0, 0, NULL))
{
//Обработка ошибки
fclose(f);
}
else if (!PEM_write_PUBKEY(f, key))
{
//Обработка ошибки
fclose(f);
}
fclose(f);
Стоит дать некоторые пояснения относительно функции
int PEM_write_PrivateKey(FILE *fp, EVP_PKEY *x, const EVP_CIPHER *enc, unsigned char *kstr, int klen, pem_password_cb *cb, void *u);
, где const EVP_CIPHER *enc
— это указатель на алгоритм шифрования для шифрования закрытого ключа перед его сохранением.
Например, EVP_aes_256_cbc()
значит "AES with a 256-bit key in CBC".
Алгоритмов шифрования очень много, и всегда можно
подобрать что-то по душе. Соответствующие определения можно найти в openssl/evp.h
.
unsigned char *kstr
ожидает получить указатель на строку с паролем для шифрования ключа, а int klen
— длину этой строки.
Если заданы kstr
и klen
, то параметры cb
и u
игнорируются, где:— cb — это указатель на функцию вида:
int cb(char *buf, int size, int rwflag, void *u);
— buf — указатель на буфер для записи пароля
— size — максимальный размер пароля (т.е. размер буфера)
— rwflag равен 0 при чтении и 1 при записи
Результат выполнения функции — длина пароля или 0 в случае возникновения ошибки.
Параметр void *u
для обеих функций используется для передачи дополнительных данных. Например, как указатель на окно для GUI приложения.
Загрузка ключей из PEM-файла
Загрузка ключей происходит при помощи функций PEM_read_PrivateKey
и PEM_read_PUBKEY
. Обе функции имеют одинаковые параметры и возвращаемое значение:
EVP_PKEY *PEM_read_PUBKEY(FILE *fp, EVP_PKEY **x, pem_password_cb *cb, void *u);
EVP_PKEY *PEM_read_PrivateKey(FILE *fp, EVP_PKEY **x, pem_password_cb *cb, void *u);
где:
— FILE *fp
— открытый для чтения файловый дескриптор
— EVP_PKEY **x
— структура, которая должна быть перезаписана
— pem_password_cb *cb
— функция для получения пароля расшифровки ключа
— void *u
— строка с паролем ключа, завершающаяся
Вот пример функции для получения пароля для расшифровки ключа:
int pass_cb(char *buf, int size, int rwflag, void *u)
{
int len;
char *tmp;
if (rwflag == 1)
std::cout << "Введите пароль для " << (char*)u << ": ";
else
std::cout << "Введите пароль для загрузки ключа: ";
std::string pass;
std::cin >> pass;
if (pass.empty() || pass.length() <=0)
return 0;
len = pass.length();
if (len > size)
len = size;
memcpy(buf, pass.c_str(), len);
return len;
}
Вот пример того, как можно загрузить не зашифрованный закрытый ключ из файла:
FILE *f = NULL;
f = fopen(fileName.c_str(), "rb");
if (f == NULL)
{
//Обработка ошибки
}
EVP_PKEY *key = NULL;
key = PEM_read_PrivateKey(f, NULL, NULL, NULL);
if (key == NULL)
{
//Обработка ошибки
}
fclose(f);
Загрузка ключей из памяти
Иногда бывает удобно хранить ключ или сертификат как константу в программе. Для таких случаев можно использовать структуру типа BIO. Эта структура и связанные с ней функции повторяют функционал ввода-вывода для FILE
.
Вот так можно загрузить ключ из памяти:
const char *key = "-----BEGIN RSA PRIVATE KEY-----n"
"MIIJKAIBAAKCAgEA40vjOGzVpuJv+wIfNBQSr9U/EeRyvSy/L6Idwh799LOPIwjFn"
.....
"zkxvkGMPBY3BcSPjipuydWTt8xE8MOe0SmEcytHZ/DifwF9qyToDlTFOUN8=n"
"-----END RSA PRIVATE KEY-----";
BIO *buf = NULL;
buf = BIO_new_mem_buf(key, -1);
if (buf == NULL)
{
//Обработка ошибки
}
EVP_PKEY *pkey = NULL;
pkey = PEM_read_bio_PrivateKey(buf, NULL, NULL, NULL);
if (pkey == NULL)
{
//Обработка ошибки
}
Запрос сертификата
Теперь, когда мы умеем создавать ключи, посмотрим, как создавать сертификаты. Сертификаты могут быть самоподписанными или иметь подпись удостоверяющего центра. Для получения сертификата, подписанного удостоверяющим центром, требуется создать запрос сертификата (CSR) и отправить его удостоверяющему центру. В ответ он пришлет подписанный сертификат.
В том случае, когда мы хотим создать самоподписанный сертификат или сертификат для собственного удостоверяющего центра, CSR создавать не нужно, можно сразу переходить к разделу Сертификаты.
Создание
Certificate Signing Request (CSR) — это сообщение или запрос, который создатель сертификата посылает удостоверяющему центру (CA) и который содержит в себе информацию о публичном ключе, стране выпуска, а также цифровую подпись создателя.
Для создания CSR нам понадобится ключ EVP_PKEY
, созданный ранее. Все начинается с выделения памяти под структуру CSR:
X509_REQ *req = NULL;
req = X509_REQ_new();
if (req == NULL)
{
//Обработка ошибки
}
Обратной функцией к X509_REQ_new
является X509_REQ_free
.
Теперь нужно задать версию сертификата. В данном случае версия равна 2:
if (X509_REQ_set_version(req, 2) != 1)
{
//Обработка ошибки
X509_REQ_free(req);
}
По стандарту X.509 эта версия должна быть на единицу меньше версии сертификата. Т.е. для версии сертификата 3 следует использовать число 2.
Теперь будем задавать данные создателя запроса. Будем использовать следующие поля:
—С — двухбуквенный код страны, например RU
— ST — область, в нашем случае Moscow
— L — город, снова Moscow
— O — организация, например Taigasystem
— CN — доменное имя, для нас будет taigasystem.com
Вот так эти поля задаются в запрос:
X509_NAME *name = X509_REQ_get_subject_name(req);
if (X509_NAME_add_entry_by_txt(name, "C", MBSTRING_ASC, (const unsigned char *)"RU", -1, -1, 0) != 1)
{
//Обработка ошибки
X509_REQ_free(req);
}
if (X509_NAME_add_entry_by_txt(name, "ST", MBSTRING_ASC, (const unsigned char *)"Moscow", -1, -1, 0) != 1)
{
//Обработка ошибки
}
if (X509_NAME_add_entry_by_txt(name, "L", MBSTRING_ASC, (const unsigned char *)"Moscow", -1, -1, 0) != 1)
{
//Обработка ошибки
}
if (X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, (const unsigned char *)"Taigasystem", -1, -1, 0) != 1)
{
//Обработка ошибки
}
if (X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, (const unsigned char *)"taigasystem.com", -1, -1, 0) != 1)
{
//Обработка ошибки
}
Заметим, что сначала мы получаем структуру X509_NAME
из структуры CSR запроса и задаем значения для нее.
Теперь нужно задать публичный ключ для этого запроса:
if (X509_REQ_set_pubkey(req, key) != 1)
{
//Обработка ошибки
}
Последний штрих — подпись запроса:
if (X509_REQ_sign(req, key, EVP_sha256()) <= 0)
{
//Обработка ошибки
}
В отличие от других функций OpenSSL, X509_REQ_sign
возвращает
размер подписи в байтах, а не 1, при успешном завершении, и 0 — в случае ошибки.
Теперь запрос сертификата готов.
Сохранение CSR в файл
Сохранить CSR в файл довольно просто. Необходимо открыть файл, а затем — вызвать функцию PEM_write_X509_REQ
:
FILE *f = NULL;
f = fopen("server.csr", "wb");
if (PEM_write_X509_REQ(f, csr) != 1)
{
//Обработка ошибки
fclose(f);
}
fclose(f);
В результате мы получим такой текст в файле server.csr:
-----BEGIN CERTIFICATE REQUEST-----
MIICnzCCAYcCAQEwWjELMAkGA1UEBhMCUlUxDzANBgNVBAgMBlJ1c3NpYTEPMA0G
...
fbzFJ6EM00mbyr472lEXZpvdZgBCfxpkNDyp9nsiIQf0EyC05MgufOAKDT/fGQfa
4gWK
-----END CERTIFICATE REQUEST-----
Загрузка CSR из файла
Для загрузки CSR следует использовать функции:
X509_REQ *PEM_read_X509_REQ(FILE *fp, X509_REQ **x, pem_password_cb *cb, void *u);
X509_REQ *PEM_read_bio_X509_REQ(BIO *bp, X509_REQ **x, pem_password_cb *cb, void *u);
Первая загружает CSR из файла, вторая позволяет загрузить CSR из памяти.
Параметры данных функция аналогичны загрузке ключей из PEM-файла, за
исключением pem_password_cb
и u
, которые игнорируются.
Сертификаты
X.509 сертификат
X.509 — это одна из вариаций языка ASN.1, стандартизованная в rfc2459.
Более подробно о формате X.509 можно прочитать здесь и здесь.
Генерация сертификата без CSR
Можно сгенерировать сертификат без использования CSR. Это пригодится для создания CA.
Для генерации сертификата без CSR нам понадобится пара открытый/закрытый ключ в структуре EVP_PKEY
. Начинаем с выделения памяти под структуру сертификата:
X509 *x509 = X509_new();
if (!x509)
//Обработка ошибки
Обратной для X509_new
является X509_free
.
Сертификат создается точно так же, как и CSR-запрос, с одной лишь разницей — помимо версии и данных издателя, требуется задать серийный номер сертификата.
Также нужно использовать другие функции для доступа к данным сертификата:
— X509_set_version
вместо X509_REQ_set_version
— X509_get_subject_name
вместо X509_REQ_get_subject_name
— X509_set_pubkey
вместо X509_REQ_set_pubkey
— X509_sign
вместо X509_REQ_sign
Таким образом отличить по именам, для каких объектов предназначены те или иные функции, становится довольно просто.
Теперь можно установить серийный номер сертификата:
ASN1_INTEGER *aserial = NULL;
aserial = M_ASN1_INTEGER_new();
ASN1_INTEGER_set(aserial, 1);
if (X509_set_serialNumber(x509, aserial) != 1)
{
//Обработка ошибки
}
Для каждого нового сертификата необходимо создавать новый серийный номер.
Теперь пришло время установить время жизни сертификата. Для этого устанавливается два параметра — начало и конец жизни сертификата:
if (!(X509_gmtime_adj(X509_get_notBefore(cert), 0)))
{
//Обработка ошибки
X509_free(cert);
}
// 31536000 * 3 = 3 year valid period
if (!(X509_gmtime_adj(X509_get_notAfter(cert), 31536000 * 3)))
{
//Обработка ошибки
X509_free(cert);
}
Началом жизни сертификата будет момент его выпуска — значение 0 для функции X509_get_notBefore
. Конец жизни сертификата задается функцией X509_get_notAfter
.
Последний штрих — подпись сертификата при помощи закрытого ключа:
EVP_PKEY *key; // Not null
if (X509_sign(cert, key, EVP_sha256()) <=0)
{
long e = ERR_get_error();
if (e != 0)
{
//Обработка ошибки
X509_free(cert);
}
}
Здесь есть интересная особенность: функция X509_sign
возвращает размер подписи в байтах, если все прошло успешно, и 0 — в случае ошибки. Иногда функция возвращает ноль, даже если ошибки нет. Поэтому здесь приходится вводить дополнительную проверку на ошибку.
Генерация сертификата с CSR
Для генерации сертификата по CSR нам нужен закрытый ключ CA для подписи сертификата, сертификат CA для задания данных издателя и сам CSR-запрос.
Создание самого сертификата и установка версии и номера такие же, как и для сертификата без CSR. Разница появляется, когда нужно извлечь данные издателя из CSR-запроса и установить их в сертификат:
X509_REQ *csr; //not null
X509_NAME *name = NULL;
name = X509_REQ_get_subject_name(csr);
if (name == NULL)
{
//Обработка ошибки
X509_free(cert);
}
if (X509_set_subject_name(cert, name) != 1)
{
//Обработка ошибки
X509_free(cert);
}
После этого нужно уставить данные издателя сертификата. Для этого требуется сертификат CA:
X509 *CAcert; //not null
name = X509_get_subject_name(CAcert);
if (name == NULL)
{
//Обработка ошибки
X509_free(cert);
}
if (X509_set_issuer_name(cert, name) != 1)
{
//Обработка ошибки
X509_free(cert);
}
Видно, что данные из CSR мы устанавливаем при помощи X509_set_subject_name
, а данные CA — при помощи X509_set_issuer_name
.
Следующий шаг — необходимо получить открытый ключ из CSR и установить его в новый сертификат.
Помимо установки ключа можно сразу проверить, был ли подписан CSR данным ключом:
// Get pub key from CSR
EVP_PKEY *csr_key = NULL;
csr_key = X509_REQ_get_pubkey(csr);
if (csr_key == NULL)
{
//Обработка ошибки
}
// Verify CSR
if (X509_REQ_verify(csr, csr_key) !=1)
{
//Обработка ошибки
X509_free(cert);
}
// Set pub key to new cert
if (X509_set_pubkey(cert, csr_key) != 1)
{
//Обработка ошибки
X509_free(cert);
}
Теперь можно установить серийный номер сертификата:
ASN1_INTEGER *aserial = NULL;
aserial = M_ASN1_INTEGER_new();
ASN1_INTEGER_set(aserial, 1);
if (X509_set_serialNumber(cert, aserial) != 1)
{
//Обработка ошибки
}
Последний штрих — следует подписать сертификат при помощи закрытого ключа CA:
EVP_PKEY *CAkey; // Not null
if (X509_sign(cert, CAkey, EVP_sha256()) <=0)
{
long e = ERR_get_error();
if (e != 0)
{
//Обработка ошибки
X509_free(cert);
}
}
После подписи наш новый сертификат готов.
Сохранение X.509 сертификата
Сохранение происходит довольно просто:
X509 *cert;
...
FILE *f = NULL;
f = fopen("server.crt", "wb");
if (!PEM_write_X509(f, cert))
{
//Обработка ошибки
fclose(f);
}
fclose(f);
Загрузка X.509 сертификата
Загрузка происходит при помощи двух функций:
X509 *PEM_read_X509(FILE *fp, X509 **x, pem_password_cb *cb, void *u);
X509 *PEM_read_bio_X509(BIO *bp, X509 **x, pem_password_cb *cb, void *u);
Параметры описаны выше.
Сетевая часть
Клиент
Подключение к хосту при помощи SSL-сокетов не очень отличается от обычного TCP-подключения.
Сначала нужно создать TCP-подключение к серверу:
//Попробуем получить IP для хоста
struct hostent *ip = nullptr;
ip = gethostbyname(host.c_str());
if (ip == nullptr)
{
//Обработка ошибки
}
//Создаем сокет
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
{
//Обработка ошибки
}
struct sockaddr_in dest_addr;
memset(&dest_addr, 0, sizeof(struct sockaddr_in));
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(port);
dest_addr.sin_addr.s_addr = *(long *)(ip->h_addr);
//Подключаемся:
if (connect(sock, (struct sockaddr *)&dest_addr, sizeof(struct sockaddr)) == -1)
{
//Подключиться не удалось
}
Теперь нужно создать клиентский SSL-контекст:
const SSL_METHOD *method = SSLv23_client_method();
SSL_CTX *ctx = NULL;
ctx = SSL_CTX_new(method);
if (ctx == NULL)
//Обработка ошибки
Теперь нужно установить полученный сокет в SSL-структуру. Она получается из контекста:
SSL *ssl = SSL_new(ctx);
if (ssl == NULL)
{
//Обработка ошибки
}
if (SSL_set_fd(ssl, sock) != 1)
{
//Обработка ошибки
}
Последний штрих — само подключение:
if (SSL_connect(ssl) != 1)
{
//Обработка ошибки
}
Чтение данных
Для чтения данных из SSL-сокета нужно использовать функцию:
int SSL_read(SSL *ssl, void *buf, int num);
Она читает в буфер данные из сокета, привязанного к SSL-структуре. Если нужно узнать, есть ли в буфере сокета данные, которые нужно прочитать, можно использовать функцию
int SSL_pending(const SSL *ssl);
Т.е. для чтения можно использовать такую конструкцию:
const int size = SSL_pending(ssl);
char *buf = new char[size];
memset(buf, 0, size);
if (SSL_read(ssl, buf, size) <= 0)
{
//Обработка ошибки
}
В результате в буфере будут находиться уже декодированные данные.
Запись данных
Для записи используется функция:
int SSL_write(SSL *ssl, const void *buf, int num);
На входе она получает указатель на SSL-структуру, которая будет осуществлять запись, сами данные и их размер.
Вот пример такой записи:
char buf[] = "12345678"
if (SSL_write(ssl, buf, strlen(buf)) <= 0)
{
//Обработка ошибки
}
Сервер
Серверная часть схожа с клиентской — в ней также требуется получить SSL-контекст, и в ней — те же функции чтения записи. Отличие состоит в том, что сервер должен подготовить контекст, чтобы отдать клиенту сертификат, а также организовать рукопожатие с клиентом.
Начнем с подготовки контекста:
const SSL_METHOD *method = SSLv23_server_method();
SSL_CTX *ctx = NULL;
ctx = SSL_CTX_new(method);
if (ctx == NULL)
{
//Обработка ошибки
}
Документация говорит нам, о том, что выбор SSLv23_server_method
позволит библиотеке самостоятельно определить максимально безопасную версию протокола из поддерживаемых клиентом.
Если нужно включить или выключить определенную версию или изменить другие настройки, можно воспользоваться функцией SSL_set_options
. Документацию для нее можно найти здесь.
Загрузку X.509 сертификата и загрузку ключей мы рассмотрели чуть раньше, поэтому считаем, что у нас уже есть пара этих структур.
Установим для серверного контекста сертификат и ключ сертификата:
X509 *serverCert; //not null
EVP_PKEY *serverKey; //not null
if (SSL_CTX_use_certificate(ctx, serverCert) != 1)
{
//Обработка ошибки
}
if (SSL_CTX_use_PrivateKey(ctx, serverKey) != 1)
{
//Обработка ошибки
}
Наш сервер готов принимать входящие соединения. Пусть этим занимается обычный accept
. Нас будет интересовать полученный от этой функции сокет.
Начинаем с того, что для каждого такого сокета, нам нужна новая SSL-структура:
SSL *ssl = NULL;
ssl = SSL_new(ctx);
if (ssl == NULL)
{
//Обработка ошибки
}
Теперь устанавливаем в эту структуру наш сокет:
int sock; //accepted tcp socket
if (SSL_set_fd(ssl, sock) != 1)
{
//Hadle error
}
Сам механизм рукопожатия:
if (SSL_accept(ssl) != 1)
{
//Обработка ошибки
}
Стоит отметить следующий момент: если используемый нами сокет находится в неблокирующем режиме, то рукопожатие с первого раза не пройдет. В этом случае нужно проверять не только возвращаемое значение, но и код ошибки:
int ret = SSL_accept(ssl);
if (ret == 0)
{
//Обработка ошибки
}
if (ret < 0)
{
unsigned long error = ERR_get_error();
if (error == SSL_ERROR_WANT_READ ||
error == SSL_ERROR_WANT_WRITE ||
error == 0)
{
//Не ошибка, нужно дождаться получения следующего сообщения
//в протоколе рукопожатия
}
else
{
//Обработка ошибки
}
}
if (ret == 1)
{
//Подключено
}
После установки соединения мы можем использовать описанные ранее функции ввода-вывода для чтения и записи данных в сокет.
Пример работы
Сборка программы
Для сборки примера к статье нужно использовать команды:
mkdir build
cd build
cmake ..
make
Запуск
В папке сборки (build) следует выполнить:
./openssl_api
При этом в этой же папке появится файл ca.crt — созданный программой
сертификат.
Чтобы проверить его работоспособность, нужно выполнить
cppurl --cacert ca.crt -v https://127.0.0.1:5566 -H "Host: taigasystem.com"
Первым параметром мы задаем используемый CA Сертификат, -v показывает нам все переданные и принятые сообщения. -H "Host: taigasystem.com" нужен, чтобы заметить в GET-запросе заголовок Host. Без этого параметра программа отработает, но в ответ на запрос мы получим 404-ю ошибку.
Вывод curl
Вот вывод запроса curl при запросе через нашу программу (не полный):
$ curl --cacert ca.crt -v https://127.0.0.1:5566 -H "Host: taigasystem.com"
* Rebuilt URL to: https://127.0.0.1:5566/
* Trying 127.0.0.1...
* Connected to 127.0.0.1 (127.0.0.1) port 5566 (#0)
* found 1 certificates in ca.crt
* found 700 certificates in /etc/ssl/certs
* ALPN, offering http/1.1
* SSL connection using TLS1.2 / RSA_AES_128_GCM_SHA256
* server certificate verification OK
* server certificate status verification SKIPPED
* common name: 127.0.0.1 (matched)
* server certificate expiration date OK
* server certificate activation date OK
* certificate public key: RSA
* certificate version: #3
* subject: C=RU,CN=127.0.0.1,L=Moscow,O=Taigasystem,ST=Moscow
* start date: Mon, 28 Aug 2017 07:36:42 GMT
* expire date: Thu, 27 Aug 2020 07:36:42 GMT
* issuer: C=RU,CN=127.0.0.1,L=Moscow,O=Taigasystem,ST=Moscow
* compression: NULL
* ALPN, server did not agree to a protocol
> GET / HTTP/1.1
> Host: taigasystem.com
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.4.6 (Ubuntu)
< Date: Mon, 28 Aug 2017 07:39:18 GMT
< Content-Type: text/html; charset=utf-8
< Transfer-Encoding: chunked
< Connection: keep-alive
< Vary: Accept-Language, Cookie
< X-Frame-Options: SAMEORIGIN
< Content-Language: ru
< Strict-Transport-Security: max-age=604800
<
....
Видим следующее: во-первых, наш сертификат прочитан и принят; во-вторых, сервер ответил на запрос и передает ответ.
Вывод программы
После запуска программы и подключения к ней curl видим такой (неполный) вывод:
$ ./openssl_api
Библиотека OpenSSL инициализирована
Для проверки воспользуйтесь командой 'curl --cacert ca.crt -v https://127.0.0.1:5566 -H "Host: taigasystem.com" '
Структура хранения ключей создана
Создана структура BIGNUM
Изменен размер структуры BIGNUM
Создана RSA структура
Ключи сгенерированы
Ключи убраны в EVP
Сертификат создан. Длина подписи: 512
Структура хранения ключей создана
Создана структура BIGNUM
Изменен размер структуры BIGNUM
Создана RSA структура
Ключи сгенерированы
Ключи убраны в EVP
CSR создан. Длина подписи: 512
Сертификат создан. Длина подписи: 512
Структура для хранения ключей удалена
Сертификат успешно записан в ca.crt
SSL контекст создан
Сокет ожидает подключения на 5566 порту
Новое входящие соединение
Подключаемся к taigasystem.com на порту 443
Подключен к хосту taigasystem.com[188.225.73.237]:443
SSL контекст создан
SSL соединение с taigasystem.com:443 установлено
Цикл чтения/записи данных
#######################################
Прочитано 79 байт от клиента
#######################################
GET / HTTP/1.1
Host: taigasystem.com
User-Agent: curl/7.47.0
Accept: */*
#######################################
Прочитано 4096 байт от сервера
#######################################
HTTP/1.1 200 OK
Server: nginx/1.4.6 (Ubuntu)
Date: Mon, 28 Aug 2017 07:39:18 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Language, Cookie
X-Frame-Options: SAMEORIGIN
Content-Language: ru
Strict-Transport-Security: max-age=604800
...
Т.е. очевидно, что мы получили GET-запрос от клиента (curl) и ответ на него от сервера.
Ссылки
- Traffic Analysis of an SSL/TLS Session
- Документация библиотеки
- Примеры SSL от Wireshark
- Документация от CMake
- Инструкция по сборке библиотеки
- Пример SSL Сервера
- Пример SSL клиента
- Отличный набор примеров по всем аспектам
- Руководство по выживанию — TLS/SSL и сертификаты SSL (X.509)
Огромная благодарность за исследования tomasloh
Автор: Last_angel