hack.connect
 
[ru.scene.community]
HackConnect.E-zine issue #3
// 00100 Работаем с OpenSSL
Введение.
На время около полутора лет назад приходился пик моего интереса к программированию утилит удаленного доступа, тогда же и возникли вопросы обеспечения конфиденциальности передаваемых данных. В получившемся проекте вопрос защиты соединения решался следующим образом – по минимальной программе – исключительно на ассиметричных алгоритмах шифрования, по макси, в целях повышения степени защиты и выигрыша в скорости – с комбинированием ассиметричных и симметричных криптоалгоритмов. Тут, как говорится, энтузиазм иссяк. А произошло это по некоторым причинам, одной из главных среди которых было то количество времени, которое пришлось бы мне затратить на изучение реализаций протокола SSL без нормально написанной документации. Возможно, проблемы в этом вопросе были и не только в неналичии доков, но и в моей кривой голове, но не суть :) Сейчас же я, все же, заполню дыру в своих, да и в ваших знаниях в аспекте применения реализации SSL/TSL под названием OpenSSL.
Статья эта написана, в основном, на базе перевода одной очень хорошей статьи Eric Rescorla под названием «An Introduction to OpenSSL Programming», с небольшими дополнениями от меня и интегрированными тонкостями от И.Сысоева :) Тема программирования с использованием OpenSSL будет продолжена в будущих выпусках нашего журнала. Приятного чтения :)

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

  • SSL_library_init() - производит загрузку базовых алгоритмов, используемых OpenSSL в дальнейшей работе.
  • SSL_load_error_strings() – грузит строки описания ошибок (естественно, полезно при тестировании и отладке приложений)
  • SSL_CTX_use_certificate_chain_file() – загрузка доверенного сертификата и сертификатов CA, образующих цепь сертификатов. Нужна, если вы пишете сервер или клиент, которому необходимо осуществлять аутентификацию.
  • SSL_CTX_use_PrivateKey_file() используется, соответственно, для загрузки приватного ключа.
  • SSL_CTX_set_default_passwd_cb() – устанавливает пароль для защиты приватного ключа.
  • SSL_CTX_load_verify_locations() – грузит сертификаты CA, которым вы доверяете.
  • SSL_get_peer_sertificate() – извлекает сертификат сервера.
  • SSL_write() – запись данных. Флаг SSL_MODE_ENABLE_PARTIAL_WRITE разрешает запись по-частям, в этом случае вам придется использовать циклы.
  • SSL_read() – считывание данных. Флаг SSL_CTRL_SET_READ_AHEAD позволяет считать за 1 вызов несколько небольших пакетов.
  • SSL_get_error() - проверяет возвращенное значение и вычисляет по численному идентификатору ошибки ее описание. Если возвращенное значение – 0, это не значит, что нет доступных данных, скорее, сокет закрыт и данные, естественно, нельзя будет считать.
  • SSL_shutdown() – посылает респонденту сигнал close_notify. Данные не могут быть переданы после того, как послан сигнал close_notify.
  • SSL_accept() - производит серверную часть процедуры обмена заголовками.
  • BIO_gets() - аналогичная обычной stdio fgets(). Принимает в качестве аргумента произвольного размера буфер и количество символов и считывает строку из соединения SSL в буфер. Результат всегда оканчивается последовательностью %00 (но включает конечный LF)
  • BIO_puts() – будет использована вместо SSL_write(). Это позволяет нам составлять запрос по строке, при этом отправляя его в одном пакете SSL.

Общие процедуры.
Наша первая задача заключается в том, чтобы получить объект контекста SSL (SSL_CTX). Этот контекст будет использован для создания каждого нового SSL соединения, произведения обмена заголовками, приема и передачи данных.

Такой подход имеет 2 преимущества – во-первых, объект контекста позволяет инициализировать многие структуры за раз, улучшая производительность – мы храним весь константные ключевые данные, списки CA и прочее в объекте контекста, созданном в начале работы приложения; во-вторых, он позволяет получать такие данные, как кэш сессии SSL многим соединениям SSL, например, для возобновления сессии после разрыва. Мы реализуем все это в нижеприведенной функции initialize_ctx(), принимающей 2 аргумента – имя файла с ключами и пароль.

SSL_CTX *initialize_ctx(keyfile,password)
char *keyfile;
char *password;
{
  SSL_METHOD *meth;
  SSL_CTX *ctx;

  if(!bio_err){
    // Инициализируем SSL и грузим строки описаний ошибок
    SSL_library_init();
    SSL_load_error_strings();

  // Контекст вывода ошибок
    bio_err=BIO_new_fp(stderr,BIO_NOCLOSE);
  }

  // Обработчик-заглушка для SIGPIPE
  signal(SIGPIPE,sigpipe_handle);

  // Создаем наш контекст
  meth=SSLv23_method();
  ctx=SSL_CTX_new(meth);

  // Грузим свои ключи и сертификаты
  if(!(SSL_CTX_use_certificate_chain_file(ctx,
  keyfile)))
    berr_exit("Can’t read certificate file");

  pass=password;
  SSL_CTX_set_default_passwd_cb(ctx,
  password_cb);
  if(!(SSL_CTX_use_PrivateKey_file(ctx,
  keyfile,SSL_FILETYPE_PEM)))
    berr_exit("Can’t read key file");

  // Грузим CA, которым доверяем
  if(!(SSL_CTX_load_verify_locations(ctx,
  CA_LIST,0)))
    berr_exit("Ca’t read CA list");
  #if (OPENSSL_VERSION_NUMBER < 0x0090600fL)
  SSL_CTX_set_verify_depth(ctx,1);
  #endif

  return ctx;
}

Клиентская сторона.
Мы будем писать простой HTTPS клиент (RFC 2818) под одну из ОС, построенных на ядре linux (в моем случае, это FC8).

Первый шаг в соединении SSL это обмен заголовками, которое аутентифицирует сервер (и, опционально, клиента) и устанавливает ключевые данные, которые будут использованы для защиты в течение всего времени обмена данными, для чего используется вызов функции SSL_connect(). Так как мы используем блокирующиеся сокеты, SSL_connect() не возвратит управление потоком выполнения до тех пор, пока не будет осуществлен обмен заголовками, или не будет возвращена ошибка. Вызов приведен ниже:
// Создаем TCP соединение
sock=tcp_connect(host,port);

// Соединяемся с SSL сокетом
ssl=SSL_new(ctx);
sbio=BIO_new_socket(sock,BIO_NOCLOSE);
SSL_set_bio(ssl,sbio,sbio);
if(SSL_connect(ssl)<=0)
  berr_exit("SSL connect error");
if(require_server_auth)
  check_cert(ssl,host);

Когда мы инициируем SSL соединение с сервером, нам необходимо проверить его цепочку сертификатов . OpenSSL делает за нас часть проверок, но, к несчастью, другая часть возлагается на наше приложение и мы должны реализовать ее сами. Основная проверка, которую делает наша программа – проверка совпадения сертификатов, реализованная функцией check_cert:
void check_cert(ssl,host)
SSL *ssl;
char *host;
{
  X509 *peer;
  char peer_CN[256];

  if(SSL_get_verify_result(ssl)!=X509_V_OK)
    berr_exit("Certificate doesn’t verify");

  /* Проверяем цепь сертификатов. Длина цепи автоматически
  проверяется OpenSSL когда мы устанавливаем глубину проверки в ctx
  */

  // Проверяем common name
  peer=SSL_get_peer_certificate(ssl);
  X509_NAME_get_text_by_NID
  (X509_get_subject_name(peer),
  NID_commonName, peer_CN, 256);
  if(strcasecmp(peer_CN,host))
    err_exit("Common name doesn’t match host name");
}

Отправим свой запрос серверу:
request_len=strlen(REQUEST_TEMPLATE)+
strlen(host)+6;
if(!(request=(char *)malloc(request_len)))
  err_exit("Couldn’t allocate request");
sprintf(request,REQUEST_TEMPLATE,
host,port);

// Вычисляем количество символов запроса
request_len=strlen(request);
r=SSL_write(ssl,request,request_len);
switch(SSL_get_error(ssl,r)){
  case SSL_ERROR_NONE:
    if(request_len!=r)
      err_exit("Incomplete write!");
    break;
  default:
    berr_exit("SSL write problem");
}

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

OpenSSL использует API вызов SSL_read(), чтобы считывать данные, как показано ниже. Так же как и в вызове read(), мы просто выбираем буфер необходимого размера и передаем его SSL_read() (заметим, что размер буфера не очень важен здесь). Семантика SSL_read(), как и семантика read(), заключается в том, что функция возвращает доступные для считывания данные, даже если они по размеру меньше, чем было указано в качестве размера буфера.

Читаем ответ сервера:
while(1){
  r=SSL_read(ssl,buf,BUFSIZZ);
  switch(SSL_get_error(ssl,r)){
    case SSL_ERROR_NONE:
      len=r;
      break;
    case SSL_ERROR_ZERO_RETURN:
      goto shutdown; // не мой кусок кода :)
    case SSL_ERROR_SYSCALL:
      fprintf(stderr,
      "SSL Error: Premature close0);
      goto done;
    default:
      berr_exit("SSL read problem");
  }

  fwrite(buf,1,len,stdout);
}

Выбор BUFSIZZ при использовании OpenSSL уже не так влияет на производительность, как в обычных сокетах.

Например, если клиент записывает 1000-байтное пакет и мы вызываем SSL_read() по частям в 1 байт, в первом вызове SSL_read() будет получен весь пакет, а остальные вызовы всего лишь считают данные из буфера SSL. Таким образом, выбор размера буфера менее важен, когда вы используете SSL, в отличие от обычных сокетов. Если данные были записаны серией небольших по размеру пакетов, вы можете захотеть прочесть их все за один вызов read(). OpenSSL предоставляет соответствующий флаг, SSL_CTRL_SET_READ_AHEAD, переключающий работу в этот режим.

Протокол TCP использует сегмент FIN для того, чтобы определять, когда отправитель отослал все данные, которые собирался. SSL версии 2 просто разрешает другой стороне отправлять пакет TCP FIN для обрыва соединения, что позволяет провести атаку, направленную на прерывание соединения; атакующий может создать видимость того, что сообщение было короче, просто послав пакет TCP FIN. Если подвергнутый атаке не получил каким-либо другим способом размер предполагаемых данных, он будет просто верить, что размер реально переданных данных корректен.

Для того, чтобы устранить эту проблему безопасности, в SSLv3 был введен сигнал “close_notify”. Это сообщение SSL (соответственно, защищенное), но не часть потока данных, вследствие чего не виден приложению явно. Данные не могут быть переданы после того, как послан сигнал close_notify.

Таким образом, когда SSL_read() возвращает 0 для того, чтобы сообщить о том, что сокет был закрыт, на самом деле это означает, что был получен сигнал close_notify. Если клиент получает FIN перед получением close_notify, SSL_read() вернет управление с ошибкой. Это состояние и называется прерванной передачей.

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

Если мы считываем ответ сервера без ошибок, нам необходимо послать серверу наш собственный сигнал close_notify. Это делается с помощью API вызова SSL_shutdown(). Мы обсудим SSL_shutdown() подробнее, когда будем говорить о сервере, но основная идея этого вызова проста и понятна: он возвращает 1 для завершенного закрытия соединения, 0 для незавершенного и -1 для ошибки. С тех пор как мы уже получили close_notify от сервера, только одна ошибка может произойти – некорректная отсылка нашего собственного соответствующего сигнала. Иначе вызов SSL_shutdown() будет успешен.

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

Сторона сервера.
Наш веб-сервер в целом является отражением клиента с небольшими изменениями. Во-первых, мы будем использовать fork() для того, чтобы сервер мог обслуживать одновременно множество клиентов. Во-вторых, мы используем API вызовы OpenSSL BIO для чтения запросов клиента строка за строкой, а также для буферизованной отсылки данных клиенту. Кроме того, последовательность завершения работы сервера более сложна и запутанна.

На системах, построенных на Linux, самый простой путь в написании сервера, который сможет работать одновременно с несколькими клиентами – создание нового серверного процесса для каждого подсоединившегося клиента. Мы реализуем это вызовом fork() после того, как accept() вернет управление потоком. Каждый новый процесс выполняется независимо и завершается, когда обслуживание клиента окончено. Хотя этот метод может быть довольно медленным на веб-серверах с большой нагрузкой, он отлично нам подходит. Основной accept цикл сервера:
while(1){
  if((s=accept(sock,0,0))<0)
    err_exit("Problem accepting");

  if((pid=fork())){
    close(s);
  }
  else {
    sbio=BIO_new_socket(s,BIO_NOCLOSE);
    ssl=SSL_new(ctx);
    SSL_set_bio(ssl,sbio,sbio);

    if((r=SSL_accept(ssl)<=0))
      berr_exit("SSL accept error");

    http_serve(ssl,s);
    exit(0);
  }
}
Запрос HTTP состоит строки запроса, сопровождаемой заголовками и, в части случаев, тела. Конец строк заголовков обозначается пустой строкой (например, парой CRLF, хотя иногда некорректные клиенты будут вместо нее отсылать пару LF). Самый удобный способ прочитать строку запроса и заголовки – считывать по строке до тех пор, пока не увидим пустую строку. Мы можем делать это используя функцию OpenSSL BIO_gets():
while(1){
  r=BIO_gets(io,buf,BUFSIZZ-1);

  switch(SSL_get_error(ssl,r)){
    case SSL_ERROR_NONE:
      len=r;
      break;
    default:
      berr_exit("SSL read problem");
  }
  // ищем пустую строку, чтобы определить конец заголовков
  if(!strcmp(buf,”\r\n”) || !strcmp(buf,”\n”))
    break;
}
Вызов BIO_gets() ведет себя аналогично обычному stdio fgets(). Он принимает в качестве аргумента произвольного размера буфер и количество символов и считывает строку из соединения SSL в буфер. Результат всегда оканчивается последовательностью %00 (но включает конечный LF). Таким образом, мы просто считываем строку за строкой, до тех пор пока не получим строку, состоящую всего лишь из LF или CRLF.

Так как мы используем буфер фиксированной длины, возможно, хотя и нежелательно, что мы получим слишком большую строку заголовка от респондента. В таком случае, длинная строка будет разбита на 2, причем все может случиться настолько неудачно, что разрыв получится ровно перед CRLF, как следствие, следующая строка будет состоять только из CRLF, которая по идее принадлежала предыдущей строке – соответственно мы будем введены в заблуждение относительно преждевременного окончания заголовков. Реальный веб-сервер будет производить проверку на этот счет, но это не имеет особого значения здесь. Учтите, что здесь нет шансов получить переполнение буфера, вне зависимости от длины полученной строки, единственное, что может произойти – некорректная обработка заголовков.

В нашей программе мы ничего не будем делать с запросом HTTP - всего лишь читаем его и сбрасываем. Реальный сервер будет считывать строку запроса и заголовки, проверять, есть ли тело запроса и читать его, в случае, если оно есть.

Следующим шагом будет отправка ответа HTTP и закрытие соединения:
if((r=BIO_puts
(io,"HTTP/1.0 200 OK\r\n"))<0)
  err_exit("Write error");
if((r=BIO_puts
(io,"Server: EKRServer\r\n\r\n"))<0)
  err_exit("Write error");
if((r=BIO_puts
 (io,"Server test page\r\n"))<0)
  err_exit("Write error");
if((r=BIO_flush(io))<0)
  err_exit("Error flushing BIO");
Обратите внимание на то, что мы используем BIO_puts() вместо SSL_write(). Это позволяет нам составлять запрос по строке, при этом отправляя его в одном пакете SSL. Это важно, так как временная стоимость подготовки пакета SSL для передачи (вычисление контрольной суммы и шифрование) довольно высока. Понятно, что это хорошая идея сделать пакет как можно больше.

Очень важно уяснить пару тонкостей в использовании такого метода буферизованной записи. Во-первых, вам нужно очистить буфер перед завершением выполнения. Объект SSL не имеет сведений о том, что мы «наложили» поверх него объект BIO, так что, если вы оборвете соединение SSL, должны оставить последнюю часть данных в буфере - вызов BIO_flush() позаботится об этом. Также, по-умолчанию, OpenSSL использует 1024-байтовый размер для буферизованных BIO. Так как пакеты SSL могут быть размером до 16кб, использование 1024 байтового буфера может вызвать чрезмерную фрагментацию (и, никуда не годную производительность). Вы можете использовать BIO_ctrl() API для того, чтобы увеличить размер буфера.

Как только мы закончили передачу ответа, нам надо отправить наш close_notify. Как и прежде, это делается вызовом SSL_shutdown().К несчастью, жизнь усложняется, когда сервер закрывает соединение первым. Наш первый вызов SSL_shutdown() отправляет close_notify, но не проверяет его наличие на другом конце соединения. Таким образом, он возвращает выполнение немедленно, но со значением 0, оповещающим о том, что последовательность завершения не закончена, соответственно, под ответственностью приложения лежит повторный вызов SSL_shutdown().

Здесь существует 2 варианта реализации. Мы можем решить для себя, что мы видим весь HTTP запрос, который ожидаем - нас не интересует что-либо другое. Следовательно, мы не заботимся о том, послал клиент close_notify или нет. В качестве альтернативы, мы строго будем следовать протоколу и ожидать, что это сделает кто-то за нас – таким образом, нам нужен close_notify.

Если у мы будем придерживаться 1 варианта, жизнь становится простой как пень. Мы вызываем SSL_shutdown() для отправки собственного close_notify и завершаем выполнение, не обращая внимания на то, отослал ли клиент свой соответствующий сигнал. Если же мы возьмем 2 взгляд за направление (как и делает наш тестовый сервер), все становится сложнее, так как клиенты часто не ведут себя корректно в этом плане.

С первой проблемой мы встречаемся, когда клиенты часто вообще не отсылают сигналы close_notify. В действительности, некоторые клиенты закрывают соединение сразу же после прочтения всего ответа HTTP (некоторые версии IE так и делают). Когда мы отправляем свой close_notify, с другого конца может быть отправлен пакет TCP RST, который заставит программу получить SIGPIPE. Для решения этой проблемы мы поставим заглушку обработчика SIGPIPE в initialize_ctx().

Вторая проблема, с которой мы столкнемся – то, что клиент может и не отослать close_notify сразу же в ответ на наш close_notify. Некоторые версии Netscape требуют от нас сначала отправки TCP FIN. Таким образом, мы вызываем shutdown(s,1) перед вторым вызовом SSL_shutdown(). При вызове с аргументом “1”, shutdown() отправит пакет FIN, но оставит сокет открытым для чтения. Код завершения работы сервера представлен ниже:
r=SSL_shutdown(ssl);
if(!r){
  /* если мы сначала вызовем SSL_shutdown() - всегда будем получать ’0’. 
  Попробуем совершить еще один вызов, но сначала отправим TCP FIN, чтобы заставить респондента послать close_notify*/
  shutdown(s,1);
  r=SSL_shutdown(ssl);
}

switch(r){
  case 1:
    break; // ok
  case 0:
  case -1:
  default:
    berr_exit("Shutdown failed");
}
В приложении к этой статье есть несколько тестовых программ от Eric Rescorla, я оставил их без изменений. Все протестировано под FC8.

Материалы по теме:
- An Introduction to OpenSSL Programming, Part II
http://www.linuxjournal.com/article/5487

- Некоторые малоизвестные возможности и особенности OpenSSL
http://sysoev.ru/prog/openssl.html
/* ----------------------------------------------------[contents]----------------------------------------------------- */